Skip to content

Repository files navigation

hashcards-wrapper

Note

See also: Hatchards - A Go reimplementation of hashcards featuring a web application, deck selection, statistical graphs, Git mirroring with post-mirror hook execution, authentication, and an SQLite viewer.

On-demand reverse proxy launcher for hashcards

An on-demand reverse proxy launcher layered on top of hashcards. hashwrap starts a hashcards process the moment a browser request arrives and forwards traffic as soon as it is ready. Multiple decks can be served simultaneously under different URL paths, with automatic PWA support, dark mode, and mobile layout injection.

It runs entirely from a pre-built Alpine-based 22.5 MB container provided on GitHub, requiring no additional build steps. After pulling the container, you still need to provide a compose.yaml and configure the access URL in config.json.

example


Features

Feature Description
On-demand launch Starts hashcards automatically on the first request. Restarts on the next request if the process has exited
Multi-route Manages independent hashcards instances per path
Transparent URL rewriting Rewrites http://127.0.0.1:{port}/... URLs generated by the backend to the public-facing URL
PWA support Serves manifest.json / sw.js / icons / favicon and injects PWA tags into every HTML response
Dark mode Applies a CSS invert trick to every page — no white flash
Mobile layout Controls font sizes via @media (max-width: 768px) using plain HTML tag selectors
Redirect correction Rewrites Location headers to include the correct path prefix

Usage

Clone the repository to get the example config and deck:

git clone https://github.com/asano69/hashcards-wrapper.git
cd hashcards-wrapper

Then update config.json so that public_base_url matches the URL you will use to access it from your browser (e.g. http://localhost:3001). Start with Docker Compose:

# compose.yaml
services:
  hashwrap:
    image: ghcr.io/asano69/hashcards-wrapper:latest
    ports:
      - "3001:3000"
    volumes:
      - ./config.json:/app/config.json:ro
      - ./example:/app/data
      - ./extras/menu.sh:/app/menu.sh:ro # optional
    restart: unless-stopped
docker compose up -d

Configuration

Example (config.json)

{
  "public_base_url": "http://localhost:3001",
  "listen": ":3000",
  "pwa_dir": "/app/pwa",
  "style": {
    "dark_brightness": 0.75,
    "mobile_root_size": "100%",
    "mobile_h1_size": "1.6rem",
    "mobile_p_size": "1.2rem"
  },
  "startup_timeout_sec": 10,
  "routes": [
    {
      "path": "/",
      "command": "hashcards drill --host=0.0.0.0 --port={port} --open-browser=false"
    },
    {
      "path": "/math",
      "command": "hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Mathematics"
    },
    {
      "path": "/art",
      "command": "hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Art"
    },
    {
      "path": "/audio",
      "command": "hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Audio"
    },
    {
      "path": "/menu",
      "command": "/app/menu.sh 8001",
      "port": 8001
    }
  ]
}

Top-level fields

Field Type Default Description
listen string ":8080" Address hashwrap binds to (e.g. ":3000", "0.0.0.0:3000")
startup_timeout_sec int 10 Seconds to wait for hashcards to start listening
public_base_url string auto-detected Externally reachable base URL. Must include the scheme (e.g. "https://hashcards.example.com", "http://localhost:3000"). Required when running behind a reverse proxy
pwa_dir string Directory containing PWA static files. Leave empty to disable PWA
style object CSS injection settings (see below)
routes array Route definitions (see below)

style fields

Field Type Default Description
dark_brightness float 0.70 CSS brightness() value applied in dark mode. Lower = darker text. Recommended range: 0.60–0.85
mobile_root_size string "90%" html font-size on mobile, proportionally scaling all rem-based text
mobile_h1_size string <h1> font-size on mobile. Falls back to mobile_root_size if omitted
mobile_p_size string <p> font-size on mobile. Falls back to mobile_root_size if omitted

routes[] fields

Field Type Default Description
path string required URL path to match (e.g. "/", "/math")
command string required Command to run. {port} is replaced with the actual port number
port int 0 (auto) Port for hashcards to bind to. Omit or set to 0 to auto-assign a free port

Menu page (optional)

extras/menu.sh is a minimal index page that lists all configured routes as clickable links.

hashcards

Mount it and register it as a route to enable it:

volumes:
  - ./extras/menu.sh:/app/menu.sh:ro
{
  "path": "/menu",
  "command": "/app/menu.sh 8001",
  "port": 8001
}

The port passed to menu.sh must match the port field in the route definition. The page reads config.json at startup and builds the link list automatically.


Behind a reverse proxy (nginx / Caddy)

Always set public_base_url when running behind a reverse proxy. The scheme must be included — hashwrap cannot infer whether the public endpoint is HTTP or HTTPS.

nginx example

server {
    listen 443 ssl;
    server_name hashcards.example.com;
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
{
  "public_base_url": "https://hashcards.example.com",
  "listen": ":3000"
}

PWA support

hashcards

Place the following files in the directory specified by pwa_dir:

/app/pwa/
  manifest.json
  sw.js
  icon-192.png
  icon-512.png
  favicon.ico

When enabled, hashwrap:

  • Serves /manifest.json, /sw.js, /icon-192.png, /icon-512.png, and /favicon.ico directly
  • Injects PWA meta tags and a Service Worker registration script just before </head> in every HTML response
  • Injects <link rel="icon"> tags into every HTML response so that the favicon resolves correctly regardless of the current path (e.g. /art/, /math/)

Mounting pwa_dir as a Docker volume is the recommended way to customise icons without rebuilding the image — including platform-specific assets for iOS and Android home screens.

Default icons (black background, white # glyph) can be regenerated with:

make icon

Requires ImageMagick.


How it works

Browser → hashwrap (:3000)
              │
              ├─ /       → hashcards process A (auto-assigned port)
              ├─ /math   → hashcards process B (auto-assigned port)
              ├─ /art    → hashcards process C (auto-assigned port)
              └─ /audio  → hashcards process D (auto-assigned port)

A process is started only when it is not already running and is reused for all subsequent requests. If a process exits, it is restarted on the next incoming request.

hashwrap | 2026/03/26 21:36:14 hashwrap listening on :3000
hashwrap | 2026/03/26 21:36:14 PWA enabled: serving static files from /app/pwa
hashwrap | 2026/03/26 21:36:14 route registered: / -> hashcards drill --host=0.0.0.0 --port={port} --open-browser=false (strip_prefix=false)
hashwrap | 2026/03/26 21:36:14 route registered: /math -> hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Mathematics (strip_prefix=true)
hashwrap | 2026/03/26 21:36:14 route registered: /art -> hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Art (strip_prefix=true)
hashwrap | 2026/03/26 21:36:14 route registered: /audio -> hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Audio (strip_prefix=true)
hashwrap | 2026/03/26 21:36:24 -> GET /art
hashwrap | 2026/03/26 21:36:24 [/art] starting: hashcards drill --host=0.0.0.0 --port=38367 --open-browser=false --from-deck=Art
hashwrap | 2026/03/26 21:36:25 [/art] ready (port=38367)
hashwrap | 2026/03/26 21:36:25 -> GET /art/file/thetempest.webp
hashwrap | 2026/03/26 21:36:25 [/art] process running (port=38367), reusing

Sub-path routes (/math, /art, etc.) automatically strip their prefix before forwarding requests to the backend. hashcards always expects to run at the root path and has no awareness of the prefix.


Request path prefix behaviour

This applies to incoming requests. Requests to sub-path routes always have their prefix stripped before being forwarded to hashcards. hashcards expects to run at the root path and would return 404 errors if the prefix were forwarded unchanged.

Request from browser Forwarded to backend
/art/file/assets/thetempest.webp /file/assets/thetempest.webp
/math/file/assets/diagram.png /file/assets/diagram.png

Routes mounted at / forward paths unchanged.


Response URL rewriting behaviour

This applies to outgoing responses. hashcards embeds self-referencing URLs in its responses (e.g. http://127.0.0.1:8000/file/img.jpg) that are unreachable by the browser. hashwrap reads the response body (HTML / CSS / JS / JSON) and rewrites any such URL to the public-facing address.

http://127.0.0.1:8000/file/img.jpg
        ↓
https://hashcards.example.com/art/file/img.jpg

Absolute paths such as href="/" are also prefixed for sub-path routes.

Note: Accept-Encoding: gzip is never forwarded to the backend, because compressed response bodies cannot be rewritten.


Error behaviour

Process exits before the port is ready

hashwrap returns an HTML error page showing the process's stdout / stderr output and a "check again" link. Clicking the link triggers a fresh startup attempt.

Startup timeout

If the port does not open within startup_timeout_sec seconds, hashwrap returns HTTP 500.


Acknowledgements

hashwrap is built on top of hashcards by @eudoxia0.

Docker configuration is inspired by hashcards-docker by @Bastian.


License

MIT

About

An on-demand reverse proxy for hashcards — study your flashcards from anywhere, on any device.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages