INFRA-616 — Server-side Work Order PDF rendering: spike findings
Time-boxed investigation for INFRA-616.
The design these findings validate is written up in
work-order-pdf-export-design.md.
Verdict: the approach works. Build INFRA-617 as designed, with two simplifications — no translation shim module is needed, and images can be inlined as data URIs rather than served from a local URL.
Harness: scripts/spike-render-wo-pdf.mts (renderer + measurements) and
scripts/spike-export-wo-fixture.mts (dump a real PO's render payload to JSON). Nothing here
is production code; the branch is not intended to merge.
1 · SSR of the existing template — works, zero component changes
renderToStaticMarkup(WorkOrderMainDisplay) renders the real component tree. Translations
resolve, the stored barcode SVG passes through inline, product image <img> tags are emitted.
The only requirement is initialising i18next before the first render:
await i18next.use(initReactI18next).init({
lng: 'zh-CN', fallbackLng: 'en', interpolation: { escapeValue: false },
resources: { en: { translation: en }, 'zh-CN': { translation: zhCn } },
});
AdminJS's useTranslation delegates to react-i18next, and translateComponent(name) resolves
under the components namespace of the same locale JSON admin.router.ts already
registers. No shim module, no module aliasing, no component refactor — the fallback plan of
pointing Chromium at the live AdminJS page is not needed.
Entry point is WorkOrderMainDisplay, not WorkOrderTemplate: the latter owns data fetching,
loading states and i18n.language detection, none of which apply server-side.
⚠️ The trap that hides this
Run the render from a real ESM module (.mts). Under CJS — which tsx -e produces — a
second copy of react-i18next is loaded (its CommonJS build) while AdminJS uses the ESM build.
initReactI18next then registers the instance on a copy AdminJS never sees, and rendering dies
with TypeError: i18n.t is not a function, preceded by the misleading warning
react-i18next:: You will need to pass in an i18next instance by using initReactI18next — which
reads like the init was forgotten rather than duplicated. Cost me the first hour.
2 · Image dedupe — Chromium dedupes, and data URIs are safe
This was the open question worth ~4× on Aolong's file size. Answered: repeated data URIs are deduped exactly like shared URLs.
500 synthetic Aolong work orders, 110 distinct images, 1,000 <img> elements:
--images mode | PDF size | Embedded JPEG objects (/DCTDecode) |
|---|---|---|
inline (data URI) | 17.18 MB | 110 |
url (shared local URL) | 17.18 MB | 110 |
Byte-identical totals, and exactly one embedded object per distinct image rather than one per reference. Milly's header-only pages embed 0, confirming the count tracks real images rather than an artifact.
So INFRA-617 should inline downscaled data URIs — simpler than standing up a local server to hold image bytes during a render, with no size penalty.
3 · Page-break parity — break-after: page matches today exactly
Today the browser measures each [data-id="workorderContainer"] and pads its height up to a
multiple of 1123px (A4 at 96dpi), so a work order occupies ceil(height / 1123) pages.
Server-side that is replaced by:
.wo-page { break-after: page; page-break-after: always; }
.wo-page:last-child { break-after: auto; page-break-after: auto; }
The harness verifies this on every run rather than by eyeballing: it measures each work order's
rendered height in the browser, computes ceil(height / 1123), and compares the sum against the
PDF's actual page count.
| Case | Tallest work order | Expected pages | Actual pages |
|---|---|---|---|
| Aolong, 12 measurement rows | < 1123px | 1 per WO | ✅ match |
| 150 measurement rows | 1669px | 2 per WO | ✅ match |
| 300 measurement rows | 2883px | 3 per WO | ✅ match |
| Milly, header only | < 1123px | 1 per WO | ✅ match |
Multi-page work orders — the case where the two approaches could plausibly diverge — match too. Note that the measurements table splits into two columns, so it takes ~150 rows before a work order needs a second page; the real libraries are far below that.
4 · Render-unit size is a three-way trade-off — and it changes file size
The surprise of the spike. Dedupe is per-document, so splitting a 500-work-order delivery file into smaller render units re-embeds images and font subsets in every document. Smaller units cost bytes; larger units cost memory.
500 synthetic Aolong work orders in the Alpine container, lang=zh:
--unit | Documents | PDF total | Per work order | Peak browser RSS | Wall-clock |
|---|---|---|---|---|---|
| 25 | 20 | 26.56 MB | 54.4 KB | 1,015 MB | 34.0s |
| 50 | 10 | 24.47 MB | 50.1 KB | 1,082 MB | 17.3s |
| 250 | 2 | 19.95 MB | 40.9 KB | 1,605 MB | 18.3s |
| 500 | 1 | 19.42 MB | 39.8 KB | 2,364 MB | 24.0s |
Chromium's floor is ~1 GB no matter how small the unit, so no unit size makes a 1 GB instance viable. Wall-clock is U-shaped: tiny units pay per-document overhead, huge ones pay for one enormous layout.
Recommendation: render unit = delivery unit = 500, at 4 GB. It produces the smallest files, needs no merge step and no PDF-merging dependency. Rendering at 50 and merging would halve memory but inflate output ~26% and need a merge that won't re-dedupe across documents.
⚠️ Per-PO wall-clock must be derived from the per-factory rates below, not from this table. The table above is Aolong work orders, and the two factory shapes differ by ~4× (39.8 KB vs 8.4 KB per work order), so their rates are not interchangeable. Sequentially:
- Aolong — 24.0s per 500, so ~855 work orders (2 chunks) ≈ 41s
- Milly — 6.1s per 500, so 7,135 work orders (15 chunks) ≈ 92s
An earlier version of this paragraph applied Aolong's 24s rate to Milly's chunk count and concluded "about 6 minutes" for Milly — 4× too high. That figure propagated into INFRA-621's ticket description before being caught, so it is called out here rather than quietly corrected.
Note the 4 GB sizing is unchanged by hosting, but the where is: INFRA-621 replaced the
DigitalOcean worker with a Cloud Run job. See infra-621-cloud-run-hosting-decision.md.
Per-factory sizes (container, unit=500)
| Milly shape (header + barcode) | Aolong shape (+ measurements, notes, 2 images) | |
|---|---|---|
| Per work order | 8.4 KB | 39.8 KB |
| 500-page PDF | 4.12 MB | 19.42 MB |
| Zip of that PDF | 1.08 MB (26%) | 3.40 MB (20%) |
| Wall-clock | 6.1s | 24.0s |
Extrapolated to the real POs — both well below the design-doc estimates:
| Work orders | Files | PDF total | Zip | Design doc said | |
|---|---|---|---|---|---|
| Milly PO23182 | 7,135 | 15 | ~60 MB | ~16 MB | ~125 MB zip — ~8× too high |
| Aolong PO23224 | 855 | 2 | ~34 MB | ~7 MB | ~21 MB zip — ~3× too high |
Storage revised down to ~1.5 GB steady state (Milly ~330 MB/month, Aolong ~1.2 GB/month, both counting zip + loose PDFs, 30-day lifecycle) versus 2.4 GB in the design doc.
✅ Confirmed against real production densities
The row counts were queried from production on 2026-07-29 and differ from my first assumptions in both directions — but they cancel out, so every size figure above holds:
| Assumed | Real (production) | |
|---|---|---|
| Measurement rows per work order | 12 | 7 median (2 min, 10 max) |
| Material library rows | 15 across 3 categories | 54 across 7 categories |
Re-measured at real density: 19.42 MB per 500-page file, 39.8 KB per work order — identical.
⚠️ But Aolong work orders sit 16px from a page break
At real density an Aolong work order renders 1107px tall against a 1123px A4 page — a 1.4% margin. The threshold probe:
| Measurements | Material rows | Height | Pages per work order |
|---|---|---|---|
| 7 (median) | 54 (today) | 1107px | 1 |
| 10 (observed max) | 54 | 1124px | 2 |
| 7 | 58 (+4 rows) | 1172px | 2 |
| 7 | 62 | 1237px | 2 |
Two consequences:
- Some Aolong work orders already spill onto a second page today. Styles at the top of the measurement range (10 rows) cross the boundary. This is pre-existing behaviour — today's browser flow pads to whole pages the same way — not something the server-side change introduces.
- Adding ~4 rows to the shared Material library doubles every Aolong page count. The library is
global and editable from AdminJS, and every one of its rows renders on every work order
(
makeMaterialCategorysfilters nothing). Four more rows turns a 500-page file into ~1000 pages, roughly doubling file size, render time and peak memory.
For INFRA-617: don't hardcode or assume one page per work order anywhere, and budget storage and memory for up to 2× the figures above. The chunk-to-file mapping is by work order count (500), not page count, so the file structure itself is unaffected. Worth flagging to whoever curates the Material library that it now has a downstream cost.
5 · Alpine, CJK fonts and memory
Chromium runs. Chromium 150.0.7871.128 Alpine Linux, headless, via puppeteer-core and
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser. Needs --no-sandbox (no user namespace
as root in a container) and --disable-dev-shm-usage.
⚠️ Image cost is much higher than expected: the apk layer is 821 MB, taking the whole image from ~225 MB to ~1.05 GB. That is a real deploy-time and registry cost, especially given the registry's GC constraints. Since only the worker needs Chromium, consider a separate worker image — the design's "same image, different run command" means the web service carries the 821 MB too, for nothing.
CJK renders. font-noto-cjk installs ~30 font files. Verified by rendering the same 20 work
orders twice: lang=en → 188,196 bytes, lang=zh → 309,833 bytes. A 65% increase is the
signature of a real CJK glyph subset being embedded; tofu boxes would add almost nothing.
A nuance worth keeping: fc-match ":lang=zh" returns FreeSans, not Noto CJK, so the CJK font
is reached through Chromium's per-glyph fallback rather than as fontconfig's default. It works,
but it means the font stack is sensitive to changes in the installed font packages — if someone
trims ttf-freefont or font-noto-cjk later, verify CJK output again rather than assuming.
Layout is stable across platforms. The same 500 work orders measured 553px tall under macOS Chrome and 555px under Alpine Chromium — 0.4% drift, and identical page counts. Only a work order sitting within a couple of pixels of a page boundary could flip, which the parity check in §3 would catch.
Worker sizing: 4 GB. Peak RSS was 2,364 MB for Aolong at unit=500 and 1,753 MB for Milly. 2 GB would be too tight; 1 GB is impossible at any unit size.
6 · What still needs real production data
Everything above except §4's absolute sizes is settled. Remaining:
tsx scripts/spike-export-wo-fixture.mts --po PO23182 --limit 500 --out ./fixture-milly.json
tsx scripts/spike-export-wo-fixture.mts --po PO23224 --limit 500 --out ./fixture-aolong.json
tsx scripts/spike-render-wo-pdf.mts --fixture ./fixture-aolong.json --unit 50 --zip
markPrinted: false is hardcoded in the exporter, so it cannot move work orders to Printed.
One latent write remains: makeBatchInfosByPoId → _makeBatchBarcodeAndSave early-returns when
every work order already has a valid barcode, but will generate and persist any missing ones.
7 · Recommendations for INFRA-617
- Inline downscaled images as data URIs. Dedupe is automatic; skip the local-server design.
- Declare
react,react-domandpuppeteer-coreindependencies. react/react-dom are currently undeclared, arriving transitively throughadminjs(both are already invite.config.ts'sexternallist). Production code importingreact-dom/servershould not rely on a transitive hoist. - Pin
archiverto^7. v8 ships a class-based ESM-only API (ZipArchive) that does not match its own published@types/archiver, and has no CJS entry. v7'sarchiver('zip', …)factory is what the ecosystem and the types expect. - The renderer must be a real ESM module for the react-i18next reason in §1.
- Render one document per 500-work-order delivery file, at 4 GB (§4). Avoids a merge
step and a PDF-merging dependency, and produces the smallest output. SSR itself is trivial
(~10 ms per document); nearly all wall-clock is inside
page.pdf(). Derive per-PO timings from the per-factory rates — Aolong 24.0s/500, Milly 6.1s/500 — never from one shared figure. - Give the renderer its own image if the 821 MB Chromium layer on the web service is
unwelcome (§5). INFRA-621 made this unconditional: the renderer is a Cloud Run job with its
own
Dockerfile.renderer, so the layer never reaches the MES deploy pipeline at all. font-noto-cjkis required in the image, not optional — without it every Chinese style and colour name becomes tofu boxes.- For local GCS testing, copy the sibling service's emulator.
inventory-integration-servicealready runsfsouza/fake-gcs-server(port 4443, data persisted under.emulator-datas/) in its compose file. Same pattern here means INFRA-617 can be developed and tested without real GCP credentials — worth adding to this repo'scompose.yamlalongsidedb.
8 · The design these findings validate
Written up separately in work-order-pdf-export-design.md —
the architecture and job-lifecycle diagrams, the delivery and expiry rules, the measured sizing, and
the decision log with rationale. Kept there rather than duplicated here so there is one place to
maintain.