@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.
Files changed (120) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +47 -6
  3. package/package.json +61 -21
  4. package/skills/cache-guide/SKILL.md +8 -6
  5. package/skills/caching/SKILL.md +148 -1
  6. package/skills/hooks/SKILL.md +38 -27
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +38 -16
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +27 -15
  15. package/skills/route/SKILL.md +4 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/skills/use-cache/SKILL.md +9 -7
  32. package/src/browser/action-fence.ts +37 -0
  33. package/src/browser/cookie-name.ts +140 -0
  34. package/src/browser/invalidate-client-cache.ts +52 -0
  35. package/src/browser/navigation-bridge.ts +14 -1
  36. package/src/browser/navigation-client.ts +14 -1
  37. package/src/browser/navigation-store-handle.ts +39 -0
  38. package/src/browser/navigation-store.ts +26 -12
  39. package/src/browser/prefetch/fetch.ts +7 -0
  40. package/src/browser/rango-state.ts +176 -97
  41. package/src/browser/react/index.ts +0 -6
  42. package/src/browser/rsc-router.tsx +12 -4
  43. package/src/browser/server-action-bridge.ts +77 -15
  44. package/src/browser/types.ts +7 -1
  45. package/src/cache/cache-error.ts +104 -0
  46. package/src/cache/cache-policy.ts +95 -1
  47. package/src/cache/cache-runtime.ts +79 -13
  48. package/src/cache/cache-scope.ts +55 -4
  49. package/src/cache/cache-tag.ts +135 -0
  50. package/src/cache/cf/cf-cache-store.ts +2080 -224
  51. package/src/cache/cf/index.ts +15 -1
  52. package/src/cache/document-cache.ts +74 -7
  53. package/src/cache/index.ts +17 -0
  54. package/src/cache/memory-segment-store.ts +164 -14
  55. package/src/cache/tag-invalidation.ts +230 -0
  56. package/src/cache/types.ts +27 -0
  57. package/src/client.rsc.tsx +1 -1
  58. package/src/client.tsx +0 -6
  59. package/src/component-utils.ts +19 -0
  60. package/src/handle.ts +29 -9
  61. package/src/host/testing.ts +43 -14
  62. package/src/index.rsc.ts +29 -1
  63. package/src/index.ts +43 -1
  64. package/src/loader.rsc.ts +24 -3
  65. package/src/loader.ts +16 -2
  66. package/src/prerender.ts +24 -3
  67. package/src/router/basename.ts +14 -0
  68. package/src/router/match-handlers.ts +62 -20
  69. package/src/router/prerender-match.ts +6 -0
  70. package/src/router/router-interfaces.ts +7 -0
  71. package/src/router/router-options.ts +30 -0
  72. package/src/router/segment-resolution/loader-cache.ts +8 -17
  73. package/src/router/state-cookie-name.ts +33 -0
  74. package/src/router/telemetry.ts +99 -0
  75. package/src/router.ts +36 -7
  76. package/src/rsc/handler.ts +13 -1
  77. package/src/rsc/helpers.ts +19 -0
  78. package/src/rsc/progressive-enhancement.ts +2 -0
  79. package/src/rsc/response-route-handler.ts +8 -1
  80. package/src/rsc/rsc-rendering.ts +2 -0
  81. package/src/rsc/types.ts +2 -0
  82. package/src/runtime-env.ts +18 -0
  83. package/src/server/cookie-store.ts +52 -1
  84. package/src/server/request-context.ts +105 -2
  85. package/src/static-handler.ts +25 -3
  86. package/src/testing/cache-status.ts +166 -0
  87. package/src/testing/collect-handle.ts +63 -0
  88. package/src/testing/dispatch.ts +581 -0
  89. package/src/testing/dom.entry.ts +22 -0
  90. package/src/testing/e2e/fixture.ts +188 -0
  91. package/src/testing/e2e/index.ts +149 -0
  92. package/src/testing/e2e/matchers.ts +51 -0
  93. package/src/testing/e2e/page-helpers.ts +272 -0
  94. package/src/testing/e2e/parity.ts +387 -0
  95. package/src/testing/e2e/server.ts +195 -0
  96. package/src/testing/flight-matchers.ts +110 -0
  97. package/src/testing/flight-normalize.ts +38 -0
  98. package/src/testing/flight-runtime.d.ts +57 -0
  99. package/src/testing/flight-tree.ts +682 -0
  100. package/src/testing/flight.entry.ts +52 -0
  101. package/src/testing/flight.ts +234 -0
  102. package/src/testing/generated-routes.ts +223 -0
  103. package/src/testing/index.ts +119 -0
  104. package/src/testing/internal/context.ts +390 -0
  105. package/src/testing/internal/flight-client-globals.ts +30 -0
  106. package/src/testing/internal/seed-vars.ts +80 -0
  107. package/src/testing/render-handler.ts +360 -0
  108. package/src/testing/render-route.tsx +594 -0
  109. package/src/testing/run-loader.ts +474 -0
  110. package/src/testing/run-middleware.ts +231 -0
  111. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  112. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  113. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  114. package/src/testing/vitest-stubs/version.ts +5 -0
  115. package/src/testing/vitest.ts +305 -0
  116. package/src/types/cache-types.ts +13 -4
  117. package/src/types/error-types.ts +5 -1
  118. package/src/types/global-namespace.ts +11 -1
  119. package/src/types/handler-context.ts +16 -5
  120. package/src/browser/react/use-client-cache.ts +0 -58
@@ -0,0 +1,231 @@
1
+ /**
2
+ * runMiddleware — unit-test one or more middleware functions in isolation.
3
+ *
4
+ * Executes the middleware chain via the SAME executeLoaderMiddleware the router
5
+ * uses, so ordering, `next()`, short-circuit (return OR throw Response),
6
+ * double-next guards, and header/cookie merging behave identically to
7
+ * production. The chain runs inside runWithRequestContext, so cookie/header
8
+ * mutations and getRequestContext() resolve.
9
+ *
10
+ * The returned `ctx` is the underlying RequestContext (not the per-middleware
11
+ * MiddlewareContext), exposing `ctx.cookies()`, `ctx.get()`, and
12
+ * `ctx.res.headers` for assertions on what the chain produced.
13
+ *
14
+ * `nextCalled` counts how many times the terminal `next()` (the finalHandler)
15
+ * ran: 0 when the chain short-circuited, 1 when it ran to completion.
16
+ */
17
+
18
+ import {
19
+ runWithRequestContext,
20
+ type RequestContext,
21
+ } from "../server/request-context.js";
22
+ import { executeLoaderMiddleware } from "../router/middleware.js";
23
+ import { createReverseFunction } from "../router/handler-context.js";
24
+ import type { MiddlewareFn } from "../router/middleware-types.js";
25
+ import {
26
+ createTestRequestContext,
27
+ headersToObject,
28
+ snapshotRunEffects,
29
+ type CreateTestContextOptions,
30
+ type VarsInit,
31
+ type StateCookieSeed,
32
+ } from "./internal/context.js";
33
+ import type { ThemeConfig } from "../theme/types.js";
34
+ import type { SegmentCacheStore } from "../cache/types.js";
35
+ import type { CacheProfile } from "../cache/profile-registry.js";
36
+
37
+ /**
38
+ * Options for runMiddleware.
39
+ */
40
+ export interface RunMiddlewareOptions<TEnv = any> {
41
+ /**
42
+ * The request the chain runs under: a `Request`, or a URL string (absolute or
43
+ * path). Optional for parity with `runLoader`/`runInRequestContext` — when
44
+ * omitted it defaults to `http://localhost/`. Pass it for path-, header-, or
45
+ * cookie-driven middleware.
46
+ */
47
+ request?: Request | string;
48
+ /** Environment bindings surfaced as `ctx.env`. */
49
+ env?: TEnv;
50
+ /** Route params surfaced as `ctx.params`. */
51
+ params?: Record<string, string>;
52
+ /** Variables a prior middleware would have set (object or [key, value] list). */
53
+ vars?: VarsInit;
54
+ /** Route name -> pattern map enabling `ctx.reverse()`. */
55
+ routeMap?: Record<string, string>;
56
+ /**
57
+ * Matched route name surfaced as `ctx.routeName`. Does NOT scope `.name`
58
+ * reverse: the chain receives a map-only `reverse` (built from `routeMap`
59
+ * alone), matching production app/response middleware — see the reverse
60
+ * construction below.
61
+ */
62
+ routeName?: string;
63
+ /** Router basename surfaced on the context (drives redirect() prefixing). */
64
+ basename?: string;
65
+ /** Theme config in the `createRouter({ theme })` shape (enables ctx.theme). */
66
+ theme?: ThemeConfig | true;
67
+ /**
68
+ * Terminal handler invoked when the chain calls `next()` all the way through.
69
+ * Defaults to a 200 empty Response. Use this to model the downstream
70
+ * route/handler response.
71
+ */
72
+ next?: () => Promise<Response>;
73
+ /**
74
+ * Cache store backing any `use cache` function a middleware invokes. Without
75
+ * it, registerCachedFunction bypasses (it checks for a store first), so the
76
+ * cached function runs uncached and its taint/profile guards never fire.
77
+ */
78
+ cacheStore?: SegmentCacheStore;
79
+ /** Cache profiles (the `createRouter({ cacheProfiles })` shape). */
80
+ cacheProfiles?: Record<string, CacheProfile>;
81
+ /**
82
+ * Customize the rango state cookie a middleware that calls
83
+ * `invalidateClientCache()` rotates (the name is always seeded — default
84
+ * `rango-state_router_0` — so it rotates like production). Assert via the
85
+ * `Set-Cookie` on `result.response` / `result.cookies`.
86
+ */
87
+ stateCookie?: StateCookieSeed;
88
+ }
89
+
90
+ /**
91
+ * Result of runMiddleware.
92
+ */
93
+ export interface RunMiddlewareResult<TEnv = any> {
94
+ /** The final Response (downstream response, or a middleware short-circuit). */
95
+ response: Response;
96
+ /**
97
+ * The underlying RequestContext. Read `ctx.cookies()`, `ctx.get(...)`, and
98
+ * `ctx.res.headers` to assert on the chain's effects. (This is always the
99
+ * RequestContext the chain ran under — not a per-middleware MiddlewareContext —
100
+ * so `ctx.cookies()` and the other RequestContext accessors are available.)
101
+ */
102
+ ctx: RequestContext<TEnv>;
103
+ /** Number of times the terminal handler ran (0 = short-circuited, 1 = passed through). */
104
+ nextCalled: number;
105
+ /**
106
+ * The effective cookie view after the chain ran: request cookies merged with
107
+ * anything the chain set or deleted (last-write-wins), as `{ name: value }`.
108
+ * The public way to assert a cookie a middleware set, without casting through
109
+ * the `@internal` `ctx.cookies()`. Set-Cookie headers are also on `response`.
110
+ */
111
+ cookies: Record<string, string>;
112
+ /**
113
+ * The final response's headers as a plain `{ name: value }` object (the same
114
+ * view as `response.headers`), EXCLUDING `set-cookie` (use `cookies`). The
115
+ * public way to assert a header a middleware set (e.g. a security header)
116
+ * without reading `ctx.res.headers`. Header names are lowercased.
117
+ */
118
+ headers: Record<string, string>;
119
+ /**
120
+ * Location state the chain set via `ctx.setLocationState()` / `redirect({ state })`,
121
+ * resolved to the flat `{ key: value }` shape the client reads off
122
+ * `history.state` (empty object when none) — parity with `runInRequestContext`
123
+ * and `renderHandler`.
124
+ */
125
+ locationState: Record<string, unknown>;
126
+ /**
127
+ * The resolved rango state cookie name seeded for the run (default
128
+ * `rango-state_router_0`, or composed from `opts.stateCookie`). Assert a
129
+ * middleware's `invalidateClientCache()` rotation against it without
130
+ * recomputing — parity with `runInRequestContext` / `runLoaderResult` /
131
+ * `renderHandler`.
132
+ */
133
+ stateCookieName: string;
134
+ }
135
+
136
+ /**
137
+ * Run a middleware chain and return the response plus observable context.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * const { response, ctx, nextCalled } = await runMiddleware(
142
+ * async (ctx, next) => {
143
+ * if (!ctx.get("user")) return new Response(null, { status: 401 });
144
+ * return next();
145
+ * },
146
+ * { request: "/dashboard", vars: [["user", { id: 1 }]] },
147
+ * );
148
+ * // nextCalled === 1, response.status === 200
149
+ * ```
150
+ */
151
+ export async function runMiddleware<TEnv = any>(
152
+ mw: MiddlewareFn<TEnv> | MiddlewareFn<TEnv>[],
153
+ opts: RunMiddlewareOptions<TEnv>,
154
+ ): Promise<RunMiddlewareResult<TEnv>> {
155
+ const mwArray = Array.isArray(mw) ? mw : [mw];
156
+
157
+ const ctxOpts: CreateTestContextOptions<TEnv> = {
158
+ env: opts.env,
159
+ request: opts.request,
160
+ vars: opts.vars,
161
+ routeMap: opts.routeMap,
162
+ routeName: opts.routeName,
163
+ params: opts.params,
164
+ basename: opts.basename,
165
+ theme: opts.theme,
166
+ cacheStore: opts.cacheStore,
167
+ cacheProfiles: opts.cacheProfiles,
168
+ stateCookie: opts.stateCookie,
169
+ };
170
+
171
+ const {
172
+ ctx,
173
+ request: builtRequest,
174
+ variables,
175
+ stateCookieName,
176
+ } = createTestRequestContext<TEnv>(ctxOpts);
177
+
178
+ let nextCalled = 0;
179
+ const finalHandler = async (): Promise<Response> => {
180
+ nextCalled++;
181
+ return opts.next?.() ?? new Response(null, { status: 200 });
182
+ };
183
+
184
+ // Match production: app/response middleware receive ctx.reverse built from the
185
+ // route map ALONE (no matched route name or current params), so reversing a
186
+ // parameterized route without explicit params does NOT auto-fill from the
187
+ // current request. Passing routeName/params here would recreate the
188
+ // false-confidence class fixed in dispatch.
189
+ const reverse = opts.routeMap
190
+ ? (createReverseFunction(opts.routeMap) as (
191
+ name: string,
192
+ params?: Record<string, string>,
193
+ search?: Record<string, unknown>,
194
+ ) => string)
195
+ : undefined;
196
+
197
+ // Keep the RETURNED ctx.reverse consistent with the map-only reverse the
198
+ // chain receives. createTestRequestContext installs an auto-fill reverse
199
+ // (correct for the loader phase) when routeName/params are passed, but
200
+ // production app/response middleware see a map-only reverse. Without this,
201
+ // a middleware reading getRequestContext().reverse — or a consumer asserting
202
+ // on result.ctx.reverse — would observe auto-fill that production never does.
203
+ if (reverse) {
204
+ (ctx as RequestContext<TEnv>).reverse =
205
+ reverse as RequestContext<TEnv>["reverse"];
206
+ }
207
+
208
+ const response = await runWithRequestContext(ctx, () =>
209
+ executeLoaderMiddleware<TEnv>(
210
+ mwArray,
211
+ builtRequest,
212
+ (opts.env ?? {}) as TEnv,
213
+ opts.params ?? {},
214
+ variables,
215
+ finalHandler,
216
+ reverse,
217
+ ),
218
+ );
219
+
220
+ const { cookies, locationState } = snapshotRunEffects(ctx);
221
+ const headers = headersToObject(response.headers);
222
+ return {
223
+ response,
224
+ ctx,
225
+ nextCalled,
226
+ cookies,
227
+ headers,
228
+ locationState,
229
+ stateCookieName,
230
+ };
231
+ }
@@ -0,0 +1,9 @@
1
+ // Stub for the `cloudflare:email` runtime virtual, shipped for Cloudflare
2
+ // consumers (enable via `rangoTestAliases({ preset: "cloudflare" })`).
3
+ export class EmailMessage {
4
+ constructor(
5
+ public from: string,
6
+ public to: string,
7
+ public raw: unknown,
8
+ ) {}
9
+ }
@@ -0,0 +1,21 @@
1
+ // Stub for the `cloudflare:workers` runtime virtual, shipped for Cloudflare
2
+ // consumers (enable via `rangoTestAliases({ preset: "cloudflare" })`). A CF app's
3
+ // route tree commonly imports `cloudflare:workers` (e.g. `import { env } from
4
+ // "cloudflare:workers"`), which does not resolve in a bare Vitest process.
5
+ export const env: Record<string, unknown> = {};
6
+
7
+ export class DurableObject<Env = unknown> {
8
+ constructor(
9
+ public ctx: unknown,
10
+ public env: Env,
11
+ ) {}
12
+ }
13
+
14
+ export class WorkerEntrypoint<Env = unknown> {
15
+ constructor(
16
+ public ctx: unknown,
17
+ public env: Env,
18
+ ) {}
19
+ }
20
+
21
+ export class RpcTarget {}
@@ -0,0 +1,16 @@
1
+ // Stub for `@vitejs/plugin-rsc/rsc`, shipped so consumers do not have to write a
2
+ // per-file `vi.mock(...)`. Importing a router internal transitively pulls this
3
+ // module, whose real top-level body imports Vite virtuals that do not resolve in
4
+ // plain node. The unit/integration primitives (dispatch/runLoader/runMiddleware)
5
+ // never render RSC, so empty fns suffice.
6
+ export const createFromReadableStream = (): never => {
7
+ throw new Error("plugin-rsc stub: createFromReadableStream not available");
8
+ };
9
+ export const renderToReadableStream = (): never => {
10
+ throw new Error("plugin-rsc stub: renderToReadableStream not available");
11
+ };
12
+ export const loadServerAction = (): undefined => undefined;
13
+ export const decodeReply = (): undefined => undefined;
14
+ export const decodeAction = (): undefined => undefined;
15
+ export const decodeFormState = (): undefined => undefined;
16
+ export const createTemporaryReferenceSet = (): Record<string, never> => ({});
@@ -0,0 +1,5 @@
1
+ // Stub for the build-only `@rangojs/router:version` virtual module, shipped so
2
+ // consumers do not have to author it. The rango Vite plugin injects this at
3
+ // build time; in a bare Vitest process it must be aliased to a stub. Empty
4
+ // string keeps generated URLs free of a version path segment.
5
+ export const VERSION = "";
@@ -0,0 +1,305 @@
1
+ /**
2
+ * @rangojs/router/testing/vitest
3
+ *
4
+ * Vitest setup helper for the UNIT + INTEGRATION + DOM test project of a
5
+ * @rangojs/router consumer app. It returns the `resolve.alias` entries that make
6
+ * a real app's router / loaders / middleware importable in a bare Vitest process.
7
+ *
8
+ * Why this is needed (the documented "vi.mock(plugin-rsc) + import router"
9
+ * recipe is not sufficient for a real app):
10
+ *
11
+ * - `@rangojs/router` resolves to SERVER-ONLY STUBS outside the `react-server`
12
+ * condition — `urls()`, `createRouter()`, `cookies()`, `getRequestContext()`
13
+ * throw "only available in a react-server environment". Importing the app's own
14
+ * router/loaders/middleware then fails immediately. Vitest does NOT apply the
15
+ * `react-server` condition to bare-package exports resolution, and enabling it
16
+ * globally flips React to its server build (no `createContext`), crashing the
17
+ * router's client-boundary imports. The surgical fix is to alias ONLY the bare
18
+ * `@rangojs/router` specifier to its react-server entry (real impls) while
19
+ * leaving React as the client build — which is exactly what this helper does.
20
+ * - The build-only `@rangojs/router:version` virtual and `@vitejs/plugin-rsc/rsc`
21
+ * (whose real body imports unresolvable Vite virtuals) are stubbed.
22
+ * - Cloudflare apps additionally import the `cloudflare:workers` /
23
+ * `cloudflare:email` runtime virtuals; pass `{ preset: "cloudflare" }` to stub them.
24
+ *
25
+ * Usage (recommended one-call form — see {@link rangoTestConfig}):
26
+ *
27
+ * ```ts
28
+ * // vitest.config.ts
29
+ * import { defineConfig } from "vitest/config";
30
+ * import { rangoTestConfig } from "@rangojs/router/testing/vitest";
31
+ *
32
+ * export default defineConfig({
33
+ * test: {
34
+ * globals: true,
35
+ * include: ["test/**\/*.test.{ts,tsx}"],
36
+ * environment: "node",
37
+ * ...rangoTestConfig({ preset: "cloudflare" }),
38
+ * },
39
+ * });
40
+ * ```
41
+ *
42
+ * `rangoTestConfig` bundles the resolve aliases ({@link rangoTestAliases}) with
43
+ * the `server.deps.inline` contract ({@link rangoInlineDeps}) an installed
44
+ * consumer needs — @rangojs/router ships as TS source, and without `deps.inline`
45
+ * Vitest hands those `.ts` files to Node, which on Node >= 23 throws
46
+ * `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. Use the lower-level
47
+ * `rangoTestAliases` directly only if you wire `deps.inline` yourself.
48
+ *
49
+ * Notes:
50
+ * - The Flight project (real RSC rendering via `@rangojs/router/testing/flight`)
51
+ * uses the `react-server` condition AND needs this same alias whenever a
52
+ * rendered handler/component imports a server API (`getRequestContext`,
53
+ * `cookies`) from the bare `@rangojs/router` — without it that import resolves
54
+ * to the throwing out-of-react-server stub (`resolve.conditions` alone is not
55
+ * reliably applied to bare-package export resolution). The alias points at
56
+ * `index.rsc.ts` (the real react-server build) and leaves React itself
57
+ * untouched, so it does NOT crash the server React build. The router's OWN
58
+ * Flight tests omit it only because they import via RELATIVE paths, not the
59
+ * bare specifier; a consumer importing the bare specifier must include it. See
60
+ * the testing skill (`skills/testing/setup.md`, shipped in the package) for
61
+ * the complete Flight config.
62
+ * - `renderRoute` (`@rangojs/router/testing/dom`) tests run in this same project
63
+ * under a DOM environment (`happy-dom`/`jsdom`); the alias does not affect them.
64
+ * - A router using `Prerender()` / `createLoader()` / `Static()` now CONSTRUCTS in
65
+ * a bare test: each assigns a process-stable runtime fallback `$$id` ONLY under
66
+ * a test runner (`process.env.VITEST`), so `createRouter().routes(...)` builds
67
+ * without the "missing `$$id`" throw (for `dispatch` / `assertGeneratedRoutesMatch`).
68
+ * Outside a test runner (a real build) a missing id still THROWS — so an
69
+ * unsupported handler shape the plugin skipped (e.g. `export let`) fails loud
70
+ * rather than getting a silent synthetic id. (The plugin always injects for
71
+ * supported `export const` shapes, and the static manifest keys on that id.)
72
+ * - Importing your app's whole router *file* can still fail for app-specific
73
+ * reasons (page modules pulling their own deps, or plugin `virtual:` modules
74
+ * that need the rango plugin) — build whole-router `dispatch`/drift checks from
75
+ * a focused include, or use e2e.
76
+ */
77
+
78
+ import { fileURLToPath } from "node:url";
79
+
80
+ /** A single Vite/Vitest resolve alias entry. Structurally a Vite `Alias`. */
81
+ export interface TestAlias {
82
+ find: string | RegExp;
83
+ replacement: string;
84
+ }
85
+
86
+ /** Options for {@link rangoTestAliases}. */
87
+ export interface RangoTestAliasOptions {
88
+ /**
89
+ * Deployment preset, matching `rango({ preset })` in the Vite plugin. With
90
+ * `"cloudflare"` the helper additionally stubs the Cloudflare Workers runtime
91
+ * virtuals (`cloudflare:workers` / `cloudflare:email`) a CF app's route tree
92
+ * imports. A string (not a boolean) so more presets can be added without an
93
+ * API change. Default: `"node"`.
94
+ */
95
+ preset?: "node" | "cloudflare";
96
+ }
97
+
98
+ /**
99
+ * Resolve a path relative to this module. Anchored at the PACKAGE ROOT
100
+ * (`../../` from both `src/testing/vitest.ts` and the shipped
101
+ * `dist/testing/vitest.js` — each is two levels below the root), so the alias
102
+ * targets always point at the `src/*.ts` files Vite transpiles at test time,
103
+ * regardless of whether this helper is loaded as source (in-repo) or as the
104
+ * compiled `dist` entry (an installed consumer).
105
+ */
106
+ function here(relativeFromRoot: string): string {
107
+ return fileURLToPath(new URL(`../../${relativeFromRoot}`, import.meta.url));
108
+ }
109
+
110
+ /**
111
+ * Build the `resolve.alias` entries a consumer's node/DOM Vitest project needs to
112
+ * import a real @rangojs/router app's router/loaders/middleware. Spread into a
113
+ * Vitest config: `resolve: { alias: rangoTestAliases(...) }` (concat your own
114
+ * aliases as needed).
115
+ */
116
+ export function rangoTestAliases(
117
+ opts: RangoTestAliasOptions = {},
118
+ ): TestAlias[] {
119
+ const aliases: TestAlias[] = [
120
+ // Real impls (index.rsc.ts) for the bare specifier ONLY — exact regex so
121
+ // subpaths (/testing, /client, /cache, ...) are untouched. React stays the
122
+ // client build, so createContext and "use client" modules work.
123
+ { find: /^@rangojs\/router$/, replacement: here("src/index.rsc.ts") },
124
+ {
125
+ find: "@rangojs/router:version",
126
+ replacement: here("src/testing/vitest-stubs/version.ts"),
127
+ },
128
+ {
129
+ find: /^@vitejs\/plugin-rsc\/rsc$/,
130
+ replacement: here("src/testing/vitest-stubs/plugin-rsc.ts"),
131
+ },
132
+ ];
133
+
134
+ if (opts.preset === "cloudflare") {
135
+ aliases.push(
136
+ {
137
+ find: "cloudflare:workers",
138
+ replacement: here("src/testing/vitest-stubs/cloudflare-workers.ts"),
139
+ },
140
+ {
141
+ find: "cloudflare:email",
142
+ replacement: here("src/testing/vitest-stubs/cloudflare-email.ts"),
143
+ },
144
+ );
145
+ }
146
+
147
+ return aliases;
148
+ }
149
+
150
+ /**
151
+ * Vitest `server.deps.inline` patterns that force Vite (not Node) to transpile
152
+ * @rangojs/router's TypeScript source under test.
153
+ *
154
+ * REQUIRED for an installed (node_modules) consumer: @rangojs/router ships as TS
155
+ * source, and Vitest externalizes node_modules by default — so without this Node
156
+ * loads the `.ts` files directly and, on Node >= 23, throws
157
+ * `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. In this monorepo it is a no-op
158
+ * (the workspace symlink resolves to a realpath outside node_modules, which Vite
159
+ * already transpiles), which is precisely why an in-repo dogfood never surfaces
160
+ * the need and the contract has to be shipped explicitly.
161
+ */
162
+ export const rangoInlineDeps: RegExp[] = [/@rangojs[/\\]router/];
163
+
164
+ /** The Vitest `test`-block fragment {@link rangoTestConfig} returns. */
165
+ export interface RangoTestConfig {
166
+ alias: TestAlias[];
167
+ server: { deps: { inline: RegExp[] } };
168
+ }
169
+
170
+ /**
171
+ * The complete Vitest `test`-block fragment a consumer needs: the resolve
172
+ * aliases ({@link rangoTestAliases}) AND the `server.deps.inline` contract
173
+ * ({@link rangoInlineDeps}). Spread it into your `test` block so both land in
174
+ * one place and a consumer cannot forget the `deps.inline` half (omitting it
175
+ * loads rango's TS source through Node and breaks on Node >= 23):
176
+ *
177
+ * ```ts
178
+ * // vitest.config.ts
179
+ * import { defineConfig } from "vitest/config";
180
+ * import { rangoTestConfig } from "@rangojs/router/testing/vitest";
181
+ *
182
+ * export default defineConfig({
183
+ * test: {
184
+ * globals: true,
185
+ * include: ["test/**\/*.test.{ts,tsx}"],
186
+ * environment: "node",
187
+ * ...rangoTestConfig({ preset: "cloudflare" }),
188
+ * },
189
+ * });
190
+ * ```
191
+ */
192
+ export function rangoTestConfig(
193
+ opts: RangoTestAliasOptions = {},
194
+ ): RangoTestConfig {
195
+ return {
196
+ alias: rangoTestAliases(opts),
197
+ // fresh copy so the shared rangoInlineDeps const is never aliased into (or
198
+ // mutated through) a consumer's resolved config
199
+ server: { deps: { inline: [...rangoInlineDeps] } },
200
+ };
201
+ }
202
+
203
+ /** A minimal Vite plugin shape (avoids a hard dependency on Vite's types). */
204
+ interface FlightTransformPlugin {
205
+ name: string;
206
+ transform(
207
+ code: string,
208
+ id: string,
209
+ ): Promise<{ code: string; map: unknown } | undefined>;
210
+ }
211
+
212
+ /**
213
+ * A Vite plugin for the FLIGHT (react-server) Vitest project that applies the
214
+ * `"use client"` transform to a consumer's client modules — the same transform a
215
+ * real build applies. With it, `renderServerTree` (`@rangojs/router/testing/flight`)
216
+ * resolves client islands AUTOMATICALLY from the server tree's own imports: no
217
+ * `clientComponents` to pass, no filename convention. Without it, a `"use client"`
218
+ * module is imported as a plain (unmarked) function and would render server-side,
219
+ * so you must list islands via `renderServerTree(..., { clientComponents })`.
220
+ *
221
+ * Add it to your react-server Vitest project. This is the COMPLETE config — the
222
+ * alias, `server.deps.inline`, and `NODE_ENV` are load-bearing, not optional (see
223
+ * the inline notes). The testing skill (`skills/testing/setup.md`, shipped in the
224
+ * package) has the annotated walkthrough.
225
+ *
226
+ * ```ts
227
+ * // vitest.rsc.config.ts
228
+ * import { defineConfig } from "vitest/config";
229
+ * import {
230
+ * rangoUseClientTransform,
231
+ * rangoTestAliases,
232
+ * rangoInlineDeps,
233
+ * } from "@rangojs/router/testing/vitest";
234
+ *
235
+ * // Flight serialization needs React's production build; the dev build's jsxDEV
236
+ * // crashes / yields unstable snapshots.
237
+ * process.env.NODE_ENV = "production";
238
+ *
239
+ * export default defineConfig({
240
+ * plugins: [rangoUseClientTransform()],
241
+ * resolve: {
242
+ * conditions: ["react-server"],
243
+ * // Bare `@rangojs/router` -> its react-server build, so a handler/component
244
+ * // reading getRequestContext()/cookies() resolves the real impl, not the
245
+ * // throwing stub. Pass { preset: "cloudflare" } for a CF app.
246
+ * alias: rangoTestAliases(),
247
+ * },
248
+ * test: {
249
+ * include: ["test/**\/*.rsc-test.{ts,tsx}"],
250
+ * pool: "forks",
251
+ * execArgv: ["--conditions=react-server"],
252
+ * // Required for an INSTALLED consumer on Node >= 23 (rango ships TS source).
253
+ * server: { deps: { inline: rangoInlineDeps } },
254
+ * },
255
+ * });
256
+ * ```
257
+ *
258
+ * Each `"use client"` module's exports are replaced with client references keyed
259
+ * by the module's absolute path (the boundary id), the export name becoming the
260
+ * boundary name. Modules without the directive (server components) are untouched,
261
+ * so `renderToFlightString` of pure leaf trees is unaffected.
262
+ */
263
+ export function rangoUseClientTransform(): FlightTransformPlugin {
264
+ return {
265
+ name: "rango:testing-use-client",
266
+ async transform(code, id) {
267
+ if (id.includes("/node_modules/")) return undefined;
268
+ // Fast path: only parse modules that mention the directive.
269
+ if (!code.includes("use client")) return undefined;
270
+ const { parseAstAsync } = await import("vite");
271
+ const { hasDirective, transformDirectiveProxyExport } =
272
+ await import("@vitejs/plugin-rsc/transforms");
273
+ // vite's parser and the transforms ship structurally-compatible but
274
+ // distinctly-typed ASTs (oxc vs estree); cast through the transform's own
275
+ // parameter type, exactly as plugin-rsc does at runtime.
276
+ type TransformAst = Parameters<typeof transformDirectiveProxyExport>[0];
277
+ let ast: TransformAst;
278
+ try {
279
+ ast = (await parseAstAsync(code)) as unknown as TransformAst;
280
+ } catch {
281
+ return undefined;
282
+ }
283
+ if (!hasDirective(ast.body, "use client")) return undefined;
284
+ const result = transformDirectiveProxyExport(ast, {
285
+ directive: "use client",
286
+ code,
287
+ runtime: (name: string) =>
288
+ `$$RangoRSD.registerClientReference(` +
289
+ `() => { throw new Error("client reference " + ${JSON.stringify(name)} + " is not callable on the server"); }, ` +
290
+ `${JSON.stringify(id)}, ${JSON.stringify(name)})`,
291
+ });
292
+ if (!result) return undefined;
293
+ const { output } = result;
294
+ // The vendored server serializer is the one renderToFlightString uses;
295
+ // resolvable here under the react-server condition.
296
+ output.prepend(
297
+ `import * as $$RangoRSD from "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge";\n`,
298
+ );
299
+ return {
300
+ code: output.toString(),
301
+ map: output.generateMap({ hires: true }),
302
+ };
303
+ },
304
+ };
305
+ }
@@ -78,6 +78,14 @@ export interface CacheOptions<TEnv = unknown> {
78
78
  * - Loader-specific caching strategies
79
79
  * - Hot data in fast cache, cold data in larger/slower cache
80
80
  *
81
+ * Tag invalidation caveat: a per-boundary store becomes reachable by
82
+ * `updateTag()` / `revalidateTag()` once this boundary is resolved in the
83
+ * current process. If the store is *durable* (shared across processes) and the
84
+ * very first request to a fresh worker is an `updateTag`/`revalidateTag` for a
85
+ * tag held only in this store - before this boundary is matched - that
86
+ * invalidation can miss it. For data you invalidate by tag, prefer the
87
+ * app-level store (always reachable), or ensure the boundary is warmed.
88
+ *
81
89
  * @example
82
90
  * ```typescript
83
91
  * const kvStore = new CloudflareKVStore(env.CACHE_KV);
@@ -145,10 +153,11 @@ export interface CacheOptions<TEnv = unknown> {
145
153
  * Tags for cache invalidation.
146
154
  * Can be a static array or a function that returns tags.
147
155
  *
148
- * Note: Tags are passed through to the store but built-in stores
149
- * (MemorySegmentCacheStore, CFCacheStore) do not yet index or
150
- * invalidate by tag. Effective tag-based invalidation requires a
151
- * custom store implementation with secondary indices.
156
+ * The built-in `MemorySegmentCacheStore` and `CFCacheStore` index by tag.
157
+ * Invalidate on demand with `updateTag(...tags)` (awaitable, read-your-own-writes;
158
+ * for server actions) or `revalidateTag(...tags)` (background hard-purge, not
159
+ * awaited; for route handlers / webhooks). For `CFCacheStore`, distributed
160
+ * invalidation requires a `kv` namespace (markers live in that same namespace).
152
161
  *
153
162
  * @example
154
163
  * ```typescript
@@ -155,7 +155,11 @@ export interface OnErrorContext<TEnv = any> {
155
155
  stack?: string;
156
156
 
157
157
  /**
158
- * Additional metadata specific to the error phase
158
+ * Additional metadata specific to the error phase. For the `cache` phase,
159
+ * `metadata.category` is a `CacheErrorCategory` (exported from
160
+ * `@rangojs/router/cache`) identifying the failure kind, e.g. `cache-read`
161
+ * (transient outage, degraded to a miss) vs `cache-corrupt` (faulty entry
162
+ * self-healed) vs `cache-invalidate` (a failed background revalidateTag).
159
163
  */
160
164
  metadata?: Record<string, unknown>;
161
165
  }
@@ -97,7 +97,17 @@ export type DefaultHandlerRouteMap = keyof Rango.GeneratedRouteMap extends never
97
97
  export type DefaultEnv = keyof Rango.Env extends never ? unknown : Rango.Env;
98
98
 
99
99
  /**
100
- * Default variables type - uses global augmentation if available, Record<string, any> otherwise
100
+ * Variables type backing the string-key `ctx.get` / `ctx.set` overloads.
101
+ *
102
+ * Uses `Rango.Vars` augmentation when present; otherwise falls back to
103
+ * `Record<string, any>` so an un-augmented app can use string-key vars with
104
+ * zero config -- at the cost of a typo'd key being silently `any`.
105
+ *
106
+ * This `any` fallback is deliberate (see #561) and is an intentional asymmetry
107
+ * with `DefaultEnv` above, which falls back to `unknown`: env bindings are
108
+ * platform-critical and should be registered, so a forgotten binding is made a
109
+ * compile error; vars are ad-hoc and middleware-set, so the zero-config path
110
+ * wins. Augment `Rango.Vars` for type-safe keys.
101
111
  */
102
112
  export type DefaultVars = keyof Rango.Vars extends never
103
113
  ? Record<string, any>