@rangojs/router 0.0.0-experimental.ede38110 → 0.0.0-experimental.f2d1a2f1

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 (66) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +353 -49
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +5 -3
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/hooks/SKILL.md +28 -20
  7. package/skills/links/SKILL.md +88 -16
  8. package/skills/loader/SKILL.md +35 -2
  9. package/skills/migrate-react-router/SKILL.md +1 -0
  10. package/skills/response-routes/SKILL.md +8 -0
  11. package/skills/streams-and-websockets/SKILL.md +283 -0
  12. package/skills/typesafety/SKILL.md +3 -1
  13. package/src/browser/app-shell.ts +52 -0
  14. package/src/browser/navigation-bridge.ts +51 -2
  15. package/src/browser/navigation-client.ts +33 -10
  16. package/src/browser/navigation-store.ts +25 -1
  17. package/src/browser/partial-update.ts +20 -1
  18. package/src/browser/prefetch/cache.ts +124 -26
  19. package/src/browser/prefetch/fetch.ts +114 -38
  20. package/src/browser/prefetch/queue.ts +36 -5
  21. package/src/browser/rango-state.ts +53 -13
  22. package/src/browser/react/Link.tsx +18 -13
  23. package/src/browser/react/NavigationProvider.tsx +50 -11
  24. package/src/browser/react/use-navigation.ts +30 -11
  25. package/src/browser/react/use-params.ts +11 -1
  26. package/src/browser/react/use-router.ts +8 -1
  27. package/src/browser/rsc-router.tsx +34 -6
  28. package/src/browser/types.ts +13 -0
  29. package/src/cache/cf/cf-cache-store.ts +5 -7
  30. package/src/index.rsc.ts +3 -0
  31. package/src/index.ts +3 -0
  32. package/src/outlet-context.ts +1 -1
  33. package/src/response-utils.ts +28 -0
  34. package/src/reverse.ts +3 -2
  35. package/src/route-definition/dsl-helpers.ts +16 -3
  36. package/src/route-definition/resolve-handler-use.ts +6 -0
  37. package/src/router/handler-context.ts +20 -3
  38. package/src/router/lazy-includes.ts +1 -1
  39. package/src/router/loader-resolution.ts +3 -0
  40. package/src/router/match-api.ts +3 -3
  41. package/src/router/middleware-types.ts +2 -22
  42. package/src/router/middleware.ts +32 -4
  43. package/src/router/pattern-matching.ts +60 -9
  44. package/src/router/trie-matching.ts +10 -4
  45. package/src/router/url-params.ts +49 -0
  46. package/src/router.ts +1 -2
  47. package/src/rsc/handler.ts +8 -4
  48. package/src/rsc/helpers.ts +69 -41
  49. package/src/rsc/progressive-enhancement.ts +2 -0
  50. package/src/rsc/response-route-handler.ts +14 -1
  51. package/src/rsc/rsc-rendering.ts +7 -0
  52. package/src/rsc/server-action.ts +2 -0
  53. package/src/server/request-context.ts +10 -42
  54. package/src/types/handler-context.ts +2 -34
  55. package/src/types/loader-types.ts +5 -6
  56. package/src/types/request-scope.ts +126 -0
  57. package/src/urls/response-types.ts +2 -10
  58. package/src/vite/debug.ts +86 -0
  59. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  60. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  61. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  62. package/src/vite/plugins/performance-tracks.ts +4 -6
  63. package/src/vite/rango.ts +49 -14
  64. package/src/vite/router-discovery.ts +161 -23
  65. package/src/vite/utils/banner.ts +1 -1
  66. package/src/vite/utils/package-resolution.ts +41 -1
@@ -22,10 +22,10 @@ import { collectRouteMiddleware } from "./middleware.js";
22
22
  import { traverseBack } from "./pattern-matching.js";
23
23
  import { DefaultErrorFallback } from "../default-error-boundary.js";
24
24
  import {
25
- EntryData,
26
- LoaderEntry,
25
+ type EntryData,
26
+ type LoaderEntry,
27
27
  getContext,
28
- InterceptSelectorContext,
28
+ type InterceptSelectorContext,
29
29
  } from "../server/context";
30
30
  import type { ErrorBoundaryHandler, ErrorInfo, MatchResult } from "../types";
31
31
  import type { ReactNode } from "react";
@@ -14,6 +14,7 @@ import type {
14
14
  import type { ScopedReverseFunction } from "../reverse.js";
15
15
  import type { Theme } from "../theme/types.js";
16
16
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
17
+ import type { RequestScope } from "../types/request-scope.js";
17
18
 
18
19
  /**
19
20
  * Get variable function type
@@ -57,28 +58,7 @@ export interface CookieOptions {
57
58
  export interface MiddlewareContext<
58
59
  TEnv = any,
59
60
  TParams = Record<string, string>,
60
- > {
61
- /** Original request */
62
- request: Request;
63
-
64
- /** Parsed URL (with internal `_rsc*` params stripped) */
65
- url: URL;
66
-
67
- /**
68
- * The original request URL with all parameters intact, including
69
- * internal `_rsc*` transport params.
70
- */
71
- originalUrl: URL;
72
-
73
- /** URL pathname */
74
- pathname: string;
75
-
76
- /** URL search params */
77
- searchParams: URLSearchParams;
78
-
79
- /** Platform bindings (Cloudflare, etc.) */
80
- env: TEnv;
81
-
61
+ > extends RequestScope<TEnv> {
82
62
  /** URL params extracted from route/middleware pattern */
83
63
  params: TParams;
84
64
 
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { contextGet, contextSet } from "../context-var.js";
13
+ import { safeDecodeURIComponent } from "./url-params.js";
14
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
13
15
  import type {
14
16
  CollectedMiddleware,
15
17
  MiddlewareCollectableEntry,
@@ -22,6 +24,7 @@ import { _getRequestContext } from "../server/request-context.js";
22
24
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
25
  import { appendMetric, createMetricsStore } from "./metrics.js";
24
26
  import { stripInternalParams } from "./handler-context.js";
27
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
25
28
 
26
29
  // Re-export types and cookie utilities for backward compatibility
27
30
  export type {
@@ -112,7 +115,12 @@ function escapeRegex(str: string): string {
112
115
  }
113
116
 
114
117
  /**
115
- * Extract params from a pathname using a pattern's regex and param names
118
+ * Extract params from a pathname using a pattern's regex and param names.
119
+ *
120
+ * Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
121
+ * instead of the percent-encoded form ("ivo%40example.com"). This matches the
122
+ * contract assumed by ctx.reverse (which re-encodes) and aligns with
123
+ * Express/React Router/Fastify/Koa.
116
124
  */
117
125
  export function extractParams(
118
126
  pathname: string,
@@ -124,7 +132,7 @@ export function extractParams(
124
132
 
125
133
  const params: Record<string, string> = {};
126
134
  for (let i = 0; i < paramNames.length; i++) {
127
- params[paramNames[i]] = match[i + 1] || "";
135
+ params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
128
136
  }
129
137
  return params;
130
138
  }
@@ -179,14 +187,22 @@ export function createMiddlewareContext<TEnv>(
179
187
  return responseHolder.response;
180
188
  };
181
189
 
190
+ // Capture reqCtx once: the request-scoped platform fields
191
+ // (originalUrl, executionContext, waitUntil) are immutable per request,
192
+ // so snapshotting beats re-reading ALS on every access. The lazy getters
193
+ // below (routeName, theme, setTheme) stay lazy because those can change
194
+ // during `await next()`.
195
+ const reqCtx = _getRequestContext();
182
196
  return {
183
197
  request,
184
198
  url,
185
- originalUrl: new URL(request.url),
199
+ originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
186
200
  pathname: url.pathname,
187
201
  searchParams: url.searchParams,
188
202
  env: env as MiddlewareContext<TEnv>["env"],
189
203
  params,
204
+ executionContext: reqCtx?.executionContext,
205
+ waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
190
206
  // Getter: re-derives from request context on each access so that global
191
207
  // middleware sees the matched route name after await next().
192
208
  get routeName(): MiddlewareContext<TEnv>["routeName"] {
@@ -360,6 +376,11 @@ export async function executeMiddleware<TEnv>(
360
376
  });
361
377
  }
362
378
 
379
+ if (isWebSocketUpgradeResponse(response)) {
380
+ responseHolder.response = response;
381
+ return response;
382
+ }
383
+
363
384
  // Clone response with merged headers (mutable for post-next() modifications)
364
385
  responseHolder.response = new Response(response.body, {
365
386
  status: response.status,
@@ -451,6 +472,10 @@ export async function executeMiddleware<TEnv>(
451
472
  // RequestContext stub headers (from ctx.setCookie) into the
452
473
  // returned Response so they are not lost.
453
474
  if (result instanceof Response) {
475
+ if (isWebSocketUpgradeResponse(result)) {
476
+ responseHolder.response = result;
477
+ return result;
478
+ }
454
479
  const mergedHeaders = new Headers(result.headers);
455
480
  stubResponse.headers.forEach((value, name) => {
456
481
  if (name.toLowerCase() === "set-cookie") {
@@ -527,8 +552,11 @@ export async function executeMiddleware<TEnv>(
527
552
  // last merge point (e.g. cookies().set() called after await next()).
528
553
  // The reqCtx stub may have already been partially merged during finalHandler
529
554
  // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
555
+ //
556
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
557
+ // set-cookie on an upgrade is not meaningful.
530
558
  const reqCtx = _getRequestContext();
531
- if (reqCtx) {
559
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
532
560
  const stubCookies = reqCtx.res.headers.getSetCookie();
533
561
  if (stubCookies.length > 0) {
534
562
  const existingCookies = new Set(finalResponse.headers.getSetCookie());
@@ -7,6 +7,7 @@
7
7
  import type { RouteEntry, TrailingSlashMode } from "../types";
8
8
  import type { EntryData } from "../server/context";
9
9
  import { debugLog, isRouterDebugEnabled } from "./logging.js";
10
+ import { safeDecodeURIComponent } from "./url-params.js";
10
11
 
11
12
  /**
12
13
  * Parsed segment info
@@ -82,6 +83,13 @@ export interface CompiledPattern {
82
83
  paramNames: string[];
83
84
  optionalParams: Set<string>;
84
85
  hasTrailingSlash: boolean;
86
+ /**
87
+ * Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
88
+ * Validated against the **decoded** param value after regex extraction so
89
+ * a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
90
+ * path's behavior (trie-matching.ts:validateAndBuild).
91
+ */
92
+ constraints?: Record<string, string[]>;
85
93
  }
86
94
 
87
95
  // Module-level cache for compiled patterns. Route patterns are a finite set
@@ -142,6 +150,7 @@ export function compilePattern(pattern: string): CompiledPattern {
142
150
  const segments = parsePattern(normalizedPattern);
143
151
  const paramNames: string[] = [];
144
152
  const optionalParams = new Set<string>();
153
+ let constraints: Record<string, string[]> | undefined;
145
154
 
146
155
  let regexPattern = "";
147
156
 
@@ -152,11 +161,14 @@ export function compilePattern(pattern: string): CompiledPattern {
152
161
  } else if (segment.type === "param") {
153
162
  paramNames.push(segment.value);
154
163
  const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
155
- const valuePattern = segment.constraint
156
- ? `(${segment.constraint.map(escapeRegex).join("|")})`
157
- : segment.suffix
158
- ? "([^/]+?)"
159
- : "([^/]+)";
164
+ // Constrained params capture anything here; the allowed values are
165
+ // checked post-decode in findMatch so URL-encoded constraint values
166
+ // (e.g. `:lang(en GB)` via `/en%20GB`) still match.
167
+ const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
168
+
169
+ if (segment.constraint) {
170
+ (constraints ??= {})[segment.value] = segment.constraint;
171
+ }
160
172
 
161
173
  if (segment.optional) {
162
174
  optionalParams.add(segment.value);
@@ -186,9 +198,33 @@ export function compilePattern(pattern: string): CompiledPattern {
186
198
  paramNames,
187
199
  optionalParams,
188
200
  hasTrailingSlash,
201
+ ...(constraints ? { constraints } : {}),
189
202
  };
190
203
  }
191
204
 
205
+ /**
206
+ * Validate decoded params against a compiled pattern's constraints.
207
+ * Returns false if any constrained param has a non-empty value not in the
208
+ * allowed list (empty-string = absent optional, which is allowed).
209
+ */
210
+ function satisfiesConstraints(
211
+ params: Record<string, string>,
212
+ constraints: Record<string, string[]> | undefined,
213
+ ): boolean {
214
+ if (!constraints) return true;
215
+ for (const name in constraints) {
216
+ const value = params[name];
217
+ if (
218
+ value !== undefined &&
219
+ value !== "" &&
220
+ !constraints[name].includes(value)
221
+ ) {
222
+ return false;
223
+ }
224
+ }
225
+ return true;
226
+ }
227
+
192
228
  /**
193
229
  * Escape special regex characters in a string
194
230
  */
@@ -392,8 +428,13 @@ export function findMatch<TEnv>(
392
428
  fullPattern = entry.prefix + pattern;
393
429
  }
394
430
 
395
- const { regex, paramNames, optionalParams, hasTrailingSlash } =
396
- getCompiledPattern(fullPattern);
431
+ const {
432
+ regex,
433
+ paramNames,
434
+ optionalParams,
435
+ hasTrailingSlash,
436
+ constraints,
437
+ } = getCompiledPattern(fullPattern);
397
438
 
398
439
  // Get trailing slash mode for this route (per-route config or pattern-based)
399
440
  const trailingSlashMode: TrailingSlashMode | undefined =
@@ -412,9 +453,15 @@ export function findMatch<TEnv>(
412
453
  if (match) {
413
454
  const params: Record<string, string> = {};
414
455
  paramNames.forEach((name, index) => {
415
- params[name] = match[index + 1] ?? "";
456
+ params[name] = safeDecodeURIComponent(match[index + 1] ?? "");
416
457
  });
417
458
 
459
+ // Validate constraints against decoded values; a failure falls
460
+ // through to the next route so other patterns can still match.
461
+ if (!satisfiesConstraints(params, constraints)) {
462
+ continue;
463
+ }
464
+
418
465
  if (effectiveDebug) {
419
466
  debugLog("findMatch", "matched route", {
420
467
  routeKey,
@@ -467,9 +514,13 @@ export function findMatch<TEnv>(
467
514
  if (altMatch) {
468
515
  const params: Record<string, string> = {};
469
516
  paramNames.forEach((name, index) => {
470
- params[name] = altMatch[index + 1] ?? "";
517
+ params[name] = safeDecodeURIComponent(altMatch[index + 1] ?? "");
471
518
  });
472
519
 
520
+ if (!satisfiesConstraints(params, constraints)) {
521
+ continue;
522
+ }
523
+
473
524
  // Determine redirect behavior based on mode
474
525
  if (trailingSlashMode === "ignore") {
475
526
  // Match without redirect
@@ -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,
@@ -31,6 +31,7 @@ import {
31
31
  interceptRedirectForPartial,
32
32
  buildRouteMiddlewareEntries,
33
33
  } from "./helpers.js";
34
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
34
35
  import {
35
36
  handleResponseRoute,
36
37
  type ResponseRouteMatch,
@@ -56,6 +57,7 @@ import {
56
57
  getRouterTrie,
57
58
  } from "../route-map-builder.js";
58
59
  import type { HandlerContext } from "./handler-context.js";
60
+ import type { SegmentCacheStore } from "../cache/types.js";
59
61
  import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
60
62
  import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
61
63
  import {
@@ -352,7 +354,7 @@ export function createRSCHandler<
352
354
  // Resolve cache store configuration
353
355
  // Priority: options.cache (handler override) > router.cache (router default)
354
356
  // Store is enabled only if: config provided, enabled, and no ?__no_cache query param
355
- let cacheStore = undefined;
357
+ let cacheStore: SegmentCacheStore | undefined;
356
358
  const cacheOption = options.cache ?? router.cache;
357
359
  if (cacheOption && !url.searchParams.has("__no_cache")) {
358
360
  const cacheConfig =
@@ -533,7 +535,9 @@ export function createRSCHandler<
533
535
  }
534
536
 
535
537
  const fullTiming = timingParts.join(", ");
536
- if (fullTiming) response.headers.set("Server-Timing", fullTiming);
538
+ if (fullTiming && !isWebSocketUpgradeResponse(response)) {
539
+ response.headers.set("Server-Timing", fullTiming);
540
+ }
537
541
 
538
542
  return response;
539
543
  });
@@ -804,7 +808,7 @@ export function createRSCHandler<
804
808
  );
805
809
  }
806
810
  const response = responseOutcome.result;
807
- if (plan.negotiated) {
811
+ if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
808
812
  response.headers.append("Vary", "Accept");
809
813
  }
810
814
  return response;
@@ -1014,7 +1018,7 @@ export function createRSCHandler<
1014
1018
  nonce,
1015
1019
  );
1016
1020
  }
1017
- if (negotiated) {
1021
+ if (negotiated && !isWebSocketUpgradeResponse(response)) {
1018
1022
  response.headers.append("Vary", "Accept");
1019
1023
  }
1020
1024
  return response;
@@ -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
  }
@@ -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(),
@@ -353,6 +354,7 @@ async function renderPeErrorBoundary<TEnv>(
353
354
  segments: errorResult.segments,
354
355
  matched: errorResult.matched,
355
356
  diff: errorResult.diff,
357
+ params: errorResult.params,
356
358
  isPartial: false,
357
359
  isError: true,
358
360
  rootLayout: ctx.router.rootLayout,
@@ -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;
@@ -204,6 +204,13 @@ export async function handleRscRendering<TEnv>(
204
204
  "content-type": "text/x-component;charset=utf-8",
205
205
  vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
206
206
  };
207
+ // Tell the client's prefetch cache to scope this response to its source
208
+ // URL (instead of the default source-agnostic wildcard). Intercept
209
+ // responses depend on the source page matching an intercept rule, so
210
+ // they must not be reused for navigations from other sources.
211
+ if (hasInterceptSlots) {
212
+ rscHeaders["x-rsc-prefetch-scope"] = "source";
213
+ }
207
214
  // Enable browser HTTP caching for prefetch responses only.
208
215
  // Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
209
216
  // non-intercept context (intercept responses depend on source page),
@@ -213,6 +213,7 @@ export async function executeServerAction<TEnv>(
213
213
  isPartial: true,
214
214
  matched: errorResult.matched,
215
215
  diff: errorResult.diff,
216
+ params: errorResult.params,
216
217
  isError: true,
217
218
  handles: handleStore.stream(),
218
219
  version: ctx.version,
@@ -323,6 +324,7 @@ export async function revalidateAfterAction<TEnv>(
323
324
  isPartial: true,
324
325
  matched: matchResult.matched,
325
326
  diff: matchResult.diff,
327
+ params: matchResult.params,
326
328
  slots: matchResult.slots,
327
329
  handles: handleStore.stream(),
328
330
  version: ctx.version,