@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
|
@@ -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
|
+
}
|
package/src/types/cache-types.ts
CHANGED
|
@@ -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
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
package/src/types/error-types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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>
|