@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.
Files changed (141) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +43 -2
  3. package/dist/bin/rango.js +92 -16
  4. package/dist/vite/index.js +166 -70
  5. package/package.json +19 -18
  6. package/skills/breadcrumbs/SKILL.md +1 -1
  7. package/skills/bundle-analysis/SKILL.md +2 -2
  8. package/skills/cache-guide/SKILL.md +2 -2
  9. package/skills/caching/SKILL.md +16 -9
  10. package/skills/debug-manifest/SKILL.md +4 -2
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +2 -2
  14. package/skills/host-router/SKILL.md +1 -1
  15. package/skills/intercept/SKILL.md +1 -1
  16. package/skills/loader/SKILL.md +2 -0
  17. package/skills/migrate-react-router/SKILL.md +4 -2
  18. package/skills/mime-routes/SKILL.md +1 -1
  19. package/skills/prerender/SKILL.md +2 -0
  20. package/skills/rango/SKILL.md +12 -11
  21. package/skills/response-routes/SKILL.md +2 -2
  22. package/skills/route/SKILL.md +4 -0
  23. package/skills/router-setup/SKILL.md +3 -0
  24. package/skills/scripts/SKILL.md +179 -0
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +20 -6
  27. package/skills/testing/cache-prerender.md +5 -2
  28. package/skills/testing/client-components.md +2 -0
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +8 -9
  31. package/skills/testing/render-handler.md +1 -1
  32. package/skills/testing/response-routes.md +1 -1
  33. package/skills/testing/server-actions.md +11 -11
  34. package/skills/testing/setup.md +3 -0
  35. package/skills/typesafety/SKILL.md +3 -2
  36. package/skills/use-cache/SKILL.md +10 -9
  37. package/src/browser/event-controller.ts +109 -2
  38. package/src/browser/partial-update.ts +12 -0
  39. package/src/browser/prefetch/cache.ts +17 -0
  40. package/src/browser/prefetch/fetch.ts +69 -2
  41. package/src/browser/react/Link.tsx +30 -5
  42. package/src/browser/react/NavigationProvider.tsx +12 -2
  43. package/src/browser/react/location-state-shared.ts +14 -2
  44. package/src/browser/react/use-href.tsx +8 -1
  45. package/src/browser/react/use-link-status.ts +23 -2
  46. package/src/browser/response-adapter.ts +14 -3
  47. package/src/browser/rsc-router.tsx +3 -0
  48. package/src/browser/scroll-restoration.ts +8 -3
  49. package/src/browser/server-action-bridge.ts +46 -11
  50. package/src/browser/types.ts +6 -0
  51. package/src/build/generate-route-types.ts +0 -1
  52. package/src/build/route-trie.ts +33 -9
  53. package/src/build/route-types/include-resolution.ts +7 -1
  54. package/src/build/route-types/router-processing.ts +0 -6
  55. package/src/build/route-types/source-scan.ts +105 -7
  56. package/src/cache/cache-policy.ts +42 -8
  57. package/src/cache/cache-runtime.ts +65 -5
  58. package/src/cache/cache-scope.ts +71 -11
  59. package/src/cache/cache-tag.ts +7 -2
  60. package/src/cache/cf/cf-base64.ts +33 -0
  61. package/src/cache/cf/cf-cache-constants.ts +127 -0
  62. package/src/cache/cf/cf-cache-store.ts +85 -613
  63. package/src/cache/cf/cf-cache-types.ts +349 -0
  64. package/src/cache/cf/cf-kv-utils.ts +46 -0
  65. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  66. package/src/cache/document-cache.ts +11 -0
  67. package/src/cache/handle-snapshot.ts +8 -1
  68. package/src/cache/profile-registry.ts +25 -1
  69. package/src/cache/segment-codec.ts +9 -1
  70. package/src/cache/types.ts +4 -0
  71. package/src/client.rsc.tsx +38 -0
  72. package/src/client.tsx +11 -0
  73. package/src/components/DefaultDocument.tsx +8 -2
  74. package/src/context-var.ts +1 -1
  75. package/src/decode-loader-results.ts +7 -1
  76. package/src/escape-script.ts +52 -0
  77. package/src/handles/MetaTags.tsx +56 -5
  78. package/src/handles/Scripts.tsx +183 -0
  79. package/src/handles/breadcrumbs.ts +29 -11
  80. package/src/handles/is-thenable.ts +19 -0
  81. package/src/handles/meta.ts +46 -0
  82. package/src/handles/script.ts +244 -0
  83. package/src/host/cookie-handler.ts +7 -3
  84. package/src/host/pattern-matcher.ts +16 -2
  85. package/src/index.rsc.ts +5 -0
  86. package/src/index.ts +5 -0
  87. package/src/response-utils.ts +25 -0
  88. package/src/route-definition/dsl-helpers.ts +7 -0
  89. package/src/route-definition/redirect.ts +1 -2
  90. package/src/router/content-negotiation.ts +58 -10
  91. package/src/router/intercept-resolution.ts +9 -0
  92. package/src/router/match-middleware/cache-store.ts +10 -1
  93. package/src/router/middleware.ts +10 -3
  94. package/src/router/pattern-matching.ts +25 -23
  95. package/src/router/prefetch-cache-ttl.ts +51 -0
  96. package/src/router/router-interfaces.ts +7 -0
  97. package/src/router/router-options.ts +23 -0
  98. package/src/router/segment-resolution/fresh.ts +10 -0
  99. package/src/router/segment-resolution/helpers.ts +35 -1
  100. package/src/router/segment-resolution/loader-cache.ts +10 -6
  101. package/src/router/segment-resolution/revalidation.ts +6 -0
  102. package/src/router/segment-resolution.ts +1 -0
  103. package/src/router/trie-matching.ts +14 -9
  104. package/src/router.ts +18 -10
  105. package/src/rsc/handler.ts +52 -13
  106. package/src/rsc/helpers.ts +7 -1
  107. package/src/rsc/index.ts +1 -4
  108. package/src/rsc/loader-fetch.ts +107 -37
  109. package/src/rsc/progressive-enhancement.ts +18 -6
  110. package/src/rsc/response-cache-serve.ts +238 -0
  111. package/src/rsc/response-route-handler.ts +16 -133
  112. package/src/rsc/rsc-rendering.ts +13 -4
  113. package/src/rsc/server-action.ts +52 -6
  114. package/src/rsc/types.ts +7 -0
  115. package/src/search-params.ts +24 -5
  116. package/src/segment-loader-promise.ts +17 -2
  117. package/src/server/loader-registry.ts +16 -18
  118. package/src/server/request-context.ts +47 -20
  119. package/src/testing/dispatch.ts +108 -25
  120. package/src/testing/flight.ts +25 -0
  121. package/src/testing/internal/context.ts +25 -2
  122. package/src/testing/render-handler.ts +3 -1
  123. package/src/testing/render-route.tsx +15 -0
  124. package/src/testing/run-loader.ts +10 -3
  125. package/src/theme/ThemeProvider.tsx +20 -6
  126. package/src/theme/ThemeScript.tsx +7 -3
  127. package/src/theme/constants.ts +54 -3
  128. package/src/theme/theme-script.ts +22 -7
  129. package/src/types/request-scope.ts +8 -3
  130. package/src/vite/plugins/cjs-to-esm.ts +8 -1
  131. package/src/vite/plugins/expose-id-utils.ts +10 -1
  132. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
  133. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
  134. package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
  135. package/src/vite/plugins/expose-internal-ids.ts +0 -1
  136. package/src/vite/plugins/version-plugin.ts +5 -17
  137. package/src/vite/plugins/virtual-entries.ts +12 -2
  138. package/src/vite/rango.ts +15 -6
  139. package/src/vite/utils/ast-handler-extract.ts +11 -4
  140. package/src/vite/utils/directive-prologue.ts +40 -0
  141. package/src/vite/utils/prerender-utils.ts +17 -2
@@ -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 { requireRequestContext } from "../server/request-context.js";
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
- const registeredLoader = await getLoaderLazy(loaderId);
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 = requireRequestContext();
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
- const err = error instanceof Error ? error : new Error(String(error));
200
- const isDev = process.env.NODE_ENV !== "production";
201
-
202
- console.error("[RSC] Loader error:", error);
203
-
204
- ctx.callOnError(error, "loader", {
205
- request,
206
- url,
207
- env,
208
- loaderName: loaderId,
209
- handledByBoundary: false,
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
- const errorPayload = {
213
- loaderResult: null,
214
- loaderError: {
215
- message: isDev ? err.message : "An error occurred",
216
- name: err.name,
217
- },
218
- };
219
- const rscStream = ctx.renderToReadableStream(errorPayload, {
220
- onError: (error: unknown) => {
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 createResponseWithMergedHeaders(rscStream, {
231
- status: 500,
232
- headers: { "content-type": "text/x-component;charset=utf-8" },
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
- requireRequestContext,
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 requireRequestContext>["_handleStore"],
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: new Headers({ accept: "text/html" }),
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
- initialTheme: requireRequestContext().theme,
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 requireRequestContext>["_handleStore"],
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
- initialTheme: requireRequestContext().theme,
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 { requireRequestContext } from "../server/request-context.js";
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 = requireRequestContext();
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
- // Resolve cache config from entry tree (same pattern as match-api.ts)
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
- let cacheScope: ReturnType<typeof createCacheScope> = null;
243
- for (const entry of traverseBack(preview.manifestEntry)) {
244
- if (entry.cache) {
245
- cacheScope = createCacheScope(entry.cache, cacheScope);
246
- }
247
- }
248
-
249
- if (cacheScope?.enabled) {
250
- // Evaluate condition skip response cache when condition returns false
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);