@rangojs/router 0.0.0-experimental.31 → 0.0.0-experimental.3232cd17

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 (376) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +198 -44
  3. package/dist/bin/rango.js +287 -105
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3248 -1117
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +73 -21
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +107 -1
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +245 -21
  12. package/skills/caching/SKILL.md +302 -6
  13. package/skills/composability/SKILL.md +27 -2
  14. package/skills/css/SKILL.md +76 -0
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +364 -0
  17. package/skills/hooks/SKILL.md +270 -30
  18. package/skills/host-router/SKILL.md +82 -22
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +49 -5
  21. package/skills/layout/SKILL.md +35 -9
  22. package/skills/links/SKILL.md +249 -17
  23. package/skills/loader/SKILL.md +294 -30
  24. package/skills/middleware/SKILL.md +52 -13
  25. package/skills/migrate-nextjs/SKILL.md +584 -0
  26. package/skills/migrate-react-router/SKILL.md +769 -0
  27. package/skills/mime-routes/SKILL.md +27 -0
  28. package/skills/observability/SKILL.md +137 -0
  29. package/skills/parallel/SKILL.md +203 -7
  30. package/skills/prerender/SKILL.md +123 -100
  31. package/skills/rango/SKILL.md +250 -22
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +122 -47
  34. package/skills/route/SKILL.md +97 -5
  35. package/skills/router-setup/SKILL.md +90 -5
  36. package/skills/server-actions/SKILL.md +775 -0
  37. package/skills/streams-and-websockets/SKILL.md +283 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +129 -0
  40. package/skills/testing/bindings.md +89 -0
  41. package/skills/testing/cache-prerender.md +124 -0
  42. package/skills/testing/client-components.md +122 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +92 -0
  45. package/skills/testing/handles.md +129 -0
  46. package/skills/testing/loader.md +128 -0
  47. package/skills/testing/middleware.md +99 -0
  48. package/skills/testing/render-handler.md +121 -0
  49. package/skills/testing/response-routes.md +95 -0
  50. package/skills/testing/reverse-and-types.md +84 -0
  51. package/skills/testing/server-actions.md +107 -0
  52. package/skills/testing/server-tree.md +128 -0
  53. package/skills/testing/setup.md +120 -0
  54. package/skills/typesafety/SKILL.md +329 -27
  55. package/skills/use-cache/SKILL.md +36 -5
  56. package/skills/view-transitions/SKILL.md +294 -0
  57. package/src/__augment-tests__/augment.ts +81 -0
  58. package/src/__augment-tests__/augmented.check.ts +116 -0
  59. package/src/__internal.ts +67 -40
  60. package/src/browser/action-coordinator.ts +53 -36
  61. package/src/browser/action-fence.ts +47 -0
  62. package/src/browser/app-shell.ts +39 -0
  63. package/src/browser/app-version.ts +14 -0
  64. package/src/browser/cookie-name.ts +140 -0
  65. package/src/browser/event-controller.ts +86 -147
  66. package/src/browser/history-state.ts +21 -0
  67. package/src/browser/index.ts +3 -3
  68. package/src/browser/invalidate-client-cache.ts +52 -0
  69. package/src/browser/link-interceptor.ts +4 -0
  70. package/src/browser/navigation-bridge.ts +148 -19
  71. package/src/browser/navigation-client.ts +187 -67
  72. package/src/browser/navigation-store-handle.ts +38 -0
  73. package/src/browser/navigation-store.ts +76 -67
  74. package/src/browser/navigation-transaction.ts +18 -66
  75. package/src/browser/partial-update.ts +123 -94
  76. package/src/browser/prefetch/cache.ts +214 -36
  77. package/src/browser/prefetch/fetch.ts +260 -38
  78. package/src/browser/prefetch/policy.ts +6 -0
  79. package/src/browser/prefetch/queue.ts +126 -20
  80. package/src/browser/prefetch/resource-ready.ts +77 -0
  81. package/src/browser/rango-state.ts +158 -76
  82. package/src/browser/react/Link.tsx +93 -11
  83. package/src/browser/react/NavigationProvider.tsx +115 -34
  84. package/src/browser/react/ScrollRestoration.tsx +10 -6
  85. package/src/browser/react/context.ts +7 -2
  86. package/src/browser/react/filter-segment-order.ts +49 -7
  87. package/src/browser/react/index.ts +0 -48
  88. package/src/browser/react/location-state-shared.ts +166 -8
  89. package/src/browser/react/location-state.ts +39 -14
  90. package/src/browser/react/use-action.ts +6 -15
  91. package/src/browser/react/use-handle.ts +23 -69
  92. package/src/browser/react/use-link-status.ts +0 -4
  93. package/src/browser/react/use-navigation.ts +22 -5
  94. package/src/browser/react/use-params.ts +20 -10
  95. package/src/browser/react/use-reverse.ts +106 -0
  96. package/src/browser/react/use-router.ts +46 -11
  97. package/src/browser/react/use-search-params.ts +0 -5
  98. package/src/browser/react/use-segments.ts +11 -21
  99. package/src/browser/response-adapter.ts +52 -1
  100. package/src/browser/rsc-router.tsx +215 -76
  101. package/src/browser/scroll-restoration.ts +46 -39
  102. package/src/browser/segment-reconciler.ts +36 -9
  103. package/src/browser/segment-structure-assert.ts +2 -2
  104. package/src/browser/server-action-bridge.ts +176 -50
  105. package/src/browser/types.ts +95 -11
  106. package/src/browser/validate-redirect-origin.ts +43 -16
  107. package/src/build/collect-fallback-refs.ts +107 -0
  108. package/src/build/generate-manifest.ts +65 -40
  109. package/src/build/generate-route-types.ts +5 -0
  110. package/src/build/index.ts +8 -2
  111. package/src/build/prefix-tree-utils.ts +123 -0
  112. package/src/build/route-trie.ts +137 -32
  113. package/src/build/route-types/codegen.ts +4 -4
  114. package/src/build/route-types/include-resolution.ts +9 -2
  115. package/src/build/route-types/param-extraction.ts +6 -3
  116. package/src/build/route-types/per-module-writer.ts +7 -4
  117. package/src/build/route-types/router-processing.ts +278 -96
  118. package/src/build/route-types/scan-filter.ts +9 -2
  119. package/src/build/route-types/source-scan.ts +118 -0
  120. package/src/build/runtime-discovery.ts +9 -20
  121. package/src/cache/cache-error.ts +104 -0
  122. package/src/cache/cache-policy.ts +68 -28
  123. package/src/cache/cache-runtime.ts +149 -43
  124. package/src/cache/cache-scope.ts +148 -81
  125. package/src/cache/cache-tag.ts +98 -0
  126. package/src/cache/cf/cf-cache-store.ts +2550 -93
  127. package/src/cache/cf/index.ts +11 -17
  128. package/src/cache/document-cache.ts +78 -27
  129. package/src/cache/handle-snapshot.ts +63 -0
  130. package/src/cache/index.ts +23 -20
  131. package/src/cache/memory-segment-store.ts +136 -37
  132. package/src/cache/profile-registry.ts +6 -30
  133. package/src/cache/read-through-swr.ts +41 -11
  134. package/src/cache/segment-codec.ts +0 -16
  135. package/src/cache/tag-invalidation.ts +230 -0
  136. package/src/cache/taint.ts +55 -0
  137. package/src/cache/types.ts +33 -100
  138. package/src/cache/vercel/index.ts +11 -0
  139. package/src/cache/vercel/vercel-cache-store.ts +799 -0
  140. package/src/client.rsc.tsx +6 -21
  141. package/src/client.tsx +108 -290
  142. package/src/component-utils.ts +19 -0
  143. package/src/context-var.ts +84 -2
  144. package/src/debug.ts +2 -2
  145. package/src/decode-loader-results.ts +36 -0
  146. package/src/defer.ts +196 -0
  147. package/src/deps/ssr.ts +0 -1
  148. package/src/errors.ts +30 -4
  149. package/src/handle.ts +70 -22
  150. package/src/handles/MetaTags.tsx +0 -14
  151. package/src/handles/breadcrumbs.ts +16 -5
  152. package/src/handles/meta.ts +0 -39
  153. package/src/host/cookie-handler.ts +0 -36
  154. package/src/host/errors.ts +0 -24
  155. package/src/host/index.ts +8 -2
  156. package/src/host/pattern-matcher.ts +7 -50
  157. package/src/host/router.ts +107 -99
  158. package/src/host/testing.ts +40 -27
  159. package/src/host/types.ts +37 -4
  160. package/src/host/utils.ts +1 -1
  161. package/src/href-client.ts +137 -22
  162. package/src/index.rsc.ts +52 -26
  163. package/src/index.ts +100 -38
  164. package/src/internal-debug.ts +2 -4
  165. package/src/loader-store.ts +500 -0
  166. package/src/loader.rsc.ts +20 -13
  167. package/src/loader.ts +12 -11
  168. package/src/missing-id-error.ts +68 -0
  169. package/src/network-error-thrower.tsx +1 -6
  170. package/src/outlet-context.ts +1 -1
  171. package/src/outlet-provider.tsx +1 -5
  172. package/src/prerender/param-hash.ts +10 -11
  173. package/src/prerender/store.ts +37 -41
  174. package/src/prerender.ts +198 -82
  175. package/src/redirect-origin.ts +100 -0
  176. package/src/response-utils.ts +37 -0
  177. package/src/reverse.ts +65 -15
  178. package/src/root-error-boundary.tsx +1 -19
  179. package/src/route-content-wrapper.tsx +7 -72
  180. package/src/route-definition/dsl-helpers.ts +437 -274
  181. package/src/route-definition/helper-factories.ts +29 -139
  182. package/src/route-definition/helpers-types.ts +113 -37
  183. package/src/route-definition/index.ts +3 -0
  184. package/src/route-definition/redirect.ts +52 -10
  185. package/src/route-definition/resolve-handler-use.ts +161 -0
  186. package/src/route-definition/use-item-types.ts +32 -0
  187. package/src/route-map-builder.ts +7 -17
  188. package/src/route-types.ts +37 -41
  189. package/src/router/basename.ts +14 -0
  190. package/src/router/content-negotiation.ts +108 -9
  191. package/src/router/error-handling.ts +13 -17
  192. package/src/router/find-match.ts +45 -22
  193. package/src/router/handler-context.ts +83 -41
  194. package/src/router/intercept-resolution.ts +25 -23
  195. package/src/router/lazy-includes.ts +19 -53
  196. package/src/router/loader-resolution.ts +213 -30
  197. package/src/router/logging.ts +5 -8
  198. package/src/router/manifest.ts +49 -45
  199. package/src/router/match-api.ts +121 -205
  200. package/src/router/match-context.ts +0 -22
  201. package/src/router/match-handlers.ts +58 -58
  202. package/src/router/match-middleware/background-revalidation.ts +27 -6
  203. package/src/router/match-middleware/cache-lookup.ts +205 -249
  204. package/src/router/match-middleware/cache-store.ts +45 -32
  205. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  206. package/src/router/match-middleware/segment-resolution.ts +52 -18
  207. package/src/router/match-pipelines.ts +1 -42
  208. package/src/router/match-result.ts +104 -40
  209. package/src/router/metrics.ts +5 -34
  210. package/src/router/middleware-types.ts +13 -142
  211. package/src/router/middleware.ts +173 -143
  212. package/src/router/navigation-snapshot.ts +131 -0
  213. package/src/router/params-util.ts +23 -0
  214. package/src/router/pattern-matching.ts +109 -63
  215. package/src/router/prerender-match.ts +192 -54
  216. package/src/router/preview-match.ts +32 -102
  217. package/src/router/request-classification.ts +276 -0
  218. package/src/router/revalidation.ts +63 -55
  219. package/src/router/route-snapshot.ts +244 -0
  220. package/src/router/router-context.ts +6 -28
  221. package/src/router/router-interfaces.ts +100 -35
  222. package/src/router/router-options.ts +91 -11
  223. package/src/router/router-registry.ts +2 -5
  224. package/src/router/segment-resolution/fresh.ts +242 -75
  225. package/src/router/segment-resolution/helpers.ts +64 -25
  226. package/src/router/segment-resolution/loader-cache.ts +41 -37
  227. package/src/router/segment-resolution/revalidation.ts +456 -372
  228. package/src/router/segment-resolution/static-store.ts +19 -5
  229. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  230. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  231. package/src/router/segment-resolution.ts +4 -1
  232. package/src/router/segment-wrappers.ts +2 -3
  233. package/src/router/state-cookie-name.ts +33 -0
  234. package/src/router/substitute-pattern-params.ts +56 -0
  235. package/src/router/telemetry-otel.ts +0 -20
  236. package/src/router/telemetry.ts +96 -19
  237. package/src/router/timeout.ts +0 -20
  238. package/src/router/trie-matching.ts +91 -46
  239. package/src/router/types.ts +10 -63
  240. package/src/router/url-params.ts +44 -0
  241. package/src/router.ts +134 -43
  242. package/src/rsc/handler-context.ts +3 -2
  243. package/src/rsc/handler.ts +492 -383
  244. package/src/rsc/helpers.ts +162 -46
  245. package/src/rsc/index.ts +1 -1
  246. package/src/rsc/json-route-result.ts +38 -0
  247. package/src/rsc/loader-fetch.ts +23 -3
  248. package/src/rsc/manifest-init.ts +33 -42
  249. package/src/rsc/origin-guard.ts +39 -25
  250. package/src/rsc/progressive-enhancement.ts +30 -3
  251. package/src/rsc/redirect-guard.ts +99 -0
  252. package/src/rsc/response-error.ts +79 -12
  253. package/src/rsc/response-route-handler.ts +90 -63
  254. package/src/rsc/rsc-rendering.ts +56 -54
  255. package/src/rsc/runtime-warnings.ts +23 -10
  256. package/src/rsc/server-action.ts +74 -67
  257. package/src/rsc/ssr-setup.ts +18 -2
  258. package/src/rsc/types.ts +25 -6
  259. package/src/runtime-env.ts +18 -0
  260. package/src/search-params.ts +4 -20
  261. package/src/segment-content-promise.ts +67 -0
  262. package/src/segment-loader-promise.ts +134 -0
  263. package/src/segment-system.tsx +272 -129
  264. package/src/serialize.ts +243 -0
  265. package/src/server/context.ts +309 -61
  266. package/src/server/cookie-store.ts +80 -5
  267. package/src/server/handle-store.ts +26 -24
  268. package/src/server/loader-registry.ts +10 -28
  269. package/src/server/request-context.ts +348 -128
  270. package/src/ssr/index.tsx +23 -15
  271. package/src/static-handler.ts +27 -18
  272. package/src/testing/cache-status.ts +162 -0
  273. package/src/testing/collect-handle.ts +40 -0
  274. package/src/testing/dispatch.ts +618 -0
  275. package/src/testing/dom.entry.ts +22 -0
  276. package/src/testing/e2e/fixture.ts +188 -0
  277. package/src/testing/e2e/index.ts +128 -0
  278. package/src/testing/e2e/matchers.ts +35 -0
  279. package/src/testing/e2e/page-helpers.ts +272 -0
  280. package/src/testing/e2e/parity.ts +387 -0
  281. package/src/testing/e2e/server.ts +195 -0
  282. package/src/testing/flight-matchers.ts +97 -0
  283. package/src/testing/flight-normalize.ts +11 -0
  284. package/src/testing/flight-runtime.d.ts +57 -0
  285. package/src/testing/flight-tree.ts +682 -0
  286. package/src/testing/flight.entry.ts +52 -0
  287. package/src/testing/flight.ts +232 -0
  288. package/src/testing/generated-routes.ts +183 -0
  289. package/src/testing/index.ts +99 -0
  290. package/src/testing/internal/context.ts +348 -0
  291. package/src/testing/internal/flight-client-globals.ts +30 -0
  292. package/src/testing/internal/seed-vars.ts +54 -0
  293. package/src/testing/render-handler.ts +330 -0
  294. package/src/testing/render-route.tsx +566 -0
  295. package/src/testing/run-loader.ts +378 -0
  296. package/src/testing/run-middleware.ts +205 -0
  297. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  298. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  299. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  300. package/src/testing/vitest-stubs/version.ts +5 -0
  301. package/src/testing/vitest.ts +305 -0
  302. package/src/theme/ThemeProvider.tsx +0 -52
  303. package/src/theme/ThemeScript.tsx +0 -6
  304. package/src/theme/constants.ts +0 -12
  305. package/src/theme/index.ts +0 -7
  306. package/src/theme/theme-context.ts +1 -5
  307. package/src/theme/theme-script.ts +0 -14
  308. package/src/theme/use-theme.ts +0 -3
  309. package/src/types/boundaries.ts +0 -35
  310. package/src/types/cache-types.ts +17 -8
  311. package/src/types/error-types.ts +30 -90
  312. package/src/types/global-namespace.ts +54 -41
  313. package/src/types/handler-context.ts +233 -81
  314. package/src/types/index.ts +1 -10
  315. package/src/types/loader-types.ts +44 -15
  316. package/src/types/request-scope.ts +107 -0
  317. package/src/types/route-config.ts +6 -50
  318. package/src/types/route-entry.ts +19 -7
  319. package/src/types/segments.ts +37 -14
  320. package/src/urls/include-helper.ts +33 -70
  321. package/src/urls/index.ts +1 -11
  322. package/src/urls/path-helper-types.ts +58 -11
  323. package/src/urls/path-helper.ts +57 -111
  324. package/src/urls/pattern-types.ts +48 -19
  325. package/src/urls/response-types.ts +25 -22
  326. package/src/urls/type-extraction.ts +58 -139
  327. package/src/urls/urls-function.ts +1 -18
  328. package/src/use-loader.tsx +346 -89
  329. package/src/vite/debug.ts +185 -0
  330. package/src/vite/discovery/bundle-postprocess.ts +36 -38
  331. package/src/vite/discovery/discover-routers.ts +130 -85
  332. package/src/vite/discovery/discovery-errors.ts +194 -0
  333. package/src/vite/discovery/gate-state.ts +171 -0
  334. package/src/vite/discovery/prerender-collection.ts +192 -99
  335. package/src/vite/discovery/route-types-writer.ts +40 -84
  336. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  337. package/src/vite/discovery/state.ts +51 -6
  338. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  339. package/src/vite/index.ts +8 -0
  340. package/src/vite/plugin-types.ts +187 -69
  341. package/src/vite/plugins/cjs-to-esm.ts +8 -18
  342. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  343. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  344. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  345. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  346. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  347. package/src/vite/plugins/expose-action-id.ts +49 -98
  348. package/src/vite/plugins/expose-id-utils.ts +11 -50
  349. package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
  350. package/src/vite/plugins/expose-ids/handler-transform.ts +10 -48
  351. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
  352. package/src/vite/plugins/expose-ids/router-transform.ts +20 -16
  353. package/src/vite/plugins/expose-internal-ids.ts +554 -317
  354. package/src/vite/plugins/performance-tracks.ts +89 -0
  355. package/src/vite/plugins/refresh-cmd.ts +89 -27
  356. package/src/vite/plugins/use-cache-transform.ts +73 -83
  357. package/src/vite/plugins/vercel-output.ts +258 -0
  358. package/src/vite/plugins/version-injector.ts +21 -25
  359. package/src/vite/plugins/version-plugin.ts +41 -20
  360. package/src/vite/plugins/virtual-entries.ts +2 -17
  361. package/src/vite/rango.ts +257 -289
  362. package/src/vite/router-discovery.ts +930 -140
  363. package/src/vite/utils/ast-handler-extract.ts +15 -31
  364. package/src/vite/utils/banner.ts +4 -4
  365. package/src/vite/utils/bundle-analysis.ts +10 -15
  366. package/src/vite/utils/client-chunks.ts +184 -0
  367. package/src/vite/utils/forward-user-plugins.ts +171 -0
  368. package/src/vite/utils/manifest-utils.ts +4 -59
  369. package/src/vite/utils/package-resolution.ts +20 -52
  370. package/src/vite/utils/prerender-utils.ts +27 -29
  371. package/src/vite/utils/shared-utils.ts +92 -42
  372. package/src/browser/action-response-classifier.ts +0 -99
  373. package/src/browser/react/use-client-cache.ts +0 -58
  374. package/src/browser/shallow.ts +0 -40
  375. package/src/handles/index.ts +0 -7
  376. package/src/router/middleware-cookies.ts +0 -55
@@ -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
@@ -32,13 +33,6 @@ export interface ParsedSegment {
32
33
  */
33
34
  export function parsePattern(pattern: string): ParsedSegment[] {
34
35
  const segments: ParsedSegment[] = [];
35
- // Match: /segment where segment can be:
36
- // - static text
37
- // - :param
38
- // - :param?
39
- // - :param(a|b)
40
- // - :param(a|b)?
41
- // - *
42
36
  const segmentRegex =
43
37
  /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
44
38
 
@@ -80,8 +74,14 @@ export function parsePattern(pattern: string): ParsedSegment[] {
80
74
  export interface CompiledPattern {
81
75
  regex: RegExp;
82
76
  paramNames: string[];
83
- optionalParams: Set<string>;
84
77
  hasTrailingSlash: boolean;
78
+ /**
79
+ * Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
80
+ * Validated against the **decoded** param value after regex extraction so
81
+ * a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
82
+ * path's behavior (trie-matching.ts:validateAndBuild).
83
+ */
84
+ constraints?: Record<string, string[]>;
85
85
  }
86
86
 
87
87
  // Module-level cache for compiled patterns. Route patterns are a finite set
@@ -141,7 +141,7 @@ export function compilePattern(pattern: string): CompiledPattern {
141
141
 
142
142
  const segments = parsePattern(normalizedPattern);
143
143
  const paramNames: string[] = [];
144
- const optionalParams = new Set<string>();
144
+ let constraints: Record<string, string[]> | undefined;
145
145
 
146
146
  let regexPattern = "";
147
147
 
@@ -152,14 +152,16 @@ export function compilePattern(pattern: string): CompiledPattern {
152
152
  } else if (segment.type === "param") {
153
153
  paramNames.push(segment.value);
154
154
  const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
155
- const valuePattern = segment.constraint
156
- ? `(${segment.constraint.map(escapeRegex).join("|")})`
157
- : segment.suffix
158
- ? "([^/]+?)"
159
- : "([^/]+)";
155
+ // Constrained params capture anything here; the allowed values are
156
+ // checked post-decode in findMatch so URL-encoded constraint values
157
+ // (e.g. `:lang(en GB)` via `/en%20GB`) still match.
158
+ const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
159
+
160
+ if (segment.constraint) {
161
+ (constraints ??= {})[segment.value] = segment.constraint;
162
+ }
160
163
 
161
164
  if (segment.optional) {
162
- optionalParams.add(segment.value);
163
165
  // Optional: make the whole /segment optional
164
166
  regexPattern += `(?:/${valuePattern}${suffixPattern})?`;
165
167
  } else {
@@ -171,11 +173,24 @@ export function compilePattern(pattern: string): CompiledPattern {
171
173
  }
172
174
  }
173
175
 
174
- // Handle root path
175
176
  if (regexPattern === "") {
176
177
  regexPattern = "/";
177
178
  }
178
179
 
180
+ // Patterns of only optional segments (e.g. `/:locale?`, `/:a?/:b?`) need
181
+ // an explicit `/` alternative so a bare `/` matches the absent form. The
182
+ // optional template `(?:/X)?` matches `/X` or empty string, but pathnames
183
+ // are never empty. Arises from `include("/:locale?", routes)` + inner
184
+ // `path("/")`. Skip when an explicit trailing slash already anchors the
185
+ // match.
186
+ const hasOnlyOptionalSegments =
187
+ !hasTrailingSlash &&
188
+ segments.length > 0 &&
189
+ segments.every((segment) => segment.type === "param" && segment.optional);
190
+ if (hasOnlyOptionalSegments) {
191
+ regexPattern = `(?:/|${regexPattern})`;
192
+ }
193
+
179
194
  // Add trailing slash to regex if pattern has one
180
195
  if (hasTrailingSlash) {
181
196
  regexPattern += "/";
@@ -184,11 +199,36 @@ export function compilePattern(pattern: string): CompiledPattern {
184
199
  return {
185
200
  regex: new RegExp(`^${regexPattern}$`),
186
201
  paramNames,
187
- optionalParams,
188
202
  hasTrailingSlash,
203
+ ...(constraints ? { constraints } : {}),
189
204
  };
190
205
  }
191
206
 
207
+ /**
208
+ * Validate decoded params against a compiled pattern's constraints.
209
+ * Returns false if any constrained param has a non-empty value not in the
210
+ * allowed list. Absent optionals (key missing or `undefined`) are allowed;
211
+ * `""` is also tolerated as "absent" so user-provided params or fixtures
212
+ * that pass empty strings explicitly behave the same way.
213
+ */
214
+ function satisfiesConstraints(
215
+ params: Record<string, string>,
216
+ constraints: Record<string, string[]> | undefined,
217
+ ): boolean {
218
+ if (!constraints) return true;
219
+ for (const name in constraints) {
220
+ const value = params[name];
221
+ if (
222
+ value !== undefined &&
223
+ value !== "" &&
224
+ !constraints[name].includes(value)
225
+ ) {
226
+ return false;
227
+ }
228
+ }
229
+ return true;
230
+ }
231
+
192
232
  /**
193
233
  * Escape special regex characters in a string
194
234
  */
@@ -196,6 +236,27 @@ function escapeRegex(str: string): string {
196
236
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
237
  }
198
238
 
239
+ /**
240
+ * Build the named-params record from a regex match. Optional segments that
241
+ * didn't capture leave the corresponding group `undefined`; we skip those
242
+ * keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
243
+ * keeps the runtime aligned with the `ExtractParams` type and matches the
244
+ * trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
245
+ */
246
+ function buildParamsFromMatch(
247
+ match: RegExpExecArray,
248
+ paramNames: string[],
249
+ ): Record<string, string> {
250
+ const params: Record<string, string> = {};
251
+ paramNames.forEach((name, index) => {
252
+ const captured = match[index + 1];
253
+ if (captured !== undefined) {
254
+ params[name] = safeDecodeURIComponent(captured);
255
+ }
256
+ });
257
+ return params;
258
+ }
259
+
199
260
  /**
200
261
  * Extract the static prefix from a route pattern.
201
262
  * Returns everything before the first param/wildcard.
@@ -212,7 +273,6 @@ function escapeRegex(str: string): string {
212
273
  export function extractStaticPrefix(pattern: string): string {
213
274
  if (!pattern || pattern === "/") return "";
214
275
 
215
- // Find the first occurrence of : or *
216
276
  const paramIndex = pattern.indexOf(":");
217
277
  const wildcardIndex = pattern.indexOf("*");
218
278
 
@@ -226,16 +286,13 @@ export function extractStaticPrefix(pattern: string): string {
226
286
  }
227
287
 
228
288
  if (cutIndex === -1) {
229
- // No params or wildcards - entire pattern is static
230
289
  return pattern;
231
290
  }
232
291
 
233
292
  if (cutIndex === 0) {
234
- // Pattern starts with : or * - no static prefix
235
293
  return "";
236
294
  }
237
295
 
238
- // Find the last / before the param
239
296
  const lastSlash = pattern.lastIndexOf("/", cutIndex - 1);
240
297
  if (lastSlash === -1 || lastSlash === 0) {
241
298
  return "";
@@ -244,11 +301,28 @@ export function extractStaticPrefix(pattern: string): string {
244
301
  return pattern.slice(0, lastSlash);
245
302
  }
246
303
 
304
+ /**
305
+ * Join a URL prefix to a sub-prefix, collapsing the duplicate slash when the
306
+ * base ends with "/" and the sub-prefix starts with "/". This mirrors the
307
+ * canonical join in `include()` (urls/include-helper.ts) and `runWithPrefixes`
308
+ * (server/context.ts) so a nested lazy include's runtime staticPrefix matches
309
+ * the build-time trie's `sp` (e.g. `include("/parent/", …)` containing
310
+ * `include("/child", …)` resolves to `/parent/child`, not `/parent//child`).
311
+ */
312
+ export function joinPrefix(base: string | undefined, prefix: string): string {
313
+ if (!base) return prefix;
314
+ return base.endsWith("/") && prefix.startsWith("/")
315
+ ? base + prefix.slice(1)
316
+ : base + prefix;
317
+ }
318
+
247
319
  /**
248
320
  * Match a pathname against registered routes
249
321
  *
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.
322
+ * Note: Optional params that are absent in the path are omitted from the
323
+ * returned `params` (read as `undefined`), matching the trie matcher and
324
+ * the `ExtractParams<"/:locale?/...">` type. Use the pattern definition to
325
+ * determine which keys are optional.
252
326
  *
253
327
  * Trailing slash handling (priority order):
254
328
  * 1. Per-route `trailingSlash` config from route()
@@ -266,10 +340,7 @@ export interface RouteMatchResult<TEnv = any> {
266
340
  entry: RouteEntry<TEnv>;
267
341
  routeKey: string;
268
342
  params: Record<string, string>;
269
- optionalParams: Set<string>;
270
343
  redirectTo?: string;
271
- /** Ancestry shortCodes for layout pruning (from trie match) */
272
- ancestry?: string[];
273
344
  /** Route has pre-rendered data available (from trie) */
274
345
  pr?: true;
275
346
  /** Passthrough: handler kept for live fallback on unknown params (from trie) */
@@ -347,8 +418,6 @@ export function findMatch<TEnv>(
347
418
  : pathname + "/";
348
419
 
349
420
  for (const entry of routesEntries) {
350
- // Short-circuit: skip entry if pathname doesn't start with static prefix
351
- // staticPrefix is pre-computed at registration time, so this is O(1)
352
421
  if (entry.staticPrefix && !pathname.startsWith(entry.staticPrefix)) {
353
422
  if (effectiveDebug) {
354
423
  debugStats.entriesSkipped++;
@@ -360,8 +429,6 @@ export function findMatch<TEnv>(
360
429
  continue;
361
430
  }
362
431
 
363
- // Check if this is a lazy entry that needs evaluation
364
- // When staticPrefix matches but routes are not yet populated, signal caller to evaluate
365
432
  if (entry.lazy && !entry.lazyEvaluated) {
366
433
  if (effectiveDebug) {
367
434
  debugLog("findMatch", "lazy entry requires evaluation", {
@@ -382,7 +449,6 @@ export function findMatch<TEnv>(
382
449
  debugStats.routesChecked++;
383
450
  }
384
451
 
385
- // Join prefix and pattern, handling edge cases
386
452
  let fullPattern: string;
387
453
  if (entry.prefix === "" || entry.prefix === "/") {
388
454
  fullPattern = pattern;
@@ -392,14 +458,12 @@ export function findMatch<TEnv>(
392
458
  fullPattern = entry.prefix + pattern;
393
459
  }
394
460
 
395
- const { regex, paramNames, optionalParams, hasTrailingSlash } =
461
+ const { regex, paramNames, hasTrailingSlash, constraints } =
396
462
  getCompiledPattern(fullPattern);
397
463
 
398
- // Get trailing slash mode for this route (per-route config or pattern-based)
399
464
  const trailingSlashMode: TrailingSlashMode | undefined =
400
465
  entry.trailingSlash?.[routeKey];
401
466
 
402
- // Prerender flag from entry metadata (set by urls() for prerender handlers)
403
467
  const prFlag = entry.prerenderRouteKeys?.has(routeKey)
404
468
  ? { pr: true as const }
405
469
  : {};
@@ -407,13 +471,13 @@ export function findMatch<TEnv>(
407
471
  ? { pt: true as const }
408
472
  : {};
409
473
 
410
- // Try exact match first
411
474
  const match = regex.exec(pathname);
412
475
  if (match) {
413
- const params: Record<string, string> = {};
414
- paramNames.forEach((name, index) => {
415
- params[name] = match[index + 1] ?? "";
416
- });
476
+ const params = buildParamsFromMatch(match, paramNames);
477
+
478
+ if (!satisfiesConstraints(params, constraints)) {
479
+ continue;
480
+ }
417
481
 
418
482
  if (effectiveDebug) {
419
483
  debugLog("findMatch", "matched route", {
@@ -423,29 +487,24 @@ export function findMatch<TEnv>(
423
487
  });
424
488
  }
425
489
 
426
- // Check if trailing slash mode requires redirect even on exact match
427
490
  if (
428
491
  trailingSlashMode === "always" &&
429
492
  !pathnameHasTrailingSlash &&
430
493
  pathname !== "/"
431
494
  ) {
432
- // Mode says always have trailing slash, but pathname doesn't have it
433
495
  return {
434
496
  entry,
435
497
  routeKey,
436
498
  params,
437
- optionalParams,
438
499
  redirectTo: pathname + "/",
439
500
  ...prFlag,
440
501
  ...ptFlag,
441
502
  };
442
503
  } else if (trailingSlashMode === "never" && pathnameHasTrailingSlash) {
443
- // Mode says never have trailing slash, but pathname has it
444
504
  return {
445
505
  entry,
446
506
  routeKey,
447
507
  params,
448
- optionalParams,
449
508
  redirectTo: pathname.slice(0, -1),
450
509
  ...prFlag,
451
510
  ...ptFlag,
@@ -456,39 +515,33 @@ export function findMatch<TEnv>(
456
515
  entry,
457
516
  routeKey,
458
517
  params,
459
- optionalParams,
460
518
  ...prFlag,
461
519
  ...ptFlag,
462
520
  };
463
521
  }
464
522
 
465
- // Try alternate pathname (opposite trailing slash)
466
523
  const altMatch = regex.exec(alternatePathname);
467
524
  if (altMatch) {
468
- const params: Record<string, string> = {};
469
- paramNames.forEach((name, index) => {
470
- params[name] = altMatch[index + 1] ?? "";
471
- });
525
+ const params = buildParamsFromMatch(altMatch, paramNames);
526
+
527
+ if (!satisfiesConstraints(params, constraints)) {
528
+ continue;
529
+ }
472
530
 
473
- // Determine redirect behavior based on mode
474
531
  if (trailingSlashMode === "ignore") {
475
- // Match without redirect
476
532
  return {
477
533
  entry,
478
534
  routeKey,
479
535
  params,
480
- optionalParams,
481
536
  ...prFlag,
482
537
  ...ptFlag,
483
538
  };
484
539
  } else if (trailingSlashMode === "never") {
485
- // Redirect to no trailing slash
486
540
  if (pathnameHasTrailingSlash) {
487
541
  return {
488
542
  entry,
489
543
  routeKey,
490
544
  params,
491
- optionalParams,
492
545
  redirectTo: alternatePathname,
493
546
  ...prFlag,
494
547
  ...ptFlag,
@@ -498,18 +551,15 @@ export function findMatch<TEnv>(
498
551
  entry,
499
552
  routeKey,
500
553
  params,
501
- optionalParams,
502
554
  ...prFlag,
503
555
  ...ptFlag,
504
556
  };
505
557
  } else if (trailingSlashMode === "always") {
506
- // Redirect to with trailing slash
507
558
  if (!pathnameHasTrailingSlash) {
508
559
  return {
509
560
  entry,
510
561
  routeKey,
511
562
  params,
512
- optionalParams,
513
563
  redirectTo: alternatePathname,
514
564
  ...prFlag,
515
565
  ...ptFlag,
@@ -519,13 +569,10 @@ export function findMatch<TEnv>(
519
569
  entry,
520
570
  routeKey,
521
571
  params,
522
- optionalParams,
523
572
  ...prFlag,
524
573
  ...ptFlag,
525
574
  };
526
575
  } else {
527
- // No explicit mode - use pattern-based detection
528
- // Redirect to canonical form (what the pattern defines)
529
576
  const canonicalPath = hasTrailingSlash
530
577
  ? alternatePathname
531
578
  : pathname.slice(0, -1);
@@ -533,7 +580,6 @@ export function findMatch<TEnv>(
533
580
  entry,
534
581
  routeKey,
535
582
  params,
536
- optionalParams,
537
583
  redirectTo: canonicalPath,
538
584
  ...prFlag,
539
585
  ...ptFlag,
@@ -554,7 +600,7 @@ export function* traverseBack(entry: EntryData): Generator<EntryData> {
554
600
  let current: EntryData | null = entry;
555
601
  const items = [] as EntryData[];
556
602
  while (current !== null) {
557
- items.push(current); // Move up to next parent
603
+ items.push(current);
558
604
  current = current.parent;
559
605
  }
560
606
  for (let i = items.length - 1; i >= 0; i--) {