feat(export): server-rendered artifacts behind one signed download URL (#160)
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s

The finished-game export (GCG + a new PNG of the final position) is one
signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min
TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin
and delivered by the best affordance each platform has (five on-device
review rounds):

- TG Android/desktop: native showPopup chooser -> native downloadFile
  dialog (bridge-only chain, activation-safe).
- TG iOS: app-modal chooser -> OS share sheet with the fetched file
  (a popup callback cannot supply the activation the sheet needs).
- VK iOS: VKWebAppDownloadFile for both formats.
- VK Android: the PNG opens in VK's native image viewer, the GCG copies
  to the clipboard (the VK Android downloader hangs on any download,
  Content-Length/Range notwithstanding).
- VK desktop iframe / desktop browsers: plain anchor downloads.
- Mobile browsers: the OS share sheet (fetch-then-share).
- Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option.

The PNG is rasterized on demand by the new internal `renderer` sidecar
(node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts)
executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests;
the backend rebuilds the render payload from the journal +
engine.AlphabetTable, and the device date locale, IANA time zone and
localized non-play labels ride the signed URL. Nothing is stored — the
artifact re-derives from the immutable journal on each GET. The gateway
forwards /dl/* (caddy @gateway matcher extended) behind the per-IP
public rate limiter and serves bytes via http.ServeContent.

Deploy: renderer service in compose + prod overlay + rolling order +
prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke
runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN,
TESTING, deploy/README, renderer/README.
This commit was merged in pull request #160.
This commit is contained in:
2026-07-02 21:58:07 +00:00
parent 16a4431158
commit d5fbaa3034
62 changed files with 2449 additions and 234 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+29
View File
@@ -0,0 +1,29 @@
# renderer — the finished-game image-render sidecar: Node + skia-canvas running the SAME
# ui/src/lib/gameimage.ts the web client unit-tests, bundled at build time (renderer/README.md).
# Debian slim, not the repo's usual alpine/distroless: skia-canvas ships prebuilt glibc
# binaries and resolves fonts through fontconfig, and the runtime fonts are baked into the
# image — Liberation Sans (the UI font stack's Linux face) + Noto Color Emoji (the 🏆).
FROM node:22-slim AS build
WORKDIR /src/renderer
RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
COPY renderer/package.json renderer/pnpm-lock.yaml renderer/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY renderer/build.mjs ./
COPY renderer/src ./src
COPY ui/src/lib /src/ui/src/lib
RUN pnpm run bundle
FROM node:22-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-liberation fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ARG VERSION=dev
ENV APP_VERSION=${VERSION}
WORKDIR /app
COPY --from=build /src/renderer/node_modules ./node_modules
COPY --from=build /src/renderer/dist ./dist
COPY renderer/src/server.mjs renderer/src/render.mjs ./src/
USER node
EXPOSE 8090
CMD ["node", "src/server.mjs"]
+34
View File
@@ -0,0 +1,34 @@
# renderer — the finished-game image-render sidecar
An internal-only Node service that rasterizes the finished-game export PNG. It runs the
**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image
build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on
[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is
pixel-identical to the design the owner signed off in the browser.
## Interface
- `POST /render``{game, moves, alphabet, labels, dateLocale, hostname, scale?}`
`image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`);
`alphabet` is the per-variant `(index, letter, value)` table tile values are drawn
from; `labels` localizes the non-play moves (pass/exchange/resign/timeout);
`hostname` + `dateLocale` feed the footer.
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
The service draws and nothing else: authentication, the participant check and the signed
public download URL all live in the backend (`backend/internal/server/export.go`); the
network path is client → gateway `/dl/*` → backend → this sidecar.
## Development
```sh
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
# (allowBuilds) or the native binary is silently never fetched
pnpm test # bundles, then node --test against testdata/request.json
node src/server.mjs # local run on :8090 (RENDERER_PORT overrides)
```
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
UI font stack's Linux face) and Noto Color Emoji (the scoresheet 🏆); skia-canvas picks
both up through fontconfig. The fixture `testdata/request.json` is a real 35-move
self-played Russian Scrabble game.
+13
View File
@@ -0,0 +1,13 @@
// Bundles the shared ui/src/lib game-image renderer (see src/entry.ts) into
// dist/gameimage.mjs for the sidecar runtime. Uses the esbuild JS API — the CLI shim is
// unreliable under pnpm's bin wrapper (it re-runs the native binary through node).
import { build } from 'esbuild';
await build({
entryPoints: ['src/entry.ts'],
bundle: true,
format: 'esm',
platform: 'node',
outfile: 'dist/gameimage.mjs',
logLevel: 'info',
});
+23
View File
@@ -0,0 +1,23 @@
{
"name": "scrabble-renderer",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Internal sidecar that renders the finished-game export PNG with the same ui/src/lib/gameimage.ts the browser build tests, on skia-canvas.",
"scripts": {
"bundle": "node build.mjs",
"test": "pnpm run bundle && node --test test/*.test.mjs"
},
"dependencies": {
"skia-canvas": "^3.0.6"
},
"devDependencies": {
"esbuild": "^0.25.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"skia-canvas"
]
}
}
+366
View File
@@ -0,0 +1,366 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
skia-canvas:
specifier: ^3.0.6
version: 3.0.8
devDependencies:
esbuild:
specifier: ^0.25.0
version: 0.25.12
packages:
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
parenthesis@3.1.8:
resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==}
skia-canvas@3.0.8:
resolution: {integrity: sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==}
string-split-by@1.0.0:
resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==}
snapshots:
'@esbuild/aix-ppc64@0.25.12':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
agent-base@7.1.4: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
detect-libc@2.1.2: {}
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
'@esbuild/android-arm': 0.25.12
'@esbuild/android-arm64': 0.25.12
'@esbuild/android-x64': 0.25.12
'@esbuild/darwin-arm64': 0.25.12
'@esbuild/darwin-x64': 0.25.12
'@esbuild/freebsd-arm64': 0.25.12
'@esbuild/freebsd-x64': 0.25.12
'@esbuild/linux-arm': 0.25.12
'@esbuild/linux-arm64': 0.25.12
'@esbuild/linux-ia32': 0.25.12
'@esbuild/linux-loong64': 0.25.12
'@esbuild/linux-mips64el': 0.25.12
'@esbuild/linux-ppc64': 0.25.12
'@esbuild/linux-riscv64': 0.25.12
'@esbuild/linux-s390x': 0.25.12
'@esbuild/linux-x64': 0.25.12
'@esbuild/netbsd-arm64': 0.25.12
'@esbuild/netbsd-x64': 0.25.12
'@esbuild/openbsd-arm64': 0.25.12
'@esbuild/openbsd-x64': 0.25.12
'@esbuild/openharmony-arm64': 0.25.12
'@esbuild/sunos-x64': 0.25.12
'@esbuild/win32-arm64': 0.25.12
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
follow-redirects@1.16.0: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ms@2.1.3: {}
parenthesis@3.1.8: {}
skia-canvas@3.0.8:
dependencies:
detect-libc: 2.1.2
follow-redirects: 1.16.0
https-proxy-agent: 7.0.6
string-split-by: 1.0.0
transitivePeerDependencies:
- debug
- supports-color
string-split-by@1.0.0:
dependencies:
parenthesis: 3.1.8
+6
View File
@@ -0,0 +1,6 @@
# pnpm 11 records build-script approval here. esbuild's postinstall materialises its CLI
# shim; skia-canvas's postinstall downloads the prebuilt native .node binary — without the
# approval it is silently skipped and the service fails at boot.
allowBuilds:
esbuild: true
skia-canvas: true
+6
View File
@@ -0,0 +1,6 @@
// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib —
// the exact module the browser build unit-tests — plus the alphabet cache seeder the
// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source).
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
export { setAlphabet } from '../../ui/src/lib/alphabet';
+24
View File
@@ -0,0 +1,24 @@
// The render core, separated from the HTTP wiring so the node test can call it directly.
import { Canvas } from 'skia-canvas';
import { drawGameImage, setAlphabet } from '../dist/gameimage.mjs';
/**
* renderRequest rasterizes one export request — the JSON shape the backend sends:
* { game, moves, alphabet, labels, dateLocale, hostname, scale? } — into a PNG buffer.
* game/moves are the ui-model shapes (GameView / MoveRecord[]) the shared drawGameImage
* consumes; alphabet is the per-variant (index, letter, value) table it scores tiles with.
*/
export async function renderRequest(payload) {
const { game, moves, alphabet, labels, dateLocale, timeZone, hostname, scale } = payload;
if (!game || !Array.isArray(moves)) throw Object.assign(new Error('bad payload'), { status: 400 });
setAlphabet(game.variant, alphabet ?? []);
const canvas = new Canvas(1, 1);
drawGameImage(canvas, game, moves, {
actionLabel: (a) => (labels && labels[a]) || a,
hostname: hostname || '',
dateLocale: dateLocale || undefined,
timeZone: timeZone || undefined,
scale: scale || 2,
});
return canvas.toBuffer('png');
}
+55
View File
@@ -0,0 +1,55 @@
// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the
// finished-game export image. It runs the same drawGameImage the browser build ships
// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from
// the image (Liberation Sans + Noto Color Emoji via fontconfig).
//
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
// GET /healthz → 200
//
// The service is internal-only (docker network `internal`); authentication, participant
// checks and the signed public URL all live in the backend — this process only draws.
import { createServer } from 'node:http';
import { renderRequest } from './render.mjs';
const PORT = Number(process.env.RENDERER_PORT || 8090);
// A render request is a finished game's journal — generously capped.
const MAX_BODY = 2 * 1024 * 1024;
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) {
reject(Object.assign(new Error('body too large'), { status: 413 }));
req.destroy();
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
const server = createServer(async (req, res) => {
try {
if (req.method === 'GET' && req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
return;
}
if (req.method === 'POST' && req.url === '/render') {
const png = await renderRequest(JSON.parse(await readBody(req)));
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
return;
}
res.writeHead(404).end();
} catch (err) {
const status = err.status || (err instanceof SyntaxError ? 400 : 500);
console.error(`render error: ${err.message}`);
res.writeHead(status, { 'content-type': 'text/plain' }).end('render failed');
}
});
server.listen(PORT, () => console.log(`renderer listening on :${PORT}`));
+37
View File
@@ -0,0 +1,37 @@
// Smoke test for the render core: feeds the committed fixture (a real 35-move self-played
// Russian Scrabble game) through renderRequest and asserts a sane PNG comes back. Pixel
// goldens are deliberately avoided — font rasterization differs across hosts; the shared
// drawing logic itself is unit-tested in ui/ (gameimage.test.ts).
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { renderRequest } from '../src/render.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const fixture = () => JSON.parse(readFileSync(join(here, '../testdata/request.json'), 'utf8'));
function pngSize(buf) {
// IHDR is the first chunk: width/height are big-endian u32 at offsets 16/20.
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
}
test('renders the fixture game to a plausible PNG', async () => {
const png = await renderRequest(fixture());
assert.ok(png.length > 50_000, `png too small: ${png.length}`);
assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const { w, h } = pngSize(png);
// scale 2 over a MIN_SIDE 620 board + two seat columns: sanity bounds, not a golden.
assert.ok(w > 1600 && w < 3000, `unexpected width ${w}`);
assert.ok(h > 1000 && h < 3000, `unexpected height ${h}`);
});
test('rejects a payload without a game', async () => {
await assert.rejects(() => renderRequest({ moves: [] }), /bad payload/);
});
test('renders concurrently without cross-talk', async () => {
const [a, b] = await Promise.all([renderRequest(fixture()), renderRequest(fixture())]);
assert.equal(a.length, b.length);
});
File diff suppressed because one or more lines are too long