@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945

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 (239) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2103 -861
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +66 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +26 -4
  19. package/skills/layout/SKILL.md +6 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +12 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +238 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +33 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/tailwind/SKILL.md +27 -3
  37. package/skills/typesafety/SKILL.md +319 -27
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +116 -0
  42. package/src/browser/action-coordinator.ts +53 -36
  43. package/src/browser/app-shell.ts +39 -0
  44. package/src/browser/event-controller.ts +86 -70
  45. package/src/browser/history-state.ts +21 -0
  46. package/src/browser/index.ts +3 -3
  47. package/src/browser/navigation-bridge.ts +29 -9
  48. package/src/browser/navigation-client.ts +99 -77
  49. package/src/browser/navigation-store.ts +7 -8
  50. package/src/browser/navigation-transaction.ts +10 -28
  51. package/src/browser/partial-update.ts +60 -40
  52. package/src/browser/prefetch/cache.ts +196 -49
  53. package/src/browser/prefetch/fetch.ts +203 -59
  54. package/src/browser/prefetch/queue.ts +36 -5
  55. package/src/browser/rango-state.ts +37 -13
  56. package/src/browser/react/Link.tsx +18 -13
  57. package/src/browser/react/NavigationProvider.tsx +75 -31
  58. package/src/browser/react/filter-segment-order.ts +51 -7
  59. package/src/browser/react/index.ts +3 -0
  60. package/src/browser/react/location-state-shared.ts +175 -4
  61. package/src/browser/react/location-state.ts +39 -13
  62. package/src/browser/react/use-handle.ts +17 -9
  63. package/src/browser/react/use-navigation.ts +22 -2
  64. package/src/browser/react/use-params.ts +20 -8
  65. package/src/browser/react/use-reverse.ts +106 -0
  66. package/src/browser/react/use-router.ts +23 -2
  67. package/src/browser/react/use-segments.ts +11 -8
  68. package/src/browser/response-adapter.ts +52 -1
  69. package/src/browser/rsc-router.tsx +71 -22
  70. package/src/browser/scroll-restoration.ts +22 -14
  71. package/src/browser/segment-reconciler.ts +10 -14
  72. package/src/browser/segment-structure-assert.ts +2 -2
  73. package/src/browser/server-action-bridge.ts +44 -30
  74. package/src/browser/types.ts +12 -2
  75. package/src/build/collect-fallback-refs.ts +107 -0
  76. package/src/build/generate-manifest.ts +60 -35
  77. package/src/build/generate-route-types.ts +2 -0
  78. package/src/build/index.ts +8 -1
  79. package/src/build/prefix-tree-utils.ts +123 -0
  80. package/src/build/route-trie.ts +45 -1
  81. package/src/build/route-types/codegen.ts +4 -4
  82. package/src/build/route-types/include-resolution.ts +1 -1
  83. package/src/build/route-types/per-module-writer.ts +7 -4
  84. package/src/build/route-types/router-processing.ts +55 -14
  85. package/src/build/route-types/scan-filter.ts +1 -1
  86. package/src/build/route-types/source-scan.ts +118 -0
  87. package/src/build/runtime-discovery.ts +9 -20
  88. package/src/cache/cache-runtime.ts +17 -5
  89. package/src/cache/cache-scope.ts +51 -49
  90. package/src/cache/cf/cf-cache-store.ts +502 -32
  91. package/src/cache/cf/index.ts +3 -0
  92. package/src/cache/handle-snapshot.ts +103 -0
  93. package/src/cache/index.ts +3 -0
  94. package/src/cache/memory-segment-store.ts +3 -2
  95. package/src/cache/types.ts +10 -6
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +96 -205
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -4
  101. package/src/handle.ts +4 -6
  102. package/src/host/index.ts +2 -2
  103. package/src/host/router.ts +129 -57
  104. package/src/host/types.ts +31 -2
  105. package/src/host/utils.ts +1 -1
  106. package/src/href-client.ts +140 -21
  107. package/src/index.rsc.ts +10 -6
  108. package/src/index.ts +17 -8
  109. package/src/loader-store.ts +500 -0
  110. package/src/loader.rsc.ts +2 -5
  111. package/src/loader.ts +3 -10
  112. package/src/missing-id-error.ts +68 -0
  113. package/src/outlet-context.ts +1 -1
  114. package/src/prerender/store.ts +9 -7
  115. package/src/prerender.ts +4 -4
  116. package/src/response-utils.ts +37 -0
  117. package/src/reverse.ts +65 -39
  118. package/src/route-content-wrapper.tsx +6 -28
  119. package/src/route-definition/dsl-helpers.ts +253 -265
  120. package/src/route-definition/helper-factories.ts +29 -139
  121. package/src/route-definition/helpers-types.ts +43 -15
  122. package/src/route-definition/resolve-handler-use.ts +6 -0
  123. package/src/route-definition/use-item-types.ts +32 -0
  124. package/src/route-types.ts +26 -41
  125. package/src/router/content-negotiation.ts +15 -2
  126. package/src/router/error-handling.ts +1 -1
  127. package/src/router/find-match.ts +54 -6
  128. package/src/router/handler-context.ts +21 -41
  129. package/src/router/intercept-resolution.ts +4 -18
  130. package/src/router/lazy-includes.ts +41 -22
  131. package/src/router/loader-resolution.ts +82 -36
  132. package/src/router/manifest.ts +41 -19
  133. package/src/router/match-api.ts +4 -3
  134. package/src/router/match-handlers.ts +1 -0
  135. package/src/router/match-middleware/cache-lookup.ts +57 -95
  136. package/src/router/match-middleware/cache-store.ts +3 -2
  137. package/src/router/match-result.ts +53 -32
  138. package/src/router/metrics.ts +1 -1
  139. package/src/router/middleware-types.ts +15 -26
  140. package/src/router/middleware.ts +99 -84
  141. package/src/router/pattern-matching.ts +116 -19
  142. package/src/router/prerender-match.ts +40 -15
  143. package/src/router/preview-match.ts +3 -1
  144. package/src/router/request-classification.ts +40 -37
  145. package/src/router/revalidation.ts +58 -2
  146. package/src/router/router-interfaces.ts +51 -35
  147. package/src/router/router-options.ts +25 -1
  148. package/src/router/router-registry.ts +2 -5
  149. package/src/router/segment-resolution/fresh.ts +27 -6
  150. package/src/router/segment-resolution/revalidation.ts +147 -106
  151. package/src/router/segment-resolution/static-store.ts +19 -5
  152. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  153. package/src/router/substitute-pattern-params.ts +56 -0
  154. package/src/router/trie-matching.ts +40 -16
  155. package/src/router/types.ts +8 -0
  156. package/src/router/url-params.ts +49 -0
  157. package/src/router.ts +37 -25
  158. package/src/rsc/handler-context.ts +2 -2
  159. package/src/rsc/handler.ts +58 -77
  160. package/src/rsc/helpers.ts +72 -43
  161. package/src/rsc/index.ts +1 -1
  162. package/src/rsc/manifest-init.ts +28 -41
  163. package/src/rsc/origin-guard.ts +30 -10
  164. package/src/rsc/progressive-enhancement.ts +4 -0
  165. package/src/rsc/response-error.ts +79 -12
  166. package/src/rsc/response-route-handler.ts +76 -61
  167. package/src/rsc/rsc-rendering.ts +45 -51
  168. package/src/rsc/runtime-warnings.ts +9 -10
  169. package/src/rsc/server-action.ts +33 -39
  170. package/src/rsc/ssr-setup.ts +16 -0
  171. package/src/rsc/types.ts +8 -2
  172. package/src/search-params.ts +4 -4
  173. package/src/segment-content-promise.ts +67 -0
  174. package/src/segment-loader-promise.ts +122 -0
  175. package/src/segment-system.tsx +132 -116
  176. package/src/serialize.ts +243 -0
  177. package/src/server/context.ts +175 -53
  178. package/src/server/cookie-store.ts +28 -4
  179. package/src/server/request-context.ts +57 -51
  180. package/src/ssr/index.tsx +5 -1
  181. package/src/static-handler.ts +1 -1
  182. package/src/types/global-namespace.ts +39 -26
  183. package/src/types/handler-context.ts +68 -50
  184. package/src/types/index.ts +1 -0
  185. package/src/types/loader-types.ts +11 -9
  186. package/src/types/request-scope.ts +126 -0
  187. package/src/types/route-entry.ts +11 -0
  188. package/src/types/segments.ts +35 -2
  189. package/src/urls/include-helper.ts +34 -67
  190. package/src/urls/index.ts +1 -5
  191. package/src/urls/path-helper-types.ts +17 -3
  192. package/src/urls/path-helper.ts +17 -52
  193. package/src/urls/pattern-types.ts +36 -19
  194. package/src/urls/response-types.ts +22 -29
  195. package/src/urls/type-extraction.ts +58 -139
  196. package/src/urls/urls-function.ts +1 -5
  197. package/src/use-loader.tsx +413 -42
  198. package/src/vite/debug.ts +185 -0
  199. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  200. package/src/vite/discovery/discover-routers.ts +106 -75
  201. package/src/vite/discovery/discovery-errors.ts +194 -0
  202. package/src/vite/discovery/gate-state.ts +171 -0
  203. package/src/vite/discovery/prerender-collection.ts +72 -31
  204. package/src/vite/discovery/route-types-writer.ts +40 -84
  205. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  206. package/src/vite/discovery/state.ts +33 -0
  207. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  208. package/src/vite/index.ts +2 -0
  209. package/src/vite/plugin-types.ts +67 -0
  210. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  211. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  212. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  213. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  214. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  215. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  216. package/src/vite/plugins/expose-action-id.ts +54 -30
  217. package/src/vite/plugins/expose-id-utils.ts +12 -8
  218. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  219. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  220. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  221. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  222. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  223. package/src/vite/plugins/performance-tracks.ts +29 -25
  224. package/src/vite/plugins/use-cache-transform.ts +65 -50
  225. package/src/vite/plugins/version-injector.ts +39 -23
  226. package/src/vite/plugins/version-plugin.ts +59 -2
  227. package/src/vite/plugins/virtual-entries.ts +2 -2
  228. package/src/vite/rango.ts +116 -29
  229. package/src/vite/router-discovery.ts +753 -104
  230. package/src/vite/utils/ast-handler-extract.ts +15 -15
  231. package/src/vite/utils/banner.ts +1 -1
  232. package/src/vite/utils/bundle-analysis.ts +4 -2
  233. package/src/vite/utils/client-chunks.ts +190 -0
  234. package/src/vite/utils/forward-user-plugins.ts +193 -0
  235. package/src/vite/utils/manifest-utils.ts +8 -59
  236. package/src/vite/utils/package-resolution.ts +41 -1
  237. package/src/vite/utils/prerender-utils.ts +5 -4
  238. package/src/vite/utils/shared-utils.ts +107 -26
  239. package/src/browser/action-response-classifier.ts +0 -99
@@ -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"] {
@@ -291,6 +307,46 @@ export function matchMiddleware<TEnv>(
291
307
  return matches;
292
308
  }
293
309
 
310
+ // Set-Cookie is appended; for other headers stubOverridesNonCookie=true
311
+ // overwrites (chain ran to completion), false fills only missing slots (an
312
+ // explicit short-circuit Response's own headers win).
313
+ function mergeStubHeaders(
314
+ target: Headers,
315
+ stub: Headers,
316
+ stubOverridesNonCookie: boolean,
317
+ ): void {
318
+ stub.forEach((value, name) => {
319
+ if (name.toLowerCase() === "set-cookie") {
320
+ target.append(name, value);
321
+ } else if (stubOverridesNonCookie || !target.has(name)) {
322
+ target.set(name, value);
323
+ }
324
+ });
325
+ }
326
+
327
+ // Set-Cookie is deduped so a nested inner executeMiddleware that already merged
328
+ // the same reqCtx cookies does not duplicate them; other headers fill if missing.
329
+ function mergeReqCtxStub(
330
+ target: Headers,
331
+ reqCtx: ReturnType<typeof _getRequestContext>,
332
+ ): void {
333
+ if (!reqCtx) return;
334
+ const stubCookies = reqCtx.res.headers.getSetCookie();
335
+ if (stubCookies.length > 0) {
336
+ const existing = new Set(target.getSetCookie());
337
+ for (const cookie of stubCookies) {
338
+ if (!existing.has(cookie)) {
339
+ target.append("set-cookie", cookie);
340
+ }
341
+ }
342
+ }
343
+ reqCtx.res.headers.forEach((value, name) => {
344
+ if (name !== "set-cookie" && !target.has(name)) {
345
+ target.set(name, value);
346
+ }
347
+ });
348
+ }
349
+
294
350
  /**
295
351
  * Execute middleware chain
296
352
  *
@@ -329,35 +385,13 @@ export async function executeMiddleware<TEnv>(
329
385
  // End of chain - call actual RSC handler
330
386
  const response = await finalHandler();
331
387
 
332
- // Merge headers set on stub into the real response.
333
- // Use append for Set-Cookie to preserve multiple cookies.
334
388
  const mergedHeaders = new Headers(response.headers);
335
- stubResponse.headers.forEach((value, name) => {
336
- if (name.toLowerCase() === "set-cookie") {
337
- mergedHeaders.append(name, value);
338
- } else {
339
- mergedHeaders.set(name, value);
340
- }
341
- });
342
- // Also merge shared RequestContext stub (cookies written via cookies().set()).
343
- // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
344
- // may have already merged the same reqCtx cookies into the response.
345
- const reqCtx = _getRequestContext();
346
- if (reqCtx) {
347
- const stubCookies = reqCtx.res.headers.getSetCookie();
348
- if (stubCookies.length > 0) {
349
- const existing = new Set(mergedHeaders.getSetCookie());
350
- for (const cookie of stubCookies) {
351
- if (!existing.has(cookie)) {
352
- mergedHeaders.append("set-cookie", cookie);
353
- }
354
- }
355
- }
356
- reqCtx.res.headers.forEach((value, name) => {
357
- if (name !== "set-cookie" && !mergedHeaders.has(name)) {
358
- mergedHeaders.set(name, value);
359
- }
360
- });
389
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, true);
390
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
391
+
392
+ if (isWebSocketUpgradeResponse(response)) {
393
+ responseHolder.response = response;
394
+ return response;
361
395
  }
362
396
 
363
397
  // Clone response with merged headers (mutable for post-next() modifications)
@@ -426,8 +460,16 @@ export async function executeMiddleware<TEnv>(
426
460
  try {
427
461
  result = await entry.handler(ctx, wrappedNext);
428
462
  } catch (error) {
429
- finishMiddleware();
430
- throw error;
463
+ // Thrown Response is short-circuit control flow, not an error.
464
+ // Fall through to the `if (result instanceof Response)` branch below
465
+ // so stub headers and request-context cookies merge as they do for
466
+ // an explicit `return new Response(...)`. Real errors propagate.
467
+ if (error instanceof Response) {
468
+ result = error;
469
+ } else {
470
+ finishMiddleware();
471
+ throw error;
472
+ }
431
473
  }
432
474
  finishMiddleware();
433
475
 
@@ -451,34 +493,13 @@ export async function executeMiddleware<TEnv>(
451
493
  // RequestContext stub headers (from ctx.setCookie) into the
452
494
  // returned Response so they are not lost.
453
495
  if (result instanceof Response) {
454
- const mergedHeaders = new Headers(result.headers);
455
- stubResponse.headers.forEach((value, name) => {
456
- if (name.toLowerCase() === "set-cookie") {
457
- mergedHeaders.append(name, value);
458
- } else if (!mergedHeaders.has(name)) {
459
- mergedHeaders.set(name, value);
460
- }
461
- });
462
- // Also merge shared RequestContext stub (cookies written via setCookie).
463
- // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
464
- // may have already merged the same reqCtx cookies into the response.
465
- const reqCtx = _getRequestContext();
466
- if (reqCtx) {
467
- const stubCookies = reqCtx.res.headers.getSetCookie();
468
- if (stubCookies.length > 0) {
469
- const existing = new Set(mergedHeaders.getSetCookie());
470
- for (const cookie of stubCookies) {
471
- if (!existing.has(cookie)) {
472
- mergedHeaders.append("set-cookie", cookie);
473
- }
474
- }
475
- }
476
- reqCtx.res.headers.forEach((value, name) => {
477
- if (name !== "set-cookie" && !mergedHeaders.has(name)) {
478
- mergedHeaders.set(name, value);
479
- }
480
- });
496
+ if (isWebSocketUpgradeResponse(result)) {
497
+ responseHolder.response = result;
498
+ return result;
481
499
  }
500
+ const mergedHeaders = new Headers(result.headers);
501
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, false);
502
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
482
503
  const merged = new Response(result.body, {
483
504
  status: result.status,
484
505
  statusText: result.statusText,
@@ -527,23 +548,12 @@ export async function executeMiddleware<TEnv>(
527
548
  // last merge point (e.g. cookies().set() called after await next()).
528
549
  // The reqCtx stub may have already been partially merged during finalHandler
529
550
  // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
551
+ //
552
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
553
+ // set-cookie on an upgrade is not meaningful.
530
554
  const reqCtx = _getRequestContext();
531
- if (reqCtx) {
532
- const stubCookies = reqCtx.res.headers.getSetCookie();
533
- if (stubCookies.length > 0) {
534
- const existingCookies = new Set(finalResponse.headers.getSetCookie());
535
- for (const cookie of stubCookies) {
536
- if (!existingCookies.has(cookie)) {
537
- finalResponse.headers.append("set-cookie", cookie);
538
- }
539
- }
540
- }
541
- // Fill in non-cookie headers that aren't already on the response
542
- reqCtx.res.headers.forEach((value, name) => {
543
- if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
544
- finalResponse.headers.set(name, value);
545
- }
546
- });
555
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
556
+ mergeReqCtxStub(finalResponse.headers, reqCtx);
547
557
  }
548
558
 
549
559
  return finalResponse;
@@ -613,7 +623,18 @@ export async function executeInterceptMiddleware<TEnv>(
613
623
  return next();
614
624
  };
615
625
 
616
- const result = await middleware(ctx, guardedNext);
626
+ let result: Response | void;
627
+ try {
628
+ result = await middleware(ctx, guardedNext);
629
+ } catch (error) {
630
+ // Thrown Response is short-circuit control flow, parity with the
631
+ // explicit-return path below. Real errors propagate.
632
+ if (error instanceof Response) {
633
+ result = error;
634
+ } else {
635
+ throw error;
636
+ }
637
+ }
617
638
 
618
639
  if (result instanceof Response) {
619
640
  earlyResponse = result;
@@ -641,13 +662,7 @@ export async function executeInterceptMiddleware<TEnv>(
641
662
  // Only fill in missing headers — the returned Response's explicit
642
663
  // headers take precedence, matching executeMiddleware behavior.
643
664
  const mergedHeaders = new Headers(response.headers);
644
- stubResponse.headers.forEach((value, name) => {
645
- if (name.toLowerCase() === "set-cookie") {
646
- mergedHeaders.append(name, value);
647
- } else if (!mergedHeaders.has(name)) {
648
- mergedHeaders.set(name, value);
649
- }
650
- });
665
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, false);
651
666
  return new Response(response.body, {
652
667
  status: response.status,
653
668
  statusText: response.statusText,
@@ -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);
@@ -176,6 +188,20 @@ export function compilePattern(pattern: string): CompiledPattern {
176
188
  regexPattern = "/";
177
189
  }
178
190
 
191
+ // Patterns of only optional segments (e.g. `/:locale?`, `/:a?/:b?`) need
192
+ // an explicit `/` alternative so a bare `/` matches the absent form. The
193
+ // optional template `(?:/X)?` matches `/X` or empty string, but pathnames
194
+ // are never empty. Arises from `include("/:locale?", routes)` + inner
195
+ // `path("/")`. Skip when an explicit trailing slash already anchors the
196
+ // match.
197
+ const hasOnlyOptionalSegments =
198
+ !hasTrailingSlash &&
199
+ segments.length > 0 &&
200
+ segments.every((segment) => segment.type === "param" && segment.optional);
201
+ if (hasOnlyOptionalSegments) {
202
+ regexPattern = `(?:/|${regexPattern})`;
203
+ }
204
+
179
205
  // Add trailing slash to regex if pattern has one
180
206
  if (hasTrailingSlash) {
181
207
  regexPattern += "/";
@@ -186,9 +212,35 @@ export function compilePattern(pattern: string): CompiledPattern {
186
212
  paramNames,
187
213
  optionalParams,
188
214
  hasTrailingSlash,
215
+ ...(constraints ? { constraints } : {}),
189
216
  };
190
217
  }
191
218
 
219
+ /**
220
+ * Validate decoded params against a compiled pattern's constraints.
221
+ * Returns false if any constrained param has a non-empty value not in the
222
+ * allowed list. Absent optionals (key missing or `undefined`) are allowed;
223
+ * `""` is also tolerated as "absent" so user-provided params or fixtures
224
+ * that pass empty strings explicitly behave the same way.
225
+ */
226
+ function satisfiesConstraints(
227
+ params: Record<string, string>,
228
+ constraints: Record<string, string[]> | undefined,
229
+ ): boolean {
230
+ if (!constraints) return true;
231
+ for (const name in constraints) {
232
+ const value = params[name];
233
+ if (
234
+ value !== undefined &&
235
+ value !== "" &&
236
+ !constraints[name].includes(value)
237
+ ) {
238
+ return false;
239
+ }
240
+ }
241
+ return true;
242
+ }
243
+
192
244
  /**
193
245
  * Escape special regex characters in a string
194
246
  */
@@ -196,6 +248,27 @@ function escapeRegex(str: string): string {
196
248
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
249
  }
198
250
 
251
+ /**
252
+ * Build the named-params record from a regex match. Optional segments that
253
+ * didn't capture leave the corresponding group `undefined`; we skip those
254
+ * keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
255
+ * keeps the runtime aligned with the `ExtractParams` type and matches the
256
+ * trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
257
+ */
258
+ function buildParamsFromMatch(
259
+ match: RegExpExecArray,
260
+ paramNames: string[],
261
+ ): Record<string, string> {
262
+ const params: Record<string, string> = {};
263
+ paramNames.forEach((name, index) => {
264
+ const captured = match[index + 1];
265
+ if (captured !== undefined) {
266
+ params[name] = safeDecodeURIComponent(captured);
267
+ }
268
+ });
269
+ return params;
270
+ }
271
+
199
272
  /**
200
273
  * Extract the static prefix from a route pattern.
201
274
  * Returns everything before the first param/wildcard.
@@ -244,11 +317,28 @@ export function extractStaticPrefix(pattern: string): string {
244
317
  return pattern.slice(0, lastSlash);
245
318
  }
246
319
 
320
+ /**
321
+ * Join a URL prefix to a sub-prefix, collapsing the duplicate slash when the
322
+ * base ends with "/" and the sub-prefix starts with "/". This mirrors the
323
+ * canonical join in `include()` (urls/include-helper.ts) and `runWithPrefixes`
324
+ * (server/context.ts) so a nested lazy include's runtime staticPrefix matches
325
+ * the build-time trie's `sp` (e.g. `include("/parent/", …)` containing
326
+ * `include("/child", …)` resolves to `/parent/child`, not `/parent//child`).
327
+ */
328
+ export function joinPrefix(base: string | undefined, prefix: string): string {
329
+ if (!base) return prefix;
330
+ return base.endsWith("/") && prefix.startsWith("/")
331
+ ? base + prefix.slice(1)
332
+ : base + prefix;
333
+ }
334
+
247
335
  /**
248
336
  * Match a pathname against registered routes
249
337
  *
250
- * Note: Optional params that are absent in the path will have empty string value.
251
- * Use the pattern definition to determine if a param is optional.
338
+ * Note: Optional params that are absent in the path are omitted from the
339
+ * returned `params` (read as `undefined`), matching the trie matcher and
340
+ * the `ExtractParams<"/:locale?/...">` type. Use the pattern definition or
341
+ * `optionalParams` to determine which keys are optional.
252
342
  *
253
343
  * Trailing slash handling (priority order):
254
344
  * 1. Per-route `trailingSlash` config from route()
@@ -268,8 +358,6 @@ export interface RouteMatchResult<TEnv = any> {
268
358
  params: Record<string, string>;
269
359
  optionalParams: Set<string>;
270
360
  redirectTo?: string;
271
- /** Ancestry shortCodes for layout pruning (from trie match) */
272
- ancestry?: string[];
273
361
  /** Route has pre-rendered data available (from trie) */
274
362
  pr?: true;
275
363
  /** Passthrough: handler kept for live fallback on unknown params (from trie) */
@@ -392,8 +480,13 @@ export function findMatch<TEnv>(
392
480
  fullPattern = entry.prefix + pattern;
393
481
  }
394
482
 
395
- const { regex, paramNames, optionalParams, hasTrailingSlash } =
396
- getCompiledPattern(fullPattern);
483
+ const {
484
+ regex,
485
+ paramNames,
486
+ optionalParams,
487
+ hasTrailingSlash,
488
+ constraints,
489
+ } = getCompiledPattern(fullPattern);
397
490
 
398
491
  // Get trailing slash mode for this route (per-route config or pattern-based)
399
492
  const trailingSlashMode: TrailingSlashMode | undefined =
@@ -410,10 +503,13 @@ export function findMatch<TEnv>(
410
503
  // Try exact match first
411
504
  const match = regex.exec(pathname);
412
505
  if (match) {
413
- const params: Record<string, string> = {};
414
- paramNames.forEach((name, index) => {
415
- params[name] = match[index + 1] ?? "";
416
- });
506
+ const params = buildParamsFromMatch(match, paramNames);
507
+
508
+ // Validate constraints against decoded values; a failure falls
509
+ // through to the next route so other patterns can still match.
510
+ if (!satisfiesConstraints(params, constraints)) {
511
+ continue;
512
+ }
417
513
 
418
514
  if (effectiveDebug) {
419
515
  debugLog("findMatch", "matched route", {
@@ -465,10 +561,11 @@ export function findMatch<TEnv>(
465
561
  // Try alternate pathname (opposite trailing slash)
466
562
  const altMatch = regex.exec(alternatePathname);
467
563
  if (altMatch) {
468
- const params: Record<string, string> = {};
469
- paramNames.forEach((name, index) => {
470
- params[name] = altMatch[index + 1] ?? "";
471
- });
564
+ const params = buildParamsFromMatch(altMatch, paramNames);
565
+
566
+ if (!satisfiesConstraints(params, constraints)) {
567
+ continue;
568
+ }
472
569
 
473
570
  // Determine redirect behavior based on mode
474
571
  if (trailingSlashMode === "ignore") {
@@ -59,11 +59,16 @@ export async function matchForPrerender<TEnv = any>(
59
59
  devMode?: boolean,
60
60
  ): Promise<{
61
61
  segments: SerializedSegmentData[];
62
- handles: Record<string, SegmentHandleData>;
62
+ /** RSC-encoded handle map ("" when none) — see handle-snapshot.ts. Encoded in
63
+ * the producer (where the Flight codec resolves) so the node-side build/dev
64
+ * sinks can persist it without touching the codec. */
65
+ handles: string;
63
66
  routeName: string;
64
67
  params: Record<string, string>;
65
68
  interceptSegments?: SerializedSegmentData[];
66
- interceptHandles?: Record<string, SegmentHandleData>;
69
+ /** RSC-encoded MERGED (main + intercept) handle map for the intercept artifact;
70
+ * the sinks store it as-is (no longer merge raw records). */
71
+ interceptHandles?: string;
67
72
  passthrough?: true;
68
73
  } | null> {
69
74
  // 1. Find the matching route entry
@@ -126,7 +131,7 @@ export async function matchForPrerender<TEnv = any>(
126
131
  get env() {
127
132
  if (buildEnv !== undefined) return buildEnv;
128
133
  throw new Error(
129
- "[rsc-router] ctx.env is not available during dev-mode getParams(). " +
134
+ "[rango] ctx.env is not available during dev-mode getParams(). " +
130
135
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
131
136
  );
132
137
  },
@@ -142,7 +147,7 @@ export async function matchForPrerender<TEnv = any>(
142
147
  if (!isKnown) {
143
148
  return {
144
149
  segments: [],
145
- handles: {},
150
+ handles: "",
146
151
  routeName: matched.routeKey,
147
152
  params: matchedParams,
148
153
  passthrough: true as const,
@@ -162,7 +167,7 @@ export async function matchForPrerender<TEnv = any>(
162
167
  if (err?.name === "Skip") {
163
168
  return {
164
169
  segments: [],
165
- handles: {},
170
+ handles: "",
166
171
  routeName: matched.routeKey,
167
172
  params: matchedParams,
168
173
  passthrough: true as const,
@@ -261,7 +266,7 @@ export async function matchForPrerender<TEnv = any>(
261
266
  if (isPrerenderPassthrough(seg.component)) {
262
267
  return {
263
268
  segments: [],
264
- handles: {},
269
+ handles: "",
265
270
  routeName: matched.routeKey,
266
271
  params: matchedParams,
267
272
  passthrough: true as const,
@@ -278,16 +283,22 @@ export async function matchForPrerender<TEnv = any>(
278
283
 
279
284
  // 12. Serialize segments using the cache serializer
280
285
  const { serializeSegments } = await import("../cache/segment-codec.js");
286
+ const { encodeHandles } = await import("../cache/handle-snapshot.js");
281
287
  const serializedSegments = await serializeSegments(nonLoaderSegments);
282
288
 
283
- // 13. Collect handle data per segment (skip segments with no handle data)
284
- const handles: Record<string, SegmentHandleData> = {};
289
+ // 13. Collect handle data per segment (skip segments with no handle data).
290
+ // Encoded through the Flight codec (not stored raw) so Promise/ReactNode
291
+ // handle values survive the JSON-serialized build artifact / dev wire —
292
+ // the same fix the runtime cache uses. Encode happens here, in the RSC
293
+ // environment where the codec resolves; the node-side sinks only persist.
294
+ const handlesRecord: Record<string, SegmentHandleData> = {};
285
295
  for (const seg of nonLoaderSegments) {
286
296
  const segHandles = handleStore.getDataForSegment(seg.id);
287
297
  if (Object.keys(segHandles).length > 0) {
288
- handles[seg.id] = segHandles;
298
+ handlesRecord[seg.id] = segHandles;
289
299
  }
290
300
  }
301
+ const handles = await encodeHandles(handlesRecord);
291
302
 
292
303
  // Use the trie-level route key (e.g., "docs", "docs.article")
293
304
  const routeName = matched.routeKey;
@@ -297,7 +308,7 @@ export async function matchForPrerender<TEnv = any>(
297
308
  // evaluation -- we pre-render all intercepts unconditionally and let
298
309
  // runtime matching decide which to serve.
299
310
  let interceptSegments: SerializedSegmentData[] | undefined;
300
- let interceptHandles: Record<string, SegmentHandleData> | undefined;
311
+ let interceptHandles: string | undefined;
301
312
 
302
313
  const foundIntercepts: {
303
314
  intercept: InterceptEntry;
@@ -379,13 +390,20 @@ export async function matchForPrerender<TEnv = any>(
379
390
  interceptSegments = await serializeSegments(
380
391
  interceptResolvedSegments,
381
392
  );
382
- interceptHandles = {};
393
+ const interceptHandlesRecord: Record<string, SegmentHandleData> = {};
383
394
  for (const seg of interceptResolvedSegments) {
384
395
  const segHandles = handleStore.getDataForSegment(seg.id);
385
396
  if (Object.keys(segHandles).length > 0) {
386
- interceptHandles[seg.id] = segHandles;
397
+ interceptHandlesRecord[seg.id] = segHandles;
387
398
  }
388
399
  }
400
+ // The intercept artifact serves main + intercept segments together, so
401
+ // encode the MERGED handle map here (the sinks no longer merge raw
402
+ // records — they store this pre-encoded string as-is).
403
+ interceptHandles = await encodeHandles({
404
+ ...handlesRecord,
405
+ ...interceptHandlesRecord,
406
+ });
389
407
  }
390
408
  }
391
409
 
@@ -414,7 +432,7 @@ export async function renderStaticSegment<TEnv = any>(
414
432
  routeName?: string,
415
433
  buildEnv?: TEnv,
416
434
  devMode?: boolean,
417
- ): Promise<{ encoded: string; handles: Record<string, unknown[]> } | null> {
435
+ ): Promise<{ encoded: string; handles: string } | null> {
418
436
  const syntheticUrl = new URL("http://prerender/");
419
437
  const syntheticRequest = new Request(syntheticUrl);
420
438
 
@@ -492,10 +510,17 @@ export async function renderStaticSegment<TEnv = any>(
492
510
  };
493
511
 
494
512
  const { serializeSegments } = await import("../cache/segment-codec.js");
513
+ const { encodeHandleValue } = await import("../cache/handle-snapshot.js");
495
514
  const [serialized] = await serializeSegments([segment]);
496
515
 
497
- // Collect handle data pushed during rendering
498
- const handles = handleStore.getDataForSegment(handlerId);
516
+ // Collect handle data pushed during rendering and Flight-encode it (so
517
+ // Promise/ReactNode handle values survive the JSON build artifact). "" when
518
+ // nothing was pushed.
519
+ const segHandles = handleStore.getDataForSegment(handlerId);
520
+ const handles =
521
+ Object.keys(segHandles).length > 0
522
+ ? await encodeHandleValue(segHandles)
523
+ : "";
499
524
 
500
525
  return { encoded: serialized.encoded, handles };
501
526
  });
@@ -67,9 +67,11 @@ export async function previewMatch<TEnv = any>(
67
67
  responseType: negotiation.responseType,
68
68
  handler: negotiation.handler,
69
69
  params: matched.params,
70
- negotiated: true,
71
70
  manifestEntry: negotiation.manifestEntry,
72
71
  routeKey: matched.routeKey,
72
+ // omitted unless a variant negotiated, preserving the prior public
73
+ // shape (absent for plain response routes, not negotiated:false)
74
+ ...(negotiation.negotiated ? { negotiated: true } : {}),
73
75
  };
74
76
  }
75
77