feat(offer): live catalog price list in the public offer, served by the render sidecar
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with its price. Move /offer/ off the static landing container to the render sidecar: it splices the live catalog price list (§4.4) into the owner-edited ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one renderer, no drift, always matching the current catalog with no redeploy. - backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list) projects the active catalog into two markdown tables — chip packs priced per rail (roubles / VK votes / Telegram Stars) and chip-priced values — through payments.Money so no float reaches the page. Cached in memory: warmed at boot, marked stale on every catalog mutation, so a served render issues no query. - renderer: GET /offer/ fetches the tables and substitutes them at the <#pricing_template#> marker, then renders; offer_ru.md is baked into the image and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed. - caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/ handlers and the vite emit-offer plugin. - offer: fill §4.3 (the chip-payment wording) and drop the in-page back link. - landing footer: a feedback link (the offer's Telegram contact) beside the offer link. - docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe, unit + integration + node tests.
This commit is contained in:
+4
-1
@@ -23,7 +23,10 @@ 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/
|
||||
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/
|
||||
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic
|
||||
# price list is fetched from the backend at request time and spliced in.
|
||||
COPY ui/legal/offer_ru.md ./legal/offer_ru.md
|
||||
USER node
|
||||
EXPOSE 8090
|
||||
CMD ["node", "src/server.mjs"]
|
||||
|
||||
+22
-12
@@ -1,10 +1,10 @@
|
||||
# renderer — the finished-game image-render sidecar
|
||||
# renderer — the render sidecar (finished-game image + public offer page)
|
||||
|
||||
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.
|
||||
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
|
||||
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
|
||||
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
|
||||
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
|
||||
signed off) and the public **offer page**.
|
||||
|
||||
## Interface
|
||||
|
||||
@@ -12,20 +12,30 @@ pixel-identical to the design the owner signed off in the browser.
|
||||
`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.
|
||||
`hostname` + `dateLocale` feed the footer. Internal-only.
|
||||
- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md`
|
||||
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
|
||||
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
|
||||
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
|
||||
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
|
||||
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
|
||||
- `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.
|
||||
The service renders and nothing else: for the PNG, authentication, the participant check and the
|
||||
signed public download URL all live in the backend (`backend/internal/server/export.go`), the path
|
||||
being client → gateway `/dl/*` → backend → this sidecar; for the offer, the catalog projection and
|
||||
its cache live in the backend (`backend/internal/payments/offer.go`) and no user input reaches the
|
||||
page.
|
||||
|
||||
## 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)
|
||||
pnpm test # bundles, then node --test (PNG smoke + offer splice)
|
||||
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a
|
||||
# reachable backend (else the boot read / the price fetch fail):
|
||||
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
|
||||
```
|
||||
|
||||
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
|
||||
|
||||
@@ -9,5 +9,9 @@ await build({
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
outfile: 'dist/gameimage.mjs',
|
||||
// The shared ui/src/lib modules are copied without their node_modules, so a bare npm import they
|
||||
// make (offer.ts → 'marked', the offer renderer's markdown parser) is not resolvable from the ui
|
||||
// tree. NODE_PATH-style fallback to the renderer's own node_modules, where marked is a dependency.
|
||||
nodePaths: ['node_modules'],
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"test": "pnpm run bundle && node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.5",
|
||||
"skia-canvas": "^3.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+10
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
marked:
|
||||
specifier: ^18.0.5
|
||||
version: 18.0.6
|
||||
skia-canvas:
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.8
|
||||
@@ -209,6 +212,11 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
marked@18.0.6:
|
||||
resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -347,6 +355,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
marked@18.0.6: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
parenthesis@3.1.8: {}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// 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).
|
||||
// The esbuild bundle entry: re-exports the SHARED ui/src/lib modules — the exact modules the
|
||||
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
|
||||
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
|
||||
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
|
||||
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
|
||||
// used to invoke, now server-side so the live catalog price list can be spliced in.
|
||||
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
|
||||
export { setAlphabet } from '../../ui/src/lib/alphabet';
|
||||
export { renderOfferHtml } from '../../ui/src/lib/offer';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// The public-offer page renderer: splices the live catalog price list into the owner-edited offer
|
||||
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled
|
||||
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP.
|
||||
import { renderOfferHtml } from '../dist/gameimage.mjs';
|
||||
|
||||
// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It
|
||||
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
|
||||
// tables before rendering.
|
||||
export const pricingMarker = '<#pricing_template#>';
|
||||
|
||||
// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker
|
||||
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
|
||||
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$"
|
||||
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is.
|
||||
export function renderOffer(offerMarkdown, pricingTables) {
|
||||
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables));
|
||||
}
|
||||
+43
-8
@@ -1,20 +1,43 @@
|
||||
// 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).
|
||||
// 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
|
||||
//
|
||||
// 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.
|
||||
// /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 = [];
|
||||
@@ -35,11 +58,23 @@ function readBody(req) {
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'GET' && req.url === '/healthz') {
|
||||
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 === 'POST' && req.url === '/render') {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list
|
||||
// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml
|
||||
// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts);
|
||||
// here we assert the substitution and that the tables reach the rendered HTML.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderOffer, pricingMarker } from '../src/offer.mjs';
|
||||
|
||||
const tables =
|
||||
'| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n' +
|
||||
'| --- | --- | --- | --- |\n' +
|
||||
'| 50 «Фишек» | 200.00 | 30 | 100 |';
|
||||
|
||||
test('splices the price list into the offer and renders the tables to HTML', () => {
|
||||
const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
|
||||
const html = renderOffer(md, tables);
|
||||
// The marker is gone and the projected table reached the document as a real HTML table.
|
||||
assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted');
|
||||
assert.ok(html.includes('<table>'), 'the price list must render as an HTML table');
|
||||
assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present');
|
||||
assert.ok(html.includes('50 «Фишек»'), 'the product title must be present');
|
||||
// The offer chrome from the shared renderer is intact.
|
||||
assert.ok(html.includes('<!doctype html>'));
|
||||
});
|
||||
|
||||
test('a "$" in a title is substituted literally, not as a replacement pattern', () => {
|
||||
const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |');
|
||||
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
|
||||
});
|
||||
Reference in New Issue
Block a user