@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124
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/bin/rango.js +7 -2
- package/dist/vite/index.js +47 -6
- package/package.json +61 -21
- package/skills/cache-guide/SKILL.md +8 -6
- package/skills/caching/SKILL.md +148 -1
- package/skills/hooks/SKILL.md +38 -27
- package/skills/host-router/SKILL.md +16 -2
- package/skills/intercept/SKILL.md +4 -2
- package/skills/layout/SKILL.md +11 -6
- package/skills/loader/SKILL.md +6 -2
- package/skills/middleware/SKILL.md +4 -2
- package/skills/migrate-nextjs/SKILL.md +38 -16
- package/skills/parallel/SKILL.md +9 -4
- package/skills/rango/SKILL.md +27 -15
- package/skills/route/SKILL.md +4 -2
- package/skills/testing/SKILL.md +129 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +98 -0
- package/skills/testing/client-components.md +122 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +89 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +118 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/use-cache/SKILL.md +9 -7
- package/src/browser/action-fence.ts +37 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/navigation-bridge.ts +14 -1
- package/src/browser/navigation-client.ts +14 -1
- package/src/browser/navigation-store-handle.ts +39 -0
- package/src/browser/navigation-store.ts +26 -12
- package/src/browser/prefetch/fetch.ts +7 -0
- package/src/browser/rango-state.ts +176 -97
- package/src/browser/react/index.ts +0 -6
- package/src/browser/rsc-router.tsx +12 -4
- package/src/browser/server-action-bridge.ts +77 -15
- package/src/browser/types.ts +7 -1
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +95 -1
- package/src/cache/cache-runtime.ts +79 -13
- package/src/cache/cache-scope.ts +55 -4
- package/src/cache/cache-tag.ts +135 -0
- package/src/cache/cf/cf-cache-store.ts +2080 -224
- package/src/cache/cf/index.ts +15 -1
- package/src/cache/document-cache.ts +74 -7
- package/src/cache/index.ts +17 -0
- package/src/cache/memory-segment-store.ts +164 -14
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/types.ts +27 -0
- package/src/client.rsc.tsx +1 -1
- package/src/client.tsx +0 -6
- package/src/component-utils.ts +19 -0
- package/src/handle.ts +29 -9
- package/src/host/testing.ts +43 -14
- package/src/index.rsc.ts +29 -1
- package/src/index.ts +43 -1
- package/src/loader.rsc.ts +24 -3
- package/src/loader.ts +16 -2
- package/src/prerender.ts +24 -3
- package/src/router/basename.ts +14 -0
- package/src/router/match-handlers.ts +62 -20
- package/src/router/prerender-match.ts +6 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/loader-cache.ts +8 -17
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router.ts +36 -7
- package/src/rsc/handler.ts +13 -1
- package/src/rsc/helpers.ts +19 -0
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +8 -1
- package/src/rsc/rsc-rendering.ts +2 -0
- package/src/rsc/types.ts +2 -0
- package/src/runtime-env.ts +18 -0
- package/src/server/cookie-store.ts +52 -1
- package/src/server/request-context.ts +105 -2
- package/src/static-handler.ts +25 -3
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +581 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +110 -0
- package/src/testing/flight-normalize.ts +38 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +234 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +119 -0
- package/src/testing/internal/context.ts +390 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +80 -0
- package/src/testing/render-handler.ts +360 -0
- package/src/testing/render-route.tsx +594 -0
- package/src/testing/run-loader.ts +474 -0
- package/src/testing/run-middleware.ts +231 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/types/cache-types.ts +13 -4
- package/src/types/error-types.ts +5 -1
- package/src/types/global-namespace.ts +11 -1
- package/src/types/handler-context.ts +16 -5
- package/src/browser/react/use-client-cache.ts +0 -58
package/src/loader.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
LoaderFn,
|
|
20
20
|
} from "./types.js";
|
|
21
21
|
import { missingInjectedIdError } from "./missing-id-error.js";
|
|
22
|
+
import { isUnderTestRunner } from "./runtime-env.js";
|
|
22
23
|
|
|
23
24
|
// Overload 1: With function only (not fetchable)
|
|
24
25
|
export function createLoader<T>(
|
|
@@ -46,8 +47,21 @@ export function createLoader<T>(
|
|
|
46
47
|
): LoaderDefinition<Awaited<T>, Record<string, string | undefined>> {
|
|
47
48
|
const loaderId = __injectedId || "";
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
// Client/SSR build of createLoader. Under a test runner it needs no id
|
|
51
|
+
// (loaderId stays ""; the react-server build in loader.rsc.ts adds the runtime
|
|
52
|
+
// fallback for whole-router construction). Otherwise (dev or a real build) a
|
|
53
|
+
// missing id means an UNSUPPORTED shape the plugin skipped — fail loud rather
|
|
54
|
+
// than ship `$$id: ""` (which would make a client useLoader read the wrong
|
|
55
|
+
// key). The rich diagnostic stays behind the NODE_ENV check so production folds
|
|
56
|
+
// it away and ships the small throw. isUnderTestRunner() is runtime-safe.
|
|
57
|
+
if (!loaderId && !isUnderTestRunner()) {
|
|
58
|
+
if (process.env.NODE_ENV !== "production") {
|
|
59
|
+
throw missingInjectedIdError("Loader", "createLoader");
|
|
60
|
+
}
|
|
61
|
+
throw new Error(
|
|
62
|
+
"[rango] Loader is missing $$id — the build plugin did not inject one. " +
|
|
63
|
+
"Export it as `export const X = createLoader(...)`.",
|
|
64
|
+
);
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
return {
|
package/src/prerender.ts
CHANGED
|
@@ -38,6 +38,7 @@ import type { ReverseFunction } from "./reverse.js";
|
|
|
38
38
|
import type { DefaultReverseRouteMap } from "./types/global-namespace.js";
|
|
39
39
|
import type { UseItems, HandlerUseItem } from "./route-types.js";
|
|
40
40
|
import { isCachedFunction } from "./cache/taint.js";
|
|
41
|
+
import { isUnderTestRunner } from "./runtime-env.js";
|
|
41
42
|
|
|
42
43
|
// -- Named route resolution types -------------------------------------------
|
|
43
44
|
|
|
@@ -273,6 +274,11 @@ export interface PrerenderHandlerDefinition<
|
|
|
273
274
|
use?: () => UseItems<HandlerUseItem>;
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
// Process-stable fallback id counter (mirrors createHandle / createLoader). Only
|
|
278
|
+
// assigned in a bare unit test where the Vite plugin did not inject an id; never
|
|
279
|
+
// fires in a real build (the plugin always injects).
|
|
280
|
+
let runtimePrerenderIdCounter = 0;
|
|
281
|
+
|
|
276
282
|
// -- Overloads --------------------------------------------------------------
|
|
277
283
|
//
|
|
278
284
|
// T accepts: named route string (global or .local) OR explicit param object.
|
|
@@ -376,12 +382,27 @@ export function Prerender<TParams extends Record<string, any>>(
|
|
|
376
382
|
);
|
|
377
383
|
}
|
|
378
384
|
|
|
379
|
-
|
|
385
|
+
// Throw unless under a test runner. The plugin always injects $$id for a
|
|
386
|
+
// supported `export const` Prerender on every build, so a missing id means
|
|
387
|
+
// either no plugin (a bare test — fall back below) or an UNSUPPORTED shape the
|
|
388
|
+
// plugin silently skipped (dev OR a real build — fail loud; a synthetic id
|
|
389
|
+
// would degrade to a silent prerender miss). The message is already small (no
|
|
390
|
+
// stack-parsing diagnostic), so it ships as-is. isUnderTestRunner() is
|
|
391
|
+
// runtime-safe — never a bare `process.env` access.
|
|
392
|
+
if (!id && !isUnderTestRunner()) {
|
|
380
393
|
throw new Error(
|
|
381
|
-
"[rango] Prerender: missing $$id. " +
|
|
382
|
-
"
|
|
394
|
+
"[rango] Prerender: missing $$id. Use `export const X = Prerender(...)` " +
|
|
395
|
+
"and ensure the exposeInternalIds Vite plugin is configured.",
|
|
383
396
|
);
|
|
384
397
|
}
|
|
398
|
+
// Under vitest with no plugin id: assign a process-stable runtime id so a
|
|
399
|
+
// whole-app router with Prerender routes constructs in a bare test (for
|
|
400
|
+
// dispatch / assertGeneratedRoutesMatch). Never reached in a real build (the
|
|
401
|
+
// throw above fires there); prerender storage/lookup keys on routeName +
|
|
402
|
+
// paramHash, never $$id (mirrors createHandle / createLoader).
|
|
403
|
+
if (!id) {
|
|
404
|
+
id = `__rango_runtime_prerender_${runtimePrerenderIdCounter++}`;
|
|
405
|
+
}
|
|
385
406
|
|
|
386
407
|
return {
|
|
387
408
|
__brand: "prerenderHandler" as const,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a router basename to its canonical form: a single leading slash,
|
|
3
|
+
* no trailing slash, and `undefined` for an empty or bare-"/" value.
|
|
4
|
+
*
|
|
5
|
+
* This is the single source of truth used by both createRouter() (so the RSC
|
|
6
|
+
* handler stores a canonical basename on the request context) and the testing
|
|
7
|
+
* primitives (so a consumer can pass the same un-normalized string their
|
|
8
|
+
* createRouter() accepts and observe the same redirect() prefixing).
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeBasename(basename?: string): string | undefined {
|
|
11
|
+
if (!basename) return undefined;
|
|
12
|
+
const trimmed = basename.replace(/^\/+|\/+$/g, "");
|
|
13
|
+
return trimmed ? "/" + trimmed : undefined;
|
|
14
|
+
}
|
|
@@ -33,10 +33,13 @@ import type { ErrorBoundaryHandler, NotFoundBoundaryHandler } from "../types";
|
|
|
33
33
|
import type { MiddlewareFn } from "./middleware.js";
|
|
34
34
|
import {
|
|
35
35
|
type TelemetrySink,
|
|
36
|
+
type CacheSegmentSignal,
|
|
36
37
|
safeEmit,
|
|
37
38
|
resolveSink,
|
|
38
39
|
getRequestId,
|
|
40
|
+
buildCacheSignalSegments,
|
|
39
41
|
} from "./telemetry.js";
|
|
42
|
+
import { _getRequestContext } from "../server/request-context.js";
|
|
40
43
|
|
|
41
44
|
export interface MatchHandlerDeps<TEnv = any> {
|
|
42
45
|
buildRouterContext: () => RouterContext<TEnv>;
|
|
@@ -51,6 +54,12 @@ export interface MatchHandlerDeps<TEnv = any> {
|
|
|
51
54
|
isAction: boolean,
|
|
52
55
|
) => { intercept: InterceptEntry; entry: EntryData } | null;
|
|
53
56
|
telemetry?: TelemetrySink;
|
|
57
|
+
/**
|
|
58
|
+
* DEVELOPMENT/TEST ONLY gate for the X-Rango-Cache debug header. When true,
|
|
59
|
+
* match/matchPartial stash a coarse route-level cache signal on the request
|
|
60
|
+
* context for the response-finalization path to emit. Default off.
|
|
61
|
+
*/
|
|
62
|
+
cacheSignalEnabled?: boolean;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
export interface MatchHandlers<TEnv = any> {
|
|
@@ -113,6 +122,25 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
113
122
|
} = deps;
|
|
114
123
|
const hasTelemetry = !!deps.telemetry;
|
|
115
124
|
const telemetry = resolveSink(deps.telemetry);
|
|
125
|
+
const cacheSignalEnabled = !!deps.cacheSignalEnabled;
|
|
126
|
+
// Compute the coarse cache signal when EITHER telemetry needs it (for the
|
|
127
|
+
// cache.decision event) OR the debug header gate is on. When neither is set,
|
|
128
|
+
// this is never called — zero extra work on the hot path.
|
|
129
|
+
const buildSignal = (
|
|
130
|
+
routeKey: string,
|
|
131
|
+
state: {
|
|
132
|
+
cacheHit: boolean;
|
|
133
|
+
cacheSource?: "runtime" | "prerender";
|
|
134
|
+
shouldRevalidate?: boolean;
|
|
135
|
+
},
|
|
136
|
+
): CacheSegmentSignal[] => buildCacheSignalSegments(routeKey, state);
|
|
137
|
+
// Stash the signal on the request context for the response path to emit as
|
|
138
|
+
// the X-Rango-Cache header. Only when the debug gate is on.
|
|
139
|
+
const recordSignalIfEnabled = (segments: CacheSegmentSignal[]): void => {
|
|
140
|
+
if (!cacheSignalEnabled) return;
|
|
141
|
+
const reqCtx = _getRequestContext();
|
|
142
|
+
if (reqCtx) reqCtx._cacheSignal = segments;
|
|
143
|
+
};
|
|
116
144
|
|
|
117
145
|
async function createMatchContextForFull(
|
|
118
146
|
request: Request,
|
|
@@ -208,17 +236,24 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
208
236
|
const state = createPipelineState();
|
|
209
237
|
const pipeline = createMatchPartialPipeline(ctx, state);
|
|
210
238
|
const matchResult = await collectMatchResult(pipeline, ctx, state);
|
|
239
|
+
if (hasTelemetry || cacheSignalEnabled) {
|
|
240
|
+
const signalSegments = buildSignal(ctx.routeKey, state);
|
|
241
|
+
recordSignalIfEnabled(signalSegments);
|
|
242
|
+
if (hasTelemetry) {
|
|
243
|
+
safeEmit(telemetry, {
|
|
244
|
+
type: "cache.decision",
|
|
245
|
+
timestamp: performance.now(),
|
|
246
|
+
requestId,
|
|
247
|
+
pathname,
|
|
248
|
+
routeKey: ctx.routeKey,
|
|
249
|
+
hit: state.cacheHit,
|
|
250
|
+
shouldRevalidate: !!state.shouldRevalidate,
|
|
251
|
+
source: state.cacheSource,
|
|
252
|
+
segments: signalSegments,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
211
256
|
if (hasTelemetry) {
|
|
212
|
-
safeEmit(telemetry, {
|
|
213
|
-
type: "cache.decision",
|
|
214
|
-
timestamp: performance.now(),
|
|
215
|
-
requestId,
|
|
216
|
-
pathname,
|
|
217
|
-
routeKey: ctx.routeKey,
|
|
218
|
-
hit: state.cacheHit,
|
|
219
|
-
shouldRevalidate: !!state.shouldRevalidate,
|
|
220
|
-
source: state.cacheSource,
|
|
221
|
-
});
|
|
222
257
|
safeEmit(telemetry, {
|
|
223
258
|
type: "request.end",
|
|
224
259
|
timestamp: performance.now(),
|
|
@@ -363,17 +398,24 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
363
398
|
state,
|
|
364
399
|
);
|
|
365
400
|
flushRevalidationTrace();
|
|
401
|
+
if (hasTelemetry || cacheSignalEnabled) {
|
|
402
|
+
const signalSegments = buildSignal(ctx.routeKey, state);
|
|
403
|
+
recordSignalIfEnabled(signalSegments);
|
|
404
|
+
if (hasTelemetry) {
|
|
405
|
+
safeEmit(telemetry, {
|
|
406
|
+
type: "cache.decision",
|
|
407
|
+
timestamp: performance.now(),
|
|
408
|
+
requestId: partialRequestId,
|
|
409
|
+
pathname,
|
|
410
|
+
routeKey: ctx.routeKey,
|
|
411
|
+
hit: state.cacheHit,
|
|
412
|
+
shouldRevalidate: !!state.shouldRevalidate,
|
|
413
|
+
source: state.cacheSource,
|
|
414
|
+
segments: signalSegments,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
366
418
|
if (hasTelemetry) {
|
|
367
|
-
safeEmit(telemetry, {
|
|
368
|
-
type: "cache.decision",
|
|
369
|
-
timestamp: performance.now(),
|
|
370
|
-
requestId: partialRequestId,
|
|
371
|
-
pathname,
|
|
372
|
-
routeKey: ctx.routeKey,
|
|
373
|
-
hit: state.cacheHit,
|
|
374
|
-
shouldRevalidate: !!state.shouldRevalidate,
|
|
375
|
-
source: state.cacheSource,
|
|
376
|
-
});
|
|
377
419
|
safeEmit(telemetry, {
|
|
378
420
|
type: "request.end",
|
|
379
421
|
timestamp: performance.now(),
|
|
@@ -211,11 +211,14 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
211
211
|
header: () => {},
|
|
212
212
|
setStatus: () => {},
|
|
213
213
|
_setStatus: () => {},
|
|
214
|
+
_rotateStateCookie: () => {},
|
|
215
|
+
_setKeepCacheDirective: () => {},
|
|
214
216
|
use: (() => {
|
|
215
217
|
throw new Error("use() not available during pre-rendering");
|
|
216
218
|
}) as any,
|
|
217
219
|
method: "GET",
|
|
218
220
|
_handleStore: handleStore,
|
|
221
|
+
_requestTags: new Set<string>(),
|
|
219
222
|
waitUntil: () => {},
|
|
220
223
|
onResponse: () => {},
|
|
221
224
|
_onResponseCallbacks: [],
|
|
@@ -460,11 +463,14 @@ export async function renderStaticSegment<TEnv = any>(
|
|
|
460
463
|
header: () => {},
|
|
461
464
|
setStatus: () => {},
|
|
462
465
|
_setStatus: () => {},
|
|
466
|
+
_rotateStateCookie: () => {},
|
|
467
|
+
_setKeepCacheDirective: () => {},
|
|
463
468
|
use: (() => {
|
|
464
469
|
throw new Error("use() not available during static pre-rendering");
|
|
465
470
|
}) as any,
|
|
466
471
|
method: "GET",
|
|
467
472
|
_handleStore: handleStore,
|
|
473
|
+
_requestTags: new Set<string>(),
|
|
468
474
|
waitUntil: () => {},
|
|
469
475
|
onResponse: () => {},
|
|
470
476
|
_onResponseCallbacks: [],
|
|
@@ -290,6 +290,13 @@ export interface RangoInternal<
|
|
|
290
290
|
*/
|
|
291
291
|
readonly prefetchCacheTTL: number;
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Resolved rango state cookie name (`{prefix}_{routerId}`), composed once at
|
|
295
|
+
* router init and shipped to the client in payload metadata. The server-side
|
|
296
|
+
* cookie writer reads it from here; the client reads it from metadata.
|
|
297
|
+
*/
|
|
298
|
+
readonly resolvedStateCookieName: string;
|
|
299
|
+
|
|
293
300
|
/**
|
|
294
301
|
* Whether connection warmup is enabled.
|
|
295
302
|
* When true, the client sends HEAD /?_rsc_warmup after idle periods
|
|
@@ -132,6 +132,21 @@ export interface RangoOptions<TEnv = any> {
|
|
|
132
132
|
*/
|
|
133
133
|
allowDebugManifest?: boolean;
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* DEVELOPMENT/TEST ONLY. Emit an `X-Rango-Cache` response header describing
|
|
137
|
+
* the cache status of the matched route, for use by testing primitives such
|
|
138
|
+
* as `assertCacheStatus`.
|
|
139
|
+
*
|
|
140
|
+
* Defaults to `false`. When neither this option nor the
|
|
141
|
+
* `RANGO_TEST_SIGNALS=1` environment flag is set, NO header is emitted and
|
|
142
|
+
* router output is byte-identical to the default.
|
|
143
|
+
*
|
|
144
|
+
* The header encodes per-segment (v1: coarse route-level) status keyed by the
|
|
145
|
+
* route NAME, e.g. `X-Rango-Cache: product.detail=hit`. Do NOT enable in
|
|
146
|
+
* production — it exposes internal cache decisions.
|
|
147
|
+
*/
|
|
148
|
+
debugCacheSignal?: boolean;
|
|
149
|
+
|
|
135
150
|
/**
|
|
136
151
|
* Document component that wraps the entire application.
|
|
137
152
|
*
|
|
@@ -481,6 +496,21 @@ export interface RangoOptions<TEnv = any> {
|
|
|
481
496
|
*/
|
|
482
497
|
prefetchCacheTTL?: number | false;
|
|
483
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Prefix for the rango state cookie name. The resolved name is
|
|
501
|
+
* `{prefix}_{routerId}`; the prefix is sanitized to cookie-name-safe
|
|
502
|
+
* characters (`[A-Za-z0-9-]`) and an empty result falls back to the default.
|
|
503
|
+
*
|
|
504
|
+
* The rango state cookie keys the client's prefetch / HTTP caches. Overriding
|
|
505
|
+
* the prefix lets you align it with cookie-naming policies or consent-manager
|
|
506
|
+
* classification lists, or avoid colliding with an existing `rango-state`
|
|
507
|
+
* cookie. It is not a full-name override: the `_{routerId}` suffix is what
|
|
508
|
+
* keeps sibling apps on one origin from clobbering each other's state.
|
|
509
|
+
*
|
|
510
|
+
* @default "rango-state"
|
|
511
|
+
*/
|
|
512
|
+
stateCookiePrefix?: string;
|
|
513
|
+
|
|
484
514
|
/**
|
|
485
515
|
* Enable connection warmup to keep TCP+TLS alive after idle periods.
|
|
486
516
|
*
|
|
@@ -28,9 +28,11 @@ import {
|
|
|
28
28
|
resolveSwrWindow,
|
|
29
29
|
resolveCacheKey,
|
|
30
30
|
resolveCacheStore,
|
|
31
|
+
resolveTagsOption,
|
|
31
32
|
DEFAULT_ROUTE_TTL,
|
|
32
33
|
} from "../../cache/cache-policy.js";
|
|
33
34
|
import { readThroughItem } from "../../cache/read-through-swr.js";
|
|
35
|
+
import { recordRequestTags } from "../../cache/cache-tag.js";
|
|
34
36
|
// Lazy-loaded to avoid pulling @vitejs/plugin-rsc/rsc into modules that
|
|
35
37
|
// import segment-resolution but never use loader caching.
|
|
36
38
|
let _serializeResult: typeof import("../../cache/segment-codec.js").serializeResult;
|
|
@@ -87,23 +89,8 @@ async function resolveLoaderKey(
|
|
|
87
89
|
*/
|
|
88
90
|
function resolveTags(loaderEntry: LoaderEntry): string[] | undefined {
|
|
89
91
|
const options = loaderEntry.cache?.options;
|
|
90
|
-
if (!options
|
|
91
|
-
|
|
92
|
-
if (typeof options.tags === "function") {
|
|
93
|
-
const requestCtx = getRequestContext();
|
|
94
|
-
if (!requestCtx) return undefined;
|
|
95
|
-
try {
|
|
96
|
-
return options.tags(requestCtx);
|
|
97
|
-
} catch (error) {
|
|
98
|
-
console.error(
|
|
99
|
-
`[LoaderCache] Tags function failed, caching without tags:`,
|
|
100
|
-
error,
|
|
101
|
-
);
|
|
102
|
-
return undefined;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return options.tags;
|
|
92
|
+
if (!options) return undefined;
|
|
93
|
+
return resolveTagsOption(options.tags, getRequestContext(), "LoaderCache");
|
|
107
94
|
}
|
|
108
95
|
|
|
109
96
|
function getLoaderStore(
|
|
@@ -152,6 +139,10 @@ export function resolveLoaderData<TEnv>(
|
|
|
152
139
|
const swrWindow = resolveSwrWindow(options.swr, store.defaults);
|
|
153
140
|
const swr = swrWindow || undefined;
|
|
154
141
|
const tags = resolveTags(loaderEntry);
|
|
142
|
+
// Loader tags are config-derived, so they are the complete set whether this is
|
|
143
|
+
// a cache hit or miss; record them every time so a document built from this
|
|
144
|
+
// loader is tagged for invalidation.
|
|
145
|
+
recordRequestTags(tags);
|
|
155
146
|
|
|
156
147
|
// Wrap ctx.use() so cache HIT primes the handler's memoization map.
|
|
157
148
|
// ctx.use() closes over the match context's loaderPromises (not request context's).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DEFAULT_STATE_COOKIE_PREFIX } from "../browser/cookie-name.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the rango state cookie name once, server-side, at router init. The
|
|
5
|
+
* resolved string is shipped in payload metadata and the client reads it
|
|
6
|
+
* verbatim, so composition happens in exactly one place.
|
|
7
|
+
*
|
|
8
|
+
* Shape: `{sanitizedPrefix}_{sanitizedRouterId}`. The prefix charset excludes
|
|
9
|
+
* `_` so the FIRST `_` is always the prefix/routerId boundary; that keeps the
|
|
10
|
+
* name injective even though a routerId may legitimately contain `_` (the
|
|
11
|
+
* counter fallback is `router_{n}`). Without that exclusion, prefix
|
|
12
|
+
* `rango-state` + id `router_0` and prefix `rango-state_router` + id `0` would
|
|
13
|
+
* both resolve to `rango-state_router_0` and silently share a cache key.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Prefix excludes `_` so it can never collide with the separator.
|
|
17
|
+
function sanitizePrefix(prefix: string): string {
|
|
18
|
+
return prefix.replace(/[^A-Za-z0-9-]/g, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// routerId keeps `_` (so `router_0` survives); other illegal chars are dropped.
|
|
22
|
+
function sanitizeRouterId(routerId: string): string {
|
|
23
|
+
return routerId.replace(/[^A-Za-z0-9_-]/g, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveStateCookieName(
|
|
27
|
+
prefix: string | undefined,
|
|
28
|
+
routerId: string,
|
|
29
|
+
): string {
|
|
30
|
+
const sanitized = sanitizePrefix(prefix ?? DEFAULT_STATE_COOKIE_PREFIX);
|
|
31
|
+
const finalPrefix = sanitized || DEFAULT_STATE_COOKIE_PREFIX;
|
|
32
|
+
return `${finalPrefix}_${sanitizeRouterId(routerId)}`;
|
|
33
|
+
}
|
package/src/router/telemetry.ts
CHANGED
|
@@ -90,6 +90,34 @@ export interface HandlerErrorEvent extends BaseEvent {
|
|
|
90
90
|
params?: Record<string, string>;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Per-segment (or coarse route-level) cache status carried on the
|
|
95
|
+
* cache.decision telemetry event and the X-Rango-Cache debug header.
|
|
96
|
+
*
|
|
97
|
+
* v1 is COARSE: the router's pipeline tracks cache decisions at the
|
|
98
|
+
* route/entry level (cacheHit/cacheSource/shouldRevalidate), not per
|
|
99
|
+
* individual segment. The `segments` array therefore contains a single
|
|
100
|
+
* route-level entry keyed by the route key. The shape is forward-compatible
|
|
101
|
+
* with genuine per-segment status if the pipeline later exposes it.
|
|
102
|
+
*/
|
|
103
|
+
export type CacheSegmentStatus =
|
|
104
|
+
| "hit"
|
|
105
|
+
| "miss"
|
|
106
|
+
| "stale"
|
|
107
|
+
| "prerendered"
|
|
108
|
+
| "passthrough";
|
|
109
|
+
|
|
110
|
+
export interface CacheSegmentSignal {
|
|
111
|
+
/** Segment id (v1: the route key, since status is route-level). */
|
|
112
|
+
id: string;
|
|
113
|
+
/** Segment type (v1: "route" for the coarse route-level entry). */
|
|
114
|
+
type: string;
|
|
115
|
+
/** Resolved cache status for this segment. */
|
|
116
|
+
cacheStatus: CacheSegmentStatus;
|
|
117
|
+
/** Whether stale-while-revalidate was triggered for this segment. */
|
|
118
|
+
shouldRevalidate?: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
export interface CacheDecisionEvent extends BaseEvent {
|
|
94
122
|
type: "cache.decision";
|
|
95
123
|
pathname: string;
|
|
@@ -98,6 +126,12 @@ export interface CacheDecisionEvent extends BaseEvent {
|
|
|
98
126
|
/** Whether stale-while-revalidate was triggered */
|
|
99
127
|
shouldRevalidate: boolean;
|
|
100
128
|
source?: "runtime" | "prerender";
|
|
129
|
+
/**
|
|
130
|
+
* Optional per-segment (v1: coarse route-level) cache status. Present only
|
|
131
|
+
* when telemetry or the debug cache signal is enabled. Optional so existing
|
|
132
|
+
* sinks are unaffected.
|
|
133
|
+
*/
|
|
134
|
+
segments?: CacheSegmentSignal[];
|
|
101
135
|
}
|
|
102
136
|
|
|
103
137
|
export interface RevalidationDecisionEvent extends BaseEvent {
|
|
@@ -140,6 +174,71 @@ export type TelemetryEvent =
|
|
|
140
174
|
| RequestTimeoutEvent
|
|
141
175
|
| OriginCheckRejectedEvent;
|
|
142
176
|
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Cache signal derivation (coarse, route-level)
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Derive the coarse, route-level cache status from pipeline cache state.
|
|
183
|
+
*
|
|
184
|
+
* v1 mapping (route-level — see CacheSegmentSignal):
|
|
185
|
+
* - prerender hit -> "prerendered"
|
|
186
|
+
* - runtime hit + shouldRevalidate (SWR) -> "stale"
|
|
187
|
+
* - runtime hit -> "hit"
|
|
188
|
+
* - no hit -> "miss"
|
|
189
|
+
*
|
|
190
|
+
* Note: "passthrough" is a build-time prerender concept (a route opts out of
|
|
191
|
+
* being prerendered for some params). At runtime a passthrough route renders
|
|
192
|
+
* fresh and is indistinguishable from a normal miss in the pipeline state, so
|
|
193
|
+
* v1 reports it as "miss". The "passthrough" status remains in the type union
|
|
194
|
+
* for forward compatibility.
|
|
195
|
+
*/
|
|
196
|
+
export function deriveCacheStatus(state: {
|
|
197
|
+
cacheHit: boolean;
|
|
198
|
+
cacheSource?: "runtime" | "prerender";
|
|
199
|
+
shouldRevalidate?: boolean;
|
|
200
|
+
}): CacheSegmentStatus {
|
|
201
|
+
if (state.cacheHit) {
|
|
202
|
+
if (state.cacheSource === "prerender") return "prerendered";
|
|
203
|
+
if (state.shouldRevalidate) return "stale";
|
|
204
|
+
return "hit";
|
|
205
|
+
}
|
|
206
|
+
return "miss";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build the coarse route-level cache signal array (a single entry keyed by
|
|
211
|
+
* the route key). Used for both the cache.decision telemetry event and the
|
|
212
|
+
* X-Rango-Cache debug header.
|
|
213
|
+
*/
|
|
214
|
+
export function buildCacheSignalSegments(
|
|
215
|
+
routeKey: string,
|
|
216
|
+
state: {
|
|
217
|
+
cacheHit: boolean;
|
|
218
|
+
cacheSource?: "runtime" | "prerender";
|
|
219
|
+
shouldRevalidate?: boolean;
|
|
220
|
+
},
|
|
221
|
+
): CacheSegmentSignal[] {
|
|
222
|
+
return [
|
|
223
|
+
{
|
|
224
|
+
id: routeKey,
|
|
225
|
+
type: "route",
|
|
226
|
+
cacheStatus: deriveCacheStatus(state),
|
|
227
|
+
shouldRevalidate: !!state.shouldRevalidate,
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Serialize cache signal segments into the X-Rango-Cache header value:
|
|
234
|
+
* `<segId>=<status>, <segId2>=<status2>`.
|
|
235
|
+
*/
|
|
236
|
+
export function formatCacheSignalHeader(
|
|
237
|
+
segments: CacheSegmentSignal[],
|
|
238
|
+
): string {
|
|
239
|
+
return segments.map((s) => `${s.id}=${s.cacheStatus}`).join(", ");
|
|
240
|
+
}
|
|
241
|
+
|
|
143
242
|
// ---------------------------------------------------------------------------
|
|
144
243
|
// Sink interface
|
|
145
244
|
// ---------------------------------------------------------------------------
|
package/src/router.ts
CHANGED
|
@@ -57,6 +57,7 @@ import { buildDebugManifest } from "./router/debug-manifest.js";
|
|
|
57
57
|
|
|
58
58
|
import type { SegmentResolutionDeps, MatchApiDeps } from "./router/types.js";
|
|
59
59
|
import { createHandlerContext } from "./router/handler-context.js";
|
|
60
|
+
import { normalizeBasename } from "./router/basename.js";
|
|
60
61
|
import {
|
|
61
62
|
setupLoaderAccess,
|
|
62
63
|
setupLoaderAccessSilent,
|
|
@@ -110,6 +111,7 @@ import {
|
|
|
110
111
|
matchForPrerender as _matchForPrerender,
|
|
111
112
|
renderStaticSegment as _renderStaticSegment,
|
|
112
113
|
} from "./router/prerender-match.js";
|
|
114
|
+
import { resolveStateCookieName } from "./router/state-cookie-name.js";
|
|
113
115
|
|
|
114
116
|
// Re-export public types and values from extracted modules
|
|
115
117
|
export { RSC_ROUTER_BRAND, RouterRegistry } from "./router/router-registry.js";
|
|
@@ -149,6 +151,7 @@ export function createRouter<TEnv = any>(
|
|
|
149
151
|
nonce,
|
|
150
152
|
version,
|
|
151
153
|
prefetchCacheTTL: prefetchCacheTTLOption,
|
|
154
|
+
stateCookiePrefix: stateCookiePrefixOption,
|
|
152
155
|
warmup: warmupOption,
|
|
153
156
|
allowDebugManifest: allowDebugManifestOption = false,
|
|
154
157
|
telemetry: telemetrySink,
|
|
@@ -158,14 +161,22 @@ export function createRouter<TEnv = any>(
|
|
|
158
161
|
onTimeout,
|
|
159
162
|
originCheck: originCheckOption,
|
|
160
163
|
viewTransition: viewTransitionOption = "auto",
|
|
164
|
+
debugCacheSignal: debugCacheSignalOption = false,
|
|
161
165
|
} = options;
|
|
162
166
|
|
|
167
|
+
// Debug cache signal gate (DEVELOPMENT/TEST ONLY). Enabled by the
|
|
168
|
+
// debugCacheSignal option OR the RANGO_TEST_SIGNALS=1 env flag. When off,
|
|
169
|
+
// no X-Rango-Cache header is emitted and output is byte-identical.
|
|
170
|
+
const cacheSignalEnabled =
|
|
171
|
+
debugCacheSignalOption ||
|
|
172
|
+
(typeof process !== "undefined" &&
|
|
173
|
+
(process as { env?: Record<string, string | undefined> }).env
|
|
174
|
+
?.RANGO_TEST_SIGNALS === "1");
|
|
175
|
+
|
|
163
176
|
// Normalize basename: ensure leading slash, strip trailing slash.
|
|
164
|
-
// A bare "/" is equivalent to no basename.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
? "/" + basenameOption.replace(/^\/+|\/+$/g, "")
|
|
168
|
-
: undefined;
|
|
177
|
+
// A bare "/" is equivalent to no basename. Shared with the testing
|
|
178
|
+
// primitives via normalizeBasename so they can never drift.
|
|
179
|
+
const basename = normalizeBasename(basenameOption);
|
|
169
180
|
|
|
170
181
|
// Resolve telemetry sink (no-op when not configured)
|
|
171
182
|
const telemetry = resolveSink(telemetrySink);
|
|
@@ -209,6 +220,14 @@ export function createRouter<TEnv = any>(
|
|
|
209
220
|
const routerId =
|
|
210
221
|
userProvidedId ?? injectedId ?? `router_${nextRouterAutoId()}`;
|
|
211
222
|
|
|
223
|
+
// Resolve the rango state cookie name once, here, so the two cookie writers
|
|
224
|
+
// (the client document.cookie writer and the server Set-Cookie writer)
|
|
225
|
+
// consume one pre-composed name and cannot drift.
|
|
226
|
+
const resolvedStateCookieName = resolveStateCookieName(
|
|
227
|
+
stateCookiePrefixOption,
|
|
228
|
+
routerId,
|
|
229
|
+
);
|
|
230
|
+
|
|
212
231
|
// Resolve prefetch cache TTL (default: 300 seconds / 5 minutes)
|
|
213
232
|
// Clamp to a non-negative integer for valid Cache-Control max-age.
|
|
214
233
|
const rawTTL =
|
|
@@ -255,9 +274,14 @@ export function createRouter<TEnv = any>(
|
|
|
255
274
|
invokeOnError(onError, error, phase, context, "Router");
|
|
256
275
|
}
|
|
257
276
|
|
|
258
|
-
// Validate document is a client component
|
|
277
|
+
// Validate document is a client component. Under a test runner the "use
|
|
278
|
+
// client" transform has not run, so a real exported document has no marker;
|
|
279
|
+
// allowServerInTest lets the router construct in a bare unit test (for
|
|
280
|
+
// dispatch / assertGeneratedRoutesMatch) while a real build still throws.
|
|
259
281
|
if (documentOption !== undefined) {
|
|
260
|
-
assertClientComponent(documentOption, "document"
|
|
282
|
+
assertClientComponent(documentOption, "document", {
|
|
283
|
+
allowServerInTest: true,
|
|
284
|
+
});
|
|
261
285
|
}
|
|
262
286
|
|
|
263
287
|
// Use default document if none provided (keeps internal name as rootLayout)
|
|
@@ -667,6 +691,7 @@ export function createRouter<TEnv = any>(
|
|
|
667
691
|
findMatch,
|
|
668
692
|
findInterceptForRoute,
|
|
669
693
|
telemetry: telemetrySink,
|
|
694
|
+
cacheSignalEnabled,
|
|
670
695
|
});
|
|
671
696
|
|
|
672
697
|
const { match, matchPartial, matchError, previewMatch } = matchHandlers;
|
|
@@ -938,6 +963,10 @@ export function createRouter<TEnv = any>(
|
|
|
938
963
|
prefetchCacheControl,
|
|
939
964
|
prefetchCacheTTL,
|
|
940
965
|
|
|
966
|
+
// Expose the resolved rango state cookie name for the server-side writer
|
|
967
|
+
// (invalidateClientCache) and for shipping to the client in metadata.
|
|
968
|
+
resolvedStateCookieName,
|
|
969
|
+
|
|
941
970
|
// Expose warmup enabled flag for handler and client
|
|
942
971
|
warmupEnabled,
|
|
943
972
|
|
package/src/rsc/handler.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
getRouterTrie,
|
|
58
58
|
} from "../route-map-builder.js";
|
|
59
59
|
import type { HandlerContext } from "./handler-context.js";
|
|
60
|
+
import type { CacheErrorCategory } from "../cache/cache-error.js";
|
|
60
61
|
import type { SegmentCacheStore } from "../cache/types.js";
|
|
61
62
|
import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
|
|
62
63
|
import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
|
|
@@ -150,6 +151,13 @@ export function createRSCHandler<
|
|
|
150
151
|
>(options: CreateRSCHandlerOptions<TEnv, TRoutes>) {
|
|
151
152
|
const { router, version = VERSION, nonce: nonceProvider } = options;
|
|
152
153
|
|
|
154
|
+
// Handler-owned registry of explicit per-scope stores from cache({ store }).
|
|
155
|
+
// Lives in the closure so it is scoped per handler (multi-router deployments
|
|
156
|
+
// get separate registries) and accumulates every explicit store this handler
|
|
157
|
+
// resolves across requests. updateTag()/revalidateTag() iterate it to reach
|
|
158
|
+
// stores not covered by the app-level ctx._cacheStore.
|
|
159
|
+
const explicitTaggedStores = new Set<SegmentCacheStore>();
|
|
160
|
+
|
|
153
161
|
// Use provided deps or default to @vitejs/plugin-rsc/rsc exports
|
|
154
162
|
const deps = options.deps ?? rscDeps;
|
|
155
163
|
const {
|
|
@@ -441,9 +449,12 @@ export function createRSCHandler<
|
|
|
441
449
|
url,
|
|
442
450
|
variables,
|
|
443
451
|
cacheStore,
|
|
452
|
+
explicitTaggedStores,
|
|
444
453
|
cacheProfiles: router.cacheProfiles,
|
|
445
454
|
executionContext: executionCtx,
|
|
446
455
|
themeConfig: router.themeConfig,
|
|
456
|
+
stateCookieName: router.resolvedStateCookieName,
|
|
457
|
+
version,
|
|
447
458
|
});
|
|
448
459
|
if (earlyMetricsStore) {
|
|
449
460
|
requestContext._debugPerformance = true;
|
|
@@ -453,7 +464,7 @@ export function createRSCHandler<
|
|
|
453
464
|
// can surface non-fatal errors through the router's onError callback.
|
|
454
465
|
requestContext._reportBackgroundError = (
|
|
455
466
|
error: unknown,
|
|
456
|
-
category:
|
|
467
|
+
category: CacheErrorCategory,
|
|
457
468
|
) => {
|
|
458
469
|
callOnError(error, "cache", {
|
|
459
470
|
request,
|
|
@@ -1070,6 +1081,7 @@ export function createRSCHandler<
|
|
|
1070
1081
|
rootLayout: router.rootLayout,
|
|
1071
1082
|
handles: handleStore.stream(),
|
|
1072
1083
|
version,
|
|
1084
|
+
stateCookieName: router.resolvedStateCookieName,
|
|
1073
1085
|
themeConfig: router.themeConfig,
|
|
1074
1086
|
warmupEnabled: router.warmupEnabled,
|
|
1075
1087
|
initialTheme: requireRequestContext().theme,
|