// 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; caddy routes /offer/ here) // GET /privacy/ → text/html (the privacy policy: ui/legal/privacy_ru.md, static) // GET /eula/ → text/html (the EULA: ui/legal/eula_ru.md, static) // GET /offer, /privacy, /eula → 301 to the trailing-slash form // GET /healthz → 200 // // /render is internal-only (the backend calls it; authentication, participant checks and the signed // public URL live in the backend). The legal pages (/offer/, /privacy/, /eula/) are reachable from the // edge but read-only and unprivileged: /offer/ splices the price list fetched from the backend's // internal endpoint into the committed offer markdown; /privacy/ and /eula/ are static committed // markdown. No user input reaches any of them. import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import { renderRequest } from './render.mjs'; import { renderOffer } from './offer.mjs'; import { renderPrivacy, renderEula } from './legal.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; // Each legal page is bilingual (RU + EN); both markdown sources are baked into the image // (renderer/Dockerfile) and read once at boot. The offer's price list is the only dynamic part // (fetched per request from the backend's internal endpoint and spliced in); privacy + eula carry no // dynamic data. The RENDERER_*_MD / RENDERER_*_EN_MD env vars override the baked paths for a local run. const rd = (p) => readFileSync(p, 'utf8'); const OFFER_MD = rd(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url)); const OFFER_EN_MD = rd(process.env.RENDERER_OFFER_EN_MD || new URL('../legal/offer_en.md', import.meta.url)); const PRIVACY_MD = rd(process.env.RENDERER_PRIVACY_MD || new URL('../legal/privacy_ru.md', import.meta.url)); const PRIVACY_EN_MD = rd(process.env.RENDERER_PRIVACY_EN_MD || new URL('../legal/privacy_en.md', import.meta.url)); const EULA_MD = rd(process.env.RENDERER_EULA_MD || new URL('../legal/eula_ru.md', import.meta.url)); const EULA_EN_MD = rd(process.env.RENDERER_EULA_EN_MD || new URL('../legal/eula_en.md', import.meta.url)); // Render the static legal pages once at boot and serve the cached HTML: re-parsing the large EULA on // every request is slow enough under deploy-time host contention (the renderer is CPU/memory-capped) // to flake the contour probe. The offer stays per-request because it splices in the live price list. const PRIVACY_HTML = renderPrivacy(PRIVACY_MD, PRIVACY_EN_MD); const EULA_HTML = renderEula(EULA_MD, EULA_EN_MD); 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, OFFER_EN_MD, await fetchOfferPricing()); res .writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }) .end(html); return; } if (req.method === 'GET' && (path === '/privacy' || path === '/eula')) { res.writeHead(301, { location: path + '/' }).end(); return; } if (req.method === 'GET' && path === '/privacy/') { res .writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }) .end(PRIVACY_HTML); return; } if (req.method === 'GET' && path === '/eula/') { res .writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }) .end(EULA_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}`));