Skip to content

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 why destroy must 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.mjs is a deliberate second engine copy — a plain .mjs (no TS loader in the worker) that re-registers money/asset_url/safe_url/sanitize_html by hand and re-strips against the allowlist passed via workerData. It uses parseAndRender (no compile cache — it re-parses each render).
  • Two different limits — don't confuse them. The 100ms renderLimit is a cooperative budget inside LiquidJS; the 2s wall-clock is a hard terminate of 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_CATALOG is 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 label user as static and 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 (because strictFilters only fires when a filter evaluates, a banned filter behind {% if false %} would pass a smoke render); (3) the hard gate — a per-user tier 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-process engine.render; else the injected untrustedRenderer (the isolate) — wired at each Node origin via setUntrustedRenderer(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 why safe_url and sanitize_html exist as active guards, not CSP reliance.
  • Untrusted rendering is kept out of the edge-safe barrel; the registry takes renderUntrusted by injection, so the trusted graph never statically imports worker_threads.

Gotchas

  1. No trusted numeric budget — trusted uses UNTRUSTED_LIMITS; only the filter-strip differs.
  2. Two hand-maintained engine copies (engine.ts + worker.mjs), each with its own RICH_TAGS — add filters/tags to both, plus a parity case.
  3. The wall-clock timer starts after acquire() — queue-wait is excluded; dropped waiters would hang, so they're rejected on spawn failure.
  4. Author-declared tiers are ignoredBINDING_CATALOG wins; this is what stops per-user bytes reaching the shell.
  5. The filter allowlist is enforced at three points (registration, engine strip, strictFilters) — a banned filter behind {% if false %} still gets caught at registration.
  6. asset_url must be a function, not an arrow — it needs this.context; an arrow breaks it silently.
  7. Islands can't opt out of the 204-on-error rule — the shared breaker makes 5xx from one island dangerous to all.