collin/mahjong · 67a30800
A Dockerfile, a compose file, and no address a container could lie with
Collin Richards · 2026-08-25 11:02 UTC · 67a30800c94a7a04ea0b7a37233d6afa3a6fae2c · parent a62ae023 · browse files
added.dockerignore+7 −0
| 1 | + | node_modules | |
| 2 | + | dist | |
| 3 | + | .git | |
| 4 | + | .agent-queue | |
| 5 | + | .claude | |
| 6 | + | http.pid | |
| 7 | + | *.log |
addedDockerfile+42 −0
| 1 | + | # The whole production stack is one process: the built game out of dist/, and | |
| 2 | + | # the room relay on /ws of the same origin. So the image is a node runtime, the | |
| 3 | + | # bundle, and `server/`. | |
| 4 | + | # | |
| 5 | + | # No state. Rooms live in memory, and a restart drops them — the next visitor | |
| 6 | + | # on an old link recreates the room empty and a host mid-game republishes its | |
| 7 | + | # table on the next move. There is nothing here to mount a volume for. | |
| 8 | + | ||
| 9 | + | # ---- build: typecheck, bundle ------------------------------------------ | |
| 10 | + | FROM node:24-alpine AS builder | |
| 11 | + | WORKDIR /app | |
| 12 | + | ||
| 13 | + | COPY package.json package-lock.json ./ | |
| 14 | + | RUN npm ci | |
| 15 | + | ||
| 16 | + | COPY . . | |
| 17 | + | RUN npm run build | |
| 18 | + | ||
| 19 | + | # The runtime needs `ws` and nothing else the dev toolchain drags in. | |
| 20 | + | RUN npm prune --omit=dev | |
| 21 | + | ||
| 22 | + | # ---- runtime ------------------------------------------------------------ | |
| 23 | + | FROM node:24-alpine AS runtime | |
| 24 | + | WORKDIR /app | |
| 25 | + | ||
| 26 | + | RUN addgroup -S app && adduser -S app -G app | |
| 27 | + | ||
| 28 | + | COPY --from=builder --chown=app:app /app/node_modules ./node_modules | |
| 29 | + | COPY --from=builder --chown=app:app /app/dist ./dist | |
| 30 | + | COPY --from=builder --chown=app:app /app/server ./server | |
| 31 | + | COPY --from=builder --chown=app:app /app/package.json ./package.json | |
| 32 | + | ||
| 33 | + | ENV NODE_ENV=production | |
| 34 | + | ENV HOST=0.0.0.0 | |
| 35 | + | ENV PORT=3000 | |
| 36 | + | ||
| 37 | + | EXPOSE 3000 | |
| 38 | + | USER app | |
| 39 | + | ||
| 40 | + | # server/ is TypeScript and node strips the types itself — there is no build | |
| 41 | + | # step for it and nothing generated to keep in sync with the source. | |
| 42 | + | CMD ["node", "--no-warnings=ExperimentalWarning", "server/index.ts"] |
modifiedREADME.md+9 −0
| ⋯ 362 unchanged lines | |||
| 363 | 363 | ``` | |
| 364 | 364 | npm run dev # vite, with the relay on /ws of the same origin | |
| 365 | 365 | npm run build && npm run serve # production: dist/ + relay, one process | |
| 366 | + | hag deploy # onto hagrid, behind Caddy | |
| 366 | 367 | ``` | |
| 367 | 368 | ||
| 368 | 369 | Both commands listen on every interface, and both say where that is: | |
| ⋯ 14 unchanged lines | |||
| 383 | 384 | TLS in front — a reverse proxy with a certificate — since phones only get | |
| 384 | 385 | camera and fullscreen on https. | |
| 385 | 386 | ||
| 387 | + | `hag deploy` builds the image, pushes it to the private registry and recreates | |
| 388 | + | the container on the host — `Dockerfile` and `compose.yaml` at the repo root, | |
| 389 | + | the same shape every project there uses. There is no volume, because there is | |
| 390 | + | no state: rooms are in memory and the game is in the clients. **Set | |
| 391 | + | `PUBLIC_URL`** in a `.env` beside the compose file: inside a container every | |
| 392 | + | address belongs to a docker bridge nobody can reach, the relay knows better | |
| 393 | + | than to hand one out, and without it there is no QR on the felt at all. | |
| 394 | + | ||
| 386 | 395 | Rooms live in memory. A restart drops them; the next visitor on an old link | |
| 387 | 396 | recreates the room empty, and a host mid-game republishes its state on the | |
| 388 | 397 | next move. Joining a room writes the room into the address bar, so the page | |
| ⋯ 618 unchanged lines | |||
addedcompose.yaml+43 −0
| 1 | + | # 台灣麻將 on hagrid, deployed with `hag` -- the shared deploy tool for every | |
| 2 | + | # project on that host (build, push to the private registry, then pull and | |
| 3 | + | # recreate there). Both this machine and hagrid need | |
| 4 | + | # `docker login registry.vibe.richardscollin.com` once. | |
| 5 | + | # | |
| 6 | + | # IMAGE_TAG picks which build runs. hag sets it to the git sha it just pushed, | |
| 7 | + | # so `hag status` names the exact build, and rolling back on the host is | |
| 8 | + | # `IMAGE_TAG=<sha> docker compose up -d --no-build`. | |
| 9 | + | services: | |
| 10 | + | mahjong: | |
| 11 | + | image: registry.vibe.richardscollin.com/mahjong:${IMAGE_TAG:-latest} | |
| 12 | + | build: . | |
| 13 | + | # Caddy reverse-proxies to `mahjong:3000` by name, so this is load-bearing. | |
| 14 | + | container_name: mahjong | |
| 15 | + | restart: unless-stopped | |
| 16 | + | ||
| 17 | + | # PUBLIC_URL above all -- see below. Optional so the container still starts | |
| 18 | + | # on a host that has none. | |
| 19 | + | env_file: | |
| 20 | + | - path: .env | |
| 21 | + | required: false | |
| 22 | + | ||
| 23 | + | environment: | |
| 24 | + | PORT: 3000 | |
| 25 | + | # Where the outside world reaches this game, and it matters more here | |
| 26 | + | # than it looks: it is the origin every QR carries. The table's own | |
| 27 | + | # address inside the container is a docker bridge address that nobody | |
| 28 | + | # can reach, and the relay knows better than to hand that out (see | |
| 29 | + | # `lanAddress` in server/rooms.ts), so without this there is simply no | |
| 30 | + | # QR tile on the felt and the only way in is a link typed by hand. | |
| 31 | + | # | |
| 32 | + | # https, not http: a phone only gets fullscreen and the camera on a | |
| 33 | + | # secure origin, and Caddy is already terminating TLS in front of this. | |
| 34 | + | PUBLIC_URL: ${PUBLIC_URL:-} | |
| 35 | + | ||
| 36 | + | networks: | |
| 37 | + | - hagrid | |
| 38 | + | ||
| 39 | + | # Caddy runs on this network and reverse-proxies to the container by name, so | |
| 40 | + | # no host port is published. The hagrid repo owns the network's lifecycle. | |
| 41 | + | networks: | |
| 42 | + | hagrid: | |
| 43 | + | external: true |
modifiedserver/rooms.ts+8 −0
| 1 | 1 | import { spawn, type ChildProcess } from 'node:child_process'; | |
| 2 | + | import { existsSync } from 'node:fs'; | |
| 2 | 3 | import type { IncomingMessage } from 'node:http'; | |
| 3 | 4 | import { networkInterfaces } from 'node:os'; | |
| 4 | 5 | import type { Duplex } from 'node:stream'; | |
| ⋯ 110 unchanged lines | |||
| 115 | 116 | * they are to be it: the home-router range first, then the other two private | |
| 116 | 117 | * ranges, then anything else that is not loopback. Link-local (169.254) is | |
| 117 | 118 | * what an interface says when it has no network at all, so it never counts. | |
| 119 | + | * | |
| 120 | + | * Inside a container every one of them is a bridge address on a network that | |
| 121 | + | * exists only on the host, so there is no answer and it says so. A deployed | |
| 122 | + | * game gets its face from `PUBLIC_URL` — see compose.yaml — and a deploy that | |
| 123 | + | * forgot to set one shows no QR at all, which is the truth rather than a code | |
| 124 | + | * that leads nowhere. | |
| 118 | 125 | */ | |
| 119 | 126 | function lanAddress(): string { | |
| 127 | + | if (existsSync('/.dockerenv')) return ''; | |
| 120 | 128 | const rank = (ip: string): number => { | |
| 121 | 129 | if (ip.startsWith('192.168.')) return 0; | |
| 122 | 130 | if (ip.startsWith('10.')) return 1; | |
| ⋯ 296 unchanged lines | |||