@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.d98a8e9d

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 (278) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2154 -861
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +220 -30
  11. package/skills/caching/SKILL.md +116 -8
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -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 +71 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +243 -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 +57 -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/testing/SKILL.md +128 -0
  37. package/skills/testing/bindings.md +89 -0
  38. package/skills/testing/cache-prerender.md +98 -0
  39. package/skills/testing/client-components.md +121 -0
  40. package/skills/testing/e2e-parity.md +124 -0
  41. package/skills/testing/flight.md +89 -0
  42. package/skills/testing/handles.md +127 -0
  43. package/skills/testing/loader.md +108 -0
  44. package/skills/testing/middleware.md +97 -0
  45. package/skills/testing/render-handler.md +102 -0
  46. package/skills/testing/response-routes.md +94 -0
  47. package/skills/testing/reverse-and-types.md +83 -0
  48. package/skills/testing/server-actions.md +89 -0
  49. package/skills/testing/server-tree.md +128 -0
  50. package/skills/testing/setup.md +120 -0
  51. package/skills/typesafety/SKILL.md +319 -27
  52. package/skills/use-cache/SKILL.md +34 -5
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/browser/action-coordinator.ts +53 -36
  57. package/src/browser/app-shell.ts +52 -0
  58. package/src/browser/event-controller.ts +86 -70
  59. package/src/browser/history-state.ts +21 -0
  60. package/src/browser/index.ts +3 -3
  61. package/src/browser/navigation-bridge.ts +84 -11
  62. package/src/browser/navigation-client.ts +104 -68
  63. package/src/browser/navigation-store.ts +32 -9
  64. package/src/browser/navigation-transaction.ts +10 -28
  65. package/src/browser/partial-update.ts +64 -26
  66. package/src/browser/prefetch/cache.ts +183 -44
  67. package/src/browser/prefetch/fetch.ts +228 -37
  68. package/src/browser/prefetch/queue.ts +36 -5
  69. package/src/browser/rango-state.ts +53 -13
  70. package/src/browser/react/Link.tsx +30 -2
  71. package/src/browser/react/NavigationProvider.tsx +72 -31
  72. package/src/browser/react/filter-segment-order.ts +51 -7
  73. package/src/browser/react/index.ts +3 -0
  74. package/src/browser/react/location-state-shared.ts +175 -4
  75. package/src/browser/react/location-state.ts +39 -13
  76. package/src/browser/react/use-handle.ts +17 -9
  77. package/src/browser/react/use-navigation.ts +22 -2
  78. package/src/browser/react/use-params.ts +20 -8
  79. package/src/browser/react/use-reverse.ts +106 -0
  80. package/src/browser/react/use-router.ts +22 -2
  81. package/src/browser/react/use-segments.ts +11 -8
  82. package/src/browser/response-adapter.ts +32 -1
  83. package/src/browser/rsc-router.tsx +69 -22
  84. package/src/browser/scroll-restoration.ts +22 -14
  85. package/src/browser/segment-reconciler.ts +36 -14
  86. package/src/browser/segment-structure-assert.ts +2 -2
  87. package/src/browser/server-action-bridge.ts +23 -30
  88. package/src/browser/types.ts +21 -0
  89. package/src/build/collect-fallback-refs.ts +107 -0
  90. package/src/build/generate-manifest.ts +60 -35
  91. package/src/build/generate-route-types.ts +2 -0
  92. package/src/build/index.ts +8 -1
  93. package/src/build/prefix-tree-utils.ts +123 -0
  94. package/src/build/route-trie.ts +95 -25
  95. package/src/build/route-types/codegen.ts +4 -4
  96. package/src/build/route-types/include-resolution.ts +1 -1
  97. package/src/build/route-types/per-module-writer.ts +7 -4
  98. package/src/build/route-types/router-processing.ts +55 -14
  99. package/src/build/route-types/scan-filter.ts +1 -1
  100. package/src/build/route-types/source-scan.ts +118 -0
  101. package/src/build/runtime-discovery.ts +9 -20
  102. package/src/cache/cache-scope.ts +28 -42
  103. package/src/cache/cf/cf-cache-store.ts +54 -13
  104. package/src/client.rsc.tsx +3 -0
  105. package/src/client.tsx +96 -205
  106. package/src/context-var.ts +5 -5
  107. package/src/decode-loader-results.ts +36 -0
  108. package/src/errors.ts +30 -4
  109. package/src/handle.ts +32 -14
  110. package/src/host/index.ts +2 -2
  111. package/src/host/router.ts +129 -57
  112. package/src/host/types.ts +31 -2
  113. package/src/host/utils.ts +1 -1
  114. package/src/href-client.ts +140 -21
  115. package/src/index.rsc.ts +10 -6
  116. package/src/index.ts +54 -17
  117. package/src/loader-store.ts +500 -0
  118. package/src/loader.rsc.ts +25 -7
  119. package/src/loader.ts +16 -9
  120. package/src/missing-id-error.ts +68 -0
  121. package/src/outlet-context.ts +1 -1
  122. package/src/prerender.ts +27 -6
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -36
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +384 -257
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +100 -28
  129. package/src/route-definition/resolve-handler-use.ts +6 -0
  130. package/src/route-definition/use-item-types.ts +32 -0
  131. package/src/route-types.ts +26 -41
  132. package/src/router/basename.ts +14 -0
  133. package/src/router/content-negotiation.ts +15 -2
  134. package/src/router/error-handling.ts +1 -1
  135. package/src/router/find-match.ts +54 -6
  136. package/src/router/handler-context.ts +21 -38
  137. package/src/router/intercept-resolution.ts +4 -18
  138. package/src/router/lazy-includes.ts +41 -22
  139. package/src/router/loader-resolution.ts +82 -36
  140. package/src/router/manifest.ts +41 -19
  141. package/src/router/match-api.ts +4 -3
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/cache-lookup.ts +44 -91
  144. package/src/router/match-middleware/cache-store.ts +3 -2
  145. package/src/router/match-result.ts +53 -32
  146. package/src/router/metrics.ts +1 -1
  147. package/src/router/middleware-types.ts +15 -26
  148. package/src/router/middleware.ts +99 -84
  149. package/src/router/pattern-matching.ts +116 -19
  150. package/src/router/prerender-match.ts +1 -1
  151. package/src/router/preview-match.ts +3 -1
  152. package/src/router/request-classification.ts +4 -28
  153. package/src/router/revalidation.ts +58 -2
  154. package/src/router/router-interfaces.ts +45 -28
  155. package/src/router/router-options.ts +40 -1
  156. package/src/router/router-registry.ts +2 -5
  157. package/src/router/segment-resolution/fresh.ts +27 -6
  158. package/src/router/segment-resolution/revalidation.ts +147 -106
  159. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  160. package/src/router/substitute-pattern-params.ts +56 -0
  161. package/src/router/telemetry.ts +99 -0
  162. package/src/router/trie-matching.ts +40 -16
  163. package/src/router/types.ts +8 -0
  164. package/src/router/url-params.ts +49 -0
  165. package/src/router.ts +52 -30
  166. package/src/rsc/handler-context.ts +2 -2
  167. package/src/rsc/handler.ts +28 -69
  168. package/src/rsc/helpers.ts +91 -43
  169. package/src/rsc/index.ts +1 -1
  170. package/src/rsc/manifest-init.ts +28 -41
  171. package/src/rsc/origin-guard.ts +28 -10
  172. package/src/rsc/progressive-enhancement.ts +4 -0
  173. package/src/rsc/response-error.ts +79 -12
  174. package/src/rsc/response-route-handler.ts +57 -61
  175. package/src/rsc/rsc-rendering.ts +35 -51
  176. package/src/rsc/runtime-warnings.ts +9 -10
  177. package/src/rsc/server-action.ts +17 -37
  178. package/src/rsc/ssr-setup.ts +16 -0
  179. package/src/rsc/types.ts +8 -2
  180. package/src/runtime-env.ts +18 -0
  181. package/src/search-params.ts +4 -4
  182. package/src/segment-content-promise.ts +67 -0
  183. package/src/segment-loader-promise.ts +122 -0
  184. package/src/segment-system.tsx +132 -116
  185. package/src/serialize.ts +243 -0
  186. package/src/server/context.ts +175 -53
  187. package/src/server/cookie-store.ts +28 -4
  188. package/src/server/request-context.ts +67 -51
  189. package/src/ssr/index.tsx +5 -1
  190. package/src/static-handler.ts +25 -3
  191. package/src/testing/cache-status.ts +166 -0
  192. package/src/testing/collect-handle.ts +63 -0
  193. package/src/testing/dispatch.ts +581 -0
  194. package/src/testing/dom.entry.ts +22 -0
  195. package/src/testing/e2e/fixture.ts +188 -0
  196. package/src/testing/e2e/index.ts +149 -0
  197. package/src/testing/e2e/matchers.ts +51 -0
  198. package/src/testing/e2e/page-helpers.ts +272 -0
  199. package/src/testing/e2e/parity.ts +326 -0
  200. package/src/testing/e2e/server.ts +195 -0
  201. package/src/testing/flight-matchers.ts +110 -0
  202. package/src/testing/flight-normalize.ts +38 -0
  203. package/src/testing/flight-runtime.d.ts +57 -0
  204. package/src/testing/flight-tree.ts +682 -0
  205. package/src/testing/flight.entry.ts +51 -0
  206. package/src/testing/flight.ts +234 -0
  207. package/src/testing/generated-routes.ts +223 -0
  208. package/src/testing/index.ts +106 -0
  209. package/src/testing/internal/context.ts +304 -0
  210. package/src/testing/internal/flight-client-globals.ts +30 -0
  211. package/src/testing/internal/seed-vars.ts +42 -0
  212. package/src/testing/render-handler.ts +323 -0
  213. package/src/testing/render-route.tsx +590 -0
  214. package/src/testing/run-loader.ts +363 -0
  215. package/src/testing/run-middleware.ts +205 -0
  216. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  217. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  218. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  219. package/src/testing/vitest-stubs/version.ts +5 -0
  220. package/src/testing/vitest.ts +285 -0
  221. package/src/types/global-namespace.ts +39 -26
  222. package/src/types/handler-context.ts +68 -50
  223. package/src/types/index.ts +1 -0
  224. package/src/types/loader-types.ts +11 -9
  225. package/src/types/request-scope.ts +126 -0
  226. package/src/types/route-entry.ts +11 -0
  227. package/src/types/segments.ts +35 -2
  228. package/src/urls/include-helper.ts +34 -67
  229. package/src/urls/index.ts +1 -5
  230. package/src/urls/path-helper-types.ts +41 -7
  231. package/src/urls/path-helper.ts +17 -52
  232. package/src/urls/pattern-types.ts +36 -19
  233. package/src/urls/response-types.ts +22 -29
  234. package/src/urls/type-extraction.ts +58 -139
  235. package/src/urls/urls-function.ts +1 -5
  236. package/src/use-loader.tsx +413 -42
  237. package/src/vite/debug.ts +185 -0
  238. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  239. package/src/vite/discovery/discover-routers.ts +106 -75
  240. package/src/vite/discovery/discovery-errors.ts +194 -0
  241. package/src/vite/discovery/gate-state.ts +171 -0
  242. package/src/vite/discovery/prerender-collection.ts +67 -26
  243. package/src/vite/discovery/route-types-writer.ts +40 -84
  244. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  245. package/src/vite/discovery/state.ts +33 -0
  246. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  247. package/src/vite/index.ts +2 -0
  248. package/src/vite/plugin-types.ts +67 -0
  249. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  250. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  251. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  252. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  253. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  254. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  255. package/src/vite/plugins/expose-action-id.ts +54 -30
  256. package/src/vite/plugins/expose-id-utils.ts +12 -8
  257. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  258. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  259. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  260. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  261. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  262. package/src/vite/plugins/performance-tracks.ts +29 -25
  263. package/src/vite/plugins/use-cache-transform.ts +65 -50
  264. package/src/vite/plugins/version-injector.ts +39 -23
  265. package/src/vite/plugins/version-plugin.ts +59 -2
  266. package/src/vite/plugins/virtual-entries.ts +2 -2
  267. package/src/vite/rango.ts +116 -29
  268. package/src/vite/router-discovery.ts +750 -100
  269. package/src/vite/utils/ast-handler-extract.ts +15 -15
  270. package/src/vite/utils/banner.ts +1 -1
  271. package/src/vite/utils/bundle-analysis.ts +4 -2
  272. package/src/vite/utils/client-chunks.ts +190 -0
  273. package/src/vite/utils/forward-user-plugins.ts +193 -0
  274. package/src/vite/utils/manifest-utils.ts +8 -59
  275. package/src/vite/utils/package-resolution.ts +41 -1
  276. package/src/vite/utils/prerender-utils.ts +21 -6
  277. package/src/vite/utils/shared-utils.ts +107 -26
  278. 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") {
@@ -126,7 +126,7 @@ export async function matchForPrerender<TEnv = any>(
126
126
  get env() {
127
127
  if (buildEnv !== undefined) return buildEnv;
128
128
  throw new Error(
129
- "[rsc-router] ctx.env is not available during dev-mode getParams(). " +
129
+ "[rango] ctx.env is not available during dev-mode getParams(). " +
130
130
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
131
131
  );
132
132
  },
@@ -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
 
@@ -278,33 +278,9 @@ async function classifyResponseRoute<TEnv>(
278
278
  pathname: string,
279
279
  snapshot: RouteSnapshot<TEnv>,
280
280
  ): Promise<ResponseRoutePlan<TEnv> | null> {
281
- const { manifestEntry, responseType } = snapshot;
282
-
281
+ // negotiateRoute returns the response plan (variant or plain) or null for RSC.
283
282
  const negotiation = await negotiateRoute(request, pathname, snapshot);
284
- if (negotiation) {
285
- return {
286
- mode: "response",
287
- route: snapshot,
288
- ...negotiation,
289
- };
290
- }
291
-
292
- // Non-negotiated response route (no variants, or RSC won negotiation)
293
- if (responseType) {
294
- const handler =
295
- manifestEntry.type === "route" ? manifestEntry.handler : undefined;
296
- if (handler) {
297
- return {
298
- mode: "response",
299
- route: snapshot,
300
- handler,
301
- responseType,
302
- negotiated: false,
303
- manifestEntry,
304
- routeMiddleware: snapshot.routeMiddleware,
305
- };
306
- }
307
- }
308
-
309
- return null;
283
+ return negotiation
284
+ ? { mode: "response", route: snapshot, ...negotiation }
285
+ : null;
310
286
  }
@@ -4,7 +4,7 @@
4
4
  * Evaluates whether segments should revalidate based on params, actions, and custom functions.
5
5
  */
6
6
 
7
- import type { ResolvedSegment, HandlerContext } from "../types";
7
+ import type { ResolvedSegment, HandlerContext, ActionRef } from "../types";
8
8
  import type { ActionContext } from "./types";
9
9
  import {
10
10
  debugLog,
@@ -15,6 +15,47 @@ import type { RevalidationTraceEntry } from "./logging.js";
15
15
  import { _getRequestContext } from "../server/request-context.js";
16
16
  import { isAutoGeneratedRouteName } from "../route-name.js";
17
17
 
18
+ /**
19
+ * Resolve a server-action reference's stable id, mirroring how the action
20
+ * boundary derives `actionContext.actionId` in `rsc/server-action.ts`
21
+ * (`$id ?? $$id`): the file-path `$id` set by the expose-action-id plugin in a
22
+ * production RSC build when present, otherwise React's `$$id`. Resolving both
23
+ * the incoming `actionId` and the reference with the same precedence makes
24
+ * `isAction()` form-agnostic across dev and production.
25
+ */
26
+ function resolveActionRefId(ref: unknown): string | undefined {
27
+ if (ref == null) return undefined;
28
+ const r = ref as { $id?: unknown; $$id?: unknown };
29
+ if (typeof r.$id === "string") return r.$id;
30
+ if (typeof r.$$id === "string") return r.$$id;
31
+ return undefined;
32
+ }
33
+
34
+ /**
35
+ * Build the `isAction()` helper bound to the current action's id. Matches a
36
+ * single imported action reference, several (variadic), or any export of a
37
+ * namespace import (`import * as Mod`). Returns `false` when there is no action
38
+ * (plain navigation) or nothing matches.
39
+ */
40
+ function makeIsAction(
41
+ currentActionId: string | undefined,
42
+ ): (...actions: ActionRef[]) => boolean {
43
+ return (...actions: ActionRef[]): boolean => {
44
+ if (!currentActionId) return false;
45
+ for (const action of actions) {
46
+ if (typeof action === "function") {
47
+ if (resolveActionRefId(action) === currentActionId) return true;
48
+ } else if (action && typeof action === "object") {
49
+ // Namespace import: match any export of the module.
50
+ for (const value of Object.values(action)) {
51
+ if (resolveActionRefId(value) === currentActionId) return true;
52
+ }
53
+ }
54
+ }
55
+ return false;
56
+ };
57
+ }
58
+
18
59
  function paramsEqual(
19
60
  a: Record<string, string>,
20
61
  b: Record<string, string>,
@@ -59,6 +100,14 @@ interface EvaluateRevalidationOptions<TEnv> {
59
100
  stale?: boolean;
60
101
  /** Trace source hint for the revalidation trace */
61
102
  traceSource?: RevalidationTraceEntry["source"];
103
+ /**
104
+ * Override the segment-type-derived default. When set, the value is used as
105
+ * the seed `defaultShouldRevalidate` passed to user revalidate fns and the
106
+ * reason flows into the trace. Callers use this when client-knowledge
107
+ * (e.g. parallel slot not in clientSegmentIds) should dictate the seed
108
+ * instead of the params/method-based heuristic.
109
+ */
110
+ defaultOverride?: { value: boolean; reason: string };
62
111
  }
63
112
 
64
113
  /**
@@ -81,6 +130,7 @@ export async function evaluateRevalidation<TEnv>(
81
130
  actionContext,
82
131
  stale,
83
132
  traceSource,
133
+ defaultOverride,
84
134
  } = options;
85
135
  const nextParams = segment.params || {};
86
136
  const paramsChanged = !paramsEqual(nextParams, prevParams);
@@ -110,7 +160,12 @@ export async function evaluateRevalidation<TEnv>(
110
160
  let defaultShouldRevalidate: boolean;
111
161
  let defaultReason: string;
112
162
 
113
- if (request.method === "POST") {
163
+ if (defaultOverride) {
164
+ // Caller injected the seed (e.g. parallel slot not in clientSegmentIds).
165
+ // Skip the type-derived heuristic — caller knows better in this context.
166
+ defaultShouldRevalidate = defaultOverride.value;
167
+ defaultReason = defaultOverride.reason;
168
+ } else if (request.method === "POST") {
114
169
  // Actions: revalidate segments that belong to the route, skip parent chain
115
170
  if (segment.type === "route") {
116
171
  // Route segment always revalidates on actions
@@ -226,6 +281,7 @@ export async function evaluateRevalidation<TEnv>(
226
281
  slotName: segment.slot,
227
282
  // Action context (only populated when triggered by server action)
228
283
  actionId: actionContext?.actionId,
284
+ isAction: makeIsAction(actionContext?.actionId),
229
285
  actionUrl: actionContext?.actionUrl,
230
286
  actionResult: actionContext?.actionResult,
231
287
  formData: actionContext?.formData,