@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/rsc/helpers.ts
CHANGED
|
@@ -12,6 +12,24 @@ import type { RequestContext } from "../server/request-context.js";
|
|
|
12
12
|
import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
|
|
13
13
|
import { isRedirectResponse } from "../response-utils.js";
|
|
14
14
|
import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
|
|
15
|
+
import { formatCacheSignalHeader } from "../router/telemetry.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* DEVELOPMENT/TEST ONLY. When the debug cache signal gate is on,
|
|
19
|
+
* match/matchPartial populate ctx._cacheSignal. Emit it as the X-Rango-Cache
|
|
20
|
+
* header. When the gate is off, ctx._cacheSignal is undefined and NOTHING is
|
|
21
|
+
* attached — output is byte-identical to the default. Header mutation failures
|
|
22
|
+
* are swallowed so immutable Response headers (e.g. protocol-switch) are safe.
|
|
23
|
+
*/
|
|
24
|
+
function applyCacheSignalHeader(target: Headers, ctx: RequestContext): void {
|
|
25
|
+
const signal = ctx._cacheSignal;
|
|
26
|
+
if (!signal || signal.length === 0) return;
|
|
27
|
+
try {
|
|
28
|
+
target.set("X-Rango-Cache", formatCacheSignalHeader(signal));
|
|
29
|
+
} catch {
|
|
30
|
+
// Headers immutable — skip.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
/**
|
|
17
35
|
* Copy stub headers from the request context onto a target Headers instance:
|
|
@@ -85,6 +103,7 @@ export function createResponseWithMergedHeaders(
|
|
|
85
103
|
const mergedHeaders = new Headers(init.headers);
|
|
86
104
|
applyStubHeaders(mergedHeaders, ctx.res.headers);
|
|
87
105
|
ctx.res.headers.delete("set-cookie");
|
|
106
|
+
applyCacheSignalHeader(mergedHeaders, ctx);
|
|
88
107
|
|
|
89
108
|
// ctx.res.status overrides init.status when explicitly set (e.g. 404 for
|
|
90
109
|
// notFound, 500 for error). Default ctx.res.status is 200.
|
|
@@ -254,6 +254,7 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
254
254
|
rootLayout: ctx.router.rootLayout,
|
|
255
255
|
handles: handleStore.stream(),
|
|
256
256
|
version: ctx.version,
|
|
257
|
+
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
257
258
|
themeConfig: ctx.router.themeConfig,
|
|
258
259
|
warmupEnabled: ctx.router.warmupEnabled,
|
|
259
260
|
initialTheme: requireRequestContext().theme,
|
|
@@ -362,6 +363,7 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
362
363
|
rootLayout: ctx.router.rootLayout,
|
|
363
364
|
handles: handleStore.stream(),
|
|
364
365
|
version: ctx.version,
|
|
366
|
+
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
365
367
|
themeConfig: ctx.router.themeConfig,
|
|
366
368
|
warmupEnabled: ctx.router.warmupEnabled,
|
|
367
369
|
initialTheme: requireRequestContext().theme,
|
|
@@ -12,7 +12,7 @@ import { contextGet } from "../context-var.js";
|
|
|
12
12
|
import { NOCACHE_SYMBOL } from "../cache/taint.js";
|
|
13
13
|
import { traverseBack } from "../router/pattern-matching.js";
|
|
14
14
|
import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
|
|
15
|
-
import { createCacheScope } from "../cache/cache-scope.js";
|
|
15
|
+
import { createCacheScope, resolveCacheTags } from "../cache/cache-scope.js";
|
|
16
16
|
import { executeMiddleware } from "../router/middleware.js";
|
|
17
17
|
import {
|
|
18
18
|
createReverseFunction,
|
|
@@ -277,6 +277,11 @@ export async function handleResponseRoute<TEnv>(
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// Resolve cache tags for this document entry (static or dynamic),
|
|
281
|
+
// while request context is available. Passed to putResponse so the
|
|
282
|
+
// entry is tag-invalidatable.
|
|
283
|
+
const responseTags = resolveCacheTags(cacheScope.config, reqCtx);
|
|
284
|
+
|
|
280
285
|
// Save pre-handler callbacks (registered by app-level middleware
|
|
281
286
|
// before we reach the cache block) and clear the live array.
|
|
282
287
|
// createResponseWithMergedHeaders (inside the handler) eagerly
|
|
@@ -318,6 +323,7 @@ export async function handleResponseRoute<TEnv>(
|
|
|
318
323
|
fresh.clone(),
|
|
319
324
|
cacheScope!.ttl,
|
|
320
325
|
cacheScope!.swr,
|
|
326
|
+
responseTags,
|
|
321
327
|
);
|
|
322
328
|
}
|
|
323
329
|
} catch (error) {
|
|
@@ -346,6 +352,7 @@ export async function handleResponseRoute<TEnv>(
|
|
|
346
352
|
response.clone(),
|
|
347
353
|
cacheScope!.ttl,
|
|
348
354
|
cacheScope!.swr,
|
|
355
|
+
responseTags,
|
|
349
356
|
);
|
|
350
357
|
} catch (error) {
|
|
351
358
|
console.error(`[ResponseCache] Cache write failed:`, error);
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -53,6 +53,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
53
53
|
handles: handleStore.stream(),
|
|
54
54
|
version: ctx.version,
|
|
55
55
|
prefetchCacheTTL: ctx.router.prefetchCacheTTL,
|
|
56
|
+
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
56
57
|
themeConfig: ctx.router.themeConfig,
|
|
57
58
|
initialTheme: reqCtx.theme,
|
|
58
59
|
},
|
|
@@ -99,6 +100,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
99
100
|
handles: handleStore.stream(),
|
|
100
101
|
version: ctx.version,
|
|
101
102
|
prefetchCacheTTL: ctx.router.prefetchCacheTTL,
|
|
103
|
+
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
102
104
|
},
|
|
103
105
|
};
|
|
104
106
|
}
|
package/src/rsc/types.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface RscPayload {
|
|
|
43
43
|
version?: string;
|
|
44
44
|
/** TTL in milliseconds for the client-side in-memory prefetch cache */
|
|
45
45
|
prefetchCacheTTL?: number;
|
|
46
|
+
/** Server-resolved rango state cookie name; the client reads it verbatim. */
|
|
47
|
+
stateCookieName?: string;
|
|
46
48
|
/** Theme configuration for FOUC prevention */
|
|
47
49
|
themeConfig?: ResolvedThemeConfig | null;
|
|
48
50
|
/** Initial theme from cookie (for SSR hydration) */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Runtime-safe detection of a test runner (Vitest), used to decide whether a
|
|
2
|
+
// create*() call with no plugin-injected $$id may fall back to a synthetic id (a
|
|
3
|
+
// bare test) or must fail loud (dev / a real build).
|
|
4
|
+
//
|
|
5
|
+
// `process` is absent in some target runtimes (the browser, certain edge/worker
|
|
6
|
+
// RSC environments), so probe it through `globalThis` with optional chaining —
|
|
7
|
+
// NEVER a bare `process.env.VITEST`, which would ReferenceError before the
|
|
8
|
+
// intended error is thrown. Unlike `process.env.NODE_ENV` (folded by the app's
|
|
9
|
+
// build `define`), `VITEST` is not folded, so this stays a small runtime check;
|
|
10
|
+
// it lives only on the create*() error path (id missing), which never runs in a
|
|
11
|
+
// correct production build.
|
|
12
|
+
//
|
|
13
|
+
// Vitest sets `VITEST` in every test process — the node project and the
|
|
14
|
+
// react-server forks alike (the RSC project forces NODE_ENV=production, so NODE_ENV
|
|
15
|
+
// cannot distinguish it from a real build; `VITEST` can). A real build never sets it.
|
|
16
|
+
export function isUnderTestRunner(): boolean {
|
|
17
|
+
return !!globalThis.process?.env?.VITEST;
|
|
18
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { CookieOptions } from "../router/middleware-types.js";
|
|
11
|
-
import { getRequestContext } from "./request-context.js";
|
|
11
|
+
import { getRequestContext, _getRequestContext } from "./request-context.js";
|
|
12
12
|
import { isInsideCacheScope } from "./context.js";
|
|
13
13
|
import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
|
|
14
14
|
|
|
@@ -168,6 +168,57 @@ export function headers(): ReadonlyHeaders {
|
|
|
168
168
|
}) as unknown as ReadonlyHeaders;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Force the calling client's caches to miss from now on, from the server seat:
|
|
173
|
+
* write a rotated `Set-Cookie` for the rango state. The responding client
|
|
174
|
+
* applies it on receipt, and its history cache is marked stale by the
|
|
175
|
+
* jar-divergence observer at its next read. Per-client and lazy — it rotates
|
|
176
|
+
* only the client that receives this response, not every client.
|
|
177
|
+
*
|
|
178
|
+
* Idempotent within a request (one `Set-Cookie`). Inert (a dev warning) when
|
|
179
|
+
* called outside a request context. Like `cookies()`, it throws inside a
|
|
180
|
+
* `"use cache"` / `cache()` boundary, but is allowed from a loader (loaders are
|
|
181
|
+
* the dynamic holes of a cached document).
|
|
182
|
+
*/
|
|
183
|
+
export function invalidateClientCache(): void {
|
|
184
|
+
const ctx = _getRequestContext();
|
|
185
|
+
if (!ctx) {
|
|
186
|
+
if (process.env.NODE_ENV !== "production") {
|
|
187
|
+
console.warn(
|
|
188
|
+
"[rango] invalidateClientCache() was called outside a request context; ignored.",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
assertNotInsideCacheContext(ctx, "invalidateClientCache");
|
|
194
|
+
ctx._rotateStateCookie();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Suppress a server action's automatic client-cache invalidation: tell the
|
|
199
|
+
* action bridge this action changed nothing a route renders, so it should leave
|
|
200
|
+
* the client's state and caches alone (no rotation, no prefetch wipe, no
|
|
201
|
+
* broadcast, no revalidation refetch). Per-response, not per-action-definition —
|
|
202
|
+
* only the execution knows whether anything changed.
|
|
203
|
+
*
|
|
204
|
+
* Sets an internal response header the bridge reads. Idempotent within a
|
|
205
|
+
* request. Inert (a dev warning) outside a request context — there is no
|
|
206
|
+
* automatic invalidation to suppress.
|
|
207
|
+
*/
|
|
208
|
+
export function keepClientCache(): void {
|
|
209
|
+
const ctx = _getRequestContext();
|
|
210
|
+
if (!ctx) {
|
|
211
|
+
if (process.env.NODE_ENV !== "production") {
|
|
212
|
+
console.warn(
|
|
213
|
+
"[rango] keepClientCache() was called outside a request context; ignored.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
assertNotInsideCacheContext(ctx, "keepClientCache");
|
|
219
|
+
ctx._setKeepCacheDirective();
|
|
220
|
+
}
|
|
221
|
+
|
|
171
222
|
/**
|
|
172
223
|
* Create a CookieStore backed by a RequestContext.
|
|
173
224
|
* @internal Shared between cookies() shorthand and context methods.
|
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
+
import type { CacheErrorCategory } from "../cache/cache-error.js";
|
|
14
15
|
import type { CookieOptions } from "../router/middleware.js";
|
|
16
|
+
import {
|
|
17
|
+
KEEP_CACHE_HEADER,
|
|
18
|
+
getRawCookieValue,
|
|
19
|
+
mintStateValue,
|
|
20
|
+
serializeStateCookie,
|
|
21
|
+
} from "../browser/cookie-name.js";
|
|
15
22
|
import type { LoaderDefinition, LoaderContext } from "../types.js";
|
|
16
23
|
import type { ScopedReverseFunction } from "../reverse.js";
|
|
17
24
|
import type {
|
|
@@ -102,6 +109,10 @@ export interface RequestContext<
|
|
|
102
109
|
setStatus(status: number): void;
|
|
103
110
|
/** @internal Set status bypassing cache-exec guard (for framework error handling) */
|
|
104
111
|
_setStatus(status: number): void;
|
|
112
|
+
/** @internal Rotate the rango state cookie (server seat of invalidateClientCache). */
|
|
113
|
+
_rotateStateCookie(): void;
|
|
114
|
+
/** @internal Set the keepClientCache() directive header on the response. */
|
|
115
|
+
_setKeepCacheDirective(): void;
|
|
105
116
|
|
|
106
117
|
/**
|
|
107
118
|
* Access loader data or push handle data.
|
|
@@ -140,6 +151,25 @@ export interface RequestContext<
|
|
|
140
151
|
/** @internal Cache store for segment caching (optional, used by CacheScope) */
|
|
141
152
|
_cacheStore?: SegmentCacheStore;
|
|
142
153
|
|
|
154
|
+
/**
|
|
155
|
+
* @internal Handler-owned registry of explicit per-scope stores from
|
|
156
|
+
* cache({ store }). Created once per createRSCHandler() and threaded into
|
|
157
|
+
* every request context, so it accumulates every explicit store the handler
|
|
158
|
+
* resolves. updateTag()/revalidateTag() iterate this set plus _cacheStore to
|
|
159
|
+
* reach every store that may hold tagged entries. The app-level store is not
|
|
160
|
+
* added here (it is always reachable via _cacheStore).
|
|
161
|
+
*/
|
|
162
|
+
_explicitTaggedStores?: Set<SegmentCacheStore>;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @internal Union of every cache tag resolved while producing this request's
|
|
166
|
+
* response (from cache({ tags }), runtime cacheTag(), and loader cache tags).
|
|
167
|
+
* Populated at the tag-resolution sites via recordRequestTags(). Read by the
|
|
168
|
+
* document cache middleware so a full-page entry is tagged with everything its
|
|
169
|
+
* content used and can therefore be invalidated by updateTag()/revalidateTag().
|
|
170
|
+
*/
|
|
171
|
+
_requestTags: Set<string>;
|
|
172
|
+
|
|
143
173
|
/** @internal Cache profiles for "use cache" profile resolution (per-router) */
|
|
144
174
|
_cacheProfiles?: Record<
|
|
145
175
|
string,
|
|
@@ -318,9 +348,13 @@ export interface RequestContext<
|
|
|
318
348
|
* @internal Report a non-fatal background error through the router's
|
|
319
349
|
* onError callback. Wired by the RSC handler / router during request
|
|
320
350
|
* creation. Cache-runtime and other subsystems call this to surface
|
|
321
|
-
* errors without failing the response.
|
|
351
|
+
* errors without failing the response. `category` is surfaced to consumers as
|
|
352
|
+
* `metadata.category` on the onError context (phase `cache`).
|
|
322
353
|
*/
|
|
323
|
-
_reportBackgroundError?: (
|
|
354
|
+
_reportBackgroundError?: (
|
|
355
|
+
error: unknown,
|
|
356
|
+
category: CacheErrorCategory,
|
|
357
|
+
) => void;
|
|
324
358
|
|
|
325
359
|
/** @internal Per-request debug performance override (set via ctx.debugPerformance()) */
|
|
326
360
|
_debugPerformance?: boolean;
|
|
@@ -336,6 +370,15 @@ export interface RequestContext<
|
|
|
336
370
|
* to avoid a second resolveRoute call. Cleared on HMR invalidation.
|
|
337
371
|
*/
|
|
338
372
|
_classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* @internal Coarse route-level cache signal for the X-Rango-Cache debug
|
|
376
|
+
* header. Populated by match/matchPartial only when the debug cache signal
|
|
377
|
+
* gate is enabled (debugCacheSignal option or RANGO_TEST_SIGNALS=1). Read by
|
|
378
|
+
* the response-finalization path (createResponseWithMergedHeaders). Undefined
|
|
379
|
+
* when the gate is off, so no header is emitted.
|
|
380
|
+
*/
|
|
381
|
+
_cacheSignal?: import("../router/telemetry.js").CacheSegmentSignal[];
|
|
339
382
|
}
|
|
340
383
|
|
|
341
384
|
/**
|
|
@@ -355,6 +398,8 @@ export type PublicRequestContext<
|
|
|
355
398
|
| "deleteCookie"
|
|
356
399
|
| "_handleStore"
|
|
357
400
|
| "_cacheStore"
|
|
401
|
+
| "_explicitTaggedStores"
|
|
402
|
+
| "_requestTags"
|
|
358
403
|
| "_cacheProfiles"
|
|
359
404
|
| "_onResponseCallbacks"
|
|
360
405
|
| "_themeConfig"
|
|
@@ -375,8 +420,11 @@ export type PublicRequestContext<
|
|
|
375
420
|
| "_metricsStore"
|
|
376
421
|
| "_basename"
|
|
377
422
|
| "_setStatus"
|
|
423
|
+
| "_rotateStateCookie"
|
|
424
|
+
| "_setKeepCacheDirective"
|
|
378
425
|
| "_variables"
|
|
379
426
|
| "_classifiedRoute"
|
|
427
|
+
| "_cacheSignal"
|
|
380
428
|
| "res"
|
|
381
429
|
>;
|
|
382
430
|
|
|
@@ -500,6 +548,11 @@ export interface CreateRequestContextOptions<TEnv> {
|
|
|
500
548
|
initialResponse?: Response;
|
|
501
549
|
/** Optional cache store for segment caching (used by CacheScope) */
|
|
502
550
|
cacheStore?: SegmentCacheStore;
|
|
551
|
+
/**
|
|
552
|
+
* Handler-owned registry of explicit per-scope stores for cross-store tag
|
|
553
|
+
* invalidation. Created once per handler, reused across requests.
|
|
554
|
+
*/
|
|
555
|
+
explicitTaggedStores?: Set<SegmentCacheStore>;
|
|
503
556
|
/** Optional cache profiles for "use cache" resolution (per-router) */
|
|
504
557
|
cacheProfiles?: Record<
|
|
505
558
|
string,
|
|
@@ -509,6 +562,10 @@ export interface CreateRequestContextOptions<TEnv> {
|
|
|
509
562
|
executionContext?: ExecutionContext;
|
|
510
563
|
/** Optional theme configuration (enables ctx.theme and ctx.setTheme) */
|
|
511
564
|
themeConfig?: ResolvedThemeConfig | null;
|
|
565
|
+
/** Resolved rango state cookie name, for the server seat of invalidateClientCache(). */
|
|
566
|
+
stateCookieName?: string;
|
|
567
|
+
/** Build version, used as the prefix of a server-rotated rango state value. */
|
|
568
|
+
version?: string;
|
|
512
569
|
}
|
|
513
570
|
|
|
514
571
|
/**
|
|
@@ -529,11 +586,16 @@ export function createRequestContext<TEnv>(
|
|
|
529
586
|
variables,
|
|
530
587
|
initialResponse,
|
|
531
588
|
cacheStore,
|
|
589
|
+
explicitTaggedStores,
|
|
532
590
|
cacheProfiles,
|
|
533
591
|
executionContext,
|
|
534
592
|
themeConfig,
|
|
593
|
+
stateCookieName,
|
|
594
|
+
version: stateVersion,
|
|
535
595
|
} = options;
|
|
536
596
|
const cookieHeader = request.headers.get("Cookie");
|
|
597
|
+
// One Set-Cookie per request no matter how many invalidateClientCache() calls.
|
|
598
|
+
let rangoStateRotated = false;
|
|
537
599
|
let parsedCookies: Record<string, string> | null = null;
|
|
538
600
|
|
|
539
601
|
// Create stub response for collecting headers/cookies.
|
|
@@ -723,6 +785,45 @@ export function createRequestContext<TEnv>(
|
|
|
723
785
|
stubResponse.headers.set(name, value);
|
|
724
786
|
},
|
|
725
787
|
|
|
788
|
+
// Rotate the rango state cookie for the responding client (the server seat
|
|
789
|
+
// of invalidateClientCache). Writes ONE Set-Cookie per request with the
|
|
790
|
+
// value {version}:{timestamp}; the `:` stays raw (the cookie-name.ts
|
|
791
|
+
// serializer), not the URL-encoded form serializeCookieValue would produce.
|
|
792
|
+
// The timestamp is strictly greater than the client's current one (inbound
|
|
793
|
+
// X-Rango-State), so a same-millisecond server rotation still differs from
|
|
794
|
+
// the client value and the divergence observer fires.
|
|
795
|
+
_rotateStateCookie(): void {
|
|
796
|
+
if (rangoStateRotated) return;
|
|
797
|
+
rangoStateRotated = true;
|
|
798
|
+
if (!stateCookieName) return;
|
|
799
|
+
// The client's current value, for the monotonic guard: prefer the
|
|
800
|
+
// X-Rango-State header (router navigation/prefetch fetches send it), but
|
|
801
|
+
// fall back to the request's rango state cookie — action POSTs / plain
|
|
802
|
+
// app fetch()s carry no router header yet DO send the cookie. Without the
|
|
803
|
+
// fallback, prevTs stays 0 and a same-ms mint can equal the client value,
|
|
804
|
+
// leaving the divergence observer silent. `|| null` so an empty header
|
|
805
|
+
// ('' from proxy normalization) falls through instead of short-circuiting.
|
|
806
|
+
// getRawCookieValue reads the cookie undecoded (the wire value
|
|
807
|
+
// decodeStateValue decodes exactly once) AND is the same parser the client
|
|
808
|
+
// mirror uses, so both seats read the same jar entry.
|
|
809
|
+
const prevRaw =
|
|
810
|
+
(request.headers.get("x-rango-state") || null) ??
|
|
811
|
+
getRawCookieValue(cookieHeader, stateCookieName);
|
|
812
|
+
const value = mintStateValue(stateVersion ?? "0", prevRaw);
|
|
813
|
+
stubResponse.headers.append(
|
|
814
|
+
"Set-Cookie",
|
|
815
|
+
serializeStateCookie(stateCookieName, value, url.protocol === "https:"),
|
|
816
|
+
);
|
|
817
|
+
invalidateResponseCookieCache();
|
|
818
|
+
},
|
|
819
|
+
|
|
820
|
+
// Set the keepClientCache() directive header. The action bridge reads it on
|
|
821
|
+
// the response and suppresses its automatic invalidation. `.set` makes this
|
|
822
|
+
// idempotent (one header regardless of call count).
|
|
823
|
+
_setKeepCacheDirective(): void {
|
|
824
|
+
stubResponse.headers.set(KEEP_CACHE_HEADER, "1");
|
|
825
|
+
},
|
|
826
|
+
|
|
726
827
|
setStatus(status: number): void {
|
|
727
828
|
assertNotInsideCacheExec(ctx, "setStatus");
|
|
728
829
|
assertNotInsideCacheScopeALS("setStatus");
|
|
@@ -746,6 +847,8 @@ export function createRequestContext<TEnv>(
|
|
|
746
847
|
|
|
747
848
|
_handleStore: handleStore,
|
|
748
849
|
_cacheStore: cacheStore,
|
|
850
|
+
_explicitTaggedStores: explicitTaggedStores,
|
|
851
|
+
_requestTags: new Set<string>(),
|
|
749
852
|
_cacheProfiles: cacheProfiles,
|
|
750
853
|
|
|
751
854
|
waitUntil(fn: () => Promise<void>): void {
|
package/src/static-handler.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { Handler } from "./types.js";
|
|
|
35
35
|
import type { StaticBuildContext } from "./prerender.js";
|
|
36
36
|
import type { UseItems, HandlerUseItem } from "./route-types.js";
|
|
37
37
|
import { isCachedFunction } from "./cache/taint.js";
|
|
38
|
+
import { isUnderTestRunner } from "./runtime-env.js";
|
|
38
39
|
|
|
39
40
|
// -- Types ------------------------------------------------------------------
|
|
40
41
|
|
|
@@ -63,6 +64,11 @@ export interface StaticHandlerDefinition<
|
|
|
63
64
|
|
|
64
65
|
// -- Function ---------------------------------------------------------------
|
|
65
66
|
|
|
67
|
+
// Process-stable fallback id counter (mirrors createHandle / createLoader /
|
|
68
|
+
// Prerender). Only assigned in a bare unit test where the Vite plugin did not
|
|
69
|
+
// inject an id; never fires in a real build (the plugin always injects).
|
|
70
|
+
let runtimeStaticIdCounter = 0;
|
|
71
|
+
|
|
66
72
|
export function Static<TParams extends Record<string, any> = {}>(
|
|
67
73
|
handler: (ctx: StaticBuildContext) => ReactNode | Promise<ReactNode>,
|
|
68
74
|
options?: StaticHandlerOptions,
|
|
@@ -94,12 +100,28 @@ export function Static<TParams extends Record<string, any>>(
|
|
|
94
100
|
id = maybeId ?? "";
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
// Throw unless under a test runner. The plugin always injects $$id for a
|
|
104
|
+
// supported `export const` Static on every build, so a missing id means either
|
|
105
|
+
// no plugin (a bare test — fall back below) or an UNSUPPORTED shape the plugin
|
|
106
|
+
// silently skipped (dev OR a real build — fail loud; a synthetic id would
|
|
107
|
+
// degrade to a silent static/prerender miss). The message is already small (no
|
|
108
|
+
// stack-parsing diagnostic), so it ships as-is. isUnderTestRunner() is
|
|
109
|
+
// runtime-safe — never a bare `process.env` access.
|
|
110
|
+
if (!id && !isUnderTestRunner()) {
|
|
98
111
|
throw new Error(
|
|
99
|
-
"[rango] Static: missing $$id. " +
|
|
100
|
-
"
|
|
112
|
+
"[rango] Static: missing $$id. Use `export const X = Static(...)` and " +
|
|
113
|
+
"ensure the exposeInternalIds Vite plugin is configured.",
|
|
101
114
|
);
|
|
102
115
|
}
|
|
116
|
+
// Under vitest with no plugin id: assign a process-stable runtime id so a
|
|
117
|
+
// whole-app router with Static() routes constructs in a bare test. Never
|
|
118
|
+
// reached in a real build (the throw above fires there); staticHandlerId is
|
|
119
|
+
// read only during RSC serving (never in dispatch / assertGeneratedRoutesMatch),
|
|
120
|
+
// and the build static manifest keys on the plugin id. Mirrors createHandle /
|
|
121
|
+
// createLoader / Prerender.
|
|
122
|
+
if (!id) {
|
|
123
|
+
id = `__rango_runtime_static_${runtimeStaticIdCounter++}`;
|
|
124
|
+
}
|
|
103
125
|
|
|
104
126
|
return {
|
|
105
127
|
__brand: "staticHandler" as const,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-status testing primitives for @rangojs/router consumers.
|
|
3
|
+
*
|
|
4
|
+
* Two complementary paths, both DEVELOPMENT/TEST ONLY:
|
|
5
|
+
*
|
|
6
|
+
* 1. Header path — `parseCacheHeader` / `assertCacheStatus` read the
|
|
7
|
+
* `X-Rango-Cache` response header. The header is emitted only when the
|
|
8
|
+
* router's debug cache signal gate is on (the `debugCacheSignal` option or
|
|
9
|
+
* `RANGO_TEST_SIGNALS=1`). With the gate off there is no header and these
|
|
10
|
+
* helpers throw a clear "header missing" error.
|
|
11
|
+
*
|
|
12
|
+
* 2. Telemetry path — `createCacheSink` returns a `{ sink, events }` pair the
|
|
13
|
+
* consumer wires via `createRouter({ telemetry: sink })`. This has ZERO
|
|
14
|
+
* production surface: no header, just structured `cache.decision` events
|
|
15
|
+
* (which carry the same coarse `segments` cache signal).
|
|
16
|
+
*
|
|
17
|
+
* v1 cache status is COARSE (route-level): the router reports a single entry
|
|
18
|
+
* keyed by the route key (the route NAME), not per individual segment.
|
|
19
|
+
*
|
|
20
|
+
* Import path: from a Vitest unit/integration test use `@rangojs/router/testing`;
|
|
21
|
+
* from a Playwright e2e use `@rangojs/router/testing/e2e` (the barrel pulls a
|
|
22
|
+
* build-only virtual that does not resolve in a plain Playwright runner).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
CacheDecisionEvent,
|
|
27
|
+
CacheSegmentStatus,
|
|
28
|
+
TelemetryEvent,
|
|
29
|
+
TelemetrySink,
|
|
30
|
+
} from "../router/telemetry.js";
|
|
31
|
+
|
|
32
|
+
const CACHE_HEADER = "X-Rango-Cache";
|
|
33
|
+
|
|
34
|
+
/** Expected cache status passed to assertCacheStatus. */
|
|
35
|
+
export type ExpectedCacheStatus = CacheSegmentStatus;
|
|
36
|
+
|
|
37
|
+
/** A target carrying response headers (a Response or a `{ headers }` object). */
|
|
38
|
+
export type CacheStatusTarget = Response | { headers: Headers };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse an `X-Rango-Cache` header value into a `{ routeKey: status }` map.
|
|
42
|
+
*
|
|
43
|
+
* Header format: `<routeKey>=<status>, <routeKey2>=<status2>`. The key is the
|
|
44
|
+
* route NAME (ctx.routeKey, e.g. `product.detail`), NOT the URL pattern —
|
|
45
|
+
* see assertCacheStatus. Whitespace around entries and the `=` is tolerated.
|
|
46
|
+
* Entries without a status are ignored.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* parseCacheHeader("product.detail=hit, shop.layout=stale")
|
|
50
|
+
* // => { "product.detail": "hit", "shop.layout": "stale" }
|
|
51
|
+
*/
|
|
52
|
+
export function parseCacheHeader(
|
|
53
|
+
headerValue: string | null | undefined,
|
|
54
|
+
): Record<string, string> {
|
|
55
|
+
const result: Record<string, string> = {};
|
|
56
|
+
if (!headerValue) return result;
|
|
57
|
+
for (const rawEntry of headerValue.split(",")) {
|
|
58
|
+
const entry = rawEntry.trim();
|
|
59
|
+
if (entry.length === 0) continue;
|
|
60
|
+
const eq = entry.indexOf("=");
|
|
61
|
+
if (eq === -1) continue;
|
|
62
|
+
const id = entry.slice(0, eq).trim();
|
|
63
|
+
const status = entry.slice(eq + 1).trim();
|
|
64
|
+
if (id.length === 0 || status.length === 0) continue;
|
|
65
|
+
result[id] = status;
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getHeaders(target: CacheStatusTarget): Headers {
|
|
71
|
+
return target.headers;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Assert that the `X-Rango-Cache` header reports `expected` status for the
|
|
76
|
+
* given route. Throws a descriptive error when the header is missing (gate
|
|
77
|
+
* off), the route is absent, or the status differs.
|
|
78
|
+
*
|
|
79
|
+
* `routeKey` is the route NAME (e.g. `product.detail`), the same id the header
|
|
80
|
+
* carries — NOT the URL pattern (`/products/:id`). The signal is built from
|
|
81
|
+
* ctx.routeKey (telemetry.ts), so a pattern-shaped key never matches.
|
|
82
|
+
*
|
|
83
|
+
* The header is produced by the RSC render pipeline, so get the Response from
|
|
84
|
+
* the router's real fetch path (`router.fetch(...)`), with the debug cache
|
|
85
|
+
* signal gate enabled (`debugCacheSignal: true` or `RANGO_TEST_SIGNALS=1`).
|
|
86
|
+
* NOTE: `dispatch()` is the non-RSC primitive and never emits this header.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* // debugCacheSignal must be enabled on the router under test.
|
|
90
|
+
* const res = await router.fetch(new Request("https://app/products/42"));
|
|
91
|
+
* assertCacheStatus(res, "product.detail", "hit");
|
|
92
|
+
*/
|
|
93
|
+
export function assertCacheStatus(
|
|
94
|
+
target: CacheStatusTarget,
|
|
95
|
+
segment: string,
|
|
96
|
+
expected: ExpectedCacheStatus,
|
|
97
|
+
): void {
|
|
98
|
+
const headerValue = getHeaders(target).get(CACHE_HEADER);
|
|
99
|
+
if (headerValue === null) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`assertCacheStatus: response has no ${CACHE_HEADER} header. ` +
|
|
102
|
+
`Enable the debug cache signal via createRouter({ debugCacheSignal: true }) ` +
|
|
103
|
+
`or RANGO_TEST_SIGNALS=1.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const map = parseCacheHeader(headerValue);
|
|
107
|
+
const actual = map[segment];
|
|
108
|
+
if (actual === undefined) {
|
|
109
|
+
const known = Object.keys(map);
|
|
110
|
+
throw new Error(
|
|
111
|
+
`assertCacheStatus: segment "${segment}" not found in ${CACHE_HEADER} ` +
|
|
112
|
+
`("${headerValue}"). Known segments: ${
|
|
113
|
+
known.length > 0 ? known.join(", ") : "(none)"
|
|
114
|
+
}.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (actual !== expected) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`assertCacheStatus: segment "${segment}" expected "${expected}" but got "${actual}".`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A telemetry sink paired with the array it records events into.
|
|
126
|
+
*/
|
|
127
|
+
export interface CacheSink {
|
|
128
|
+
/** Wire into `createRouter({ telemetry: sink })`. */
|
|
129
|
+
sink: TelemetrySink;
|
|
130
|
+
/** All telemetry events captured so far, in emit order. */
|
|
131
|
+
events: TelemetryEvent[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create a capturing telemetry sink for asserting on `cache.decision` events.
|
|
136
|
+
*
|
|
137
|
+
* This is the ZERO-production-surface path: no response header is emitted, the
|
|
138
|
+
* consumer just inspects the captured events.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* const { sink, events } = createCacheSink();
|
|
142
|
+
* const router = createRouter({ telemetry: sink, ... });
|
|
143
|
+
* // ...send a request through the router's RSC fetch path...
|
|
144
|
+
* const decisions = filterCacheDecisions(events);
|
|
145
|
+
* expect(decisions[0].segments?.[0].cacheStatus).toBe("hit");
|
|
146
|
+
*/
|
|
147
|
+
export function createCacheSink(): CacheSink {
|
|
148
|
+
const events: TelemetryEvent[] = [];
|
|
149
|
+
const sink: TelemetrySink = {
|
|
150
|
+
emit(event: TelemetryEvent): void {
|
|
151
|
+
events.push(event);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
return { sink, events };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Filter captured telemetry events down to `cache.decision` events.
|
|
159
|
+
*/
|
|
160
|
+
export function filterCacheDecisions(
|
|
161
|
+
events: readonly TelemetryEvent[],
|
|
162
|
+
): CacheDecisionEvent[] {
|
|
163
|
+
return events.filter(
|
|
164
|
+
(e): e is CacheDecisionEvent => e.type === "cache.decision",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* collectHandle — unit-test a handle's `collect`/accumulator function directly.
|
|
3
|
+
*
|
|
4
|
+
* A handle's collect function (the `createHandle(collect)` argument that maps the
|
|
5
|
+
* per-segment pushed values into the accumulated result) is otherwise not
|
|
6
|
+
* directly reachable: createHandle keeps it in a private registry keyed by the
|
|
7
|
+
* handle's `$$id` and returns only `{ __brand, $$id }`. This primitive runs that
|
|
8
|
+
* REAL registered collect on per-segment values you provide and returns the
|
|
9
|
+
* accumulated result — so the mapper/accumulator is unit-testable without a full
|
|
10
|
+
* route match.
|
|
11
|
+
*
|
|
12
|
+
* It relies on createHandle registering the collect even in a bare test (it
|
|
13
|
+
* assigns a runtime fallback id when the Vite plugin did not inject one). If a
|
|
14
|
+
* handle's module was never imported (so createHandle never ran), the collect is
|
|
15
|
+
* unregistered and this falls back to a flat array with a warning.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getCollectFn, type Handle } from "../handle.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run a handle's collect function on per-segment pushed values.
|
|
22
|
+
*
|
|
23
|
+
* @param handle - The handle whose collect to run.
|
|
24
|
+
* @param segments - Per-segment pushed values: each entry is the array of values
|
|
25
|
+
* one route segment pushed for this handle, in parent -> child order. Empty
|
|
26
|
+
* per-segment arrays are dropped before the collect runs, matching production
|
|
27
|
+
* collectHandleData (a segment that pushed nothing is not passed through).
|
|
28
|
+
* @returns The accumulated value the handle's collect produces.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* // Default flatten
|
|
33
|
+
* collectHandle(Breadcrumbs, [[{ label: "Home", href: "/" }], [{ label: "P", href: "/p" }]]);
|
|
34
|
+
* // -> [{ label: "Home", href: "/" }, { label: "P", href: "/p" }]
|
|
35
|
+
*
|
|
36
|
+
* // Custom "last wins"
|
|
37
|
+
* const PageTitle = createHandle<string, string>((s) => s.flat().at(-1) ?? "");
|
|
38
|
+
* collectHandle(PageTitle, [["Home"], ["Product"]]); // -> "Product"
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function collectHandle<TData, TAccumulated>(
|
|
42
|
+
handle: Handle<TData, TAccumulated>,
|
|
43
|
+
segments: ReadonlyArray<ReadonlyArray<TData>>,
|
|
44
|
+
): TAccumulated {
|
|
45
|
+
const collectFn = getCollectFn(handle.$$id) as
|
|
46
|
+
| ((segments: TData[][]) => TAccumulated)
|
|
47
|
+
| undefined;
|
|
48
|
+
|
|
49
|
+
if (!collectFn) {
|
|
50
|
+
console.warn(
|
|
51
|
+
`[rango] collectHandle: handle "${handle.$$id}" has no registered collect ` +
|
|
52
|
+
`function. Import the handle's module so createHandle() runs. Falling ` +
|
|
53
|
+
`back to a flat array.`,
|
|
54
|
+
);
|
|
55
|
+
return segments.flat() as unknown as TAccumulated;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Match production collectHandleData (handle.ts): segments that pushed
|
|
59
|
+
// nothing (empty arrays) are dropped before the collect runs, so a collect
|
|
60
|
+
// that inspects segment count or indices sees the same input as at runtime.
|
|
61
|
+
const nonEmpty = segments.filter((seg) => seg.length > 0) as TData[][];
|
|
62
|
+
return collectFn(nonEmpty);
|
|
63
|
+
}
|