// The render sidecar: a minimal internal HTTP service on skia-canvas and the shared ui/src/lib // renderers (bundled from ui/src/lib at image build time). It serves two surfaces: // // POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png // GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog // price list spliced in — the only edge-exposed route; caddy routes /offer/ here) // GET /offer → 301 /offer/ // GET /healthz → 200 // // /render is internal-only (the backend calls it; authentication, participant checks and the signed // public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged // — it fetches the price list from the backend's internal endpoint and renders the committed offer // markdown; no user input reaches it. import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import { renderRequest } from './render.mjs'; import { renderOffer } from './offer.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; // The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the // price list is dynamic (fetched per request). The backend endpoint is internal (off the edge // allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked // path for a local run (point it at ../ui/legal/offer_ru.md). const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8'); const OFFER_PRICING_URL = (process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing'; // fetchOfferPricing returns the two catalog price tables as markdown from the backend, or throws a // 502 (upstream error) / 504 (timeout) so the offer never renders with a broken price list. async function fetchOfferPricing() { const resp = await fetch(OFFER_PRICING_URL, { signal: AbortSignal.timeout(5000) }); if (!resp.ok) { throw Object.assign(new Error(`offer pricing upstream ${resp.status}`), { status: 502 }); } return resp.text(); } 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 { const path = (req.url || '').split('?')[0]; if (req.method === 'GET' && path === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); return; } if (req.method === 'GET' && path === '/offer') { res.writeHead(301, { location: '/offer/' }).end(); return; } if (req.method === 'GET' && path === '/offer/') { const html = renderOffer(OFFER_MD, await fetchOfferPricing()); res .writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }) .end(html); return; } if (req.method === 'POST' && path === '/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}`));