// 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}`));