Appearance
Render engine & isolate internals
Chapter 12. Chapter 11 showed sections rendering "trusted" or "untrusted." This chapter is the machinery behind that split — the two engine copies and the worker pool that bounds a merchant's code.
The machinery beneath Render internals: the two LiquidJS engine copies, the worker-thread pool that bounds untrusted CPU, and the registry that enforces the cacheability tier. Packages: @ratio/builder-render (engine + isolate) and @ratio/builder-registry (catalog, tier gate, islands). The package splits . (edge-safe barrel) from ./isolate (Node-only) deliberately — the whole trusted graph runs on a Worker; untrusted rendering is a Node capability layered on at the origin.
The in-process engine — engine.ts
buildEngine({ trusted, limits, partials }) constructs a Liquid with the sandbox posture: strictFilters: true (an unknown filter is a hard error — this is what makes the allowlist enforced), strictVariables: false (undefined → ''), no root/fs, dynamicPartials: false (partial names must be literals). Limits:
ts
UNTRUSTED_LIMITS = { renderLimit: 100 /*ms*/, memoryLimit: 8 * 1024 * 1024, parseLimit: 64 * 1024 };There is no separate "trusted" budget
Trusted renders get UNTRUSTED_LIMITS too — the only engine-level difference between trusted and untrusted is the filter-stripping step. Don't document a bigger trusted numeric budget; there isn't one.
Custom filters (registered on every engine): money (paise → ₹, n/100), asset_url (map lookup → /assets/<64hex>, unknown path falls back HTML-escaped so a miss can't become attribute injection — must be a function not an arrow, it needs this.context), safe_url (scheme allowlist; strips chars ≤ 0x20 so java\tscript: can't slip; non-http/https/mailto/tel → #), sanitize_html (escape-then-restore an allowlist of formatting tags via literal token replacement — no parser, so any attribute or <script> stays escaped).
Untrusted stripping: when !trusted, every registered filter not in FILTER_ALLOWLIST is deleted from the engine, so strictFilters then rejects it. A compile cache keyed on (trust, fnv1a(source)) (FNV-1a, not node:crypto, so it runs on Workers) compiles a given source once.
The worker isolate — isolate.ts + worker.mjs
An engine's cooperative limits can't bound CPU alone, so untrusted Liquid runs in a pooled worker thread with a hard wall-clock terminate:
- The timer starts after
acquire(), not before — cold-start and queue-wait are excluded (the render-only budget). A queued render has no timer, which is whydestroymust reject (never silently drop) waiters when a replacement fails to spawn — a dropped waiter would hang forever. - The pool holds warm workers;
worker.ref()while rendering,unref()when idle (an idle worker must not keep the process alive). A result arriving after a timeout terminated its worker is guarded (if (!job) return). worker.mjsis a deliberate second engine copy — a plain.mjs(no TS loader in the worker) that re-registersmoney/asset_url/safe_url/sanitize_htmlby hand and re-strips against theallowlistpassed viaworkerData. It usesparseAndRender(no compile cache — it re-parses each render).- Two different limits — don't confuse them. The 100ms
renderLimitis a cooperative budget inside LiquidJS; the 2s wall-clock is a hardterminateof the whole worker. A 500ms template is not killed (it's under 2s). "Tuning the timeout" means picking which of the two you mean.
The parity test earns its keep
An engine↔isolate parity test renders a battery of cases through both copies and asserts byte-equality. The real regression it caught: the worker's money once didn't divide by 100, so every merchant-section price rendered 100× too high — invisible to the unit tests, which only exercised the in-process engine. Any new filter/tag must be added to both copies and the parity case list.
The registry & the tier gate — builder-registry
The registry is where a section's cacheability tier is decided and enforced — not by the section author.
BINDING_CATALOGis the source of tier truth.effectiveBindings(declared)remaps every declared binding's tier from the catalog (product/price/stock → shared-volatile,collection → static,user/customer/cart/session → per-user), discarding the author's declared tiers. Otherwise an author could labeluserasstaticand bake per-user bytes into the shared shell.register()runs ordered gates: (1) infer the tier (rejects{% render %}/{% include %}and undeclared globals — for every trust level, so first-party stays forkable); (2) re-check the filter allowlist at registration (becausestrictFiltersonly fires when a filter evaluates, a banned filter behind{% if false %}would pass a smoke render); (3) the hard gate — aper-usertier that isn't an island is rejected; (4) compile (enforces syntax + parse limit). The record is then deep-frozen and version-appended (re-registering never mutates; pages pin their version).renderSection(rec):rec.trusted→ in-processengine.render; else the injecteduntrustedRenderer(the isolate) — wired at each Node origin viasetUntrustedRenderer(renderUntrusted). A Worker never calls it.
Islands — the only per-user path
islandPlaceholder(name, params, { children, skipUnlessCookie }) emits an inert data-island div; params/children are public bytes only (they ride the shared cache). The runtime hydrates via same-origin /api/island/*.
Islands must answer 204, never 5xx
IslandRegistry.handle always responds no-store, private; a missing handler → 404; any ≥500 or thrown error is coerced to an empty 204 so the slot keeps its shipped markup. This is deliberate: the edge circuit breaker is shared across every tenant in an isolate, so one broken island returning 5xx could trip the breaker for unrelated stores.
Security posture
- Two-layer compute defense — the engine's cooperative limits (first layer) + the worker's wall-clock terminate + pool eviction (the hard backstop). Choosing Liquid (not Handlebars) removes the prototype-escape class entirely (proven by a
{{ x.constructor }}→ empty test). - CSP interplay — on GoKwik stores the storefront CSP loosens to
script-src 'unsafe-inline', so raw output is no longer a safe XSS backstop. That's whysafe_urlandsanitize_htmlexist as active guards, not CSP reliance. - Untrusted rendering is kept out of the edge-safe barrel; the registry takes
renderUntrustedby injection, so the trusted graph never statically importsworker_threads.
Gotchas
- No trusted numeric budget — trusted uses
UNTRUSTED_LIMITS; only the filter-strip differs. - Two hand-maintained engine copies (
engine.ts+worker.mjs), each with its ownRICH_TAGS— add filters/tags to both, plus a parity case. - The wall-clock timer starts after
acquire()— queue-wait is excluded; dropped waiters would hang, so they're rejected on spawn failure. - Author-declared tiers are ignored —
BINDING_CATALOGwins; this is what stops per-user bytes reaching the shell. - The filter allowlist is enforced at three points (registration, engine strip,
strictFilters) — a banned filter behind{% if false %}still gets caught at registration. asset_urlmust be afunction, not an arrow — it needsthis.context; an arrow breaks it silently.- Islands can't opt out of the 204-on-error rule — the shared breaker makes 5xx from one island dangerous to all.