@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.
- package/dist/vite/index.js +146 -76
- package/package.json +2 -2
- package/skills/debug-manifest/SKILL.md +2 -10
- package/skills/middleware/SKILL.md +33 -0
- package/skills/ppr/SKILL.md +37 -4
- package/skills/prerender/SKILL.md +44 -1
- package/src/build/generate-manifest.ts +5 -36
- package/src/build/route-trie.ts +5 -50
- package/src/cache/cache-key-utils.ts +0 -1
- package/src/prerender/build-shell-capture.ts +215 -21
- package/src/router/handler-context.ts +10 -1
- package/src/router/middleware-types.ts +17 -0
- package/src/router/middleware.ts +4 -0
- package/src/router/prerender-match.ts +7 -0
- package/src/router/router-interfaces.ts +0 -6
- package/src/router/router-options.ts +0 -8
- package/src/router.ts +0 -4
- package/src/rsc/handler.ts +55 -51
- package/src/rsc/helpers.ts +461 -0
- package/src/rsc/progressive-enhancement.ts +66 -27
- package/src/rsc/rsc-rendering.ts +91 -57
- package/src/rsc/server-action.ts +49 -46
- package/src/rsc/shell-build-manifest.ts +50 -8
- package/src/rsc/shell-capture.ts +17 -6
- package/src/server/context.ts +67 -10
- package/src/server/request-context.ts +37 -0
- package/src/testing/internal/context.ts +9 -0
- package/src/testing/render-handler.ts +14 -0
- package/src/testing/run-middleware.ts +14 -0
- package/src/types/handler-context.ts +12 -1
- package/src/vite/discovery/discover-routers.ts +44 -52
- package/src/vite/discovery/virtual-module-codegen.ts +112 -2
- package/src/vite/rango.ts +31 -0
- package/src/vite/router-discovery.ts +131 -22
package/src/server/context.ts
CHANGED
|
@@ -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
|
-
//
|
|
821
|
-
//
|
|
822
|
-
// loader
|
|
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().
|
|
905
|
-
*
|
|
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
|
-
|
|
914
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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 (
|
|
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: [
|