@rangojs/router 0.0.0-experimental.10 → 0.0.0-experimental.100

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 (329) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +1037 -4
  3. package/dist/bin/rango.js +1619 -157
  4. package/dist/vite/index.js +5762 -2301
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +71 -63
  7. package/skills/breadcrumbs/SKILL.md +252 -0
  8. package/skills/cache-guide/SKILL.md +294 -0
  9. package/skills/caching/SKILL.md +93 -23
  10. package/skills/composability/SKILL.md +172 -0
  11. package/skills/debug-manifest/SKILL.md +12 -8
  12. package/skills/document-cache/SKILL.md +18 -16
  13. package/skills/fonts/SKILL.md +6 -4
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +367 -71
  16. package/skills/host-router/SKILL.md +218 -0
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +176 -8
  19. package/skills/layout/SKILL.md +124 -3
  20. package/skills/links/SKILL.md +304 -25
  21. package/skills/loader/SKILL.md +474 -47
  22. package/skills/middleware/SKILL.md +207 -37
  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 +15 -11
  26. package/skills/parallel/SKILL.md +272 -1
  27. package/skills/prerender/SKILL.md +467 -65
  28. package/skills/rango/SKILL.md +89 -21
  29. package/skills/response-routes/SKILL.md +152 -91
  30. package/skills/route/SKILL.md +305 -14
  31. package/skills/router-setup/SKILL.md +210 -32
  32. package/skills/server-actions/SKILL.md +739 -0
  33. package/skills/streams-and-websockets/SKILL.md +283 -0
  34. package/skills/theme/SKILL.md +9 -8
  35. package/skills/typesafety/SKILL.md +333 -86
  36. package/skills/use-cache/SKILL.md +324 -0
  37. package/skills/view-transitions/SKILL.md +212 -0
  38. package/src/__internal.ts +102 -4
  39. package/src/bin/rango.ts +312 -15
  40. package/src/browser/action-coordinator.ts +97 -0
  41. package/src/browser/action-response-classifier.ts +99 -0
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/app-version.ts +14 -0
  44. package/src/browser/event-controller.ts +136 -68
  45. package/src/browser/history-state.ts +80 -0
  46. package/src/browser/intercept-utils.ts +52 -0
  47. package/src/browser/link-interceptor.ts +24 -4
  48. package/src/browser/logging.ts +55 -0
  49. package/src/browser/merge-segment-loaders.ts +20 -12
  50. package/src/browser/navigation-bridge.ts +374 -561
  51. package/src/browser/navigation-client.ts +228 -70
  52. package/src/browser/navigation-store.ts +97 -55
  53. package/src/browser/navigation-transaction.ts +297 -0
  54. package/src/browser/network-error-handler.ts +61 -0
  55. package/src/browser/partial-update.ts +376 -315
  56. package/src/browser/prefetch/cache.ts +314 -0
  57. package/src/browser/prefetch/fetch.ts +282 -0
  58. package/src/browser/prefetch/observer.ts +65 -0
  59. package/src/browser/prefetch/policy.ts +48 -0
  60. package/src/browser/prefetch/queue.ts +191 -0
  61. package/src/browser/prefetch/resource-ready.ts +77 -0
  62. package/src/browser/rango-state.ts +152 -0
  63. package/src/browser/react/Link.tsx +255 -71
  64. package/src/browser/react/NavigationProvider.tsx +152 -24
  65. package/src/browser/react/context.ts +11 -0
  66. package/src/browser/react/filter-segment-order.ts +55 -0
  67. package/src/browser/react/index.ts +15 -12
  68. package/src/browser/react/location-state-shared.ts +95 -53
  69. package/src/browser/react/location-state.ts +60 -15
  70. package/src/browser/react/mount-context.ts +6 -1
  71. package/src/browser/react/nonce-context.ts +23 -0
  72. package/src/browser/react/shallow-equal.ts +27 -0
  73. package/src/browser/react/use-action.ts +29 -51
  74. package/src/browser/react/use-client-cache.ts +5 -3
  75. package/src/browser/react/use-handle.ts +30 -120
  76. package/src/browser/react/use-link-status.ts +6 -5
  77. package/src/browser/react/use-navigation.ts +44 -65
  78. package/src/browser/react/use-params.ts +78 -0
  79. package/src/browser/react/use-pathname.ts +47 -0
  80. package/src/browser/react/use-reverse.ts +99 -0
  81. package/src/browser/react/use-router.ts +83 -0
  82. package/src/browser/react/use-search-params.ts +56 -0
  83. package/src/browser/react/use-segments.ts +85 -99
  84. package/src/browser/response-adapter.ts +73 -0
  85. package/src/browser/rsc-router.tsx +246 -64
  86. package/src/browser/scroll-restoration.ts +127 -52
  87. package/src/browser/segment-reconciler.ts +243 -0
  88. package/src/browser/segment-structure-assert.ts +16 -0
  89. package/src/browser/server-action-bridge.ts +510 -603
  90. package/src/browser/shallow.ts +6 -1
  91. package/src/browser/types.ts +158 -48
  92. package/src/browser/validate-redirect-origin.ts +29 -0
  93. package/src/build/generate-manifest.ts +84 -23
  94. package/src/build/generate-route-types.ts +39 -828
  95. package/src/build/index.ts +4 -5
  96. package/src/build/route-trie.ts +85 -32
  97. package/src/build/route-types/ast-helpers.ts +25 -0
  98. package/src/build/route-types/ast-route-extraction.ts +98 -0
  99. package/src/build/route-types/codegen.ts +102 -0
  100. package/src/build/route-types/include-resolution.ts +418 -0
  101. package/src/build/route-types/param-extraction.ts +48 -0
  102. package/src/build/route-types/per-module-writer.ts +128 -0
  103. package/src/build/route-types/router-processing.ts +618 -0
  104. package/src/build/route-types/scan-filter.ts +85 -0
  105. package/src/build/runtime-discovery.ts +231 -0
  106. package/src/cache/background-task.ts +34 -0
  107. package/src/cache/cache-key-utils.ts +44 -0
  108. package/src/cache/cache-policy.ts +125 -0
  109. package/src/cache/cache-runtime.ts +342 -0
  110. package/src/cache/cache-scope.ts +167 -307
  111. package/src/cache/cf/cf-cache-store.ts +573 -21
  112. package/src/cache/cf/index.ts +13 -3
  113. package/src/cache/document-cache.ts +116 -77
  114. package/src/cache/handle-capture.ts +81 -0
  115. package/src/cache/handle-snapshot.ts +41 -0
  116. package/src/cache/index.ts +1 -15
  117. package/src/cache/memory-segment-store.ts +191 -13
  118. package/src/cache/profile-registry.ts +73 -0
  119. package/src/cache/read-through-swr.ts +134 -0
  120. package/src/cache/segment-codec.ts +256 -0
  121. package/src/cache/taint.ts +153 -0
  122. package/src/cache/types.ts +72 -122
  123. package/src/client.rsc.tsx +6 -1
  124. package/src/client.tsx +118 -302
  125. package/src/component-utils.ts +4 -4
  126. package/src/components/DefaultDocument.tsx +5 -1
  127. package/src/context-var.ts +156 -0
  128. package/src/debug.ts +19 -9
  129. package/src/errors.ts +77 -7
  130. package/src/handle.ts +55 -10
  131. package/src/handles/MetaTags.tsx +73 -20
  132. package/src/handles/breadcrumbs.ts +66 -0
  133. package/src/handles/index.ts +1 -0
  134. package/src/handles/meta.ts +30 -13
  135. package/src/host/cookie-handler.ts +21 -15
  136. package/src/host/errors.ts +8 -8
  137. package/src/host/index.ts +4 -7
  138. package/src/host/pattern-matcher.ts +27 -27
  139. package/src/host/router.ts +61 -39
  140. package/src/host/testing.ts +8 -8
  141. package/src/host/types.ts +15 -7
  142. package/src/host/utils.ts +1 -1
  143. package/src/href-client.ts +65 -45
  144. package/src/index.rsc.ts +138 -21
  145. package/src/index.ts +206 -51
  146. package/src/internal-debug.ts +11 -0
  147. package/src/loader.rsc.ts +25 -143
  148. package/src/loader.ts +27 -10
  149. package/src/network-error-thrower.tsx +3 -1
  150. package/src/outlet-context.ts +1 -1
  151. package/src/outlet-provider.tsx +45 -0
  152. package/src/prerender/param-hash.ts +4 -2
  153. package/src/prerender/store.ts +159 -13
  154. package/src/prerender.ts +397 -29
  155. package/src/response-utils.ts +28 -0
  156. package/src/reverse.ts +231 -121
  157. package/src/root-error-boundary.tsx +41 -29
  158. package/src/route-content-wrapper.tsx +7 -4
  159. package/src/route-definition/dsl-helpers.ts +1134 -0
  160. package/src/route-definition/helper-factories.ts +200 -0
  161. package/src/route-definition/helpers-types.ts +483 -0
  162. package/src/route-definition/index.ts +55 -0
  163. package/src/route-definition/redirect.ts +101 -0
  164. package/src/route-definition/resolve-handler-use.ts +155 -0
  165. package/src/route-definition.ts +1 -1431
  166. package/src/route-map-builder.ts +162 -123
  167. package/src/route-name.ts +53 -0
  168. package/src/route-types.ts +66 -9
  169. package/src/router/content-negotiation.ts +215 -0
  170. package/src/router/debug-manifest.ts +72 -0
  171. package/src/router/error-handling.ts +9 -9
  172. package/src/router/find-match.ts +160 -0
  173. package/src/router/handler-context.ts +418 -86
  174. package/src/router/intercept-resolution.ts +35 -20
  175. package/src/router/lazy-includes.ts +237 -0
  176. package/src/router/loader-resolution.ts +359 -128
  177. package/src/router/logging.ts +251 -0
  178. package/src/router/manifest.ts +98 -32
  179. package/src/router/match-api.ts +196 -261
  180. package/src/router/match-context.ts +4 -2
  181. package/src/router/match-handlers.ts +441 -0
  182. package/src/router/match-middleware/background-revalidation.ts +108 -93
  183. package/src/router/match-middleware/cache-lookup.ts +415 -86
  184. package/src/router/match-middleware/cache-store.ts +91 -29
  185. package/src/router/match-middleware/intercept-resolution.ts +48 -21
  186. package/src/router/match-middleware/segment-resolution.ts +73 -9
  187. package/src/router/match-pipelines.ts +10 -45
  188. package/src/router/match-result.ts +154 -35
  189. package/src/router/metrics.ts +240 -15
  190. package/src/router/middleware-cookies.ts +55 -0
  191. package/src/router/middleware-types.ts +209 -0
  192. package/src/router/middleware.ts +373 -371
  193. package/src/router/navigation-snapshot.ts +182 -0
  194. package/src/router/pattern-matching.ts +292 -52
  195. package/src/router/prerender-match.ts +502 -0
  196. package/src/router/preview-match.ts +98 -0
  197. package/src/router/request-classification.ts +310 -0
  198. package/src/router/revalidation.ts +152 -39
  199. package/src/router/route-snapshot.ts +245 -0
  200. package/src/router/router-context.ts +41 -21
  201. package/src/router/router-interfaces.ts +484 -0
  202. package/src/router/router-options.ts +618 -0
  203. package/src/router/router-registry.ts +24 -0
  204. package/src/router/segment-resolution/fresh.ts +756 -0
  205. package/src/router/segment-resolution/helpers.ts +268 -0
  206. package/src/router/segment-resolution/loader-cache.ts +199 -0
  207. package/src/router/segment-resolution/revalidation.ts +1407 -0
  208. package/src/router/segment-resolution/static-store.ts +67 -0
  209. package/src/router/segment-resolution.ts +21 -1315
  210. package/src/router/segment-wrappers.ts +291 -0
  211. package/src/router/substitute-pattern-params.ts +56 -0
  212. package/src/router/telemetry-otel.ts +299 -0
  213. package/src/router/telemetry.ts +300 -0
  214. package/src/router/timeout.ts +148 -0
  215. package/src/router/trie-matching.ts +111 -39
  216. package/src/router/types.ts +17 -9
  217. package/src/router/url-params.ts +49 -0
  218. package/src/router.ts +642 -2011
  219. package/src/rsc/handler-context.ts +45 -0
  220. package/src/rsc/handler.ts +864 -1114
  221. package/src/rsc/helpers.ts +181 -19
  222. package/src/rsc/index.ts +0 -20
  223. package/src/rsc/loader-fetch.ts +229 -0
  224. package/src/rsc/manifest-init.ts +90 -0
  225. package/src/rsc/nonce.ts +14 -0
  226. package/src/rsc/origin-guard.ts +141 -0
  227. package/src/rsc/progressive-enhancement.ts +395 -0
  228. package/src/rsc/response-error.ts +37 -0
  229. package/src/rsc/response-route-handler.ts +360 -0
  230. package/src/rsc/rsc-rendering.ts +256 -0
  231. package/src/rsc/runtime-warnings.ts +42 -0
  232. package/src/rsc/server-action.ts +360 -0
  233. package/src/rsc/ssr-setup.ts +128 -0
  234. package/src/rsc/types.ts +52 -11
  235. package/src/search-params.ts +230 -0
  236. package/src/segment-content-promise.ts +67 -0
  237. package/src/segment-loader-promise.ts +122 -0
  238. package/src/segment-system.tsx +187 -38
  239. package/src/server/context.ts +333 -59
  240. package/src/server/cookie-store.ts +190 -0
  241. package/src/server/fetchable-loader-store.ts +37 -0
  242. package/src/server/handle-store.ts +113 -15
  243. package/src/server/loader-registry.ts +24 -64
  244. package/src/server/request-context.ts +603 -109
  245. package/src/server.ts +35 -155
  246. package/src/ssr/index.tsx +107 -30
  247. package/src/static-handler.ts +126 -0
  248. package/src/theme/ThemeProvider.tsx +21 -15
  249. package/src/theme/ThemeScript.tsx +5 -5
  250. package/src/theme/constants.ts +5 -2
  251. package/src/theme/index.ts +4 -14
  252. package/src/theme/theme-context.ts +4 -30
  253. package/src/theme/theme-script.ts +21 -18
  254. package/src/types/boundaries.ts +158 -0
  255. package/src/types/cache-types.ts +198 -0
  256. package/src/types/error-types.ts +192 -0
  257. package/src/types/global-namespace.ts +100 -0
  258. package/src/types/handler-context.ts +764 -0
  259. package/src/types/index.ts +88 -0
  260. package/src/types/loader-types.ts +209 -0
  261. package/src/types/request-scope.ts +126 -0
  262. package/src/types/route-config.ts +170 -0
  263. package/src/types/route-entry.ts +120 -0
  264. package/src/types/segments.ts +167 -0
  265. package/src/types.ts +1 -1757
  266. package/src/urls/include-helper.ts +207 -0
  267. package/src/urls/index.ts +53 -0
  268. package/src/urls/path-helper-types.ts +372 -0
  269. package/src/urls/path-helper.ts +364 -0
  270. package/src/urls/pattern-types.ts +107 -0
  271. package/src/urls/response-types.ts +108 -0
  272. package/src/urls/type-extraction.ts +372 -0
  273. package/src/urls/urls-function.ts +98 -0
  274. package/src/urls.ts +1 -1282
  275. package/src/use-loader.tsx +161 -81
  276. package/src/vite/debug.ts +184 -0
  277. package/src/vite/discovery/bundle-postprocess.ts +181 -0
  278. package/src/vite/discovery/discover-routers.ts +376 -0
  279. package/src/vite/discovery/gate-state.ts +171 -0
  280. package/src/vite/discovery/prerender-collection.ts +486 -0
  281. package/src/vite/discovery/route-types-writer.ts +258 -0
  282. package/src/vite/discovery/self-gen-tracking.ts +73 -0
  283. package/src/vite/discovery/state.ts +117 -0
  284. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  285. package/src/vite/index.ts +15 -2063
  286. package/src/vite/plugin-types.ts +103 -0
  287. package/src/vite/plugins/cjs-to-esm.ts +98 -0
  288. package/src/vite/plugins/client-ref-dedup.ts +131 -0
  289. package/src/vite/plugins/client-ref-hashing.ts +117 -0
  290. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  291. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  292. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  293. package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +107 -64
  294. package/src/vite/plugins/expose-id-utils.ts +299 -0
  295. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  296. package/src/vite/plugins/expose-ids/handler-transform.ts +209 -0
  297. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  298. package/src/vite/plugins/expose-ids/router-transform.ts +127 -0
  299. package/src/vite/plugins/expose-ids/types.ts +45 -0
  300. package/src/vite/plugins/expose-internal-ids.ts +816 -0
  301. package/src/vite/plugins/performance-tracks.ts +96 -0
  302. package/src/vite/plugins/refresh-cmd.ts +127 -0
  303. package/src/vite/plugins/use-cache-transform.ts +336 -0
  304. package/src/vite/plugins/version-injector.ts +109 -0
  305. package/src/vite/plugins/version-plugin.ts +266 -0
  306. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
  307. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  308. package/src/vite/rango.ts +497 -0
  309. package/src/vite/router-discovery.ts +1423 -0
  310. package/src/vite/utils/ast-handler-extract.ts +517 -0
  311. package/src/vite/utils/banner.ts +36 -0
  312. package/src/vite/utils/bundle-analysis.ts +137 -0
  313. package/src/vite/utils/manifest-utils.ts +70 -0
  314. package/src/vite/utils/package-resolution.ts +161 -0
  315. package/src/vite/utils/prerender-utils.ts +222 -0
  316. package/src/vite/utils/shared-utils.ts +170 -0
  317. package/CLAUDE.md +0 -43
  318. package/src/browser/lru-cache.ts +0 -69
  319. package/src/browser/request-controller.ts +0 -164
  320. package/src/cache/memory-store.ts +0 -253
  321. package/src/href-context.ts +0 -33
  322. package/src/router.gen.ts +0 -6
  323. package/src/urls.gen.ts +0 -8
  324. package/src/vite/expose-handle-id.ts +0 -209
  325. package/src/vite/expose-loader-id.ts +0 -426
  326. package/src/vite/expose-location-state-id.ts +0 -177
  327. package/src/vite/expose-prerender-handler-id.ts +0 -429
  328. package/src/vite/package-resolution.ts +0 -125
  329. /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
@@ -6,6 +6,8 @@
6
6
 
7
7
  import type { RouteEntry, TrailingSlashMode } from "../types";
8
8
  import type { EntryData } from "../server/context";
9
+ import { debugLog, isRouterDebugEnabled } from "./logging.js";
10
+ import { safeDecodeURIComponent } from "./url-params.js";
9
11
 
10
12
  /**
11
13
  * Parsed segment info
@@ -15,6 +17,7 @@ export interface ParsedSegment {
15
17
  value: string; // static text, param name, or "*"
16
18
  optional: boolean;
17
19
  constraint?: string[]; // enum values like ["en", "gb"]
20
+ suffix?: string; // literal text after param in same segment (e.g., ".html")
18
21
  }
19
22
 
20
23
  /**
@@ -37,11 +40,22 @@ export function parsePattern(pattern: string): ParsedSegment[] {
37
40
  // - :param(a|b)
38
41
  // - :param(a|b)?
39
42
  // - *
40
- const segmentRegex = /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?|(\*)|([^/]+))/g;
43
+ const segmentRegex =
44
+ /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
41
45
 
42
46
  let match;
43
47
  while ((match = segmentRegex.exec(pattern)) !== null) {
44
- const [, , paramName, , constraint, optional, wildcard, staticText] = match;
48
+ const [
49
+ ,
50
+ ,
51
+ paramName,
52
+ ,
53
+ constraint,
54
+ optional,
55
+ suffix,
56
+ wildcard,
57
+ staticText,
58
+ ] = match;
45
59
 
46
60
  if (wildcard) {
47
61
  segments.push({ type: "wildcard", value: "*", optional: false });
@@ -51,6 +65,7 @@ export function parsePattern(pattern: string): ParsedSegment[] {
51
65
  value: paramName,
52
66
  optional: optional === "?",
53
67
  constraint: constraint ? constraint.split("|") : undefined,
68
+ suffix: suffix || undefined,
54
69
  });
55
70
  } else if (staticText) {
56
71
  segments.push({ type: "static", value: staticText, optional: false });
@@ -60,6 +75,55 @@ export function parsePattern(pattern: string): ParsedSegment[] {
60
75
  return segments;
61
76
  }
62
77
 
78
+ /**
79
+ * Compiled pattern result containing regex, param metadata, and trailing slash info.
80
+ */
81
+ export interface CompiledPattern {
82
+ regex: RegExp;
83
+ paramNames: string[];
84
+ optionalParams: Set<string>;
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[]>;
93
+ }
94
+
95
+ // Module-level cache for compiled patterns. Route patterns are a finite set
96
+ // defined at build time, so this map is bounded by the number of routes.
97
+ const compiledPatternCache = new Map<string, CompiledPattern>();
98
+
99
+ /**
100
+ * Get a compiled pattern from cache or compile and cache it.
101
+ * Avoids O(routes) regex compilations per request in the fallback path.
102
+ */
103
+ export function getCompiledPattern(pattern: string): CompiledPattern {
104
+ let compiled = compiledPatternCache.get(pattern);
105
+ if (compiled) return compiled;
106
+ compiled = compilePattern(pattern);
107
+ compiledPatternCache.set(pattern, compiled);
108
+ return compiled;
109
+ }
110
+
111
+ /**
112
+ * Return the current size of the compiled pattern cache.
113
+ * Exposed for testing.
114
+ */
115
+ export function getPatternCacheSize(): number {
116
+ return compiledPatternCache.size;
117
+ }
118
+
119
+ /**
120
+ * Clear the compiled pattern cache.
121
+ * Exposed for testing.
122
+ */
123
+ export function clearPatternCache(): void {
124
+ compiledPatternCache.clear();
125
+ }
126
+
63
127
  /**
64
128
  * Compile a route pattern to regex
65
129
  *
@@ -77,12 +141,7 @@ export function parsePattern(pattern: string): ParsedSegment[] {
77
141
  * compilePattern("/:locale(en|gb)/blog") // matches /en/blog or /gb/blog
78
142
  * compilePattern("/:locale(en|gb)?/blog") // matches /blog, /en/blog, or /gb/blog
79
143
  */
80
- export function compilePattern(pattern: string): {
81
- regex: RegExp;
82
- paramNames: string[];
83
- optionalParams: Set<string>;
84
- hasTrailingSlash: boolean;
85
- } {
144
+ export function compilePattern(pattern: string): CompiledPattern {
86
145
  // Detect if pattern has trailing slash (but not just "/")
87
146
  const hasTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
88
147
  // Remove trailing slash for parsing (we'll add it back to regex if needed)
@@ -91,6 +150,7 @@ export function compilePattern(pattern: string): {
91
150
  const segments = parsePattern(normalizedPattern);
92
151
  const paramNames: string[] = [];
93
152
  const optionalParams = new Set<string>();
153
+ let constraints: Record<string, string[]> | undefined;
94
154
 
95
155
  let regexPattern = "";
96
156
 
@@ -100,16 +160,22 @@ export function compilePattern(pattern: string): {
100
160
  regexPattern += "/(.*)";
101
161
  } else if (segment.type === "param") {
102
162
  paramNames.push(segment.value);
103
- const valuePattern = segment.constraint
104
- ? `(${segment.constraint.join("|")})`
105
- : "([^/]+)";
163
+ const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
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
+ }
106
172
 
107
173
  if (segment.optional) {
108
174
  optionalParams.add(segment.value);
109
175
  // Optional: make the whole /segment optional
110
- regexPattern += `(?:/${valuePattern})?`;
176
+ regexPattern += `(?:/${valuePattern}${suffixPattern})?`;
111
177
  } else {
112
- regexPattern += `/${valuePattern}`;
178
+ regexPattern += `/${valuePattern}${suffixPattern}`;
113
179
  }
114
180
  } else {
115
181
  // Static segment
@@ -122,6 +188,20 @@ export function compilePattern(pattern: string): {
122
188
  regexPattern = "/";
123
189
  }
124
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
+
125
205
  // Add trailing slash to regex if pattern has one
126
206
  if (hasTrailingSlash) {
127
207
  regexPattern += "/";
@@ -132,9 +212,35 @@ export function compilePattern(pattern: string): {
132
212
  paramNames,
133
213
  optionalParams,
134
214
  hasTrailingSlash,
215
+ ...(constraints ? { constraints } : {}),
135
216
  };
136
217
  }
137
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
+
138
244
  /**
139
245
  * Escape special regex characters in a string
140
246
  */
@@ -142,6 +248,27 @@ function escapeRegex(str: string): string {
142
248
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
143
249
  }
144
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
+
145
272
  /**
146
273
  * Extract the static prefix from a route pattern.
147
274
  * Returns everything before the first param/wildcard.
@@ -193,8 +320,10 @@ export function extractStaticPrefix(pattern: string): string {
193
320
  /**
194
321
  * Match a pathname against registered routes
195
322
  *
196
- * Note: Optional params that are absent in the path will have empty string value.
197
- * Use the pattern definition to determine if a param is optional.
323
+ * Note: Optional params that are absent in the path are omitted from the
324
+ * returned `params` (read as `undefined`), matching the trie matcher and
325
+ * the `ExtractParams<"/:locale?/...">` type. Use the pattern definition or
326
+ * `optionalParams` to determine which keys are optional.
198
327
  *
199
328
  * Trailing slash handling (priority order):
200
329
  * 1. Per-route `trailingSlash` config from route()
@@ -239,7 +368,7 @@ export interface LazyEvaluationNeeded<TEnv = any> {
239
368
  * Type guard to check if result is a lazy evaluation needed response
240
369
  */
241
370
  export function isLazyEvaluationNeeded<TEnv>(
242
- result: RouteMatchResult<TEnv> | LazyEvaluationNeeded<TEnv> | null
371
+ result: RouteMatchResult<TEnv> | LazyEvaluationNeeded<TEnv> | null,
243
372
  ): result is LazyEvaluationNeeded<TEnv> {
244
373
  return result !== null && "lazyEntry" in result;
245
374
  }
@@ -260,22 +389,33 @@ export function enableMatchDebug(enabled: boolean): void {
260
389
  }
261
390
 
262
391
  export function getMatchDebugStats(): MatchDebugStats {
263
- return { entriesChecked: debugStats.entriesChecked, entriesSkipped: debugStats.entriesSkipped, routesChecked: debugStats.routesChecked };
392
+ return {
393
+ entriesChecked: debugStats.entriesChecked,
394
+ entriesSkipped: debugStats.entriesSkipped,
395
+ routesChecked: debugStats.routesChecked,
396
+ };
264
397
  }
265
398
 
266
399
  export function findMatch<TEnv>(
267
400
  pathname: string,
268
- routesEntries: RouteEntry<TEnv>[]
401
+ routesEntries: RouteEntry<TEnv>[],
269
402
  ): RouteMatchResult<TEnv> | LazyEvaluationNeeded<TEnv> | null {
270
- if (debugEnabled) {
403
+ const effectiveDebug = debugEnabled || isRouterDebugEnabled();
404
+
405
+ if (effectiveDebug) {
271
406
  debugStats = { entriesChecked: 0, entriesSkipped: 0, routesChecked: 0 };
272
- console.log(`[findMatch] pathname="${pathname}", entries=${routesEntries.length}`);
407
+ debugLog("findMatch", "start", { pathname, entries: routesEntries.length });
273
408
  for (const e of routesEntries) {
274
- console.log(` entry: prefix="${e.prefix}", staticPrefix="${e.staticPrefix}", routes=${Object.keys(e.routes).length}`);
409
+ debugLog("findMatch", "entry", {
410
+ prefix: e.prefix,
411
+ staticPrefix: e.staticPrefix,
412
+ routeCount: Object.keys(e.routes).length,
413
+ });
275
414
  }
276
415
  }
277
416
 
278
- const pathnameHasTrailingSlash = pathname.length > 1 && pathname.endsWith("/");
417
+ const pathnameHasTrailingSlash =
418
+ pathname.length > 1 && pathname.endsWith("/");
279
419
  // Try alternate pathname for redirect matching
280
420
  const alternatePathname = pathnameHasTrailingSlash
281
421
  ? pathname.slice(0, -1)
@@ -285,9 +425,12 @@ export function findMatch<TEnv>(
285
425
  // Short-circuit: skip entry if pathname doesn't start with static prefix
286
426
  // staticPrefix is pre-computed at registration time, so this is O(1)
287
427
  if (entry.staticPrefix && !pathname.startsWith(entry.staticPrefix)) {
288
- if (debugEnabled) {
428
+ if (effectiveDebug) {
289
429
  debugStats.entriesSkipped++;
290
- console.log(` SKIP entry prefix="${entry.prefix}" (staticPrefix="${entry.staticPrefix}" doesn't match)`);
430
+ debugLog("findMatch", "skipped entry", {
431
+ prefix: entry.prefix,
432
+ staticPrefix: entry.staticPrefix,
433
+ });
291
434
  }
292
435
  continue;
293
436
  }
@@ -295,20 +438,22 @@ export function findMatch<TEnv>(
295
438
  // Check if this is a lazy entry that needs evaluation
296
439
  // When staticPrefix matches but routes are not yet populated, signal caller to evaluate
297
440
  if (entry.lazy && !entry.lazyEvaluated) {
298
- if (debugEnabled) {
299
- console.log(` LAZY entry needs evaluation: staticPrefix="${entry.staticPrefix}"`);
441
+ if (effectiveDebug) {
442
+ debugLog("findMatch", "lazy entry requires evaluation", {
443
+ staticPrefix: entry.staticPrefix,
444
+ });
300
445
  }
301
446
  return { lazyEntry: entry };
302
447
  }
303
448
 
304
- if (debugEnabled) {
449
+ if (effectiveDebug) {
305
450
  debugStats.entriesChecked++;
306
451
  }
307
452
 
308
453
  const routeEntries = Object.entries(entry.routes);
309
454
 
310
455
  for (const [routeKey, pattern] of routeEntries) {
311
- if (debugEnabled) {
456
+ if (effectiveDebug) {
312
457
  debugStats.routesChecked++;
313
458
  }
314
459
 
@@ -322,66 +467,161 @@ export function findMatch<TEnv>(
322
467
  fullPattern = entry.prefix + pattern;
323
468
  }
324
469
 
325
- const { regex, paramNames, optionalParams, hasTrailingSlash } = compilePattern(fullPattern);
470
+ const {
471
+ regex,
472
+ paramNames,
473
+ optionalParams,
474
+ hasTrailingSlash,
475
+ constraints,
476
+ } = getCompiledPattern(fullPattern);
326
477
 
327
478
  // Get trailing slash mode for this route (per-route config or pattern-based)
328
- const trailingSlashMode: TrailingSlashMode | undefined = entry.trailingSlash?.[routeKey];
479
+ const trailingSlashMode: TrailingSlashMode | undefined =
480
+ entry.trailingSlash?.[routeKey];
329
481
 
482
+ // Prerender flag from entry metadata (set by urls() for prerender handlers)
483
+ const prFlag = entry.prerenderRouteKeys?.has(routeKey)
484
+ ? { pr: true as const }
485
+ : {};
486
+ const ptFlag = entry.passthroughRouteKeys?.has(routeKey)
487
+ ? { pt: true as const }
488
+ : {};
330
489
 
331
490
  // Try exact match first
332
491
  const match = regex.exec(pathname);
333
492
  if (match) {
334
- const params: Record<string, string> = {};
335
- paramNames.forEach((name, index) => {
336
- params[name] = match[index + 1] ?? "";
337
- });
493
+ const params = buildParamsFromMatch(match, paramNames);
494
+
495
+ // Validate constraints against decoded values; a failure falls
496
+ // through to the next route so other patterns can still match.
497
+ if (!satisfiesConstraints(params, constraints)) {
498
+ continue;
499
+ }
338
500
 
339
- if (debugEnabled) {
340
- console.log(` MATCH: routeKey="${routeKey}", pattern="${fullPattern}"`);
341
- console.log(` Stats: entriesChecked=${debugStats.entriesChecked}, entriesSkipped=${debugStats.entriesSkipped}, routesChecked=${debugStats.routesChecked}`);
501
+ if (effectiveDebug) {
502
+ debugLog("findMatch", "matched route", {
503
+ routeKey,
504
+ pattern: fullPattern,
505
+ stats: { ...debugStats },
506
+ });
342
507
  }
343
508
 
344
509
  // Check if trailing slash mode requires redirect even on exact match
345
- if (trailingSlashMode === "always" && !pathnameHasTrailingSlash && pathname !== "/") {
510
+ if (
511
+ trailingSlashMode === "always" &&
512
+ !pathnameHasTrailingSlash &&
513
+ pathname !== "/"
514
+ ) {
346
515
  // Mode says always have trailing slash, but pathname doesn't have it
347
- return { entry, routeKey, params, optionalParams, redirectTo: pathname + "/" };
516
+ return {
517
+ entry,
518
+ routeKey,
519
+ params,
520
+ optionalParams,
521
+ redirectTo: pathname + "/",
522
+ ...prFlag,
523
+ ...ptFlag,
524
+ };
348
525
  } else if (trailingSlashMode === "never" && pathnameHasTrailingSlash) {
349
526
  // Mode says never have trailing slash, but pathname has it
350
- return { entry, routeKey, params, optionalParams, redirectTo: pathname.slice(0, -1) };
527
+ return {
528
+ entry,
529
+ routeKey,
530
+ params,
531
+ optionalParams,
532
+ redirectTo: pathname.slice(0, -1),
533
+ ...prFlag,
534
+ ...ptFlag,
535
+ };
351
536
  }
352
537
 
353
- return { entry, routeKey, params, optionalParams };
538
+ return {
539
+ entry,
540
+ routeKey,
541
+ params,
542
+ optionalParams,
543
+ ...prFlag,
544
+ ...ptFlag,
545
+ };
354
546
  }
355
547
 
356
548
  // Try alternate pathname (opposite trailing slash)
357
549
  const altMatch = regex.exec(alternatePathname);
358
550
  if (altMatch) {
359
- const params: Record<string, string> = {};
360
- paramNames.forEach((name, index) => {
361
- params[name] = altMatch[index + 1] ?? "";
362
- });
551
+ const params = buildParamsFromMatch(altMatch, paramNames);
552
+
553
+ if (!satisfiesConstraints(params, constraints)) {
554
+ continue;
555
+ }
363
556
 
364
557
  // Determine redirect behavior based on mode
365
558
  if (trailingSlashMode === "ignore") {
366
559
  // Match without redirect
367
- return { entry, routeKey, params, optionalParams };
560
+ return {
561
+ entry,
562
+ routeKey,
563
+ params,
564
+ optionalParams,
565
+ ...prFlag,
566
+ ...ptFlag,
567
+ };
368
568
  } else if (trailingSlashMode === "never") {
369
569
  // Redirect to no trailing slash
370
570
  if (pathnameHasTrailingSlash) {
371
- return { entry, routeKey, params, optionalParams, redirectTo: alternatePathname };
571
+ return {
572
+ entry,
573
+ routeKey,
574
+ params,
575
+ optionalParams,
576
+ redirectTo: alternatePathname,
577
+ ...prFlag,
578
+ ...ptFlag,
579
+ };
372
580
  }
373
- return { entry, routeKey, params, optionalParams };
581
+ return {
582
+ entry,
583
+ routeKey,
584
+ params,
585
+ optionalParams,
586
+ ...prFlag,
587
+ ...ptFlag,
588
+ };
374
589
  } else if (trailingSlashMode === "always") {
375
590
  // Redirect to with trailing slash
376
591
  if (!pathnameHasTrailingSlash) {
377
- return { entry, routeKey, params, optionalParams, redirectTo: alternatePathname };
592
+ return {
593
+ entry,
594
+ routeKey,
595
+ params,
596
+ optionalParams,
597
+ redirectTo: alternatePathname,
598
+ ...prFlag,
599
+ ...ptFlag,
600
+ };
378
601
  }
379
- return { entry, routeKey, params, optionalParams };
602
+ return {
603
+ entry,
604
+ routeKey,
605
+ params,
606
+ optionalParams,
607
+ ...prFlag,
608
+ ...ptFlag,
609
+ };
380
610
  } else {
381
611
  // No explicit mode - use pattern-based detection
382
612
  // Redirect to canonical form (what the pattern defines)
383
- const canonicalPath = hasTrailingSlash ? alternatePathname : pathname.slice(0, -1);
384
- return { entry, routeKey, params, optionalParams, redirectTo: canonicalPath };
613
+ const canonicalPath = hasTrailingSlash
614
+ ? alternatePathname
615
+ : pathname.slice(0, -1);
616
+ return {
617
+ entry,
618
+ routeKey,
619
+ params,
620
+ optionalParams,
621
+ redirectTo: canonicalPath,
622
+ ...prFlag,
623
+ ...ptFlag,
624
+ };
385
625
  }
386
626
  }
387
627
  }