@rangojs/router 0.0.0-experimental.88a3b2f7 → 0.0.0-experimental.8bcfea43

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 (102) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +647 -176
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +362 -0
  7. package/skills/hooks/SKILL.md +28 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +88 -16
  11. package/skills/loader/SKILL.md +35 -2
  12. package/skills/middleware/SKILL.md +32 -3
  13. package/skills/migrate-nextjs/SKILL.md +560 -0
  14. package/skills/migrate-react-router/SKILL.md +765 -0
  15. package/skills/parallel/SKILL.md +59 -0
  16. package/skills/rango/SKILL.md +24 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/streams-and-websockets/SKILL.md +283 -0
  20. package/skills/typesafety/SKILL.md +3 -1
  21. package/src/browser/app-shell.ts +52 -0
  22. package/src/browser/navigation-bridge.ts +72 -4
  23. package/src/browser/navigation-client.ts +64 -13
  24. package/src/browser/navigation-store.ts +25 -1
  25. package/src/browser/partial-update.ts +34 -3
  26. package/src/browser/prefetch/cache.ts +129 -21
  27. package/src/browser/prefetch/fetch.ts +148 -16
  28. package/src/browser/prefetch/queue.ts +36 -5
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/Link.tsx +30 -2
  31. package/src/browser/react/NavigationProvider.tsx +50 -11
  32. package/src/browser/react/use-navigation.ts +22 -2
  33. package/src/browser/react/use-params.ts +11 -1
  34. package/src/browser/react/use-router.ts +8 -1
  35. package/src/browser/rsc-router.tsx +34 -6
  36. package/src/browser/segment-reconciler.ts +36 -14
  37. package/src/browser/types.ts +13 -0
  38. package/src/build/route-trie.ts +50 -24
  39. package/src/cache/cf/cf-cache-store.ts +5 -7
  40. package/src/client.tsx +84 -230
  41. package/src/index.rsc.ts +3 -0
  42. package/src/index.ts +44 -9
  43. package/src/outlet-context.ts +1 -1
  44. package/src/response-utils.ts +28 -0
  45. package/src/reverse.ts +7 -3
  46. package/src/route-definition/dsl-helpers.ts +180 -24
  47. package/src/route-definition/helpers-types.ts +61 -14
  48. package/src/route-definition/resolve-handler-use.ts +6 -0
  49. package/src/route-types.ts +7 -0
  50. package/src/router/handler-context.ts +24 -4
  51. package/src/router/lazy-includes.ts +6 -6
  52. package/src/router/loader-resolution.ts +73 -46
  53. package/src/router/manifest.ts +22 -13
  54. package/src/router/match-api.ts +3 -3
  55. package/src/router/match-middleware/cache-lookup.ts +10 -5
  56. package/src/router/match-middleware/segment-resolution.ts +1 -1
  57. package/src/router/match-result.ts +82 -4
  58. package/src/router/middleware-types.ts +2 -22
  59. package/src/router/middleware.ts +32 -4
  60. package/src/router/pattern-matching.ts +60 -9
  61. package/src/router/segment-resolution/fresh.ts +52 -0
  62. package/src/router/segment-resolution/revalidation.ts +69 -1
  63. package/src/router/trie-matching.ts +10 -4
  64. package/src/router/url-params.ts +49 -0
  65. package/src/router.ts +1 -2
  66. package/src/rsc/handler.ts +21 -9
  67. package/src/rsc/helpers.ts +69 -41
  68. package/src/rsc/loader-fetch.ts +23 -3
  69. package/src/rsc/progressive-enhancement.ts +12 -2
  70. package/src/rsc/response-route-handler.ts +14 -1
  71. package/src/rsc/rsc-rendering.ts +12 -1
  72. package/src/rsc/server-action.ts +8 -0
  73. package/src/rsc/types.ts +1 -0
  74. package/src/segment-content-promise.ts +67 -0
  75. package/src/segment-loader-promise.ts +122 -0
  76. package/src/segment-system.tsx +11 -61
  77. package/src/server/context.ts +26 -3
  78. package/src/server/handle-store.ts +19 -0
  79. package/src/server/request-context.ts +64 -56
  80. package/src/types/handler-context.ts +2 -34
  81. package/src/types/loader-types.ts +5 -6
  82. package/src/types/request-scope.ts +126 -0
  83. package/src/types/route-entry.ts +11 -0
  84. package/src/types/segments.ts +1 -1
  85. package/src/urls/include-helper.ts +24 -14
  86. package/src/urls/path-helper-types.ts +34 -5
  87. package/src/urls/response-types.ts +2 -10
  88. package/src/use-loader.tsx +77 -5
  89. package/src/vite/debug.ts +55 -0
  90. package/src/vite/discovery/prerender-collection.ts +124 -83
  91. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  92. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  93. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  94. package/src/vite/plugins/expose-id-utils.ts +12 -0
  95. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  96. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  97. package/src/vite/plugins/performance-tracks.ts +4 -6
  98. package/src/vite/rango.ts +49 -14
  99. package/src/vite/router-discovery.ts +186 -26
  100. package/src/vite/utils/banner.ts +1 -1
  101. package/src/vite/utils/package-resolution.ts +41 -1
  102. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -327,6 +327,7 @@ export async function resolveSegment<TEnv>(
327
327
  deps,
328
328
  options,
329
329
  routeKey,
330
+ entry,
330
331
  );
331
332
  segments.push(...orphanSegments);
332
333
  }
@@ -382,6 +383,9 @@ export async function resolveOrphanLayout<TEnv>(
382
383
  deps: SegmentResolutionDeps<TEnv>,
383
384
  options?: ResolveSegmentOptions,
384
385
  routeKey?: string,
386
+ /** Parent route entry — its loaders are inherited by the layout so
387
+ * parallel slots inside this layout can access them via useLoader(). */
388
+ parentRouteEntry?: EntryData,
385
389
  ): Promise<ResolvedSegment[]> {
386
390
  invariant(
387
391
  orphan.type === "layout" || orphan.type === "cache",
@@ -397,6 +401,30 @@ export async function resolveOrphanLayout<TEnv>(
397
401
  deps,
398
402
  );
399
403
  segments.push(...loaderSegments);
404
+
405
+ // Inherit parent route's loaders so parallel slots inside this layout
406
+ // can access them via useLoader(). Without this, the route's loaders
407
+ // are only in the route's OutletProvider (rendered as <Outlet /> content),
408
+ // which is a child — not a parent — of the layout's context.
409
+ if (
410
+ parentRouteEntry &&
411
+ parentRouteEntry.loader &&
412
+ parentRouteEntry.loader.length > 0 &&
413
+ Object.keys(orphan.parallel).length > 0
414
+ ) {
415
+ const inheritedLoaders = await resolveLoaders(
416
+ parentRouteEntry,
417
+ context,
418
+ belongsToRoute,
419
+ deps,
420
+ orphan.shortCode,
421
+ );
422
+ // Tag as inherited so buildMatchResult can deduplicate when safe
423
+ for (const s of inheritedLoaders) {
424
+ s._inherited = true;
425
+ }
426
+ segments.push(...inheritedLoaders);
427
+ }
400
428
  }
401
429
 
402
430
  // Handler-first: orphan layout handler executes before its parallels
@@ -685,6 +713,30 @@ export async function resolveLoadersOnly<TEnv>(
685
713
  const childBelongsToRoute = belongsToRoute || entry.type === "route";
686
714
  for (const layoutEntry of entry.layout) {
687
715
  await collectEntryLoaders(layoutEntry, childBelongsToRoute);
716
+ // Inherit route loaders for orphan layouts with parallels.
717
+ // Resolve directly — do NOT re-enter collectEntryLoaders with the
718
+ // route entry, as that would re-iterate route.layout and loop.
719
+ if (
720
+ entry.type === "route" &&
721
+ entry.loader &&
722
+ entry.loader.length > 0 &&
723
+ Object.keys(layoutEntry.parallel).length > 0
724
+ ) {
725
+ const inherited = await resolveLoaders(
726
+ entry,
727
+ context,
728
+ childBelongsToRoute,
729
+ deps,
730
+ layoutEntry.shortCode,
731
+ );
732
+ for (const seg of inherited) {
733
+ if (!seenIds.has(seg.id)) {
734
+ seenIds.add(seg.id);
735
+ seg._inherited = true;
736
+ loaderSegments.push(seg);
737
+ }
738
+ }
739
+ }
688
740
  }
689
741
  }
690
742
 
@@ -319,6 +319,39 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
319
319
  const childBelongsToRoute = belongsToRoute || entry.type === "route";
320
320
  for (const layoutEntry of entry.layout) {
321
321
  await collectEntryLoaders(layoutEntry, childBelongsToRoute);
322
+ // Inherit route loaders for orphan layouts with parallels.
323
+ // Resolve directly — do NOT re-enter collectEntryLoaders with the
324
+ // route entry, as that would re-iterate route.layout and loop.
325
+ if (
326
+ entry.type === "route" &&
327
+ entry.loader &&
328
+ entry.loader.length > 0 &&
329
+ Object.keys(layoutEntry.parallel).length > 0
330
+ ) {
331
+ const inherited = await resolveLoadersWithRevalidation(
332
+ entry,
333
+ context,
334
+ childBelongsToRoute,
335
+ clientSegmentIds,
336
+ prevParams,
337
+ request,
338
+ prevUrl,
339
+ nextUrl,
340
+ routeKey,
341
+ deps,
342
+ actionContext,
343
+ layoutEntry.shortCode,
344
+ stale,
345
+ );
346
+ for (const seg of inherited.segments) {
347
+ if (!seenIds.has(seg.id)) {
348
+ seenIds.add(seg.id);
349
+ seg._inherited = true;
350
+ allLoaderSegments.push(seg);
351
+ }
352
+ }
353
+ allMatchedIds.push(...inherited.matchedIds);
354
+ }
322
355
  }
323
356
  }
324
357
 
@@ -840,6 +873,7 @@ export async function resolveSegmentWithRevalidation<TEnv>(
840
873
  deps,
841
874
  actionContext,
842
875
  stale,
876
+ entry,
843
877
  );
844
878
  segments.push(...orphanResult.segments);
845
879
  matchedIds.push(...orphanResult.matchedIds);
@@ -951,6 +985,8 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
951
985
  deps: SegmentResolutionDeps<TEnv>,
952
986
  actionContext?: ActionContext,
953
987
  stale?: boolean,
988
+ /** Parent route entry — its loaders are inherited so parallel slots can access them. */
989
+ parentRouteEntry?: EntryData,
954
990
  ): Promise<SegmentRevalidationResult> {
955
991
  invariant(
956
992
  orphan.type === "layout" || orphan.type === "cache",
@@ -978,6 +1014,37 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
978
1014
  segments.push(...loaderResult.segments);
979
1015
  matchedIds.push(...loaderResult.matchedIds);
980
1016
 
1017
+ // Inherit parent route's loaders so parallel slots inside this layout
1018
+ // can access them via useLoader(). See resolveOrphanLayout in fresh.ts.
1019
+ if (
1020
+ parentRouteEntry &&
1021
+ parentRouteEntry.loader &&
1022
+ parentRouteEntry.loader.length > 0 &&
1023
+ Object.keys(orphan.parallel).length > 0
1024
+ ) {
1025
+ const inheritedResult = await resolveLoadersWithRevalidation(
1026
+ parentRouteEntry,
1027
+ context,
1028
+ belongsToRoute,
1029
+ clientSegmentIds,
1030
+ prevParams,
1031
+ request,
1032
+ prevUrl,
1033
+ nextUrl,
1034
+ routeKey,
1035
+ deps,
1036
+ actionContext,
1037
+ orphan.shortCode,
1038
+ stale,
1039
+ );
1040
+ // Tag as inherited so buildMatchResult can deduplicate when safe
1041
+ for (const s of inheritedResult.segments) {
1042
+ s._inherited = true;
1043
+ }
1044
+ segments.push(...inheritedResult.segments);
1045
+ matchedIds.push(...inheritedResult.matchedIds);
1046
+ }
1047
+
981
1048
  // Handler-first: resolve orphan layout handler before its parallels
982
1049
  // so ctx.set() values are visible to parallel children.
983
1050
  matchedIds.push(orphan.shortCode);
@@ -1064,6 +1131,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1064
1131
  );
1065
1132
 
1066
1133
  if (!resolvedParallelEntries.has(parallelEntry.id)) {
1134
+ // shortCodeOverride must match the parent layout, not the parallel entry.
1067
1135
  const loaderResult = await resolveLoadersWithRevalidation(
1068
1136
  parallelEntry,
1069
1137
  context,
@@ -1076,7 +1144,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1076
1144
  routeKey,
1077
1145
  deps,
1078
1146
  actionContext,
1079
- undefined,
1147
+ orphan.shortCode,
1080
1148
  stale,
1081
1149
  );
1082
1150
  segments.push(...loaderResult.segments);
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { TrieNode, TrieLeaf } from "../build/route-trie.js";
9
+ import { safeDecodeURIComponent } from "./url-params.js";
9
10
 
10
11
  export interface TrieMatchResult {
11
12
  /** Route name */
@@ -173,20 +174,25 @@ function validateAndBuild(
173
174
  originalPathname: string,
174
175
  pathnameHasTrailingSlash: boolean,
175
176
  ): TrieMatchResult | null {
176
- // Build named params by zipping leaf.pa with positional paramValues
177
+ // Build named params by zipping leaf.pa with positional paramValues.
178
+ // Params are URL-decoded at this boundary so ctx.params holds the values
179
+ // apps expect (matching Express/React Router) and round-trip cleanly
180
+ // through ctx.reverse.
177
181
  const params: Record<string, string> = {};
178
182
  if (leaf.pa) {
179
183
  for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
180
- params[leaf.pa[i]] = paramValues[i];
184
+ params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
181
185
  }
182
186
  }
183
187
 
184
188
  // Add wildcard param (wildcard leaves have pn from TrieNode.w type)
185
189
  if (wildcardValue !== undefined && "pn" in leaf) {
186
- params[(leaf as TrieLeaf & { pn: string }).pn] = wildcardValue;
190
+ params[(leaf as TrieLeaf & { pn: string }).pn] =
191
+ safeDecodeURIComponent(wildcardValue);
187
192
  }
188
193
 
189
- // Validate constraints
194
+ // Validate constraints against decoded values so constraint lists can be
195
+ // written in decoded form (e.g. ["en-GB", "en US"]).
190
196
  if (leaf.cv) {
191
197
  for (const paramName in leaf.cv) {
192
198
  const allowed = leaf.cv[paramName]!;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * URL param encode/decode at the route boundary.
3
+ *
4
+ * Extraction (decode): regex/trie matchers keep param values URL-encoded;
5
+ * `safeDecodeURIComponent` turns them back into raw strings so `ctx.params`
6
+ * matches the contract apps expect (Express/React Router/Fastify/Koa) and
7
+ * round-trips through reverse stay stable. Malformed %-encoding is
8
+ * preserved as-is so a broken URL doesn't crash matching.
9
+ *
10
+ * Reversal (encode): `encodePathSegment` escapes only what RFC 3986
11
+ * requires for a path segment — `/`, `?`, `#`, space, control chars,
12
+ * non-ASCII — and leaves pchar sub-delims (`@ : $ & + , ; =` and friends)
13
+ * readable. `encodeURIComponent` over-encodes for path segments, which
14
+ * makes generated URLs harder for humans to read in the address bar
15
+ * (e.g. mailbox IDs like `ivo@example.com` would become
16
+ * `ivo%40example.com` even though `@` is path-legal).
17
+ */
18
+
19
+ export function safeDecodeURIComponent(raw: string): string {
20
+ if (raw === "" || raw.indexOf("%") === -1) return raw;
21
+ try {
22
+ return decodeURIComponent(raw);
23
+ } catch {
24
+ return raw;
25
+ }
26
+ }
27
+
28
+ // encodeURIComponent over-encodes for path segments. After running it,
29
+ // un-encode the pchar sub-delims + (`:` / `@`) so the resulting URL
30
+ // keeps human-readable characters that are legal in a path segment.
31
+ // Everything dangerous — `/ ? # %` and space/control/non-ASCII — stays
32
+ // encoded.
33
+ const PATH_SAFE_ESCAPES: Record<string, string> = {
34
+ "%3A": ":",
35
+ "%40": "@",
36
+ "%24": "$",
37
+ "%26": "&",
38
+ "%2B": "+",
39
+ "%2C": ",",
40
+ "%3B": ";",
41
+ "%3D": "=",
42
+ };
43
+
44
+ export function encodePathSegment(value: string): string {
45
+ return encodeURIComponent(value).replace(
46
+ /%(?:3A|40|24|26|2B|2C|3B|3D)/gi,
47
+ (match) => PATH_SAFE_ESCAPES[match.toUpperCase()] ?? match,
48
+ );
49
+ }
package/src/router.ts CHANGED
@@ -22,8 +22,7 @@ import type { UrlPatterns } from "./urls.js";
22
22
  import type { UrlBuilder } from "./urls/pattern-types.js";
23
23
  import { urls } from "./urls.js";
24
24
  import {
25
- EntryData,
26
- InterceptSelectorContext,
25
+ type EntryData,
27
26
  getContext,
28
27
  RSCRouterContext,
29
28
  type MetricsStore,
@@ -15,6 +15,7 @@ import {
15
15
  setRequestContextParams,
16
16
  requireRequestContext,
17
17
  getRequestContext,
18
+ _getRequestContext,
18
19
  createRequestContext,
19
20
  } from "../server/request-context.js";
20
21
  import * as rscDeps from "@vitejs/plugin-rsc/rsc";
@@ -30,6 +31,7 @@ import {
30
31
  interceptRedirectForPartial,
31
32
  buildRouteMiddlewareEntries,
32
33
  } from "./helpers.js";
34
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
33
35
  import {
34
36
  handleResponseRoute,
35
37
  type ResponseRouteMatch,
@@ -55,6 +57,7 @@ import {
55
57
  getRouterTrie,
56
58
  } from "../route-map-builder.js";
57
59
  import type { HandlerContext } from "./handler-context.js";
60
+ import type { SegmentCacheStore } from "../cache/types.js";
58
61
  import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
59
62
  import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
60
63
  import {
@@ -166,10 +169,13 @@ export function createRSCHandler<
166
169
  phase: ErrorPhase,
167
170
  context: Parameters<typeof invokeOnError<TEnv>>[3],
168
171
  ): void {
169
- if (error != null && typeof error === "object") {
170
- const reportedErrors = requireRequestContext()._reportedErrors;
171
- if (reportedErrors.has(error)) return;
172
- reportedErrors.add(error);
172
+ // Guard: abort signal handlers fire asynchronously outside the ALS
173
+ // request scope, so the context may be gone. Skip dedup in that
174
+ // case — the error is from a cancelled stream, not a real failure.
175
+ const reqCtx = _getRequestContext();
176
+ if (error != null && typeof error === "object" && reqCtx) {
177
+ if (reqCtx._reportedErrors.has(error)) return;
178
+ reqCtx._reportedErrors.add(error);
173
179
  }
174
180
  invokeOnError(router.onError, error, phase, context, "RSC");
175
181
  }
@@ -348,7 +354,7 @@ export function createRSCHandler<
348
354
  // Resolve cache store configuration
349
355
  // Priority: options.cache (handler override) > router.cache (router default)
350
356
  // Store is enabled only if: config provided, enabled, and no ?__no_cache query param
351
- let cacheStore = undefined;
357
+ let cacheStore: SegmentCacheStore | undefined;
352
358
  const cacheOption = options.cache ?? router.cache;
353
359
  if (cacheOption && !url.searchParams.has("__no_cache")) {
354
360
  const cacheConfig =
@@ -529,7 +535,9 @@ export function createRSCHandler<
529
535
  }
530
536
 
531
537
  const fullTiming = timingParts.join(", ");
532
- if (fullTiming) response.headers.set("Server-Timing", fullTiming);
538
+ if (fullTiming && !isWebSocketUpgradeResponse(response)) {
539
+ response.headers.set("Server-Timing", fullTiming);
540
+ }
533
541
 
534
542
  return response;
535
543
  });
@@ -800,7 +808,7 @@ export function createRSCHandler<
800
808
  );
801
809
  }
802
810
  const response = responseOutcome.result;
803
- if (plan.negotiated) {
811
+ if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
804
812
  response.headers.append("Vary", "Accept");
805
813
  }
806
814
  return response;
@@ -1010,7 +1018,7 @@ export function createRSCHandler<
1010
1018
  nonce,
1011
1019
  );
1012
1020
  }
1013
- if (negotiated) {
1021
+ if (negotiated && !isWebSocketUpgradeResponse(response)) {
1014
1022
  response.headers.append("Vary", "Accept");
1015
1023
  }
1016
1024
  return response;
@@ -1090,7 +1098,11 @@ export function createRSCHandler<
1090
1098
  },
1091
1099
  };
1092
1100
 
1093
- const rscStream = renderToReadableStream(payload);
1101
+ const rscStream = renderToReadableStream(payload, {
1102
+ onError: (error: unknown) => {
1103
+ callOnError(error, "rendering", { request, url, env });
1104
+ },
1105
+ });
1094
1106
 
1095
1107
  const isRscRequest =
1096
1108
  isPartial ||
@@ -8,9 +8,49 @@ import {
8
8
  _getRequestContext,
9
9
  getLocationState,
10
10
  } from "../server/request-context.js";
11
+ import type { RequestContext } from "../server/request-context.js";
11
12
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
12
13
  import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
13
14
 
15
+ /**
16
+ * Copy stub headers from the request context onto a target Headers instance:
17
+ * append Set-Cookie entries, set everything else only if absent. Header
18
+ * mutation failures are swallowed so the same logic works against Response
19
+ * headers that may be immutable (e.g. Cloudflare protocol-switch responses).
20
+ */
21
+ function applyStubHeaders(target: Headers, stub: Headers): void {
22
+ stub.forEach((value, name) => {
23
+ try {
24
+ if (name.toLowerCase() === "set-cookie") {
25
+ target.append(name, value);
26
+ } else if (!target.has(name)) {
27
+ target.set(name, value);
28
+ }
29
+ } catch {
30
+ // Headers immutable — skip.
31
+ }
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Drain ctx._onResponseCallbacks onto a response. Swapping the array before
37
+ * iteration prevents re-entrant registrations from double-firing and matches
38
+ * the contract that each callback runs at most once per request.
39
+ */
40
+ function drainOnResponseCallbacks(
41
+ ctx: RequestContext,
42
+ response: Response,
43
+ ): Response {
44
+ const callbacks = ctx._onResponseCallbacks;
45
+ if (callbacks.length === 0) return response;
46
+ ctx._onResponseCallbacks = [];
47
+ let result = response;
48
+ for (const callback of callbacks) {
49
+ result = callback(result) ?? result;
50
+ }
51
+ return result;
52
+ }
53
+
14
54
  /**
15
55
  * Check if a request body has content to decode
16
56
  */
@@ -39,40 +79,23 @@ export function createResponseWithMergedHeaders(
39
79
  return new Response(body, init);
40
80
  }
41
81
 
42
- // Merge headers from stub response into the new response.
43
- // Delete Set-Cookie from the stub after consuming so that downstream
44
- // merge points (e.g. executeMiddleware) do not duplicate them.
82
+ // Delete Set-Cookie from the stub after consuming so downstream merge
83
+ // points (e.g. executeMiddleware) don't duplicate them.
45
84
  const mergedHeaders = new Headers(init.headers);
46
- ctx.res.headers.forEach((value, name) => {
47
- if (name.toLowerCase() === "set-cookie") {
48
- mergedHeaders.append(name, value);
49
- } else if (!mergedHeaders.has(name)) {
50
- // Only set if not already present in init.headers
51
- mergedHeaders.set(name, value);
52
- }
53
- });
85
+ applyStubHeaders(mergedHeaders, ctx.res.headers);
54
86
  ctx.res.headers.delete("set-cookie");
55
87
 
56
- // Use ctx.res.status if it was set (e.g., 404 for notFound, 500 for error)
57
- // Otherwise use the status from init
88
+ // ctx.res.status overrides init.status when explicitly set (e.g. 404 for
89
+ // notFound, 500 for error). Default ctx.res.status is 200.
58
90
  const status = ctx.res.status !== 200 ? ctx.res.status : init.status;
59
91
 
60
- let response = new Response(body, {
92
+ const response = new Response(body, {
61
93
  ...init,
62
94
  status,
63
95
  headers: mergedHeaders,
64
96
  });
65
97
 
66
- // Run onResponse callbacks - each can inspect/modify the response.
67
- // Drain the array so that downstream callers (e.g. finalizeResponse)
68
- // do not re-execute the same callbacks on this response.
69
- const callbacks = ctx._onResponseCallbacks;
70
- ctx._onResponseCallbacks = [];
71
- for (const callback of callbacks) {
72
- response = callback(response) ?? response;
73
- }
74
-
75
- return response;
98
+ return drainOnResponseCallbacks(ctx, response);
76
99
  }
77
100
 
78
101
  /**
@@ -175,24 +198,29 @@ export function buildRouteMiddlewareEntries<TEnv>(
175
198
  }
176
199
 
177
200
  /**
178
- * Run onResponse callbacks on an existing Response.
179
- *
180
- * Used for code paths that bypass createResponseWithMergedHeaders(), such as
181
- * middleware short-circuits where the Response is already constructed but
182
- * ctx.onResponse() callbacks still need to fire.
201
+ * Merge stub headers from the request context onto an existing Response in
202
+ * place, then drain onResponse callbacks. Used when a Response cannot flow
203
+ * through `new Response()` status 101 is outside the constructor's
204
+ * 200-599 range, and the Cloudflare-specific `webSocket` property would be
205
+ * lost on reconstruction.
183
206
  */
184
- export function finalizeResponse(response: Response): Response {
207
+ export function mergeStubHeadersAndFinalize(response: Response): Response {
185
208
  const ctx = _getRequestContext();
186
- if (!ctx || ctx._onResponseCallbacks.length === 0) {
187
- return response;
188
- }
209
+ if (!ctx) return response;
189
210
 
190
- // Drain the array so callbacks run at most once per request.
191
- const callbacks = ctx._onResponseCallbacks;
192
- ctx._onResponseCallbacks = [];
193
- let result = response;
194
- for (const callback of callbacks) {
195
- result = callback(result) ?? result;
196
- }
197
- return result;
211
+ applyStubHeaders(response.headers, ctx.res.headers);
212
+ ctx.res.headers.delete("set-cookie");
213
+
214
+ return drainOnResponseCallbacks(ctx, response);
215
+ }
216
+
217
+ /**
218
+ * Run onResponse callbacks on an existing Response. Used by code paths that
219
+ * bypass createResponseWithMergedHeaders (e.g. middleware short-circuits)
220
+ * but still need ctx.onResponse() callbacks to fire.
221
+ */
222
+ export function finalizeResponse(response: Response): Response {
223
+ const ctx = _getRequestContext();
224
+ if (!ctx) return response;
225
+ return drainOnResponseCallbacks(ctx, response);
198
226
  }
@@ -168,8 +168,19 @@ export async function handleLoaderFetch<TEnv>(
168
168
  loaderResult: unknown;
169
169
  }
170
170
  const loaderPayload: LoaderPayload = { loaderResult: result };
171
- const rscStream =
172
- ctx.renderToReadableStream<LoaderPayload>(loaderPayload);
171
+ const rscStream = ctx.renderToReadableStream<LoaderPayload>(
172
+ loaderPayload,
173
+ {
174
+ onError: (error: unknown) => {
175
+ ctx.callOnError(error, "rendering", {
176
+ request,
177
+ url,
178
+ env,
179
+ loaderName: loaderId,
180
+ });
181
+ },
182
+ },
183
+ );
173
184
 
174
185
  return createResponseWithMergedHeaders(rscStream, {
175
186
  headers: { "content-type": "text/x-component;charset=utf-8" },
@@ -199,7 +210,16 @@ export async function handleLoaderFetch<TEnv>(
199
210
  name: err.name,
200
211
  },
201
212
  };
202
- const rscStream = ctx.renderToReadableStream(errorPayload);
213
+ const rscStream = ctx.renderToReadableStream(errorPayload, {
214
+ onError: (error: unknown) => {
215
+ ctx.callOnError(error, "rendering", {
216
+ request,
217
+ url,
218
+ env,
219
+ loaderName: loaderId,
220
+ });
221
+ },
222
+ });
203
223
 
204
224
  return createResponseWithMergedHeaders(rscStream, {
205
225
  status: 500,
@@ -248,6 +248,7 @@ export async function handleProgressiveEnhancement<TEnv>(
248
248
  segments: match.segments,
249
249
  matched: match.matched,
250
250
  diff: match.diff,
251
+ params: match.params,
251
252
  isPartial: false,
252
253
  rootLayout: ctx.router.rootLayout,
253
254
  handles: handleStore.stream(),
@@ -259,7 +260,11 @@ export async function handleProgressiveEnhancement<TEnv>(
259
260
  formState: actionResult,
260
261
  };
261
262
 
262
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
263
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
264
+ onError: (error: unknown) => {
265
+ ctx.callOnError(error, "rendering", { request, url, env });
266
+ },
267
+ });
263
268
  // metricsStore=undefined is safe: the handler already stashed the early
264
269
  // SSR setup promise on request variables, so getSSRSetup returns it
265
270
  // without falling back to a fresh startSSRSetup.
@@ -349,6 +354,7 @@ async function renderPeErrorBoundary<TEnv>(
349
354
  segments: errorResult.segments,
350
355
  matched: errorResult.matched,
351
356
  diff: errorResult.diff,
357
+ params: errorResult.params,
352
358
  isPartial: false,
353
359
  isError: true,
354
360
  rootLayout: ctx.router.rootLayout,
@@ -360,7 +366,11 @@ async function renderPeErrorBoundary<TEnv>(
360
366
  },
361
367
  };
362
368
 
363
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
369
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
370
+ onError: (error: unknown) => {
371
+ ctx.callOnError(error, "rendering", { request, url, env });
372
+ },
373
+ });
364
374
  // metricsStore=undefined is safe: the handler already stashed the early
365
375
  // SSR setup promise on request variables, so getSSRSetup returns it
366
376
  // without falling back to a fresh startSSRSetup.
@@ -26,7 +26,9 @@ import {
26
26
  finalizeResponse,
27
27
  isCacheableStatus,
28
28
  buildRouteMiddlewareEntries,
29
+ mergeStubHeadersAndFinalize,
29
30
  } from "./helpers.js";
31
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
30
32
 
31
33
  export interface ResponseRouteMatch {
32
34
  responseType: string;
@@ -78,10 +80,13 @@ export async function handleResponseRoute<TEnv>(
78
80
  env,
79
81
  searchParams: cleanUrl.searchParams,
80
82
  url: cleanUrl,
83
+ originalUrl: reqCtx.originalUrl,
81
84
  pathname: url.pathname,
82
85
  reverse: createReverseFunction(handlerCtx.getRequiredRouteMap()),
83
86
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
84
87
  header: (name: string, value: string) => reqCtx.header(name, value),
88
+ waitUntil: reqCtx.waitUntil.bind(reqCtx),
89
+ executionContext: reqCtx.executionContext,
85
90
  _responseType: preview.responseType,
86
91
  };
87
92
  // Brand with taint symbol so "use cache" detects it as request-scoped
@@ -96,6 +101,12 @@ export async function handleResponseRoute<TEnv>(
96
101
  // so that stub headers (cookies, custom headers set via ctx.header()) are included.
97
102
  // Use Headers (not Record<string, string>) to preserve duplicate entries like Set-Cookie.
98
103
  const rewrapResponse = (result: Response) => {
104
+ // 204/205/304 are NOT short-circuited — they're valid for the Response
105
+ // constructor and must honor ctx.setStatus() overrides. Only upgrade
106
+ // responses (status 101 / `webSocket` property) bypass reconstruction.
107
+ if (isWebSocketUpgradeResponse(result)) {
108
+ return mergeStubHeadersAndFinalize(result);
109
+ }
99
110
  const headers = new Headers();
100
111
  result.headers.forEach((value, key) => {
101
112
  if (key.toLowerCase() === "set-cookie") {
@@ -196,7 +207,9 @@ export async function handleResponseRoute<TEnv>(
196
207
  // Wrap callHandler to append Vary: Accept on content-negotiated responses
197
208
  const callHandlerWithVary = async () => {
198
209
  const response = await callHandler();
199
- if (preview.negotiated) {
210
+ if (preview.negotiated && !isWebSocketUpgradeResponse(response)) {
211
+ // Skip Vary on upgrade responses: headers are semantically immutable
212
+ // on some runtimes, and Vary is meaningless for a 101 response.
200
213
  response.headers.append("Vary", "Accept");
201
214
  }
202
215
  return response;