Skip to content

perf(vite): ship route manifest as a workerd Text module on cloudflare builds (refs #665) - #710

Merged
ivogt merged 1 commit into
mainfrom
perf/manifest-text-module
Jul 7, 2026
Merged

ivogt merged 1 commit into
mainfrom
perf/manifest-text-module

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

At route scale the per-router manifest chunk inlines the route trie + precomputedEntries as JSON.parse('<literal>'). That costs the first request twice — the isolate/process must lex and compile the multi-megabyte JS source literal before JSON.parse even runs — and it ships bloated: the build minifier re-quotes the literal to double quotes, escaping every JSON " as \". Measured at 26k routes: 5.71MB (double-quoted) vs 4.64MB un-escaped, and ~37ms cold manifest-load on Node.

The one fact that shaped the design

The only way to fix both the compile and the bloat is to keep the JSON out of JavaScript source — as a raw asset file that never passes through the JS minifier/compiler. Anything that lands the JSON as a JS string literal gets double-quoted and compiled. This was verified empirically in the router's own build, not assumed:

form (26k routes) size compiled as JS?
inline JSON.parse("...") (status quo) 5.71MB yes → ~37ms
import("…?raw") 5.71MB (double-quoted in this build) yes → ~36ms
.txt Text module (raw asset) 4.64MB no → ~21ms

?raw looked like a clean universal option but delivers neither win here — the router's minifier double-quotes its inlined string just like the inline literal. Only a raw asset file escapes that.

Design — Text module on Cloudflare, inline everywhere else

  • Cloudflare: import a staged .txt as a workerd Text module. workerd has no filesystem, so a Text module is the only way to get raw bytes into an isolate without JS compilation. The isolate gets the un-escaped bytes as a string, no compile pass. Measured on a real wrangler deploy at 26k routes: 517ms → 429ms cold first hit (~17% / ~88ms), and it shrinks the worker upload against the hard size limit.
  • node / vercel / anything else: keep the inline literal (status quo). An efficient channel there would need fs (a raw asset read), whose fragility (asset co-location, runtime ENOENT) isn't worth a win that only appears past ~5,000 routes, that Fluid Compute amortizes, and that essentially no real app reaches.
// generated cloudflare per-router module
import __manifestJson from "…/manifest-<id>-<hash>.txt";   // Text module — raw bytes, no JS compile
const __manifestData = JSON.parse(__manifestJson);
export const trie = __manifestData.t;
export const precomputedEntries = __manifestData.p;

Why Cloudflare-only (the scale calibration)

The win is a large-app phenomenon, and it's concentrated on the edge. Cold-start delta the channel removes, by route count (Node local; edge amplifies ~5–6×):

routes manifest local win edge win
200–300 ~0.05 MB 0.0 ms ~0.5 ms
1,000 0.2 MB 0.3 ms ~3 ms
5,000 1.0 MB 2.5 ms ~15 ms
26,000 5.5 MB 15.6 ms ~88 ms

Real apps land at 200–1,000 routes, where it's ~0–0.3ms locally — so the machinery is scoped to the one runtime where it pays (Cloudflare: per-request cold isolates under load and on every deploy, plus the upload-size limit), and gated behind a 512KB threshold (~2,500 routes) so the entire long tail of normal apps stays byte-identical inline.

Semantics

context manifest emission
build, preset cloudflare, ≥ 512KB Text module (.txt)
build, preset cloudflare, < 512KB inline literal
build, preset node/vercel (any size) inline literal — Text is workerd-only
dev, any preset inline literal — rebuilt per HMR, parse cost irrelevant
RANGO_MANIFEST_TEXT=0 force inline
RANGO_MANIFEST_TEXT=1 force Text module (cloudflare), bypass threshold

Bundle hygiene preserved: data still lives in exactly one lazy artifact per router; a Text asset can't be duplicated into an eager chunk. Staged filename carries an FNV-1a hash of the router id so distinct ids that sanitize to the same string (a/b vs a.b) don't overwrite each other.

Code changes

  • src/vite/discovery/virtual-module-codegen.ts — generatePerRouterModule gains the cloudflare Text-module branch (stage {t,p} JSON to node_modules/.rango/manifest-<id>-<hash>.txt, import as Text) gated on build mode + preset + 512KB threshold + RANGO_MANIFEST_TEXT; shortHash for collision-safe filenames. Everything else falls through to the existing inline jsonParseExpression.
  • tests/cloudflare-basic/playwright.config.ts — production webServer sets RANGO_MANIFEST_TEXT=1 (its 15KB manifest is below threshold) so the prod suite dogfoods the Text channel on real workerd.

This supersedes the earlier fs-asset (Node/Vercel) channel and its import.meta.ROLLUP_FILE_URL/co-location machinery and filename-collision bug — dropped after the scaling data and an empirical check showed the fs win is a large-app-only concern real apps don't hit, and ?raw (its universal stand-in) delivers no win in this build.

Test plan

  • Unit virtual-module-codegen.test.ts (10) — cloudflare→Text above threshold, inline below/dev, node+vercel always inline even when forced, collision-safe filenames, un-escaped staged payload, RANGO_MANIFEST_TEXT 0/1.
  • Cloudflare production e2e manifest-text-module.test.ts — routes resolve through the Text-module manifest on real workerd; asserts the .txt exists, is imported by a chunk, and is raw un-escaped JSON (no \"). 2 passed.
  • Broader cloudflare-basic production suite runs on the forced Text channel (dogfood).
  • pnpm run typecheck, test:unit:all, lint, format, check:e2e-bucketing — green. route-types-hmr (vite-plugin obligation) — 18/18.

Notes

  • Feature is build-only, so the e2e is production-only (dev = inline = every existing dev test).
  • Higher-value companion lever, not in this PR: the serialization diet (intern repeated ancestry/param arrays, derive precomputedEntries) attacks the ~22ms JSON.parse floor the channel can't touch, is portable, and shrinks the payload everywhere. Ship order would be diet first, this second.

@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@710

commit: 7e768c8

@ivogt
ivogt marked this pull request as ready for review July 7, 2026 17:42
@ivogt
ivogt force-pushed the perf/manifest-text-module branch from bc0492b to ee7eb9c Compare July 7, 2026 18:32
@ivogt ivogt changed the title perf(vite): ship route-manifest data as a workerd Text module on Cloudflare builds (refs #665) perf(vite): ship route-manifest data through a runtime channel on node + workerd (refs #665) Jul 7, 2026
…e builds (refs #665)

At route scale the per-router manifest chunk inlines trie +
precomputedEntries as JSON.parse('<literal>'). That costs the first
request twice — the isolate/process lexes and compiles the multi-MB JS
source literal before JSON.parse runs — and ships bloated, because the
build minifier re-quotes the literal to double quotes, escaping every
JSON " (measured 5.71MB vs 4.64MB un-escaped at 26k routes).

The only way to fix both is to keep the JSON out of JavaScript source, as
a raw asset file that never passes through the JS minifier/compiler. On
cloudflare that is a workerd Text module: importing a staged .txt hands
the isolate the raw un-escaped bytes as a string with no compile pass
(measured ~17% / ~88ms cold first-hit on a real edge deploy at 26k, and
it shrinks the worker upload against the hard size limit). The CF vite
plugin types .txt as Text; wrangler's default rules type **/*.txt as
Text for deploy and dev.

Scoped to cloudflare deliberately. A Text module is the only no-JS byte
channel needing no filesystem, and cloudflare is where it pays (per-
request cold isolates, upload-size limit). node/vercel keep the inline
literal: an efficient channel there needs fs (a raw asset read), whose
fragility isn't worth a large-app-only win that Fluid Compute amortizes
and that essentially no real app reaches. Verified that ?raw is NOT a
node/vercel shortcut — in this build it inlines the JSON as a double-
quoted compiled JS string literal (same bloat, same compile as inline).

Gating: build mode only (dev rebuilds per HMR, parse cost irrelevant);
512KB serialized-payload threshold (~2500 routes — the crossover where
the benefit clears the extra artifact; the long tail of normal apps
stays byte-identical inline); RANGO_MANIFEST_TEXT=0 forces inline, =1
forces the Text module and bypasses the threshold. Staged filename
carries an FNV-1a hash of the router id so distinct ids that sanitize to
the same string ("a/b" vs "a.b") don't overwrite each other.

Coverage: unit tests over the gate matrix (cloudflare->Text above
threshold, inline below/dev, node+vercel always inline even when forced,
collision-safe filenames, un-escaped staged payload); cloudflare-basic
production e2e (forced via RANGO_MANIFEST_TEXT=1 since its 15KB manifest
is below threshold — whole prod suite dogfoods the Text channel on real
workerd, asserts the .txt exists, is imported, and is raw un-escaped
JSON). bench/{cold-only,edge-cold}.ts A/B harnesses retained.
@ivogt
ivogt force-pushed the perf/manifest-text-module branch from ee7eb9c to 7e768c8 Compare July 7, 2026 20:09
@ivogt ivogt changed the title perf(vite): ship route-manifest data through a runtime channel on node + workerd (refs #665) perf(vite): ship route manifest as a workerd Text module on cloudflare builds (refs #665) Jul 7, 2026
@ivogt
ivogt merged commit 291c724 into main Jul 7, 2026
34 checks passed
@ivogt
ivogt deleted the perf/manifest-text-module branch July 7, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant