@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26

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 (225) hide show
  1. package/README.md +294 -28
  2. package/dist/bin/rango.js +355 -47
  3. package/dist/vite/index.js +1658 -1239
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +9 -5
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +40 -29
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/intercept/SKILL.md +79 -0
  11. package/skills/layout/SKILL.md +62 -2
  12. package/skills/loader/SKILL.md +229 -15
  13. package/skills/middleware/SKILL.md +109 -30
  14. package/skills/parallel/SKILL.md +57 -2
  15. package/skills/prerender/SKILL.md +189 -19
  16. package/skills/rango/SKILL.md +1 -2
  17. package/skills/response-routes/SKILL.md +3 -3
  18. package/skills/route/SKILL.md +44 -3
  19. package/skills/router-setup/SKILL.md +80 -3
  20. package/skills/theme/SKILL.md +5 -4
  21. package/skills/typesafety/SKILL.md +59 -16
  22. package/skills/use-cache/SKILL.md +16 -2
  23. package/src/__internal.ts +1 -1
  24. package/src/bin/rango.ts +56 -19
  25. package/src/browser/action-coordinator.ts +97 -0
  26. package/src/browser/event-controller.ts +29 -48
  27. package/src/browser/history-state.ts +80 -0
  28. package/src/browser/intercept-utils.ts +1 -1
  29. package/src/browser/link-interceptor.ts +19 -3
  30. package/src/browser/merge-segment-loaders.ts +9 -2
  31. package/src/browser/navigation-bridge.ts +66 -443
  32. package/src/browser/navigation-client.ts +34 -62
  33. package/src/browser/navigation-store.ts +4 -33
  34. package/src/browser/navigation-transaction.ts +295 -0
  35. package/src/browser/partial-update.ts +103 -151
  36. package/src/browser/prefetch/cache.ts +67 -0
  37. package/src/browser/prefetch/fetch.ts +137 -0
  38. package/src/browser/prefetch/observer.ts +65 -0
  39. package/src/browser/prefetch/policy.ts +42 -0
  40. package/src/browser/prefetch/queue.ts +88 -0
  41. package/src/browser/rango-state.ts +112 -0
  42. package/src/browser/react/Link.tsx +154 -44
  43. package/src/browser/react/NavigationProvider.tsx +32 -0
  44. package/src/browser/react/context.ts +6 -0
  45. package/src/browser/react/filter-segment-order.ts +11 -0
  46. package/src/browser/react/index.ts +2 -6
  47. package/src/browser/react/location-state-shared.ts +29 -11
  48. package/src/browser/react/location-state.ts +6 -4
  49. package/src/browser/react/nonce-context.ts +23 -0
  50. package/src/browser/react/shallow-equal.ts +27 -0
  51. package/src/browser/react/use-action.ts +23 -45
  52. package/src/browser/react/use-client-cache.ts +5 -3
  53. package/src/browser/react/use-handle.ts +21 -64
  54. package/src/browser/react/use-navigation.ts +7 -32
  55. package/src/browser/react/use-params.ts +5 -34
  56. package/src/browser/react/use-pathname.ts +2 -3
  57. package/src/browser/react/use-router.ts +3 -6
  58. package/src/browser/react/use-search-params.ts +2 -1
  59. package/src/browser/react/use-segments.ts +75 -114
  60. package/src/browser/response-adapter.ts +73 -0
  61. package/src/browser/rsc-router.tsx +46 -22
  62. package/src/browser/scroll-restoration.ts +10 -7
  63. package/src/browser/server-action-bridge.ts +458 -405
  64. package/src/browser/types.ts +21 -35
  65. package/src/browser/validate-redirect-origin.ts +29 -0
  66. package/src/build/generate-manifest.ts +38 -13
  67. package/src/build/generate-route-types.ts +4 -0
  68. package/src/build/index.ts +1 -0
  69. package/src/build/route-trie.ts +19 -3
  70. package/src/build/route-types/codegen.ts +13 -4
  71. package/src/build/route-types/include-resolution.ts +13 -0
  72. package/src/build/route-types/per-module-writer.ts +15 -3
  73. package/src/build/route-types/router-processing.ts +170 -18
  74. package/src/build/runtime-discovery.ts +13 -1
  75. package/src/cache/background-task.ts +34 -0
  76. package/src/cache/cache-key-utils.ts +44 -0
  77. package/src/cache/cache-policy.ts +125 -0
  78. package/src/cache/cache-runtime.ts +136 -123
  79. package/src/cache/cache-scope.ts +76 -83
  80. package/src/cache/cf/cf-cache-store.ts +12 -7
  81. package/src/cache/document-cache.ts +93 -69
  82. package/src/cache/handle-capture.ts +81 -0
  83. package/src/cache/index.ts +0 -15
  84. package/src/cache/memory-segment-store.ts +43 -69
  85. package/src/cache/profile-registry.ts +43 -8
  86. package/src/cache/read-through-swr.ts +134 -0
  87. package/src/cache/segment-codec.ts +140 -117
  88. package/src/cache/taint.ts +30 -3
  89. package/src/cache/types.ts +1 -115
  90. package/src/client.rsc.tsx +0 -1
  91. package/src/client.tsx +53 -76
  92. package/src/errors.ts +6 -1
  93. package/src/handle.ts +1 -1
  94. package/src/handles/MetaTags.tsx +5 -2
  95. package/src/host/cookie-handler.ts +8 -3
  96. package/src/host/index.ts +0 -3
  97. package/src/host/router.ts +14 -1
  98. package/src/href-client.ts +3 -1
  99. package/src/index.rsc.ts +53 -10
  100. package/src/index.ts +73 -43
  101. package/src/loader.rsc.ts +12 -4
  102. package/src/loader.ts +8 -0
  103. package/src/prerender/store.ts +60 -18
  104. package/src/prerender.ts +76 -18
  105. package/src/reverse.ts +11 -7
  106. package/src/root-error-boundary.tsx +30 -26
  107. package/src/route-definition/dsl-helpers.ts +9 -6
  108. package/src/route-definition/index.ts +0 -3
  109. package/src/route-definition/redirect.ts +15 -3
  110. package/src/route-map-builder.ts +38 -2
  111. package/src/route-name.ts +53 -0
  112. package/src/route-types.ts +7 -0
  113. package/src/router/content-negotiation.ts +1 -1
  114. package/src/router/debug-manifest.ts +16 -3
  115. package/src/router/handler-context.ts +96 -17
  116. package/src/router/intercept-resolution.ts +6 -4
  117. package/src/router/lazy-includes.ts +4 -0
  118. package/src/router/loader-resolution.ts +6 -11
  119. package/src/router/logging.ts +100 -3
  120. package/src/router/manifest.ts +32 -3
  121. package/src/router/match-api.ts +62 -54
  122. package/src/router/match-context.ts +3 -0
  123. package/src/router/match-handlers.ts +185 -11
  124. package/src/router/match-middleware/background-revalidation.ts +65 -85
  125. package/src/router/match-middleware/cache-lookup.ts +78 -10
  126. package/src/router/match-middleware/cache-store.ts +2 -0
  127. package/src/router/match-pipelines.ts +8 -43
  128. package/src/router/match-result.ts +0 -9
  129. package/src/router/metrics.ts +233 -13
  130. package/src/router/middleware-types.ts +34 -39
  131. package/src/router/middleware.ts +290 -130
  132. package/src/router/pattern-matching.ts +61 -10
  133. package/src/router/prerender-match.ts +36 -6
  134. package/src/router/preview-match.ts +7 -1
  135. package/src/router/revalidation.ts +61 -2
  136. package/src/router/router-context.ts +15 -0
  137. package/src/router/router-interfaces.ts +158 -40
  138. package/src/router/router-options.ts +223 -1
  139. package/src/router/router-registry.ts +5 -2
  140. package/src/router/segment-resolution/fresh.ts +165 -242
  141. package/src/router/segment-resolution/helpers.ts +263 -0
  142. package/src/router/segment-resolution/loader-cache.ts +102 -98
  143. package/src/router/segment-resolution/revalidation.ts +394 -272
  144. package/src/router/segment-resolution/static-store.ts +2 -2
  145. package/src/router/segment-resolution.ts +1 -3
  146. package/src/router/segment-wrappers.ts +3 -0
  147. package/src/router/telemetry-otel.ts +299 -0
  148. package/src/router/telemetry.ts +300 -0
  149. package/src/router/timeout.ts +148 -0
  150. package/src/router/trie-matching.ts +20 -2
  151. package/src/router/types.ts +7 -1
  152. package/src/router.ts +203 -18
  153. package/src/rsc/handler-context.ts +13 -2
  154. package/src/rsc/handler.ts +489 -438
  155. package/src/rsc/helpers.ts +125 -5
  156. package/src/rsc/index.ts +0 -20
  157. package/src/rsc/loader-fetch.ts +84 -42
  158. package/src/rsc/manifest-init.ts +3 -2
  159. package/src/rsc/origin-guard.ts +141 -0
  160. package/src/rsc/progressive-enhancement.ts +245 -19
  161. package/src/rsc/response-route-handler.ts +347 -0
  162. package/src/rsc/rsc-rendering.ts +47 -43
  163. package/src/rsc/runtime-warnings.ts +42 -0
  164. package/src/rsc/server-action.ts +166 -66
  165. package/src/rsc/ssr-setup.ts +128 -0
  166. package/src/rsc/types.ts +20 -2
  167. package/src/search-params.ts +38 -23
  168. package/src/server/context.ts +61 -7
  169. package/src/server/cookie-store.ts +190 -0
  170. package/src/server/fetchable-loader-store.ts +11 -6
  171. package/src/server/handle-store.ts +84 -12
  172. package/src/server/loader-registry.ts +11 -46
  173. package/src/server/request-context.ts +275 -49
  174. package/src/server.ts +6 -0
  175. package/src/ssr/index.tsx +67 -28
  176. package/src/static-handler.ts +7 -0
  177. package/src/theme/ThemeProvider.tsx +6 -1
  178. package/src/theme/index.ts +4 -18
  179. package/src/theme/theme-context.ts +1 -28
  180. package/src/theme/theme-script.ts +2 -1
  181. package/src/types/cache-types.ts +6 -1
  182. package/src/types/error-types.ts +3 -0
  183. package/src/types/global-namespace.ts +22 -0
  184. package/src/types/handler-context.ts +103 -16
  185. package/src/types/index.ts +1 -1
  186. package/src/types/loader-types.ts +9 -6
  187. package/src/types/route-config.ts +17 -26
  188. package/src/types/route-entry.ts +28 -0
  189. package/src/types/segments.ts +0 -5
  190. package/src/urls/include-helper.ts +49 -8
  191. package/src/urls/index.ts +1 -0
  192. package/src/urls/path-helper-types.ts +30 -12
  193. package/src/urls/path-helper.ts +17 -2
  194. package/src/urls/pattern-types.ts +21 -1
  195. package/src/urls/response-types.ts +29 -7
  196. package/src/urls/type-extraction.ts +23 -15
  197. package/src/use-loader.tsx +27 -9
  198. package/src/vite/discovery/bundle-postprocess.ts +32 -52
  199. package/src/vite/discovery/discover-routers.ts +52 -26
  200. package/src/vite/discovery/prerender-collection.ts +58 -41
  201. package/src/vite/discovery/route-types-writer.ts +7 -7
  202. package/src/vite/discovery/state.ts +7 -7
  203. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  204. package/src/vite/index.ts +10 -51
  205. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  206. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  207. package/src/vite/plugins/expose-internal-ids.ts +4 -3
  208. package/src/vite/plugins/refresh-cmd.ts +65 -0
  209. package/src/vite/plugins/use-cache-transform.ts +91 -3
  210. package/src/vite/plugins/version-plugin.ts +188 -18
  211. package/src/vite/rango.ts +61 -36
  212. package/src/vite/router-discovery.ts +173 -100
  213. package/src/vite/utils/prerender-utils.ts +81 -0
  214. package/src/vite/utils/shared-utils.ts +19 -9
  215. package/skills/testing/SKILL.md +0 -226
  216. package/src/browser/lru-cache.ts +0 -61
  217. package/src/browser/react/prefetch.ts +0 -27
  218. package/src/browser/request-controller.ts +0 -164
  219. package/src/cache/memory-store.ts +0 -253
  220. package/src/href-context.ts +0 -33
  221. package/src/route-definition/route-function.ts +0 -119
  222. package/src/router.gen.ts +0 -6
  223. package/src/static-handler.gen.ts +0 -5
  224. package/src/urls.gen.ts +0 -8
  225. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -10,9 +10,33 @@ import {
10
10
  requireRequestContext,
11
11
  setRequestContextParams,
12
12
  } from "../server/request-context.js";
13
+ import { getSSRSetup } from "./ssr-setup.js";
14
+ import type { MiddlewareFn } from "../router/middleware.js";
15
+ import { executeMiddleware } from "../router/middleware.js";
13
16
  import type { RscPayload, ReactFormState } from "./types.js";
14
- import { createResponseWithMergedHeaders } from "./helpers.js";
17
+ import {
18
+ createResponseWithMergedHeaders,
19
+ finalizeResponse,
20
+ buildRouteMiddlewareEntries,
21
+ } from "./helpers.js";
15
22
  import type { HandlerContext } from "./handler-context.js";
23
+ import {
24
+ extractRedirectResponse,
25
+ warnNonRedirectPeResponse,
26
+ } from "./runtime-warnings.js";
27
+
28
+ export interface PeRouteMiddlewareInfo {
29
+ routeMiddleware?: Array<{
30
+ handler: MiddlewareFn;
31
+ params: Record<string, string>;
32
+ }>;
33
+ variables: Record<string, any>;
34
+ routeReverse?: (
35
+ name: string,
36
+ params?: Record<string, string>,
37
+ search?: Record<string, unknown>,
38
+ ) => string;
39
+ }
16
40
 
17
41
  export async function handleProgressiveEnhancement<TEnv>(
18
42
  ctx: HandlerContext<TEnv>,
@@ -22,6 +46,7 @@ export async function handleProgressiveEnhancement<TEnv>(
22
46
  isAction: boolean,
23
47
  handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
24
48
  nonce: string | undefined,
49
+ routeMwInfo?: PeRouteMiddlewareInfo,
25
50
  ): Promise<Response | null> {
26
51
  const contentType = request.headers.get("content-type") || "";
27
52
  const isFormSubmission =
@@ -32,8 +57,42 @@ export async function handleProgressiveEnhancement<TEnv>(
32
57
  return null;
33
58
  }
34
59
 
35
- // Clone the request to read FormData without consuming it
36
- const formData = await request.clone().formData();
60
+ // Clone the request to read FormData without consuming it.
61
+ // Wrap in try-catch so malformed POST bodies are reported as action
62
+ // errors, not routing errors from the outer catch in handler.ts.
63
+ let formData: FormData;
64
+ try {
65
+ formData = await request.clone().formData();
66
+ } catch (error) {
67
+ // Attempt error boundary rendering so the user sees a meaningful page.
68
+ const errorHtml = await renderPeErrorBoundary(
69
+ ctx,
70
+ request,
71
+ env,
72
+ url,
73
+ error,
74
+ handleStore,
75
+ nonce,
76
+ );
77
+ if (errorHtml) {
78
+ ctx.callOnError(error, "action", {
79
+ request,
80
+ url,
81
+ env,
82
+ handledByBoundary: true,
83
+ });
84
+ return errorHtml;
85
+ }
86
+
87
+ ctx.callOnError(error, "action", {
88
+ request,
89
+ url,
90
+ env,
91
+ handledByBoundary: false,
92
+ });
93
+ console.error("[RSC] Progressive enhancement form parse error:", error);
94
+ return createResponseWithMergedHeaders(null, { status: 400 });
95
+ }
37
96
 
38
97
  // Look for React's progressive enhancement hidden fields
39
98
  let isDirectAction = false;
@@ -58,14 +117,37 @@ export async function handleProgressiveEnhancement<TEnv>(
58
117
  let reactFormState: ReactFormState | null = null;
59
118
 
60
119
  if (isUseActionState) {
120
+ // Decode and extract action identity before execution so error
121
+ // handlers can report actionId even when the action throws.
122
+ let useActionStateId: string | undefined;
61
123
  try {
62
124
  const boundAction = await ctx.decodeAction(formData);
125
+ // React's custom .bind() preserves $$id on server references.
126
+ useActionStateId = (boundAction as { $$id?: string }).$$id ?? undefined;
63
127
  actionResult = await boundAction();
64
128
  } catch (error) {
129
+ // Handle thrown redirect (e.g., throw redirect('/path'))
130
+ const redirectResponse = extractRedirectResponse(error);
131
+ if (redirectResponse) return redirectResponse;
132
+
133
+ // Attempt error boundary rendering for the PE path
134
+ const errorHtml = await renderPeErrorBoundary(
135
+ ctx,
136
+ request,
137
+ env,
138
+ url,
139
+ error,
140
+ handleStore,
141
+ nonce,
142
+ useActionStateId,
143
+ );
144
+ if (errorHtml) return errorHtml;
145
+
65
146
  ctx.callOnError(error, "action", {
66
147
  request,
67
148
  url,
68
149
  env,
150
+ actionId: useActionStateId,
69
151
  handledByBoundary: false,
70
152
  });
71
153
  console.error("[RSC] Progressive enhancement action error:", error);
@@ -84,6 +166,23 @@ export async function handleProgressiveEnhancement<TEnv>(
84
166
  const loadedAction = await ctx.loadServerAction(directActionId);
85
167
  actionResult = await loadedAction.apply(null, args);
86
168
  } catch (error) {
169
+ // Handle thrown redirect (e.g., throw redirect('/path'))
170
+ const redirectResponse = extractRedirectResponse(error);
171
+ if (redirectResponse) return redirectResponse;
172
+
173
+ // Attempt error boundary rendering for the PE path
174
+ const errorHtml = await renderPeErrorBoundary(
175
+ ctx,
176
+ request,
177
+ env,
178
+ url,
179
+ error,
180
+ handleStore,
181
+ nonce,
182
+ directActionId,
183
+ );
184
+ if (errorHtml) return errorHtml;
185
+
87
186
  ctx.callOnError(error, "action", {
88
187
  request,
89
188
  url,
@@ -95,6 +194,20 @@ export async function handleProgressiveEnhancement<TEnv>(
95
194
  }
96
195
  }
97
196
 
197
+ // Handle Response returned from action during PE.
198
+ // In the JS path, executeServerAction intercepts redirect Responses and
199
+ // short-circuits. The PE path must handle them too.
200
+ if (actionResult instanceof Response) {
201
+ const redirectResponse = extractRedirectResponse(actionResult);
202
+ if (redirectResponse) return redirectResponse;
203
+ // W3: Non-redirect Response — discard it so it doesn't flow into
204
+ // decodeFormState or the re-render payload.
205
+ if (process.env.NODE_ENV !== "production") {
206
+ warnNonRedirectPeResponse();
207
+ }
208
+ actionResult = undefined;
209
+ }
210
+
98
211
  // Decode form state for useActionState progressive enhancement
99
212
  try {
100
213
  reactFormState = await ctx.decodeFormState(actionResult, formData);
@@ -108,28 +221,132 @@ export async function handleProgressiveEnhancement<TEnv>(
108
221
  console.error("[RSC] Failed to decode form state:", error);
109
222
  }
110
223
 
111
- // Re-render the page and return HTML
112
- const renderRequest = new Request(url.toString(), {
113
- method: "GET",
114
- headers: new Headers({ accept: "text/html" }),
115
- });
224
+ // Re-render the page and return HTML.
225
+ // Route middleware wraps the render so context variables, headers, and
226
+ // cookies set by route middleware are available during re-render — matching
227
+ // the behavior of JS-enabled requests.
228
+ const renderPage = async (): Promise<Response> => {
229
+ const renderRequest = new Request(url.toString(), {
230
+ method: "GET",
231
+ headers: new Headers({ accept: "text/html" }),
232
+ });
116
233
 
117
- const match = await ctx.router.match(renderRequest, { env });
234
+ const match = await ctx.router.match(renderRequest, { env });
118
235
 
119
- if (match.redirect) {
120
- return createResponseWithMergedHeaders(null, {
121
- status: 308,
122
- headers: { Location: match.redirect },
236
+ if (match.redirect) {
237
+ return createResponseWithMergedHeaders(null, {
238
+ status: 308,
239
+ headers: { Location: match.redirect },
240
+ });
241
+ }
242
+
243
+ const payload: RscPayload = {
244
+ metadata: {
245
+ pathname: url.pathname,
246
+ segments: match.segments,
247
+ matched: match.matched,
248
+ diff: match.diff,
249
+ isPartial: false,
250
+ rootLayout: ctx.router.rootLayout,
251
+ handles: handleStore.stream(),
252
+ version: ctx.version,
253
+ themeConfig: ctx.router.themeConfig,
254
+ warmupEnabled: ctx.router.warmupEnabled,
255
+ initialTheme: requireRequestContext().theme,
256
+ },
257
+ formState: actionResult,
258
+ };
259
+
260
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
261
+ // metricsStore=undefined is safe: the handler already stashed the early
262
+ // SSR setup promise on request variables, so getSSRSetup returns it
263
+ // without falling back to a fresh startSSRSetup.
264
+ const [ssrModule, streamMode] = await getSSRSetup(
265
+ ctx,
266
+ request,
267
+ env,
268
+ url,
269
+ undefined,
270
+ );
271
+ const htmlStream = await ssrModule.renderHTML(rscStream, {
272
+ formState: reactFormState,
273
+ nonce,
274
+ streamMode,
123
275
  });
276
+
277
+ return createResponseWithMergedHeaders(htmlStream, {
278
+ headers: { "content-type": "text/html;charset=utf-8" },
279
+ });
280
+ };
281
+
282
+ // Execute route middleware wrapping the render, if any.
283
+ // finalizeResponse drains onResponse callbacks that middleware short-circuits
284
+ // may leave behind (executeMiddleware does not finalize them itself).
285
+ if (routeMwInfo?.routeMiddleware && routeMwInfo.routeMiddleware.length > 0) {
286
+ return finalizeResponse(
287
+ await executeMiddleware(
288
+ buildRouteMiddlewareEntries(routeMwInfo.routeMiddleware),
289
+ request,
290
+ env,
291
+ routeMwInfo.variables,
292
+ renderPage,
293
+ routeMwInfo.routeReverse,
294
+ ),
295
+ );
124
296
  }
125
297
 
298
+ return renderPage();
299
+ }
300
+
301
+ /**
302
+ * Attempt to render an error boundary as full HTML for the PE path.
303
+ * Returns null if no error boundary is found (caller falls through to
304
+ * normal page re-render).
305
+ */
306
+ async function renderPeErrorBoundary<TEnv>(
307
+ ctx: HandlerContext<TEnv>,
308
+ request: Request,
309
+ env: TEnv,
310
+ url: URL,
311
+ error: unknown,
312
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
313
+ nonce: string | undefined,
314
+ actionId?: string | null,
315
+ ): Promise<Response | null> {
316
+ let errorResult;
317
+ try {
318
+ errorResult = await ctx.router.matchError(request, { env }, error, "route");
319
+ } catch (matchErr) {
320
+ ctx.callOnError(error, "action", {
321
+ request,
322
+ url,
323
+ env,
324
+ actionId: actionId ?? undefined,
325
+ handledByBoundary: false,
326
+ });
327
+ throw matchErr;
328
+ }
329
+
330
+ if (!errorResult) return null;
331
+
332
+ ctx.callOnError(error, "action", {
333
+ request,
334
+ url,
335
+ env,
336
+ actionId: actionId ?? undefined,
337
+ handledByBoundary: true,
338
+ });
339
+
340
+ setRequestContextParams(errorResult.params, errorResult.routeName);
341
+
126
342
  const payload: RscPayload = {
127
343
  metadata: {
128
344
  pathname: url.pathname,
129
- segments: match.segments,
130
- matched: match.matched,
131
- diff: match.diff,
345
+ segments: errorResult.segments,
346
+ matched: errorResult.matched,
347
+ diff: errorResult.diff,
132
348
  isPartial: false,
349
+ isError: true,
133
350
  rootLayout: ctx.router.rootLayout,
134
351
  handles: handleStore.stream(),
135
352
  version: ctx.version,
@@ -137,17 +354,26 @@ export async function handleProgressiveEnhancement<TEnv>(
137
354
  warmupEnabled: ctx.router.warmupEnabled,
138
355
  initialTheme: requireRequestContext().theme,
139
356
  },
140
- formState: actionResult,
141
357
  };
142
358
 
143
359
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
144
- const ssrModule = await ctx.loadSSRModule();
360
+ // metricsStore=undefined is safe: the handler already stashed the early
361
+ // SSR setup promise on request variables, so getSSRSetup returns it
362
+ // without falling back to a fresh startSSRSetup.
363
+ const [ssrModule, streamMode] = await getSSRSetup(
364
+ ctx,
365
+ request,
366
+ env,
367
+ url,
368
+ undefined,
369
+ );
145
370
  const htmlStream = await ssrModule.renderHTML(rscStream, {
146
- formState: reactFormState,
147
371
  nonce,
372
+ streamMode,
148
373
  });
149
374
 
150
375
  return createResponseWithMergedHeaders(htmlStream, {
376
+ status: 500,
151
377
  headers: { "content-type": "text/html;charset=utf-8" },
152
378
  });
153
379
  }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Response Route Handler
3
+ *
4
+ * Handles response routes (JSON, text, HTML, XML, markdown, image, stream)
5
+ * that bypass the RSC rendering pipeline entirely. Includes content-type
6
+ * dispatch, route middleware execution, and response caching with SWR.
7
+ */
8
+
9
+ import { RouterError } from "../errors.js";
10
+ import { requireRequestContext } from "../server/request-context.js";
11
+ import { contextGet } from "../context-var.js";
12
+ import { NOCACHE_SYMBOL } from "../cache/taint.js";
13
+ import { traverseBack } from "../router/pattern-matching.js";
14
+ import { createCacheScope } from "../cache/cache-scope.js";
15
+ import { executeMiddleware } from "../router/middleware.js";
16
+ import {
17
+ createReverseFunction,
18
+ stripInternalParams,
19
+ } from "../router/handler-context.js";
20
+ import type { MiddlewareFn } from "../router/middleware.js";
21
+ import type { EntryData } from "../server/context.js";
22
+ import type { HandlerContext } from "./handler-context.js";
23
+ import { createResponseErrorPayload } from "./response-error.js";
24
+ import {
25
+ createResponseWithMergedHeaders,
26
+ finalizeResponse,
27
+ isCacheableStatus,
28
+ buildRouteMiddlewareEntries,
29
+ } from "./helpers.js";
30
+
31
+ export interface ResponseRouteMatch {
32
+ responseType: string;
33
+ handler: Function;
34
+ params?: Record<string, string>;
35
+ negotiated?: boolean;
36
+ manifestEntry?: EntryData;
37
+ routeMiddleware?: Array<{
38
+ handler: MiddlewareFn;
39
+ params: Record<string, string>;
40
+ }>;
41
+ }
42
+
43
+ /**
44
+ * Handle a response route (non-RSC). Dispatches by content type, wraps
45
+ * with route middleware and response caching when configured.
46
+ *
47
+ * For partial (client-side navigation) requests, returns X-RSC-Reload
48
+ * so the browser triggers a hard navigation to the response route URL.
49
+ */
50
+ export async function handleResponseRoute<TEnv>(
51
+ handlerCtx: HandlerContext<TEnv>,
52
+ preview: ResponseRouteMatch,
53
+ request: Request,
54
+ env: TEnv,
55
+ url: URL,
56
+ variables: Record<string, any>,
57
+ ): Promise<Response> {
58
+ const isPartial = url.searchParams.has("_rsc_partial");
59
+
60
+ // Partial requests (client-side navigation) to response routes
61
+ // get X-RSC-Reload to trigger hard navigation in the browser
62
+ if (isPartial) {
63
+ return createResponseWithMergedHeaders(null, {
64
+ status: 200,
65
+ headers: {
66
+ "X-RSC-Reload": stripInternalParams(url).toString(),
67
+ "content-type": "text/x-component;charset=utf-8",
68
+ },
69
+ });
70
+ }
71
+
72
+ // Build lightweight context for response handler
73
+ const reqCtx = requireRequestContext();
74
+ const cleanUrl = stripInternalParams(url);
75
+ const responseHandlerCtx = {
76
+ request,
77
+ params: preview.params || {},
78
+ env,
79
+ searchParams: cleanUrl.searchParams,
80
+ url: cleanUrl,
81
+ pathname: url.pathname,
82
+ reverse: createReverseFunction(handlerCtx.getRequiredRouteMap()),
83
+ get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
84
+ header: (name: string, value: string) => reqCtx.header(name, value),
85
+ _responseType: preview.responseType,
86
+ };
87
+ // Brand with taint symbol so "use cache" detects it as request-scoped
88
+ // and extracts route-identifying properties (params, pathname, _responseType)
89
+ (responseHandlerCtx as any)[NOCACHE_SYMBOL] = true;
90
+
91
+ // Call handler directly, wrapped by route middleware if present
92
+ const callHandler = async () => {
93
+ const errorCtx = { request, url, env };
94
+
95
+ // Re-wrap a handler-returned Response through createResponseWithMergedHeaders
96
+ // so that stub headers (cookies, custom headers set via ctx.header()) are included.
97
+ // Use Headers (not Record<string, string>) to preserve duplicate entries like Set-Cookie.
98
+ const rewrapResponse = (result: Response) => {
99
+ const headers = new Headers();
100
+ result.headers.forEach((value, key) => {
101
+ if (key.toLowerCase() === "set-cookie") {
102
+ headers.append(key, value);
103
+ } else {
104
+ headers.set(key, value);
105
+ }
106
+ });
107
+ return createResponseWithMergedHeaders(result.body, {
108
+ status: result.status,
109
+ headers,
110
+ });
111
+ };
112
+
113
+ // JSON response routes: wrap in { data } / { error } envelope
114
+ if (preview.responseType === "json") {
115
+ try {
116
+ const result = await (preview.handler as Function)(responseHandlerCtx);
117
+ if (result instanceof Response) {
118
+ return rewrapResponse(result);
119
+ }
120
+ return createResponseWithMergedHeaders(
121
+ JSON.stringify({ data: result }),
122
+ {
123
+ status: 200,
124
+ headers: { "content-type": "application/json;charset=utf-8" },
125
+ },
126
+ );
127
+ } catch (error) {
128
+ handlerCtx.callOnError(error, "handler", errorCtx);
129
+ const isDev = process.env.NODE_ENV !== "production";
130
+ const status = error instanceof RouterError ? error.status : 500;
131
+ return createResponseWithMergedHeaders(
132
+ JSON.stringify({
133
+ error: createResponseErrorPayload(error, isDev),
134
+ }),
135
+ {
136
+ status,
137
+ headers: { "content-type": "application/json;charset=utf-8" },
138
+ },
139
+ );
140
+ }
141
+ }
142
+
143
+ // Non-JSON response routes: catch errors and return plain Response
144
+ try {
145
+ const result = await (preview.handler as Function)(responseHandlerCtx);
146
+
147
+ if (result instanceof Response) {
148
+ return rewrapResponse(result);
149
+ }
150
+
151
+ // Auto-wrap based on response type tag
152
+ switch (preview.responseType) {
153
+ case "text":
154
+ return createResponseWithMergedHeaders(String(result), {
155
+ status: 200,
156
+ headers: { "content-type": "text/plain;charset=utf-8" },
157
+ });
158
+ case "html":
159
+ return createResponseWithMergedHeaders(String(result), {
160
+ status: 200,
161
+ headers: { "content-type": "text/html;charset=utf-8" },
162
+ });
163
+ case "xml":
164
+ return createResponseWithMergedHeaders(String(result), {
165
+ status: 200,
166
+ headers: { "content-type": "application/xml;charset=utf-8" },
167
+ });
168
+ case "md":
169
+ return createResponseWithMergedHeaders(String(result), {
170
+ status: 200,
171
+ headers: { "content-type": "text/markdown;charset=utf-8" },
172
+ });
173
+ default:
174
+ // image, stream, any -- must return Response
175
+ throw new Error(
176
+ `Response route handler for "${preview.responseType}" must return a Response object, got ${typeof result}`,
177
+ );
178
+ }
179
+ } catch (error) {
180
+ handlerCtx.callOnError(error, "handler", errorCtx);
181
+ const isDev = process.env.NODE_ENV !== "production";
182
+ const status = error instanceof RouterError ? error.status : 500;
183
+ const message =
184
+ error instanceof RouterError
185
+ ? error.message
186
+ : isDev && error instanceof Error
187
+ ? error.message
188
+ : "Internal Server Error";
189
+ return createResponseWithMergedHeaders(message, {
190
+ status,
191
+ headers: { "content-type": "text/plain;charset=utf-8" },
192
+ });
193
+ }
194
+ };
195
+
196
+ // Wrap callHandler to append Vary: Accept on content-negotiated responses
197
+ const callHandlerWithVary = async () => {
198
+ const response = await callHandler();
199
+ if (preview.negotiated) {
200
+ response.headers.append("Vary", "Accept");
201
+ }
202
+ return response;
203
+ };
204
+
205
+ // Wrap with route middleware if present
206
+ const executeHandler = async () => {
207
+ if (preview.routeMiddleware && preview.routeMiddleware.length > 0) {
208
+ return executeMiddleware(
209
+ buildRouteMiddlewareEntries<TEnv>(preview.routeMiddleware),
210
+ request,
211
+ env,
212
+ variables,
213
+ callHandlerWithVary,
214
+ createReverseFunction(handlerCtx.getRequiredRouteMap()),
215
+ );
216
+ }
217
+ return callHandlerWithVary();
218
+ };
219
+
220
+ // Resolve cache config from entry tree (same pattern as match-api.ts)
221
+ if (preview.manifestEntry) {
222
+ let cacheScope: ReturnType<typeof createCacheScope> = null;
223
+ for (const entry of traverseBack(preview.manifestEntry)) {
224
+ if (entry.cache) {
225
+ cacheScope = createCacheScope(entry.cache, cacheScope);
226
+ }
227
+ }
228
+
229
+ if (cacheScope?.enabled) {
230
+ // Evaluate condition — skip response cache when condition returns false
231
+ let conditionPassed = true;
232
+ if (cacheScope.config !== false && cacheScope.config.condition) {
233
+ try {
234
+ conditionPassed = !!cacheScope.config.condition(reqCtx);
235
+ } catch {
236
+ conditionPassed = false;
237
+ }
238
+ }
239
+
240
+ const store = cacheScope.getStore() ?? reqCtx._cacheStore;
241
+ if (conditionPassed && store?.getResponse && store?.putResponse) {
242
+ // Build cache key with response:{type}: prefix to avoid collision
243
+ // with segment keys and differentiate between response types.
244
+ // Include host and url.search so query-driven and multi-host
245
+ // responses cache separately.
246
+ let cacheKey = `response:${preview.responseType}:${url.host}${url.pathname}${url.search}`;
247
+
248
+ // Priority 1: Route-level key function (full override)
249
+ if (cacheScope.config !== false && cacheScope.config.key) {
250
+ try {
251
+ const customKey = await cacheScope.config.key(reqCtx);
252
+ cacheKey = `response:${customKey}`;
253
+ } catch {
254
+ // Fall back to default key on route-level key failure
255
+ }
256
+ } else if (store.keyGenerator) {
257
+ // Priority 2: Store-level keyGenerator (modifies default key)
258
+ try {
259
+ cacheKey = await store.keyGenerator(reqCtx, cacheKey);
260
+ } catch {
261
+ // Fall back to default key on keyGenerator failure
262
+ }
263
+ }
264
+
265
+ // Save pre-handler callbacks (registered by app-level middleware
266
+ // before we reach the cache block) and clear the live array.
267
+ // createResponseWithMergedHeaders (inside the handler) eagerly
268
+ // executes any callbacks present in _onResponseCallbacks, so
269
+ // handler-registered callbacks are baked into the handler's
270
+ // response and the cached artifact. Pre-handler callbacks are
271
+ // NOT in the live array during execution, so they are applied
272
+ // once per serve on every path (hit + miss) below.
273
+ const savedCallbacks = reqCtx._onResponseCallbacks;
274
+ reqCtx._onResponseCallbacks = [];
275
+
276
+ const applyPreHandlerCallbacks = (response: Response): Response => {
277
+ let result = response;
278
+ for (const callback of savedCallbacks) {
279
+ result = callback(result) ?? result;
280
+ }
281
+ return result;
282
+ };
283
+
284
+ try {
285
+ const cached = await store.getResponse(cacheKey);
286
+
287
+ if (cached && isCacheableStatus(cached.response.status)) {
288
+ if (!cached.shouldRevalidate) {
289
+ // Fresh hit
290
+ return applyPreHandlerCallbacks(cached.response);
291
+ }
292
+
293
+ // Stale hit (SWR) - return cached, revalidate in background
294
+ reqCtx.waitUntil(async () => {
295
+ try {
296
+ // finalizeResponse drains any onResponse callbacks registered
297
+ // during middleware execution (e.g. middleware short-circuit)
298
+ // that createResponseWithMergedHeaders didn't reach.
299
+ const fresh = finalizeResponse(await executeHandler());
300
+ if (isCacheableStatus(fresh.status)) {
301
+ await store.putResponse!(
302
+ cacheKey,
303
+ fresh.clone(),
304
+ cacheScope!.ttl,
305
+ cacheScope!.swr,
306
+ );
307
+ }
308
+ } catch (error) {
309
+ console.error(`[ResponseCache] Revalidation failed:`, error);
310
+ }
311
+ });
312
+
313
+ return applyPreHandlerCallbacks(cached.response);
314
+ }
315
+ } catch (error) {
316
+ console.error(`[ResponseCache] Cache lookup failed:`, error);
317
+ }
318
+
319
+ // Cache miss - execute handler and cache the result.
320
+ // createResponseWithMergedHeaders inside the handler drains callbacks
321
+ // registered during handler execution. finalizeResponse catches any
322
+ // remaining callbacks (e.g. from middleware short-circuit where the
323
+ // handler never ran) so the cached artifact includes all transforms.
324
+ const response = finalizeResponse(await executeHandler());
325
+
326
+ if (isCacheableStatus(response.status)) {
327
+ reqCtx.waitUntil(async () => {
328
+ try {
329
+ await store.putResponse!(
330
+ cacheKey,
331
+ response.clone(),
332
+ cacheScope!.ttl,
333
+ cacheScope!.swr,
334
+ );
335
+ } catch (error) {
336
+ console.error(`[ResponseCache] Cache write failed:`, error);
337
+ }
338
+ });
339
+ }
340
+
341
+ return applyPreHandlerCallbacks(response);
342
+ }
343
+ }
344
+ }
345
+
346
+ return executeHandler().then(finalizeResponse);
347
+ }