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
+5
View File
@@ -14,6 +14,7 @@ import type {
FriendCode,
GameList,
GameView,
ExportUrl,
GcgExport,
History,
HintResult,
@@ -161,6 +162,10 @@ export interface GatewayClient {
profileUpdate(p: ProfileUpdate): Promise<Profile>;
statsGet(): Promise<Stats>;
exportGcg(gameId: string): Promise<GcgExport>;
/** Mints a signed download URL for a finished game's artifact. dateLocale and the
* localized non-play labels (pass/exchange/resign/timeout, in that order) ride the
* URL so the server-rendered image matches the player's presentation. */
exportUrl(gameId: string, kind: 'png' | 'gcg', dateLocale: string, actionLabels: string[], timeZone: string): Promise<ExportUrl>;
// --- account linking & merge ---
linkEmailRequest(email: string): Promise<void>;
+26
View File
@@ -6,6 +6,7 @@ import {
decodeBlockStatus,
decodeDraftView,
decodeEvent,
decodeExportUrl,
decodeFriendList,
decodeGameList,
decodeInvitation,
@@ -24,6 +25,7 @@ import {
encodeDraftSave,
encodeEnqueue,
encodeExchange,
encodeExportUrlRequest,
encodeGuestLogin,
encodeStateRequest,
encodeSubmitPlay,
@@ -34,6 +36,30 @@ import {
} from './codec';
describe('codec', () => {
it('round-trips the export-url request and response', () => {
const req = fb.ExportUrlRequest.getRootAsExportUrlRequest(
new ByteBuffer(
encodeExportUrlRequest('g-1', 'png', 'ru-RU', ['пас', 'обмен', 'сдался', 'таймаут'], 'Europe/Moscow'),
),
);
expect(req.gameId()).toBe('g-1');
expect(req.kind()).toBe('png');
expect(req.dateLocale()).toBe('ru-RU');
expect(req.timeZone()).toBe('Europe/Moscow');
expect(req.actionLabelsLength()).toBe(4);
expect(req.actionLabels(1)).toBe('обмен');
const b = new Builder(128);
const path = b.createString('/dl/g-1/png?e=1&s=x');
const fn = b.createString('game-g-1.png');
fb.ExportUrl.startExportUrl(b);
fb.ExportUrl.addPath(b, path);
fb.ExportUrl.addFilename(b, fn);
b.finish(fb.ExportUrl.endExportUrl(b));
const out = decodeExportUrl(b.asUint8Array());
expect(out).toEqual({ path: '/dl/g-1/png?e=1&s=x', filename: 'game-g-1.png' });
});
it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => {
for (const vsAi of [false, true]) {
const req = fb.EnqueueRequest.getRootAsEnqueueRequest(
+31
View File
@@ -22,6 +22,7 @@ import type {
EvalResult,
FeedbackState,
FriendCode,
ExportUrl,
GameList,
GameView,
GcgExport,
@@ -882,6 +883,36 @@ export function decodeGcg(buf: Uint8Array): GcgExport {
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
}
export function encodeExportUrlRequest(
gameId: string,
kind: 'png' | 'gcg',
dateLocale: string,
actionLabels: string[],
timeZone: string,
): Uint8Array {
const b = new Builder(256);
const gid = b.createString(gameId);
const k = b.createString(kind);
const dl = b.createString(dateLocale);
const tz = b.createString(timeZone);
const labels = fb.ExportUrlRequest.createActionLabelsVector(
b,
actionLabels.map((l) => b.createString(l)),
);
fb.ExportUrlRequest.startExportUrlRequest(b);
fb.ExportUrlRequest.addGameId(b, gid);
fb.ExportUrlRequest.addKind(b, k);
fb.ExportUrlRequest.addDateLocale(b, dl);
fb.ExportUrlRequest.addActionLabels(b, labels);
fb.ExportUrlRequest.addTimeZone(b, tz);
return finish(b, fb.ExportUrlRequest.endExportUrlRequest(b));
}
export function decodeExportUrl(buf: Uint8Array): ExportUrl {
const e = fb.ExportUrl.getRootAsExportUrl(new ByteBuffer(buf));
return { path: s(e.path()), filename: s(e.filename()) };
}
function emptyGame(): GameView {
return {
id: '',
+38 -26
View File
@@ -1,12 +1,12 @@
// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
// Canvas 2D — no dependencies — and the module is reached only through a dynamic import, so
// it stays out of the startup bundle. The scoresheet typography is fixed; the board instead
// stretches to the scoresheet's height (never below MIN_SIDE), so a long game grows the
// board rather than leaving a hole under it.
// Canvas 2D — no dependencies. The module is the SHARED source of truth for the export
// image: the render sidecar bundles it (renderer/src/entry.ts) and rasterizes on
// skia-canvas, while this ui project keeps the pure parts unit-tested (the browser app
// itself no longer imports it — delivery is the server's signed URL).
//
// buildScoresheet and layoutImage are pure (vitest-covered); renderGameImage is the thin
// canvas pass exercised by the Playwright e2e.
// buildScoresheet and layoutImage are pure (vitest-covered); drawGameImage is the thin
// canvas pass exercised by the sidecar's node test.
import type { GameView, MoveRecord, Variant } from './model';
import { replay } from './board';
@@ -56,16 +56,22 @@ export interface Scoresheet {
}
/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
* (regional date conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
* dateLocale is for tests; production passes undefined = the system default. */
export function formatFinishedDate(unixSec: number, dateLocale?: string): string {
return new Intl.DateTimeFormat(dateLocale, {
* and time zone (regional conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
* Both ride in from the client on a server render; an invalid zone falls back to the
* process default rather than failing the whole image. */
export function formatFinishedDate(unixSec: number, dateLocale?: string, timeZone?: string): string {
const opts: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(unixSec * 1000));
};
try {
return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone }).format(new Date(unixSec * 1000));
} catch {
return new Intl.DateTimeFormat(dateLocale, opts).format(new Date(unixSec * 1000));
}
}
/**
@@ -78,7 +84,7 @@ export function buildScoresheet(
game: GameView,
moves: MoveRecord[],
actionLabel: (action: string) => string,
opts?: { hostname?: string; dateLocale?: string },
opts?: { hostname?: string; dateLocale?: string; timeZone?: string },
): Scoresheet {
const seats = game.seats;
const grid = historyGrid(moves, seats.length, null);
@@ -106,7 +112,7 @@ export function buildScoresheet(
rows,
adjustments,
hasAdjustments: adjustments.some((a) => a !== 0),
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale)}`,
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale, opts?.timeZone)}`,
};
}
@@ -190,27 +196,36 @@ export interface RenderOptions {
hostname?: string;
/** Footer date locale override (defaults to the device locale) — for tests. */
dateLocale?: string;
/** Footer IANA time zone (the device zone on a server render; defaults to local). */
timeZone?: string;
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
scale?: number;
}
/** CanvasLike is the minimal canvas surface drawGameImage needs — satisfied by both the
* browser HTMLCanvasElement and the render sidecar's skia-canvas Canvas, so the drawing
* code is shared verbatim between the two runtimes. */
export interface CanvasLike {
width: number;
height: number;
getContext(contextId: '2d'): CanvasRenderingContext2D | null;
}
/**
* renderGameImage draws the finished game as a PNG blob: final board with classic axes on
* the left, the scoresheet on the right, the site + finish date at the bottom. Light theme
* always; no last-move highlight (an archival artifact, not a live frame).
* drawGameImage sizes the canvas and draws the finished game onto it: final board with
* classic axes on the left, the scoresheet on the right, the site + finish date at the
* bottom. Light theme always; no last-move highlight (an archival artifact, not a live
* frame). The caller owns the canvas and its PNG encoding (toBlob in the browser,
* toBuffer in the sidecar).
*/
export async function renderGameImage(
game: GameView,
moves: MoveRecord[],
opts: RenderOptions,
): Promise<Blob> {
export function drawGameImage(canvas: CanvasLike, game: GameView, moves: MoveRecord[], opts: RenderOptions): void {
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
hostname: opts.hostname,
dateLocale: opts.dateLocale,
timeZone: opts.timeZone,
});
const layout = layoutImage(sheet);
const scale = opts.scale ?? 2;
const canvas = document.createElement('canvas');
canvas.width = Math.round(layout.w * scale);
canvas.height = Math.round(layout.h * scale);
const ctx = canvas.getContext('2d');
@@ -228,12 +243,9 @@ export async function renderGameImage(
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('gameimage: toBlob failed'))), 'image/png');
});
}
function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
const { x, y, cell } = l.board;
ctx.fillStyle = C.muted;
+1
View File
@@ -311,6 +311,7 @@ export const en = {
'stats.guestHint': 'Sign in to track your statistics.',
'game.exportGcg': 'Export game',
'game.exportChoice': 'Export the finished game as:',
'game.exportImageOpt': 'Image (PNG)',
'game.exportGcgOpt': 'GCG file',
'game.gcgActiveOnly': 'Available once the game is finished.',
+1
View File
@@ -311,6 +311,7 @@ export const ru: Record<MessageKey, string> = {
'stats.guestHint': 'Войдите, чтобы вести статистику.',
'game.exportGcg': 'Экспорт партии',
'game.exportChoice': 'Экспортировать завершённую партию как:',
'game.exportImageOpt': 'Картинка (PNG)',
'game.exportGcgOpt': 'Файл GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
+7
View File
@@ -21,6 +21,7 @@ import type {
FeedbackState,
FriendCode,
GameList,
ExportUrl,
GcgExport,
History,
HintResult,
@@ -677,6 +678,12 @@ export class MockGateway implements GatewayClient {
content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
};
}
async exportUrl(gameId: string, kind: 'png' | 'gcg'): Promise<ExportUrl> {
const g = this.game(gameId);
if (g.view.status !== 'finished') throw new GatewayError('game_active');
// A shape-faithful signed path; the e2e intercepts the route and serves bytes.
return { path: `/dl/${gameId}/${kind}?e=9999999999&s=mock`, filename: `game-${gameId}.${kind}` };
}
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
+8
View File
@@ -326,6 +326,14 @@ export interface GcgExport {
content: string;
}
/** A minted signed download link for a finished game's export artifact. path is
* relative — the client resolves it against its own origin (the SPA and the
* gateway edge share it), so the server never needs to know the public host. */
export interface ExportUrl {
path: string;
filename: string;
}
export interface Session {
token: string;
userId: string;
+1
View File
@@ -47,6 +47,7 @@ export const READ_OPS: ReadonlySet<string> = new Set([
'game.state',
'game.history',
'game.gcg',
'game.export_url',
'game.evaluate',
'game.check_word',
'stats.get',
+7 -38
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { pickGcgDelivery, pickImageDelivery, pickTextShare, shareOrDownloadGcg, shareOrDownloadImage, shareText } from './share';
import { downloadUrl, pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share';
import type { GcgExport } from './model';
const file = {} as File;
@@ -83,52 +83,21 @@ describe('shareOrDownloadGcg', () => {
});
});
describe('pickImageDelivery', () => {
const canShareNav = { canShare: () => true, share: async () => {} };
const noShareNav = { canShare: () => false, share: async () => {} };
it('shares when the platform can share files', () => {
expect(pickImageDelivery(canShareNav, file)).toBe('share');
});
it('downloads on a plain browser without Web Share (desktop)', () => {
expect(pickImageDelivery(noShareNav, file)).toBe('download');
expect(pickImageDelivery(undefined, file)).toBe('download');
});
});
describe('shareOrDownloadImage', () => {
describe('downloadUrl', () => {
afterEach(() => vi.unstubAllGlobals());
function stubEnv(canShare: boolean, share: () => Promise<void>) {
it('clicks a temporary anchor carrying the URL and the filename', () => {
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
const createElement = vi.fn(() => anchor);
vi.stubGlobal('navigator', { canShare: () => canShare, share });
vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } });
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:x'), revokeObjectURL: vi.fn() });
return { anchor, createElement };
}
const png = () => ({ name: 'game-1.png', type: 'image/png' }) as File;
it('never falls back to the navigating Blob download when a share is cancelled', async () => {
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
const { createElement } = stubEnv(true, share);
expect(await shareOrDownloadImage(png())).toBe('shared');
expect(share).toHaveBeenCalledOnce();
expect(createElement).not.toHaveBeenCalled();
});
it('downloads via an anchor on a desktop browser that cannot share files', async () => {
const { anchor, createElement } = stubEnv(false, vi.fn());
expect(await shareOrDownloadImage(png())).toBe('downloaded');
downloadUrl('https://example.test/dl/g-1/png?e=1&s=x', 'game-1.png');
expect(createElement).toHaveBeenCalledWith('a');
expect(anchor.click).toHaveBeenCalledOnce();
expect(anchor.href).toBe('https://example.test/dl/g-1/png?e=1&s=x');
expect(anchor.download).toBe('game-1.png');
expect(anchor.click).toHaveBeenCalledOnce();
expect(anchor.remove).toHaveBeenCalledOnce();
});
});
+36 -26
View File
@@ -81,42 +81,52 @@ function downloadFile(content: Blob | string, filename: string, type = 'applicat
}
/**
* pickImageDelivery decides how to deliver the PNG game image: Web Share (with the file)
* wherever it exists — mobile browsers and the iOS Telegram Mini App — else a Blob download.
* The image option is not offered inside the in-app WebViews at all (Android Telegram/VK, the
* desktop VK iframe): a binary PNG has no working route there until the server-rendered
* signed-URL delivery lands, so this decision never sees them. Pure, unit-tested with a mock
* navigator.
* downloadUrl saves a same-origin signed export URL as a file through a temporary
* anchor: the server names it via Content-Disposition, the download attribute is the
* same-origin hint. This is the browser leg of the unified export delivery; the in-app
* WebViews never reach it (they use the platform's native download call instead), so
* the iOS blob-URL navigation trap does not apply — this is a real https URL.
*/
export function pickImageDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' {
if (
nav &&
typeof nav.canShare === 'function' &&
typeof nav.share === 'function' &&
nav.canShare({ files: [file] })
) {
return 'share';
}
return 'download';
export function downloadUrl(url: string, filename: string): void {
if (typeof document === 'undefined') return;
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
/**
* shareOrDownloadImage delivers the rendered game image and reports the route taken. The Web
* Share invariant matches shareOrDownloadGcg: a cancelled or failed share is a deliberate
* no-op, never a Blob-download fallback (an <a download> strands the iOS WKWebView on the
* blob: URL).
* shareUrlAsFile delivers a signed export URL through the OS share sheet where files can
* be shared (mobile browsers): it fetches the same-origin bytes, wraps them in a File and
* calls navigator.share — the sheet opens directly, nothing lands in Downloads first. The
* fetch-then-share chain keeps the click's user activation (the proven GCG pattern).
* Where file sharing is absent (desktop) it falls back to the plain anchor download. A
* cancelled share is a no-op by the shareOrDownloadGcg invariant.
*/
export async function shareOrDownloadImage(file: File): Promise<'shared' | 'downloaded'> {
export async function shareUrlAsFile(url: string, filename: string, mime: string): Promise<'shared' | 'downloaded' | 'failed'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (pickImageDelivery(nav, file) === 'share' && nav) {
if (nav && typeof nav.share === 'function' && typeof nav.canShare === 'function') {
try {
await nav.share({ files: [file], title: file.name });
const resp = await fetch(url);
if (!resp.ok) return 'failed';
const file = new File([await resp.blob()], filename, { type: mime });
if (nav.canShare({ files: [file] })) {
try {
// No title: iOS pastes it as accompanying text next to the image; the named
// file speaks for itself.
await nav.share({ files: [file] });
} catch {
/* cancelled — intentionally a no-op */
}
return 'shared';
}
} catch {
/* cancelled or failed — intentionally a no-op (see shareOrDownloadGcg) */
return 'failed';
}
return 'shared';
}
downloadFile(file, file.name, file.type);
downloadUrl(url, filename);
return 'downloaded';
}
+26 -1
View File
@@ -67,6 +67,7 @@ interface TelegramWebApp {
};
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
downloadFile?: (params: { url: string; file_name: string }, cb?: (accepted: boolean) => void) => void;
}
function webApp(): TelegramWebApp | undefined {
@@ -372,7 +373,8 @@ export function telegramShowConfirm(message: string): Promise<boolean> {
/**
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
* predating the popup, so callers can fall back to their own modal.
* predating the popup, so callers can fall back to their own modal. NOTE: the callback runs with
* no user activation — never lead from it into navigator.share or a clipboard write.
*/
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
const w = webApp();
@@ -380,6 +382,29 @@ export function telegramShowPopup(params: TelegramPopupParams): Promise<string |
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
}
/** telegramCanDownloadFile reports whether the native download dialog exists (Bot API 8.0). */
export function telegramCanDownloadFile(): boolean {
return !!webApp()?.downloadFile;
}
/** telegramPlatform returns the SDK's platform tag ('ios', 'android', 'tdesktop', …) or ''
* outside Telegram — the export delivery branches on it (iOS gets the OS share sheet). */
export function telegramPlatform(): string {
return webApp()?.platform ?? '';
}
/**
* telegramDownloadFile asks Telegram to download url as fileName through its native
* dialog — the Mini App file delivery that works on every Telegram platform (a webview
* <a download> does not). Returns false outside Telegram or on a client predating it.
*/
export function telegramDownloadFile(url: string, fileName: string): boolean {
const w = webApp();
if (!w?.downloadFile) return false;
w.downloadFile({ url, file_name: fileName });
return true;
}
/** Haptic is the set of feedbacks the app triggers. */
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
+5
View File
@@ -271,6 +271,11 @@ export function createTransport(baseUrl: string): GatewayClient {
async exportGcg(gameId) {
return codec.decodeGcg(await exec('game.gcg', codec.encodeGameAction(gameId)));
},
async exportUrl(gameId, kind, dateLocale, actionLabels, timeZone) {
return codec.decodeExportUrl(
await exec('game.export_url', codec.encodeExportUrlRequest(gameId, kind, dateLocale, actionLabels, timeZone)),
);
},
subscribe(onEvent, onError) {
const ctrl = new AbortController();
+30
View File
@@ -155,6 +155,36 @@ export async function vkShare(link: string): Promise<boolean> {
}
}
/**
* vkShowImages opens VK's native image viewer on url — on the Android client this is the
* PNG delivery: the viewer's own "save" works there, while VKWebAppDownloadFile hangs
* indefinitely (on-device finding). Resolves false on any failure, so the caller can
* fall back.
*/
export async function vkShowImages(url: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppShowImages', { images: [url] });
return true;
} catch {
return false;
}
}
/**
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
* on any failure or where the method is unavailable (notably the desktop iframe), so the
* caller falls back to a plain browser download of the same URL.
*/
export async function vkDownloadFile(url: string, filename: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppDownloadFile', { url, filename });
return true;
} catch {
return false;
}
}
/**
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.