@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133
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/AGENTS.md +8 -0
- package/README.md +43 -2
- package/dist/bin/rango.js +92 -16
- package/dist/vite/index.js +166 -70
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/bundle-analysis/SKILL.md +2 -2
- package/skills/cache-guide/SKILL.md +2 -2
- package/skills/caching/SKILL.md +16 -9
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +2 -2
- package/skills/host-router/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +1 -1
- package/skills/loader/SKILL.md +2 -0
- package/skills/migrate-react-router/SKILL.md +4 -2
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/prerender/SKILL.md +2 -0
- package/skills/rango/SKILL.md +12 -11
- package/skills/response-routes/SKILL.md +2 -2
- package/skills/route/SKILL.md +4 -0
- package/skills/router-setup/SKILL.md +3 -0
- package/skills/scripts/SKILL.md +179 -0
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +20 -6
- package/skills/testing/cache-prerender.md +5 -2
- package/skills/testing/client-components.md +2 -0
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +8 -9
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/server-actions.md +11 -11
- package/skills/testing/setup.md +3 -0
- package/skills/typesafety/SKILL.md +3 -2
- package/skills/use-cache/SKILL.md +10 -9
- package/src/browser/event-controller.ts +109 -2
- package/src/browser/partial-update.ts +12 -0
- package/src/browser/prefetch/cache.ts +17 -0
- package/src/browser/prefetch/fetch.ts +69 -2
- package/src/browser/react/Link.tsx +30 -5
- package/src/browser/react/NavigationProvider.tsx +12 -2
- package/src/browser/react/location-state-shared.ts +14 -2
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +23 -2
- package/src/browser/response-adapter.ts +14 -3
- package/src/browser/rsc-router.tsx +3 -0
- package/src/browser/scroll-restoration.ts +8 -3
- package/src/browser/server-action-bridge.ts +46 -11
- package/src/browser/types.ts +6 -0
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-trie.ts +33 -9
- package/src/build/route-types/include-resolution.ts +7 -1
- package/src/build/route-types/router-processing.ts +0 -6
- package/src/build/route-types/source-scan.ts +105 -7
- package/src/cache/cache-policy.ts +42 -8
- package/src/cache/cache-runtime.ts +65 -5
- package/src/cache/cache-scope.ts +71 -11
- package/src/cache/cache-tag.ts +7 -2
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +85 -613
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/document-cache.ts +11 -0
- package/src/cache/handle-snapshot.ts +8 -1
- package/src/cache/profile-registry.ts +25 -1
- package/src/cache/segment-codec.ts +9 -1
- package/src/cache/types.ts +4 -0
- package/src/client.rsc.tsx +38 -0
- package/src/client.tsx +11 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +1 -1
- package/src/decode-loader-results.ts +7 -1
- package/src/escape-script.ts +52 -0
- package/src/handles/MetaTags.tsx +56 -5
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +29 -11
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +46 -0
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +7 -3
- package/src/host/pattern-matcher.ts +16 -2
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +5 -0
- package/src/response-utils.ts +25 -0
- package/src/route-definition/dsl-helpers.ts +7 -0
- package/src/route-definition/redirect.ts +1 -2
- package/src/router/content-negotiation.ts +58 -10
- package/src/router/intercept-resolution.ts +9 -0
- package/src/router/match-middleware/cache-store.ts +10 -1
- package/src/router/middleware.ts +10 -3
- package/src/router/pattern-matching.ts +25 -23
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +23 -0
- package/src/router/segment-resolution/fresh.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +35 -1
- package/src/router/segment-resolution/loader-cache.ts +10 -6
- package/src/router/segment-resolution/revalidation.ts +6 -0
- package/src/router/segment-resolution.ts +1 -0
- package/src/router/trie-matching.ts +14 -9
- package/src/router.ts +18 -10
- package/src/rsc/handler.ts +52 -13
- package/src/rsc/helpers.ts +7 -1
- package/src/rsc/index.ts +1 -4
- package/src/rsc/loader-fetch.ts +107 -37
- package/src/rsc/progressive-enhancement.ts +18 -6
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-route-handler.ts +16 -133
- package/src/rsc/rsc-rendering.ts +13 -4
- package/src/rsc/server-action.ts +52 -6
- package/src/rsc/types.ts +7 -0
- package/src/search-params.ts +24 -5
- package/src/segment-loader-promise.ts +17 -2
- package/src/server/loader-registry.ts +16 -18
- package/src/server/request-context.ts +47 -20
- package/src/testing/dispatch.ts +108 -25
- package/src/testing/flight.ts +25 -0
- package/src/testing/internal/context.ts +25 -2
- package/src/testing/render-handler.ts +3 -1
- package/src/testing/render-route.tsx +15 -0
- package/src/testing/run-loader.ts +10 -3
- package/src/theme/ThemeProvider.tsx +20 -6
- package/src/theme/ThemeScript.tsx +7 -3
- package/src/theme/constants.ts +54 -3
- package/src/theme/theme-script.ts +22 -7
- package/src/types/request-scope.ts +8 -3
- package/src/vite/plugins/cjs-to-esm.ts +8 -1
- package/src/vite/plugins/expose-id-utils.ts +10 -1
- package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
- package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
- package/src/vite/plugins/expose-internal-ids.ts +0 -1
- package/src/vite/plugins/version-plugin.ts +5 -17
- package/src/vite/plugins/virtual-entries.ts +12 -2
- package/src/vite/rango.ts +15 -6
- package/src/vite/utils/ast-handler-extract.ts +11 -4
- package/src/vite/utils/directive-prologue.ts +40 -0
- package/src/vite/utils/prerender-utils.ts +17 -2
package/src/rsc/loader-fetch.ts
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { getLoaderLazy } from "../server/loader-registry.js";
|
|
15
|
+
import { DataNotFoundError } from "../errors.js";
|
|
15
16
|
import { executeLoaderMiddleware } from "../router/middleware.js";
|
|
16
|
-
import {
|
|
17
|
+
import { getRequestContext } from "../server/request-context.js";
|
|
17
18
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
18
19
|
import {
|
|
19
20
|
createReverseFunction,
|
|
@@ -31,6 +32,62 @@ import {
|
|
|
31
32
|
} from "./helpers.js";
|
|
32
33
|
import type { HandlerContext } from "./handler-context.js";
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Build the 500 RSC error Response for a failed fetchable loader. Shared by the
|
|
37
|
+
* module-load-error catch (the import itself threw) and the loader-execution
|
|
38
|
+
* error catch — both call onError("loader"), serialize the same dev-gated error
|
|
39
|
+
* payload via renderToReadableStream (reporting render failures through
|
|
40
|
+
* onError("rendering")), and return a 500 text/x-component Response. The only
|
|
41
|
+
* per-site difference is the console.error label, passed in.
|
|
42
|
+
*/
|
|
43
|
+
function buildLoaderErrorResponse<TEnv>(
|
|
44
|
+
ctx: HandlerContext<TEnv>,
|
|
45
|
+
error: unknown,
|
|
46
|
+
meta: { request: Request; url: URL; env: TEnv; loaderId: string },
|
|
47
|
+
logLabel: string,
|
|
48
|
+
): Response {
|
|
49
|
+
const { request, url, env, loaderId } = meta;
|
|
50
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
51
|
+
|
|
52
|
+
console.error(logLabel, error);
|
|
53
|
+
|
|
54
|
+
ctx.callOnError(error, "loader", {
|
|
55
|
+
request,
|
|
56
|
+
url,
|
|
57
|
+
env,
|
|
58
|
+
loaderName: loaderId,
|
|
59
|
+
handledByBoundary: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
63
|
+
const errorPayload = {
|
|
64
|
+
loaderResult: null,
|
|
65
|
+
loaderError: {
|
|
66
|
+
message: isDev ? err.message : "An error occurred",
|
|
67
|
+
// Gate err.name to dev. In production it leaks the consumer's error class
|
|
68
|
+
// name (e.g. AuthError, PrismaClientKnownRequestError) to the client; the
|
|
69
|
+
// client only ever reads `message`, so the field is dead data outside dev.
|
|
70
|
+
// Matches sanitizeError's dev-only name contract.
|
|
71
|
+
name: isDev ? err.name : "Error",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const rscStream = ctx.renderToReadableStream(errorPayload, {
|
|
75
|
+
onError: (renderError: unknown) => {
|
|
76
|
+
ctx.callOnError(renderError, "rendering", {
|
|
77
|
+
request,
|
|
78
|
+
url,
|
|
79
|
+
env,
|
|
80
|
+
loaderName: loaderId,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return createResponseWithMergedHeaders(rscStream, {
|
|
86
|
+
status: 500,
|
|
87
|
+
headers: { "content-type": "text/x-component;charset=utf-8" },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
34
91
|
export async function handleLoaderFetch<TEnv>(
|
|
35
92
|
ctx: HandlerContext<TEnv>,
|
|
36
93
|
request: Request,
|
|
@@ -47,8 +104,22 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
47
104
|
});
|
|
48
105
|
}
|
|
49
106
|
|
|
50
|
-
// Look up loader lazily
|
|
51
|
-
|
|
107
|
+
// Look up loader lazily. getLoaderLazy returns undefined only when the id was
|
|
108
|
+
// never registered (genuine 404). A thrown error means the loader module
|
|
109
|
+
// EXISTS but its import failed (broken transitive import, syntax error, throw
|
|
110
|
+
// in top-level code) — a real server breakage that must surface as a 500 and
|
|
111
|
+
// fire onError, not be collapsed into a misleading "not found".
|
|
112
|
+
let registeredLoader;
|
|
113
|
+
try {
|
|
114
|
+
registeredLoader = await getLoaderLazy(loaderId);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return buildLoaderErrorResponse(
|
|
117
|
+
ctx,
|
|
118
|
+
error,
|
|
119
|
+
{ request, url, env, loaderId },
|
|
120
|
+
`[RSC] Loader module load failed for "${loaderId}":`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
52
123
|
if (!registeredLoader) {
|
|
53
124
|
return createResponseWithMergedHeaders(
|
|
54
125
|
`Loader "${loaderId}" not found in registry`,
|
|
@@ -125,7 +196,7 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
125
196
|
loaderParams,
|
|
126
197
|
variables,
|
|
127
198
|
async () => {
|
|
128
|
-
const reqCtx =
|
|
199
|
+
const reqCtx = getRequestContext();
|
|
129
200
|
// Merge route params (from previewMatch) with explicit loader params.
|
|
130
201
|
// Explicit params take precedence over route-matched params.
|
|
131
202
|
const resolvedRouteParams = routeParams ?? {};
|
|
@@ -196,40 +267,39 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
196
267
|
),
|
|
197
268
|
);
|
|
198
269
|
} catch (error) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
270
|
+
// A thrown Response is control flow, not an error: `throw redirect('/x')`
|
|
271
|
+
// throws a real Response (a 3xx). The with-middleware path already converts
|
|
272
|
+
// this to a returned Response (middleware.ts: `if (error instanceof Response)
|
|
273
|
+
// result = error`), but a fetchable loader with NO middleware reaches this
|
|
274
|
+
// catch directly, where the generic Error coercion below would turn it into a
|
|
275
|
+
// 500. Honor it the same way: re-wrap through createResponseWithMergedHeaders
|
|
276
|
+
// so the request context's stub cookies/headers merge, exactly like the
|
|
277
|
+
// returned-Response path. Mirrors rsc/handler.ts's `error instanceof Response`
|
|
278
|
+
// special-case.
|
|
279
|
+
if (error instanceof Response) {
|
|
280
|
+
return finalizeResponse(
|
|
281
|
+
createResponseWithMergedHeaders(error.body, {
|
|
282
|
+
status: error.status,
|
|
283
|
+
headers: error.headers,
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
211
287
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
ctx.callOnError(error, "rendering", {
|
|
222
|
-
request,
|
|
223
|
-
url,
|
|
224
|
-
env,
|
|
225
|
-
loaderName: loaderId,
|
|
226
|
-
});
|
|
227
|
-
},
|
|
228
|
-
});
|
|
288
|
+
// notFound() throws a DataNotFoundError (an Error subclass, NOT a Response),
|
|
289
|
+
// so it does not match the branch above. Map it to a 404 before the generic
|
|
290
|
+
// 500 coercion so a no-middleware fetchable loader's notFound() is honored
|
|
291
|
+
// (the with-middleware path resolves it through the notFoundBoundary).
|
|
292
|
+
if (error instanceof DataNotFoundError) {
|
|
293
|
+
return finalizeResponse(
|
|
294
|
+
createResponseWithMergedHeaders(null, { status: 404 }),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
229
297
|
|
|
230
|
-
return
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
298
|
+
return buildLoaderErrorResponse(
|
|
299
|
+
ctx,
|
|
300
|
+
error,
|
|
301
|
+
{ request, url, env, loaderId },
|
|
302
|
+
"[RSC] Loader error:",
|
|
303
|
+
);
|
|
234
304
|
}
|
|
235
305
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
getRequestContext,
|
|
11
11
|
setRequestContextParams,
|
|
12
12
|
} from "../server/request-context.js";
|
|
13
13
|
import { getSSRSetup } from "./ssr-setup.js";
|
|
@@ -45,7 +45,7 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
45
45
|
env: TEnv,
|
|
46
46
|
url: URL,
|
|
47
47
|
isAction: boolean,
|
|
48
|
-
handleStore: ReturnType<typeof
|
|
48
|
+
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
49
49
|
nonce: string | undefined,
|
|
50
50
|
routeMwInfo?: PeRouteMiddlewareInfo,
|
|
51
51
|
): Promise<Response | null> {
|
|
@@ -254,9 +254,19 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
254
254
|
// cookies set by route middleware are available during re-render — matching
|
|
255
255
|
// the behavior of JS-enabled requests.
|
|
256
256
|
const renderPage = async (): Promise<Response> => {
|
|
257
|
+
// Preserve the original POST request's headers (Authorization, Cookie,
|
|
258
|
+
// custom headers) so loaders that read request headers/cookies behave
|
|
259
|
+
// identically under PE and the JS action path. Drop body-framing headers
|
|
260
|
+
// from the bodyless GET and force the HTML accept.
|
|
261
|
+
const headers = new Headers(request.headers);
|
|
262
|
+
headers.delete("content-type");
|
|
263
|
+
headers.delete("content-length");
|
|
264
|
+
headers.delete("content-encoding");
|
|
265
|
+
headers.delete("transfer-encoding");
|
|
266
|
+
headers.set("accept", "text/html");
|
|
257
267
|
const renderRequest = new Request(url.toString(), {
|
|
258
268
|
method: "GET",
|
|
259
|
-
headers
|
|
269
|
+
headers,
|
|
260
270
|
});
|
|
261
271
|
|
|
262
272
|
const match = await ctx.router.match(renderRequest, { env });
|
|
@@ -285,7 +295,8 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
285
295
|
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
286
296
|
themeConfig: ctx.router.themeConfig,
|
|
287
297
|
warmupEnabled: ctx.router.warmupEnabled,
|
|
288
|
-
|
|
298
|
+
strictMode: ctx.router.strictMode,
|
|
299
|
+
initialTheme: getRequestContext().theme,
|
|
289
300
|
},
|
|
290
301
|
};
|
|
291
302
|
|
|
@@ -354,7 +365,7 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
354
365
|
env: TEnv,
|
|
355
366
|
url: URL,
|
|
356
367
|
error: unknown,
|
|
357
|
-
handleStore: ReturnType<typeof
|
|
368
|
+
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
358
369
|
nonce: string | undefined,
|
|
359
370
|
actionId?: string | null,
|
|
360
371
|
): Promise<Response | null> {
|
|
@@ -402,7 +413,8 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
402
413
|
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
403
414
|
themeConfig: ctx.router.themeConfig,
|
|
404
415
|
warmupEnabled: ctx.router.warmupEnabled,
|
|
405
|
-
|
|
416
|
+
strictMode: ctx.router.strictMode,
|
|
417
|
+
initialTheme: getRequestContext().theme,
|
|
406
418
|
},
|
|
407
419
|
};
|
|
408
420
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared response-route cache serve.
|
|
3
|
+
*
|
|
4
|
+
* Owns the single response-cache contract — cache-scope resolution from the
|
|
5
|
+
* matched entry tree, condition eval, key resolution (route key() > store
|
|
6
|
+
* keyGenerator > default), tag resolution, pre-handler-callback timing, and the
|
|
7
|
+
* fresh-hit / SWR-revalidate / miss-write branches — for BOTH the production
|
|
8
|
+
* response-route handler (rsc/response-route-handler.ts) and the dispatch testing
|
|
9
|
+
* primitive (testing/dispatch.ts), so the two can never drift.
|
|
10
|
+
*
|
|
11
|
+
* Plugin-rsc hazard: cache-scope.ts pulls @vitejs/plugin-rsc (via segment-codec),
|
|
12
|
+
* which the non-Vite unit-test runner cannot resolve, and this module is on the
|
|
13
|
+
* testing barrel's EAGER graph (dispatch imports it). So `createCacheScope` and
|
|
14
|
+
* `resolveCacheTags` are NOT imported here at runtime — they are INJECTED by the
|
|
15
|
+
* caller (production imports them statically; dispatch lazy-imports them only once
|
|
16
|
+
* a response route matches). The only runtime imports here are plugin-rsc-free
|
|
17
|
+
* (helpers' isCacheableStatus/finalizeResponse, traverseBack); cache-scope is a
|
|
18
|
+
* type-only import (erased at build).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { CacheScope } from "../cache/cache-scope.js";
|
|
22
|
+
import type { PartialCacheOptions } from "../types.js";
|
|
23
|
+
import type { RequestContext } from "../server/request-context.js";
|
|
24
|
+
import type { SegmentCacheStore } from "../cache/types.js";
|
|
25
|
+
import type { EntryCacheConfig, EntryData } from "../server/context.js";
|
|
26
|
+
import { traverseBack } from "../router/pattern-matching.js";
|
|
27
|
+
import { isCacheableStatus, finalizeResponse } from "./helpers.js";
|
|
28
|
+
import { reportCacheError } from "../cache/cache-error.js";
|
|
29
|
+
|
|
30
|
+
/** Injected cache-scope builders (kept off this module's runtime import graph). */
|
|
31
|
+
export interface CacheScopeDeps {
|
|
32
|
+
createCacheScope: (
|
|
33
|
+
config: EntryCacheConfig | undefined,
|
|
34
|
+
parent?: CacheScope | null,
|
|
35
|
+
) => CacheScope | null;
|
|
36
|
+
resolveCacheTags: (
|
|
37
|
+
config: PartialCacheOptions | false,
|
|
38
|
+
ctx: RequestContext | undefined,
|
|
39
|
+
) => string[] | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ServeResponseRouteWithCacheArgs {
|
|
43
|
+
reqCtx: RequestContext;
|
|
44
|
+
manifestEntry: EntryData;
|
|
45
|
+
responseType: string;
|
|
46
|
+
url: URL;
|
|
47
|
+
/** callHandler wrapped by route-level middleware — the unit the cache wraps. */
|
|
48
|
+
executeHandler: () => Promise<Response>;
|
|
49
|
+
deps: CacheScopeDeps;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Serve a response route through its cache, or return `undefined` when no cache
|
|
54
|
+
* applies (no scope, disabled, condition false, or store lacks get/putResponse)
|
|
55
|
+
* so the caller falls through to a plain `executeHandler()` run.
|
|
56
|
+
*
|
|
57
|
+
* Must run inside runWithRequestContext (reads the ambient request context via
|
|
58
|
+
* the helpers and reqCtx.waitUntil for background writes).
|
|
59
|
+
*/
|
|
60
|
+
export async function serveResponseRouteWithCache(
|
|
61
|
+
args: ServeResponseRouteWithCacheArgs,
|
|
62
|
+
): Promise<Response | undefined> {
|
|
63
|
+
const { reqCtx, manifestEntry, responseType, url, executeHandler, deps } =
|
|
64
|
+
args;
|
|
65
|
+
|
|
66
|
+
let cacheScope: CacheScope | null = null;
|
|
67
|
+
for (const entry of traverseBack(manifestEntry)) {
|
|
68
|
+
if (entry.cache) {
|
|
69
|
+
cacheScope = deps.createCacheScope(entry.cache, cacheScope);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!cacheScope?.enabled) return undefined;
|
|
74
|
+
|
|
75
|
+
// Evaluate condition — skip the response cache when condition returns false.
|
|
76
|
+
let conditionPassed = true;
|
|
77
|
+
if (cacheScope.config !== false && cacheScope.config.condition) {
|
|
78
|
+
try {
|
|
79
|
+
conditionPassed = !!cacheScope.config.condition(reqCtx);
|
|
80
|
+
} catch {
|
|
81
|
+
conditionPassed = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const store = cacheScope.getStore() ?? reqCtx._cacheStore;
|
|
86
|
+
if (!conditionPassed || !store?.getResponse || !store?.putResponse) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build cache key with the response:{type}: prefix (avoids collision with
|
|
91
|
+
// segment keys); include host + url.search so query-driven and multi-host
|
|
92
|
+
// responses cache separately.
|
|
93
|
+
let cacheKey = `response:${responseType}:${url.host}${url.pathname}${url.search}`;
|
|
94
|
+
|
|
95
|
+
// Priority 1: route-level key() (full override). Priority 2: store-level
|
|
96
|
+
// keyGenerator (modifies the default key).
|
|
97
|
+
//
|
|
98
|
+
// A CONFIGURED key()/keyGenerator that THROWS must DEGRADE TO A MISS, not fall
|
|
99
|
+
// back to the broad default key. The default key
|
|
100
|
+
// `response:${type}:${host}${path}${search}` is intentionally broad; if the
|
|
101
|
+
// configured key encodes tenant/user/auth state, falling back to the broad key
|
|
102
|
+
// would cache PERSONALIZED output under it and serve it cross-user (cache
|
|
103
|
+
// poisoning). Mirrors the segment-cache behavior (cache-scope.ts lookupRoute):
|
|
104
|
+
// a throwing key degrades to a cache miss, never a collision onto the default
|
|
105
|
+
// slot. The no-key default path is left untouched (the broad key is correct
|
|
106
|
+
// when no key is configured).
|
|
107
|
+
let keyResolutionFailed = false;
|
|
108
|
+
if (cacheScope.config !== false && cacheScope.config.key) {
|
|
109
|
+
try {
|
|
110
|
+
const customKey = await cacheScope.config.key(reqCtx);
|
|
111
|
+
cacheKey = `response:${customKey}`;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
keyResolutionFailed = true;
|
|
114
|
+
reportCacheError(
|
|
115
|
+
error,
|
|
116
|
+
"cache-read",
|
|
117
|
+
"[ResponseCache] Key resolution failed",
|
|
118
|
+
reqCtx,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
} else if (store.keyGenerator) {
|
|
122
|
+
try {
|
|
123
|
+
cacheKey = await store.keyGenerator(reqCtx, cacheKey);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
keyResolutionFailed = true;
|
|
126
|
+
reportCacheError(
|
|
127
|
+
error,
|
|
128
|
+
"cache-read",
|
|
129
|
+
"[ResponseCache] keyGenerator failed",
|
|
130
|
+
reqCtx,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Degrade to a MISS: return undefined so the caller runs the route UNCACHED.
|
|
136
|
+
// This early-returns BEFORE _onResponseCallbacks is saved/cleared below, so the
|
|
137
|
+
// pre-handler onResponse callbacks are still intact for the uncached run.
|
|
138
|
+
if (keyResolutionFailed) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Resolve cache tags for this document entry (static or dynamic) while the
|
|
143
|
+
// request context is available, so the stored entry is tag-invalidatable.
|
|
144
|
+
const responseTags = deps.resolveCacheTags(cacheScope.config, reqCtx);
|
|
145
|
+
|
|
146
|
+
// Pre-handler callbacks (registered by app-level middleware before the cache
|
|
147
|
+
// block) are saved and the live array is cleared:
|
|
148
|
+
// createResponseWithMergedHeaders inside the handler eagerly drains whatever is
|
|
149
|
+
// in _onResponseCallbacks, so handler-registered callbacks bake into the cached
|
|
150
|
+
// artifact, while these pre-handler callbacks are applied once per serve on
|
|
151
|
+
// every path (hit + miss).
|
|
152
|
+
const savedCallbacks = reqCtx._onResponseCallbacks;
|
|
153
|
+
reqCtx._onResponseCallbacks = [];
|
|
154
|
+
const applyPreHandlerCallbacks = (response: Response): Response => {
|
|
155
|
+
let result = response;
|
|
156
|
+
for (const callback of savedCallbacks) {
|
|
157
|
+
result = callback(result) ?? result;
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const putFresh = (
|
|
163
|
+
store2: SegmentCacheStore,
|
|
164
|
+
fresh: Response,
|
|
165
|
+
): Promise<void> =>
|
|
166
|
+
store2.putResponse!(
|
|
167
|
+
cacheKey,
|
|
168
|
+
fresh.clone(),
|
|
169
|
+
cacheScope!.ttl,
|
|
170
|
+
cacheScope!.swr,
|
|
171
|
+
responseTags,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const cached = await store.getResponse(cacheKey);
|
|
176
|
+
if (cached && isCacheableStatus(cached.response.status)) {
|
|
177
|
+
if (!cached.shouldRevalidate) {
|
|
178
|
+
return applyPreHandlerCallbacks(cached.response);
|
|
179
|
+
}
|
|
180
|
+
// Stale hit (SWR): return cached, revalidate in background.
|
|
181
|
+
reqCtx.waitUntil(async () => {
|
|
182
|
+
try {
|
|
183
|
+
const fresh = finalizeResponse(await executeHandler());
|
|
184
|
+
if (isCacheableStatus(fresh.status)) await putFresh(store, fresh);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
reportCacheError(
|
|
187
|
+
error,
|
|
188
|
+
"stale-revalidation",
|
|
189
|
+
"[ResponseCache] background revalidation",
|
|
190
|
+
reqCtx,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return applyPreHandlerCallbacks(cached.response);
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
reportCacheError(
|
|
198
|
+
error,
|
|
199
|
+
"cache-read",
|
|
200
|
+
"[ResponseCache] Cache lookup failed",
|
|
201
|
+
reqCtx,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Cache miss: execute the handler and cache the result.
|
|
206
|
+
const response = finalizeResponse(await executeHandler());
|
|
207
|
+
if (isCacheableStatus(response.status)) {
|
|
208
|
+
// Clone SYNCHRONOUSLY here, before returning. The original `response` is
|
|
209
|
+
// handed back to the middleware chain, where mergeResponse rebuilds it as
|
|
210
|
+
// `new Response(response.body, ...)`. Deferring the clone into the waitUntil
|
|
211
|
+
// callback (putFresh(response), which clones inside the async body) raced
|
|
212
|
+
// that rebuild: the background clone() and the foreground body read could
|
|
213
|
+
// interleave and throw "Response body object should not be disturbed or
|
|
214
|
+
// locked" (a flaky 500). Teeing now keeps the returned body independent of
|
|
215
|
+
// the cache write. The SWR path above is unaffected (its `fresh` is created
|
|
216
|
+
// inside the background callback and never returned to the caller).
|
|
217
|
+
const toCache = response.clone();
|
|
218
|
+
reqCtx.waitUntil(async () => {
|
|
219
|
+
try {
|
|
220
|
+
await store.putResponse!(
|
|
221
|
+
cacheKey,
|
|
222
|
+
toCache,
|
|
223
|
+
cacheScope!.ttl,
|
|
224
|
+
cacheScope!.swr,
|
|
225
|
+
responseTags,
|
|
226
|
+
);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
reportCacheError(
|
|
229
|
+
error,
|
|
230
|
+
"cache-write",
|
|
231
|
+
"[ResponseCache] Cache write failed",
|
|
232
|
+
reqCtx,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return applyPreHandlerCallbacks(response);
|
|
238
|
+
}
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { RouterError } from "../errors.js";
|
|
10
|
-
import {
|
|
10
|
+
import { getRequestContext } from "../server/request-context.js";
|
|
11
11
|
import { contextGet } from "../context-var.js";
|
|
12
12
|
import { NOCACHE_SYMBOL } from "../cache/taint.js";
|
|
13
|
-
import { traverseBack } from "../router/pattern-matching.js";
|
|
14
13
|
import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
|
|
15
14
|
import { createCacheScope, resolveCacheTags } from "../cache/cache-scope.js";
|
|
15
|
+
import { serveResponseRouteWithCache } from "./response-cache-serve.js";
|
|
16
16
|
import { executeMiddleware } from "../router/middleware.js";
|
|
17
17
|
import {
|
|
18
18
|
createReverseFunction,
|
|
@@ -79,7 +79,7 @@ export async function handleResponseRoute<TEnv>(
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// Build lightweight context for response handler
|
|
82
|
-
const reqCtx =
|
|
82
|
+
const reqCtx = getRequestContext();
|
|
83
83
|
const cleanUrl = stripInternalParams(url);
|
|
84
84
|
const responseHandlerCtx = {
|
|
85
85
|
request,
|
|
@@ -237,137 +237,20 @@ export async function handleResponseRoute<TEnv>(
|
|
|
237
237
|
return callHandlerWithVary();
|
|
238
238
|
};
|
|
239
239
|
|
|
240
|
-
//
|
|
240
|
+
// Response-route cache: resolved through the shared serve leaf
|
|
241
|
+
// (rsc/response-cache-serve.ts) so production and the dispatch testing
|
|
242
|
+
// primitive share ONE owner of the cache contract. Returns undefined when no
|
|
243
|
+
// cache applies, so we fall through to a plain handler run.
|
|
241
244
|
if (preview.manifestEntry) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
let conditionPassed = true;
|
|
252
|
-
if (cacheScope.config !== false && cacheScope.config.condition) {
|
|
253
|
-
try {
|
|
254
|
-
conditionPassed = !!cacheScope.config.condition(reqCtx);
|
|
255
|
-
} catch {
|
|
256
|
-
conditionPassed = false;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const store = cacheScope.getStore() ?? reqCtx._cacheStore;
|
|
261
|
-
if (conditionPassed && store?.getResponse && store?.putResponse) {
|
|
262
|
-
// Build cache key with response:{type}: prefix to avoid collision
|
|
263
|
-
// with segment keys and differentiate between response types.
|
|
264
|
-
// Include host and url.search so query-driven and multi-host
|
|
265
|
-
// responses cache separately.
|
|
266
|
-
let cacheKey = `response:${preview.responseType}:${url.host}${url.pathname}${url.search}`;
|
|
267
|
-
|
|
268
|
-
// Priority 1: Route-level key function (full override)
|
|
269
|
-
if (cacheScope.config !== false && cacheScope.config.key) {
|
|
270
|
-
try {
|
|
271
|
-
const customKey = await cacheScope.config.key(reqCtx);
|
|
272
|
-
cacheKey = `response:${customKey}`;
|
|
273
|
-
} catch {
|
|
274
|
-
// Fall back to default key on route-level key failure
|
|
275
|
-
}
|
|
276
|
-
} else if (store.keyGenerator) {
|
|
277
|
-
// Priority 2: Store-level keyGenerator (modifies default key)
|
|
278
|
-
try {
|
|
279
|
-
cacheKey = await store.keyGenerator(reqCtx, cacheKey);
|
|
280
|
-
} catch {
|
|
281
|
-
// Fall back to default key on keyGenerator failure
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Resolve cache tags for this document entry (static or dynamic),
|
|
286
|
-
// while request context is available. Passed to putResponse so the
|
|
287
|
-
// entry is tag-invalidatable.
|
|
288
|
-
const responseTags = resolveCacheTags(cacheScope.config, reqCtx);
|
|
289
|
-
|
|
290
|
-
// Save pre-handler callbacks (registered by app-level middleware
|
|
291
|
-
// before we reach the cache block) and clear the live array.
|
|
292
|
-
// createResponseWithMergedHeaders (inside the handler) eagerly
|
|
293
|
-
// executes any callbacks present in _onResponseCallbacks, so
|
|
294
|
-
// handler-registered callbacks are baked into the handler's
|
|
295
|
-
// response and the cached artifact. Pre-handler callbacks are
|
|
296
|
-
// NOT in the live array during execution, so they are applied
|
|
297
|
-
// once per serve on every path (hit + miss) below.
|
|
298
|
-
const savedCallbacks = reqCtx._onResponseCallbacks;
|
|
299
|
-
reqCtx._onResponseCallbacks = [];
|
|
300
|
-
|
|
301
|
-
const applyPreHandlerCallbacks = (response: Response): Response => {
|
|
302
|
-
let result = response;
|
|
303
|
-
for (const callback of savedCallbacks) {
|
|
304
|
-
result = callback(result) ?? result;
|
|
305
|
-
}
|
|
306
|
-
return result;
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
const cached = await store.getResponse(cacheKey);
|
|
311
|
-
|
|
312
|
-
if (cached && isCacheableStatus(cached.response.status)) {
|
|
313
|
-
if (!cached.shouldRevalidate) {
|
|
314
|
-
// Fresh hit
|
|
315
|
-
return applyPreHandlerCallbacks(cached.response);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Stale hit (SWR) - return cached, revalidate in background
|
|
319
|
-
reqCtx.waitUntil(async () => {
|
|
320
|
-
try {
|
|
321
|
-
// finalizeResponse drains any onResponse callbacks registered
|
|
322
|
-
// during middleware execution (e.g. middleware short-circuit)
|
|
323
|
-
// that createResponseWithMergedHeaders didn't reach.
|
|
324
|
-
const fresh = finalizeResponse(await executeHandler());
|
|
325
|
-
if (isCacheableStatus(fresh.status)) {
|
|
326
|
-
await store.putResponse!(
|
|
327
|
-
cacheKey,
|
|
328
|
-
fresh.clone(),
|
|
329
|
-
cacheScope!.ttl,
|
|
330
|
-
cacheScope!.swr,
|
|
331
|
-
responseTags,
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
} catch (error) {
|
|
335
|
-
console.error(`[ResponseCache] Revalidation failed:`, error);
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
return applyPreHandlerCallbacks(cached.response);
|
|
340
|
-
}
|
|
341
|
-
} catch (error) {
|
|
342
|
-
console.error(`[ResponseCache] Cache lookup failed:`, error);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Cache miss - execute handler and cache the result.
|
|
346
|
-
// createResponseWithMergedHeaders inside the handler drains callbacks
|
|
347
|
-
// registered during handler execution. finalizeResponse catches any
|
|
348
|
-
// remaining callbacks (e.g. from middleware short-circuit where the
|
|
349
|
-
// handler never ran) so the cached artifact includes all transforms.
|
|
350
|
-
const response = finalizeResponse(await executeHandler());
|
|
351
|
-
|
|
352
|
-
if (isCacheableStatus(response.status)) {
|
|
353
|
-
reqCtx.waitUntil(async () => {
|
|
354
|
-
try {
|
|
355
|
-
await store.putResponse!(
|
|
356
|
-
cacheKey,
|
|
357
|
-
response.clone(),
|
|
358
|
-
cacheScope!.ttl,
|
|
359
|
-
cacheScope!.swr,
|
|
360
|
-
responseTags,
|
|
361
|
-
);
|
|
362
|
-
} catch (error) {
|
|
363
|
-
console.error(`[ResponseCache] Cache write failed:`, error);
|
|
364
|
-
}
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return applyPreHandlerCallbacks(response);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
245
|
+
const cached = await serveResponseRouteWithCache({
|
|
246
|
+
reqCtx,
|
|
247
|
+
manifestEntry: preview.manifestEntry,
|
|
248
|
+
responseType: preview.responseType,
|
|
249
|
+
url,
|
|
250
|
+
executeHandler,
|
|
251
|
+
deps: { createCacheScope, resolveCacheTags },
|
|
252
|
+
});
|
|
253
|
+
if (cached !== undefined) return cached;
|
|
371
254
|
}
|
|
372
255
|
|
|
373
256
|
return executeHandler().then(finalizeResponse);
|