@rangojs/router 0.0.0-experimental.150 → 0.0.0-experimental.152

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -817,9 +817,14 @@ const loaderBodyScopeALS: AsyncLocalStorage<{
817
817
  */
818
818
  export function isInsideCacheScope(): boolean {
819
819
  if (RangoContext.getStore()?.insideCacheScope !== true) return false;
820
- // Loaders are always fresh even inside a cache() boundary, the loader
821
- // function re-executes on every request (DSL loaders AND handler-invoked
822
- // loader bodies alike), so request-scoped reads inside any loader are safe.
820
+ // Request-scoped READS are exempt in any loader body DSL loaders re-run on
821
+ // every request (including cache() HITs via resolveLoadersOnly), and a
822
+ // handler-invoked loader body, though skipped with its handler on a HIT,
823
+ // yields a BAKED shared copy in the cached artifact — an accepted
824
+ // consumption-lane tradeoff (#672/#674). This is deliberately BROADER than
825
+ // the WRITE guard (assertCachedHeaderWriteAllowed narrows the cache()
826
+ // exemption to DSL scope, #725): a read bakes-and-accepts, a Set-Cookie/header
827
+ // write drops-and-throws because it has no baked-copy semantics on a HIT.
823
828
  if (isInsideAnyLoaderScope()) return false;
824
829
  return true;
825
830
  }
@@ -898,11 +903,47 @@ export function latchPprHeaderScopeForEntries(
898
903
  }
899
904
  }
900
905
 
906
+ /**
907
+ * Clear the ppr header-write latch for the remainder of this render (issue
908
+ * #735). Called by ctx.dynamic(): a dynamic() render opts off the SHELL axis
909
+ * (rsc-rendering.ts skips both the HIT commit and the MISS capture on
910
+ * `_dynamic`), so it is ALWAYS live — every request re-runs the handler and its
911
+ * header write lands identically each time. The guard's reason to forbid it
912
+ * (MISS/HIT divergence) evaporates, so the write is re-permitted.
913
+ *
914
+ * ONLY the ppr (shell) axis is dropped — dynamic() does NOT opt off the CACHE
915
+ * axis. Two cases:
916
+ * - Pure ppr funnel (no cache() boundary): clear the latch → writes re-permit.
917
+ * - ppr route nested under a cache() boundary: fresh.ts latches "ppr" at the
918
+ * funnel top (first-wins), which MASKS the positional cache() latch, but the
919
+ * handler still runs inside the cache scope (`insideCacheScope`). A cache()
920
+ * HIT skips that handler, so the write is still non-deterministic — UNMASK to
921
+ * "cache" instead of clearing, so the guard keeps throwing (accurate cache()
922
+ * wording). This is why the check keys off `insideCacheScope`, not just kind.
923
+ *
924
+ * A subsequent cache() entered AFTER dynamic() on a pure-ppr funnel re-latches
925
+ * "cache" via latchCachedHeaderScope's `!store.cachedHeaderScope` guard (the
926
+ * field is undefined again once cleared). No-op when there is no funnel store or
927
+ * no ppr latch (dynamic() from middleware runs outside the funnel Store.run
928
+ * scope, so nothing is latched — the middleware exemption is unchanged).
929
+ */
930
+ export function clearPprHeaderScope(): void {
931
+ const store = RangoContext.getStore();
932
+ if (store?.cachedHeaderScope?.kind !== "ppr") return;
933
+ store.cachedHeaderScope = store.insideCacheScope
934
+ ? { kind: "cache", routeKey: store.cachedHeaderScope.routeKey }
935
+ : undefined;
936
+ }
937
+
901
938
  /**
902
939
  * RULE (issue #713): in any cached scenario ONLY MIDDLEWARE writes response
903
940
  * headers — handler and loader writes throw while a scope is latched; the one
904
- * exemption is loaders under plain cache(). Full layer rules and rationale:
905
- * docs/design/ppr-shell-resume.md "The header doctrine".
941
+ * exemption is DSL (registered) loaders under plain cache(). A handler-invoked
942
+ * loader body (ctx.use from a handler, never registered with loader()) is
943
+ * skipped with its handler on a HIT and throws like a handler write (#725).
944
+ * A ctx.dynamic() render clears the ppr latch (clearPprHeaderScope, #735) so
945
+ * its always-live handler header writes are re-permitted. Full layer rules and
946
+ * rationale: docs/design/ppr-shell-resume.md "The header doctrine".
906
947
  */
907
948
  export function assertCachedHeaderWriteAllowed(
908
949
  surface: string,
@@ -910,21 +951,37 @@ export function assertCachedHeaderWriteAllowed(
910
951
  ): void {
911
952
  const scope = RangoContext.getStore()?.cachedHeaderScope;
912
953
  if (!scope) return;
913
- const insideLoader = isInsideAnyLoaderScope();
914
- if (scope.kind === "cache" && insideLoader) return;
954
+ // Exempt DSL loaders (loaderScopeALS) ONLY. A registered loader re-runs on
955
+ // every cache HIT (fresh.ts runInsideLoaderScope -> cache-lookup.ts
956
+ // resolveLoadersOnly), so its header/cookie writes merge into every response
957
+ // with no MISS/HIT divergence. A handler-invoked loader body has
958
+ // loaderBodyScopeALS active but loaderScopeALS unset (loader-resolution.ts
959
+ // derives isDslLoader from isInsideLoaderScope()); on a HIT the handler is
960
+ // skipped so that loader never re-runs and its write would land only on the
961
+ // MISS — throw it. isInsideLoaderScope() (not isInsideAnyLoaderScope) is the
962
+ // discriminator; the DSL scope ALS survives nested ctx.use bodies, so a
963
+ // handler-invoked loader nested under a DSL loader stays exempt (its DSL
964
+ // parent re-invokes it on every HIT). This is the exempt fast path, so the
965
+ // broad predicate for the error label is deferred to the throw path below.
966
+ if (scope.kind === "cache" && isInsideLoaderScope()) return;
915
967
  // Everything below runs only on the throw path — `surfaceProp` exists so
916
968
  // callers pass constants and the success path allocates nothing (the full
917
- // surface, e.g. "ctx.headers.set()", is assembled here).
969
+ // surface, e.g. "ctx.headers.set()", is assembled here). isInsideAnyLoaderScope
970
+ // (broad) labels a now-throwing handler-invoked loader body "loader".
918
971
  const fullSurface =
919
972
  surfaceProp === undefined ? surface : `${surface}.${String(surfaceProp)}()`;
920
- const layer = insideLoader ? "loader" : "handler";
973
+ const layer = isInsideAnyLoaderScope() ? "loader" : "handler";
921
974
  const route = scope.routeKey ? ` (route "${scope.routeKey}")` : "";
922
975
  const where =
923
976
  scope.kind === "ppr"
924
977
  ? `on a ppr route${route} — the document shell is cached and replayed`
925
978
  : `inside a cache() boundary${route}`;
979
+ // ppr loader writes fail by physics (headers flush before loaders settle);
980
+ // every other throw — a handler, or a handler-invoked loader under cache() —
981
+ // fails because the handler is skipped on a HIT, so key the reason on the
982
+ // scope kind, not the layer.
926
983
  const why =
927
- layer === "loader"
984
+ scope.kind === "ppr" && layer === "loader"
928
985
  ? "The response headers flush with the shell before loaders settle, so this write is dropped on cache hits."
929
986
  : "On a cache hit the handler is skipped, so this write would silently vanish.";
930
987
  throw new Error(
@@ -59,6 +59,7 @@ import type { LocationStateEntry } from "../browser/react/location-state-shared.
59
59
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
60
60
  import {
61
61
  assertCachedHeaderWriteAllowed,
62
+ clearPprHeaderScope,
62
63
  isInsideCacheScope,
63
64
  } from "./context.js";
64
65
  import {
@@ -108,6 +109,27 @@ export interface RequestContext<
108
109
  /** @internal Stub response for collecting headers/cookies. Use ctx.headers or ctx.header() instead. */
109
110
  readonly res: Response;
110
111
 
112
+ /**
113
+ * True for build-time render/capture requests. Live requests use false.
114
+ * Build-time shell capture sets this while replaying middleware so apps can
115
+ * skip side-effectful runtime work.
116
+ */
117
+ readonly build: boolean;
118
+
119
+ /**
120
+ * Opt this request out of PPR shell serving/capture.
121
+ * Runtime middleware can call this before the PPR commit point; handlers can
122
+ * call it on a MISS to prevent the follow-up capture.
123
+ *
124
+ * Scope: the PPR SHELL axis only. It does NOT disable prerender B-segment
125
+ * (Prerender/Static) serving, and it is inert in the prerender-collect /
126
+ * static-render contexts (no live shell decision to influence there).
127
+ */
128
+ dynamic(): void;
129
+
130
+ /** @internal Request-local PPR opt-out marker set by ctx.dynamic(). */
131
+ _dynamic?: boolean;
132
+
111
133
  /** @internal Get a cookie value (effective: request + response mutations). Use cookies().get() instead. */
112
134
  cookie(name: string): string | undefined;
113
135
  /** @internal Get all cookies (effective merged view). Use cookies().getAll() instead. */
@@ -628,6 +650,7 @@ export type PublicRequestContext<
628
650
  | "_variables"
629
651
  | "_classifiedRoute"
630
652
  | "_cacheSignal"
653
+ | "_dynamic"
631
654
  | "res"
632
655
  >;
633
656
 
@@ -775,6 +798,8 @@ export interface CreateRequestContextOptions<TEnv> {
775
798
  >;
776
799
  /** Optional Cloudflare execution context for waitUntil support */
777
800
  executionContext?: ExecutionContext;
801
+ /** Build-time render/capture request marker. Defaults to false. */
802
+ build?: boolean;
778
803
  /** Optional theme configuration (enables ctx.theme and ctx.setTheme) */
779
804
  themeConfig?: ResolvedThemeConfig | null;
780
805
  /** Resolved rango state cookie name, for the server seat of invalidateClientCache(). */
@@ -804,6 +829,7 @@ export function createRequestContext<TEnv>(
804
829
  explicitTaggedStores,
805
830
  cacheProfiles,
806
831
  executionContext,
832
+ build = false,
807
833
  themeConfig,
808
834
  stateCookieName,
809
835
  version: stateVersion,
@@ -951,6 +977,16 @@ export function createRequestContext<TEnv>(
951
977
  pathname: url.pathname,
952
978
  searchParams: cleanUrl.searchParams,
953
979
  _variables: variables,
980
+ build,
981
+ dynamic(): void {
982
+ ctx._dynamic = true;
983
+ // A dynamic() render is always live (never a shell HIT), so its handler
984
+ // header writes are deterministic — clear the ppr latch to re-permit them
985
+ // (#735). No-op from middleware (outside the funnel scope) or on non-ppr
986
+ // routes; cache() latches are left alone.
987
+ clearPprHeaderScope();
988
+ },
989
+ _dynamic: false,
954
990
  get: ((keyOrVar: any) => {
955
991
  if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
956
992
  throw new Error(
@@ -1089,6 +1125,7 @@ export function createRequestContext<TEnv>(
1089
1125
  _cacheProfiles: cacheProfiles,
1090
1126
 
1091
1127
  waitUntil(fn: () => Promise<void>): void {
1128
+ if (ctx.build) return;
1092
1129
  // Wrap in Promise.resolve().then(fn) so a SYNCHRONOUS throw in a
1093
1130
  // non-async callback becomes a rejected promise handed to the host's
1094
1131
  // waitUntil (logged as a background failure), instead of escaping into
@@ -55,6 +55,14 @@ export interface CreateTestContextOptions<TEnv> {
55
55
  routeMap?: Record<string, string>;
56
56
  routeName?: string;
57
57
  params?: Record<string, string>;
58
+ /**
59
+ * Seed `ctx.build` (default false). Set `true` to unit-test middleware or a
60
+ * handler that branches on the build-time PPR shell-capture pass — e.g.
61
+ * `if (ctx.build) ctx.dynamic()`. Mirrors the synthetic build request the
62
+ * shell-capture producer creates; also makes `ctx.waitUntil()` inert, as at
63
+ * build time.
64
+ */
65
+ build?: boolean;
58
66
  /**
59
67
  * Router basename for this request (what the RSC handler stores on the
60
68
  * context). Drives redirect() prefixing. Normalized exactly like
@@ -150,6 +158,7 @@ export function createTestRequestContext<TEnv>(
150
158
  request,
151
159
  url,
152
160
  variables,
161
+ build: opts.build,
153
162
  themeConfig:
154
163
  opts.theme === undefined ? undefined : resolveThemeConfig(opts.theme),
155
164
  cacheStore: opts.cacheStore,
@@ -80,6 +80,12 @@ export interface RenderHandlerOptions<TEnv = any> {
80
80
  routeName?: string;
81
81
  /** Route name -> pattern map enabling `ctx.reverse()`. */
82
82
  routeMap?: Record<string, string>;
83
+ /**
84
+ * Seed `ctx.build` (default false) so a handler that branches on the
85
+ * build-time pass — including calling `ctx.dynamic()` on a MISS — is
86
+ * unit-testable. Assert a `ctx.dynamic()` call via `result.dynamic`.
87
+ */
88
+ build?: boolean;
83
89
  /**
84
90
  * Seed the data `ctx.use(SomeLoader)` returns — NO real loader runs (same model
85
91
  * as `runLoader`'s `loaders`). Matched by loader reference, so a real
@@ -163,6 +169,12 @@ export interface RenderHandlerResult {
163
169
  locationState: Record<string, unknown>;
164
170
  /** What the handler pushed via `ctx.use(Handle)(...)` (e.g. Meta, Breadcrumbs), keyed by handle. */
165
171
  handles: Map<Handle<any, any>, unknown[]>;
172
+ /**
173
+ * Whether the handler called `ctx.dynamic()` (the PPR shell opt-out). The
174
+ * public way to assert the opt-out without reading the `@internal`
175
+ * `ctx._dynamic`.
176
+ */
177
+ dynamic: boolean;
166
178
  }
167
179
 
168
180
  /**
@@ -239,6 +251,7 @@ export async function renderHandler<TEnv = any>(
239
251
  request,
240
252
  url,
241
253
  variables: seedVariables({}, opts.vars),
254
+ build: opts.build,
242
255
  stateCookieName,
243
256
  version: opts.stateCookie?.version,
244
257
  cacheStore: opts.cacheStore,
@@ -353,5 +366,6 @@ export async function renderHandler<TEnv = any>(
353
366
  stateCookieName,
354
367
  locationState,
355
368
  handles: handlePushes,
369
+ dynamic: (reqCtx as RequestContext<TEnv>)._dynamic === true,
356
370
  };
357
371
  }
@@ -49,6 +49,13 @@ export interface RunMiddlewareOptions<TEnv = any> {
49
49
  env?: TEnv;
50
50
  /** Route params surfaced as `ctx.params`. */
51
51
  params?: Record<string, string>;
52
+ /**
53
+ * Seed `ctx.build` (default false) so a middleware that branches on the
54
+ * build-time PPR shell-capture pass (e.g. `if (ctx.build) ctx.dynamic()`) is
55
+ * unit-testable. With `build: true`, `ctx.waitUntil()` is inert, matching the
56
+ * build producer. Assert a `ctx.dynamic()` call via `result.dynamic`.
57
+ */
58
+ build?: boolean;
52
59
  /** Variables a prior middleware would have set (object or [key, value] list). */
53
60
  vars?: VarsInit;
54
61
  /** Route name -> pattern map enabling `ctx.reverse()`. */
@@ -102,6 +109,11 @@ export interface RunMiddlewareResult<TEnv = any> {
102
109
  ctx: RequestContext<TEnv>;
103
110
  /** Number of times the terminal handler ran (0 = short-circuited, 1 = passed through). */
104
111
  nextCalled: number;
112
+ /**
113
+ * Whether the chain called `ctx.dynamic()` (the PPR shell opt-out). The public
114
+ * way to assert the opt-out without reading the `@internal` `ctx._dynamic`.
115
+ */
116
+ dynamic: boolean;
105
117
  /**
106
118
  * The effective cookie view after the chain ran: request cookies merged with
107
119
  * anything the chain set or deleted (last-write-wins), as `{ name: value }`.
@@ -146,6 +158,7 @@ export async function runMiddleware<TEnv = any>(
146
158
  routeMap: opts.routeMap,
147
159
  routeName: opts.routeName,
148
160
  params: opts.params,
161
+ build: opts.build,
149
162
  basename: opts.basename,
150
163
  theme: opts.theme,
151
164
  cacheStore: opts.cacheStore,
@@ -197,6 +210,7 @@ export async function runMiddleware<TEnv = any>(
197
210
  response,
198
211
  ctx,
199
212
  nextCalled,
213
+ dynamic: ctx._dynamic === true,
200
214
  cookies,
201
215
  headers,
202
216
  locationState,
@@ -202,7 +202,18 @@ export type HandlerContext<
202
202
  * Build-time collection and dev on-demand prerender use `true`.
203
203
  * Live request rendering, including passthrough fallback, uses `false`.
204
204
  */
205
- build: boolean;
205
+ readonly build: boolean;
206
+ /**
207
+ * Opt this request out of PPR shell serving/capture.
208
+ * Middleware can call this before a shell HIT commits. Handlers run after
209
+ * that commit point, so they only prevent scheduling a new capture on MISS.
210
+ *
211
+ * Scope: this gates the PPR SHELL axis only. It does NOT disable prerender
212
+ * B-segment (Prerender/Static) serving — a Prerender() route's build-baked
213
+ * segments still replay at runtime — and it is inert in the prerender-collect
214
+ * and static-render contexts, which have no live shell decision to influence.
215
+ */
216
+ dynamic(): void;
206
217
  /**
207
218
  * True when running in Vite dev mode, false during production build or
208
219
  * live request rendering. Use this to branch on runtime mode without
@@ -150,7 +150,6 @@ export async function discoverRouters(
150
150
  const newPerRouterManifestDataMap = new Map<string, any>();
151
151
  const newPerRouterPrecomputedMap = new Map<string, PrecomputedEntry[]>();
152
152
  const newPerRouterTrieMap = new Map<string, any>();
153
- let mergedRouteAncestry: Record<string, string[]> = {};
154
153
  let mergedRouteTrailingSlash: Record<string, string> = {};
155
154
 
156
155
  let routerMountIndex = 0;
@@ -245,10 +244,6 @@ export async function discoverRouters(
245
244
  factoryOnlyPrefixes,
246
245
  });
247
246
 
248
- // Merge ancestry (internal field, used only for trie building)
249
- if (manifest._routeAncestry) {
250
- Object.assign(mergedRouteAncestry, manifest._routeAncestry);
251
- }
252
247
  // Merge trailing slash config
253
248
  if (manifest.routeTrailingSlash) {
254
249
  Object.assign(mergedRouteTrailingSlash, manifest.routeTrailingSlash);
@@ -303,64 +298,61 @@ export async function discoverRouters(
303
298
  (performance.now() - manifestGenStart).toFixed(1),
304
299
  );
305
300
 
306
- // Build route trie from merged manifest + ancestry
301
+ // Build route trie from merged manifest
307
302
  let newMergedRouteTrie: any = null;
308
303
  const trieStart = debug ? performance.now() : 0;
309
304
  if (Object.keys(newMergedRouteManifest).length > 0) {
310
- if (mergedRouteAncestry) {
311
- // Build routeToStaticPrefix from saved manifests
312
- const routeToStaticPrefix: Record<string, string> = {};
313
- for (const { manifest } of allManifests) {
314
- // Root-level routes have empty static prefix
315
- for (const name of Object.keys(manifest.routeManifest)) {
316
- if (!(name in routeToStaticPrefix)) {
317
- routeToStaticPrefix[name] = "";
318
- }
305
+ // Build routeToStaticPrefix from saved manifests
306
+ const routeToStaticPrefix: Record<string, string> = {};
307
+ for (const { manifest } of allManifests) {
308
+ // Root-level routes have empty static prefix
309
+ for (const name of Object.keys(manifest.routeManifest)) {
310
+ if (!(name in routeToStaticPrefix)) {
311
+ routeToStaticPrefix[name] = "";
319
312
  }
320
- buildRouteToStaticPrefix(manifest.prefixTree, routeToStaticPrefix);
321
313
  }
314
+ buildRouteToStaticPrefix(manifest.prefixTree, routeToStaticPrefix);
315
+ }
322
316
 
323
- // Collect prerender route names and response type routes from all manifests
324
- const prerenderRouteNames = new Set<string>();
325
- const passthroughRouteNames = new Set<string>();
326
- const mergedResponseTypeRoutes: Record<string, string> = {};
327
- for (const { manifest } of allManifests) {
328
- if (manifest.prerenderRoutes) {
329
- for (const name of manifest.prerenderRoutes) {
330
- prerenderRouteNames.add(name);
331
- }
317
+ // Collect prerender route names and response type routes from all manifests
318
+ const prerenderRouteNames = new Set<string>();
319
+ const passthroughRouteNames = new Set<string>();
320
+ const mergedResponseTypeRoutes: Record<string, string> = {};
321
+ for (const { manifest } of allManifests) {
322
+ if (manifest.prerenderRoutes) {
323
+ for (const name of manifest.prerenderRoutes) {
324
+ prerenderRouteNames.add(name);
332
325
  }
333
- if (manifest.passthroughRoutes) {
334
- for (const name of manifest.passthroughRoutes) {
335
- passthroughRouteNames.add(name);
336
- }
337
- }
338
- if (manifest.responseTypeRoutes) {
339
- Object.assign(mergedResponseTypeRoutes, manifest.responseTypeRoutes);
326
+ }
327
+ if (manifest.passthroughRoutes) {
328
+ for (const name of manifest.passthroughRoutes) {
329
+ passthroughRouteNames.add(name);
340
330
  }
341
331
  }
332
+ if (manifest.responseTypeRoutes) {
333
+ Object.assign(mergedResponseTypeRoutes, manifest.responseTypeRoutes);
334
+ }
335
+ }
342
336
 
343
- // buildRouteTrie reads these via ?.has / ?.[] — empty is observationally
344
- // identical to undefined, so no empty->undefined coercion is needed.
345
- newMergedRouteTrie = buildRouteTrie(
346
- newMergedRouteManifest,
347
- mergedRouteAncestry,
348
- routeToStaticPrefix,
349
- mergedRouteTrailingSlash,
350
- prerenderRouteNames,
351
- passthroughRouteNames,
352
- mergedResponseTypeRoutes,
353
- );
337
+ // buildRouteTrie reads these via ?.has / ?.[] — empty is observationally
338
+ // identical to undefined, so no empty->undefined coercion is needed.
339
+ newMergedRouteTrie = buildRouteTrie(
340
+ newMergedRouteManifest,
341
+ routeToStaticPrefix,
342
+ mergedRouteTrailingSlash,
343
+ prerenderRouteNames,
344
+ passthroughRouteNames,
345
+ mergedResponseTypeRoutes,
346
+ );
354
347
 
355
- // Build per-router tries for multi-router isolation. Uses the single
356
- // shared buildPerRouterTrie so the production serialized trie is built by
357
- // exactly the same code as the dev/HMR runtime rebuild (manifest-init.ts).
358
- // Returns null for route-less manifests (route-trie.ts).
359
- for (const { id, manifest } of allManifests) {
360
- const perRouterTrie = buildPerRouterTrie(manifest);
361
- if (perRouterTrie) {
362
- newPerRouterTrieMap.set(id, perRouterTrie);
363
- }
348
+ // Build per-router tries for multi-router isolation. Uses the single
349
+ // shared buildPerRouterTrie so the production serialized trie is built by
350
+ // exactly the same code as the dev/HMR runtime rebuild (manifest-init.ts).
351
+ // Returns null for route-less manifests (route-trie.ts).
352
+ for (const { id, manifest } of allManifests) {
353
+ const perRouterTrie = buildPerRouterTrie(manifest);
354
+ if (perRouterTrie) {
355
+ newPerRouterTrieMap.set(id, perRouterTrie);
364
356
  }
365
357
  }
366
358
  }
@@ -5,11 +5,25 @@
5
5
  * per-router virtual modules used by the load() hook.
6
6
  */
7
7
 
8
+ import { mkdirSync, writeFileSync } from "node:fs";
8
9
  import { dirname, basename, join } from "node:path";
9
10
  import { jsonParseExpression } from "../utils/manifest-utils.js";
10
11
  import { VIRTUAL_ROUTES_MANIFEST_ID } from "./state.js";
11
12
  import type { DiscoveryState } from "./state.js";
12
13
 
14
+ /**
15
+ * Serialized-payload size (bytes) at or above which the trie/precomputedEntries
16
+ * are shipped through a runtime channel (Text module / `?raw` import) instead of
17
+ * an inline `JSON.parse` literal (issue #665). 512KB is ~2500 routes — the
18
+ * crossover where the channel's benefit (removing the JS compile on cloudflare,
19
+ * reclaiming ~19% escaping bloat everywhere) clears the cost of an extra
20
+ * artifact. Below it the inline literal wins (sub-millisecond compile, ~0 gain),
21
+ * so the whole long tail of normal apps stays byte-identical. The compile/parse
22
+ * cost tracks bytes, not route count, so this stays a byte threshold.
23
+ * RANGO_MANIFEST_TEXT=1 bypasses it.
24
+ */
25
+ const MANIFEST_EXTERNALIZE_THRESHOLD = 512 * 1024;
26
+
13
27
  /**
14
28
  * Generate the code for the main virtual:rsc-router/routes-manifest module.
15
29
  */
@@ -137,6 +151,22 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
137
151
  return `// Route manifest will be populated at runtime`;
138
152
  }
139
153
 
154
+ /**
155
+ * Deterministic short hash (FNV-1a, base36) used to disambiguate staged
156
+ * manifest filenames. The sanitized `safeId` alone collapses distinct router
157
+ * ids — e.g. "a/b" and "a.b" both sanitize to "a_b" — which in a multi-router
158
+ * build would overwrite one router's staged manifest with another's. Appending
159
+ * a hash of the ORIGINAL id keeps them distinct.
160
+ */
161
+ function shortHash(input: string): string {
162
+ let h = 0x811c9dc5;
163
+ for (let i = 0; i < input.length; i++) {
164
+ h ^= input.charCodeAt(i);
165
+ h = Math.imul(h, 0x01000193);
166
+ }
167
+ return (h >>> 0).toString(36);
168
+ }
169
+
140
170
  /**
141
171
  * Generate the code for a per-router virtual module.
142
172
  */
@@ -171,13 +201,93 @@ export function generatePerRouterModule(
171
201
  lines.push(`export const manifest = ${jsonParseExpression(manifest)};`);
172
202
  }
173
203
  }
174
- if (trie) {
204
+ const hasTrie = !!trie;
205
+ const hasEntries = !!entries && entries.length > 0;
206
+
207
+ // The trie + precomputedEntries are the largest generated data. Inlined as a
208
+ // `JSON.parse('<literal>')` in the JS chunk, the isolate/process must LEX and
209
+ // compile that multi-MB source literal on first request before JSON.parse even
210
+ // runs — pure overhead, since it is data, not logic (issue #665). It also
211
+ // ships bloated: the build minifier re-quotes the literal to double quotes,
212
+ // escaping every JSON `"` as `\"` (measured 5.71MB vs 4.64MB un-escaped at 26k
213
+ // routes).
214
+ //
215
+ // The ONLY way to fix both is to keep the JSON out of JavaScript source — as a
216
+ // raw asset file that never passes through the JS minifier/compiler. On
217
+ // cloudflare that is a workerd Text module: import the staged `.txt` and the
218
+ // isolate gets the raw un-escaped bytes as a string with no compile pass
219
+ // (measured ~17% / ~88ms cold first-hit on a real edge deploy at 26k, and it
220
+ // shrinks the worker upload). The CF vite plugin types `.txt` as Text;
221
+ // wrangler's default rules type **/*.txt as Text for deploy and dev.
222
+ //
223
+ // Scoped to cloudflare deliberately. A Text module is the only no-JS byte
224
+ // channel that needs no filesystem, and cloudflare is where it pays: edge cold
225
+ // isolates spin up per-request under load and on every deploy, and Workers
226
+ // have a hard upload-size limit. node/vercel fall through to the inline
227
+ // literal below — an efficient runtime channel there needs `fs` (a raw asset
228
+ // read), whose fragility (asset co-location, runtime ENOENT) is not worth a
229
+ // large-app-only win that Fluid Compute amortizes and that essentially no real
230
+ // app reaches. `?raw` is NOT an option: in this build it inlines the JSON as a
231
+ // (double-quoted, compiled) JS string literal — same bloat, same compile as
232
+ // the inline form, plus a staged file for nothing.
233
+ //
234
+ // Build mode only (dev rebuilds the manifest per HMR; parse cost is
235
+ // irrelevant). Below MANIFEST_EXTERNALIZE_THRESHOLD the inline literal wins.
236
+ // RANGO_MANIFEST_TEXT overrides: "0" forces inline, "1" forces the Text module
237
+ // and bypasses the threshold (used by the cloudflare-basic dogfood e2e, whose
238
+ // real manifest is below the threshold).
239
+ const preset = state.opts?.preset ?? "node";
240
+ const override = process.env.RANGO_MANIFEST_TEXT;
241
+ const payload = manifestPayload(trie, hasTrie, entries, hasEntries);
242
+ const payloadJson = hasTrie || hasEntries ? JSON.stringify(payload) : "";
243
+ const useTextModule =
244
+ state.isBuildMode &&
245
+ preset === "cloudflare" &&
246
+ override !== "0" &&
247
+ (hasTrie || hasEntries) &&
248
+ (override === "1" || payloadJson.length >= MANIFEST_EXTERNALIZE_THRESHOLD);
249
+
250
+ if (useTextModule) {
251
+ const dir = join(state.projectRoot, "node_modules", ".rango");
252
+ mkdirSync(dir, { recursive: true });
253
+ const safeId = `${routerId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${shortHash(routerId)}`;
254
+ const filePath = join(dir, `manifest-${safeId}.txt`).replaceAll("\\", "/");
255
+ writeFileSync(filePath, payloadJson);
256
+ lines.push(`import __manifestJson from ${JSON.stringify(filePath)};`);
257
+ lines.push(`const __manifestData = JSON.parse(__manifestJson);`);
258
+ emitManifestExports(lines, hasTrie, hasEntries);
259
+ return lines.join("\n");
260
+ }
261
+
262
+ if (hasTrie) {
175
263
  lines.push(`export const trie = ${jsonParseExpression(trie)};`);
176
264
  }
177
- if (entries && entries.length > 0) {
265
+ if (hasEntries) {
178
266
  lines.push(
179
267
  `export const precomputedEntries = ${jsonParseExpression(entries)};`,
180
268
  );
181
269
  }
182
270
  return lines.join("\n") || "";
183
271
  }
272
+
273
+ function manifestPayload(
274
+ trie: unknown,
275
+ hasTrie: boolean,
276
+ entries: unknown,
277
+ hasEntries: boolean,
278
+ ): Record<string, unknown> {
279
+ const payload: Record<string, unknown> = {};
280
+ if (hasTrie) payload.t = trie;
281
+ if (hasEntries) payload.p = entries;
282
+ return payload;
283
+ }
284
+
285
+ function emitManifestExports(
286
+ lines: string[],
287
+ hasTrie: boolean,
288
+ hasEntries: boolean,
289
+ ): void {
290
+ if (hasTrie) lines.push(`export const trie = __manifestData.t;`);
291
+ if (hasEntries)
292
+ lines.push(`export const precomputedEntries = __manifestData.p;`);
293
+ }
package/src/vite/rango.ts CHANGED
@@ -46,6 +46,31 @@ import { createRangoDebugger, NS } from "./debug.js";
46
46
 
47
47
  const debugConfig = createRangoDebugger(NS.config);
48
48
 
49
+ /**
50
+ * Syntax target for the node/vercel server (`ssr` + `rsc`) build environments.
51
+ *
52
+ * Vite 8's build-target default is `baseline-widely-available`, which resolves
53
+ * to a BROWSER baseline (`chrome111`/`edge111`/…, ≈ES2022) and is applied
54
+ * per-environment with NO server carve-out: `resolveRolldownOptions` pipes it
55
+ * straight into rolldown/oxc's `transform.target` for `consumer:"server"` chunks
56
+ * too. So without an explicit server target our ssr/rsc bundles — which only run
57
+ * on Node or workerd — get compiled for browsers, needlessly rewriting modern
58
+ * syntax (e.g. Explicit Resource Management `using`/`await using`) into
59
+ * `_usingCtx()` runtime helpers. `esnext` emits server code at authored
60
+ * modernity: no helpers, marginally smaller/faster bundles, semantics that match
61
+ * reality. The `client` env is intentionally left at the browser baseline.
62
+ *
63
+ * The cloudflare preset does NOT use this: `@cloudflare/vite-plugin` hardcodes
64
+ * `es2024` on its server envs and its `config()` merges after ours, so anything
65
+ * we set there is a dead no-op (workerd runs es2024 fine).
66
+ *
67
+ * Caveat: `esnext` stops downleveling ERM `using`, which parses natively only on
68
+ * Node 24+ (workerd runs it). Ordinary ≤ES2022 server code still runs across the
69
+ * whole supported Node range; only an app that authors `using` AND deploys to
70
+ * Node < 24 would need to downlevel it itself. See issue #729.
71
+ */
72
+ const SERVER_BUILD_TARGET = "esnext";
73
+
49
74
  // The leading-directive 'use client' sniff is shared with version-plugin's
50
75
  // getClientModuleSignature so the two cannot drift. Imported for local use by the
51
76
  // HMR transform below and re-exported because the E8 sniff test imports it from
@@ -409,6 +434,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
409
434
  },
410
435
  ssr: {
411
436
  ...(vercelServerEnv ?? {}),
437
+ build: {
438
+ target: SERVER_BUILD_TARGET,
439
+ },
412
440
  optimizeDeps: {
413
441
  entries: [VIRTUAL_IDS.ssr],
414
442
  include: [
@@ -428,6 +456,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
428
456
  },
429
457
  rsc: {
430
458
  ...(vercelServerEnv ?? {}),
459
+ build: {
460
+ target: SERVER_BUILD_TARGET,
461
+ },
431
462
  optimizeDeps: {
432
463
  entries: [VIRTUAL_IDS.rsc],
433
464
  include: [