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
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:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+366
@@ -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
|
||||
@@ -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
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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}`));
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user