@nifrajs/core 3.5.0 → 4.0.0

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 (325) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +2 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +26 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +24 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +168 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +40 -8
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +8 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts.map +1 -1
  75. package/dist/manifest.js +41 -37
  76. package/dist/manifest.js.map +1 -1
  77. package/dist/mount.d.ts +36 -0
  78. package/dist/mount.d.ts.map +1 -1
  79. package/dist/mount.js +32 -0
  80. package/dist/mount.js.map +1 -1
  81. package/dist/reflection.d.ts +26 -0
  82. package/dist/reflection.d.ts.map +1 -1
  83. package/dist/reflection.js +65 -1
  84. package/dist/reflection.js.map +1 -1
  85. package/dist/response-observer.d.ts +1 -1
  86. package/dist/response-observer.d.ts.map +1 -1
  87. package/dist/response-observer.js.map +1 -1
  88. package/dist/router/overlap.d.ts +23 -0
  89. package/dist/router/overlap.d.ts.map +1 -0
  90. package/dist/router/overlap.js +224 -0
  91. package/dist/router/overlap.js.map +1 -0
  92. package/dist/router/pattern.d.ts +117 -2
  93. package/dist/router/pattern.d.ts.map +1 -1
  94. package/dist/router/pattern.js +283 -23
  95. package/dist/router/pattern.js.map +1 -1
  96. package/dist/router/router.d.ts +18 -4
  97. package/dist/router/router.d.ts.map +1 -1
  98. package/dist/router/router.js +51 -44
  99. package/dist/router/router.js.map +1 -1
  100. package/dist/server/auth.d.ts +53 -0
  101. package/dist/server/auth.d.ts.map +1 -0
  102. package/dist/server/auth.js +9 -0
  103. package/dist/server/auth.js.map +1 -0
  104. package/dist/server/bare-error-lane.d.ts +12 -7
  105. package/dist/server/bare-error-lane.d.ts.map +1 -1
  106. package/dist/server/bare-error-lane.js +13 -11
  107. package/dist/server/bare-error-lane.js.map +1 -1
  108. package/dist/server/body-lane.d.ts +20 -2
  109. package/dist/server/body-lane.d.ts.map +1 -1
  110. package/dist/server/body-lane.js +26 -7
  111. package/dist/server/body-lane.js.map +1 -1
  112. package/dist/server/body-parser.d.ts +68 -0
  113. package/dist/server/body-parser.d.ts.map +1 -0
  114. package/dist/server/body-parser.js +172 -0
  115. package/dist/server/body-parser.js.map +1 -0
  116. package/dist/server/body.d.ts.map +1 -1
  117. package/dist/server/body.js +62 -7
  118. package/dist/server/body.js.map +1 -1
  119. package/dist/server/client-ip.d.ts +8 -0
  120. package/dist/server/client-ip.d.ts.map +1 -1
  121. package/dist/server/client-ip.js +8 -0
  122. package/dist/server/client-ip.js.map +1 -1
  123. package/dist/server/context.d.ts +71 -6
  124. package/dist/server/context.d.ts.map +1 -1
  125. package/dist/server/contract.d.ts +15 -5
  126. package/dist/server/contract.d.ts.map +1 -1
  127. package/dist/server/contract.js +25 -4
  128. package/dist/server/contract.js.map +1 -1
  129. package/dist/server/core-copies.d.ts +2 -0
  130. package/dist/server/core-copies.d.ts.map +1 -0
  131. package/dist/server/core-copies.js +20 -0
  132. package/dist/server/core-copies.js.map +1 -0
  133. package/dist/server/edge.d.ts.map +1 -1
  134. package/dist/server/edge.js +2 -0
  135. package/dist/server/edge.js.map +1 -1
  136. package/dist/server/http.d.ts +11 -0
  137. package/dist/server/http.d.ts.map +1 -1
  138. package/dist/server/http.js +36 -0
  139. package/dist/server/http.js.map +1 -1
  140. package/dist/server/idempotency-lane.d.ts +6 -0
  141. package/dist/server/idempotency-lane.d.ts.map +1 -1
  142. package/dist/server/idempotency-lane.js +130 -73
  143. package/dist/server/idempotency-lane.js.map +1 -1
  144. package/dist/server/install.d.ts +4 -0
  145. package/dist/server/install.d.ts.map +1 -1
  146. package/dist/server/install.js +4 -0
  147. package/dist/server/install.js.map +1 -1
  148. package/dist/server/methods.d.ts +45 -0
  149. package/dist/server/methods.d.ts.map +1 -0
  150. package/dist/server/methods.js +37 -0
  151. package/dist/server/methods.js.map +1 -0
  152. package/dist/server/multipart.d.ts +63 -0
  153. package/dist/server/multipart.d.ts.map +1 -0
  154. package/dist/server/multipart.js +374 -0
  155. package/dist/server/multipart.js.map +1 -0
  156. package/dist/server/node-outcome-hook.d.ts +20 -0
  157. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  158. package/dist/server/node-outcome-hook.js +15 -6
  159. package/dist/server/node-outcome-hook.js.map +1 -1
  160. package/dist/server/not-found-answer.d.ts +40 -0
  161. package/dist/server/not-found-answer.d.ts.map +1 -0
  162. package/dist/server/not-found-answer.js +56 -0
  163. package/dist/server/not-found-answer.js.map +1 -0
  164. package/dist/server/not-found.d.ts +23 -0
  165. package/dist/server/not-found.d.ts.map +1 -0
  166. package/dist/server/not-found.js +81 -0
  167. package/dist/server/not-found.js.map +1 -0
  168. package/dist/server/plugin.d.ts +26 -1
  169. package/dist/server/plugin.d.ts.map +1 -1
  170. package/dist/server/plugin.js.map +1 -1
  171. package/dist/server/proto-guard.d.ts +18 -5
  172. package/dist/server/proto-guard.d.ts.map +1 -1
  173. package/dist/server/proto-guard.js +114 -9
  174. package/dist/server/proto-guard.js.map +1 -1
  175. package/dist/server/query.d.ts +7 -0
  176. package/dist/server/query.d.ts.map +1 -1
  177. package/dist/server/query.js +47 -0
  178. package/dist/server/query.js.map +1 -1
  179. package/dist/server/registry.d.ts +13 -2
  180. package/dist/server/registry.d.ts.map +1 -1
  181. package/dist/server/request-context.d.ts +11 -0
  182. package/dist/server/request-context.d.ts.map +1 -1
  183. package/dist/server/request-context.js +33 -19
  184. package/dist/server/request-context.js.map +1 -1
  185. package/dist/server/request-lineage.d.ts +8 -0
  186. package/dist/server/request-lineage.d.ts.map +1 -0
  187. package/dist/server/request-lineage.js +14 -0
  188. package/dist/server/request-lineage.js.map +1 -0
  189. package/dist/server/request-target.d.ts +31 -0
  190. package/dist/server/request-target.d.ts.map +1 -0
  191. package/dist/server/request-target.js +44 -0
  192. package/dist/server/request-target.js.map +1 -0
  193. package/dist/server/respond.d.ts +1 -1
  194. package/dist/server/respond.d.ts.map +1 -1
  195. package/dist/server/respond.js +16 -4
  196. package/dist/server/respond.js.map +1 -1
  197. package/dist/server/response-contract-lane.d.ts +19 -1
  198. package/dist/server/response-contract-lane.d.ts.map +1 -1
  199. package/dist/server/response-contract-lane.js +59 -14
  200. package/dist/server/response-contract-lane.js.map +1 -1
  201. package/dist/server/response-observer-runtime.d.ts +9 -5
  202. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  203. package/dist/server/response-observer-runtime.js +10 -6
  204. package/dist/server/response-observer-runtime.js.map +1 -1
  205. package/dist/server/runtime-core.d.ts +2 -0
  206. package/dist/server/runtime-core.d.ts.map +1 -1
  207. package/dist/server/runtime-core.js.map +1 -1
  208. package/dist/server/server-types.d.ts +53 -3
  209. package/dist/server/server-types.d.ts.map +1 -1
  210. package/dist/server/server.d.ts +191 -19
  211. package/dist/server/server.d.ts.map +1 -1
  212. package/dist/server/server.js +1206 -218
  213. package/dist/server/server.js.map +1 -1
  214. package/dist/server/sse.d.ts.map +1 -1
  215. package/dist/server/sse.js +10 -7
  216. package/dist/server/sse.js.map +1 -1
  217. package/dist/server/static-headers.d.ts +3 -0
  218. package/dist/server/static-headers.d.ts.map +1 -1
  219. package/dist/server/static-headers.js +19 -1
  220. package/dist/server/static-headers.js.map +1 -1
  221. package/dist/server/transport-lane.d.ts +8 -0
  222. package/dist/server/transport-lane.d.ts.map +1 -1
  223. package/dist/server/transport-lane.js +9 -8
  224. package/dist/server/transport-lane.js.map +1 -1
  225. package/dist/server/validation.d.ts.map +1 -1
  226. package/dist/server/validation.js +5 -1
  227. package/dist/server/validation.js.map +1 -1
  228. package/dist/server/websocket.d.ts +10 -3
  229. package/dist/server/websocket.d.ts.map +1 -1
  230. package/dist/server/websocket.js +7 -4
  231. package/dist/server/websocket.js.map +1 -1
  232. package/dist/server/ws-bun.d.ts +2 -0
  233. package/dist/server/ws-bun.d.ts.map +1 -1
  234. package/dist/server/ws-bun.js +6 -3
  235. package/dist/server/ws-bun.js.map +1 -1
  236. package/dist/server/ws-hook.d.ts +2 -1
  237. package/dist/server/ws-hook.d.ts.map +1 -1
  238. package/dist/server.d.ts +7 -4
  239. package/dist/server.d.ts.map +1 -1
  240. package/dist/server.js +4 -1
  241. package/dist/server.js.map +1 -1
  242. package/dist/single-copy-register.js +5 -0
  243. package/dist/single-copy-register.js.map +1 -1
  244. package/dist/single-copy.d.ts +46 -3
  245. package/dist/single-copy.d.ts.map +1 -1
  246. package/dist/single-copy.js +125 -12
  247. package/dist/single-copy.js.map +1 -1
  248. package/dist/tool-contract.d.ts +23 -0
  249. package/dist/tool-contract.d.ts.map +1 -1
  250. package/dist/tool-contract.js +37 -3
  251. package/dist/tool-contract.js.map +1 -1
  252. package/dist/transport-codec.d.ts.map +1 -1
  253. package/dist/transport-codec.js +2 -1
  254. package/dist/transport-codec.js.map +1 -1
  255. package/package.json +27 -2
  256. package/src/assurance.ts +52 -2
  257. package/src/capabilities.ts +81 -22
  258. package/src/diff.ts +31 -6
  259. package/src/durable-adapters.ts +20 -3
  260. package/src/durable-effect.ts +10 -3
  261. package/src/edge-kit.ts +7 -0
  262. package/src/effect-lifecycle.ts +5 -1
  263. package/src/effect-scope.ts +2 -1
  264. package/src/errors.ts +3 -0
  265. package/src/evidence.ts +237 -12
  266. package/src/idempotency.ts +72 -8
  267. package/src/index.ts +40 -3
  268. package/src/internal/code-unit-order.ts +8 -0
  269. package/src/internal/durable-saga-protocol.ts +2 -0
  270. package/src/internal/route-catalog.ts +19 -18
  271. package/src/internal/route-compiler.ts +62 -13
  272. package/src/internal/route-execution.ts +13 -3
  273. package/src/internal/route-lanes.ts +37 -28
  274. package/src/internal/route-program.ts +434 -20
  275. package/src/ledger.ts +11 -5
  276. package/src/manifest.ts +41 -36
  277. package/src/mount.ts +62 -0
  278. package/src/reflection.ts +84 -1
  279. package/src/response-observer.ts +1 -0
  280. package/src/router/overlap.ts +258 -0
  281. package/src/router/pattern.ts +349 -25
  282. package/src/router/router.ts +76 -51
  283. package/src/server/auth.ts +75 -0
  284. package/src/server/bare-error-lane.ts +12 -11
  285. package/src/server/body-lane.ts +46 -6
  286. package/src/server/body-parser.ts +241 -0
  287. package/src/server/body.ts +82 -9
  288. package/src/server/client-ip.ts +9 -0
  289. package/src/server/context.ts +219 -12
  290. package/src/server/contract.ts +54 -9
  291. package/src/server/core-copies.ts +21 -0
  292. package/src/server/edge.ts +2 -0
  293. package/src/server/http.ts +34 -0
  294. package/src/server/idempotency-lane.ts +147 -72
  295. package/src/server/install.ts +6 -0
  296. package/src/server/methods.ts +143 -0
  297. package/src/server/multipart.ts +424 -0
  298. package/src/server/node-outcome-hook.ts +40 -7
  299. package/src/server/not-found-answer.ts +109 -0
  300. package/src/server/not-found.ts +121 -0
  301. package/src/server/plugin.ts +45 -1
  302. package/src/server/proto-guard.ts +128 -9
  303. package/src/server/query.ts +49 -0
  304. package/src/server/registry.ts +17 -2
  305. package/src/server/request-context.ts +44 -16
  306. package/src/server/request-lineage.ts +15 -0
  307. package/src/server/request-target.ts +46 -0
  308. package/src/server/respond.ts +14 -3
  309. package/src/server/response-contract-lane.ts +97 -16
  310. package/src/server/response-observer-runtime.ts +37 -8
  311. package/src/server/runtime-core.ts +2 -0
  312. package/src/server/server-types.ts +59 -2
  313. package/src/server/server.ts +1613 -269
  314. package/src/server/sse.ts +10 -8
  315. package/src/server/static-headers.ts +22 -1
  316. package/src/server/transport-lane.ts +17 -9
  317. package/src/server/validation.ts +7 -1
  318. package/src/server/websocket.ts +23 -9
  319. package/src/server/ws-bun.ts +8 -3
  320. package/src/server/ws-hook.ts +2 -1
  321. package/src/server.ts +38 -2
  322. package/src/single-copy-register.ts +5 -0
  323. package/src/single-copy.ts +160 -12
  324. package/src/tool-contract.ts +66 -3
  325. package/src/transport-codec.ts +2 -1
@@ -11,10 +11,54 @@ export type RoutePatternSegment =
11
11
  /** A segment that is part literal, part parameter: `:key.txt`, `feed.:format`, `v:major.:minor`. */
12
12
  | { readonly kind: "mixed"; readonly parts: readonly MixedPart[] }
13
13
 
14
- /** One piece of a {@link RoutePatternSegment} of kind `mixed`, in left-to-right order. */
14
+ /**
15
+ * One piece of a {@link RoutePatternSegment} of kind `mixed`, in left-to-right order. A parameter
16
+ * written with a constraint (`:id{[0-9]+}`) carries it as `c`; a segment that is one constrained
17
+ * parameter and nothing else is a `mixed` segment with that single part.
18
+ */
15
19
  export type MixedPart =
16
20
  | { readonly t: "lit"; readonly v: string }
17
- | { readonly t: "param"; readonly name: string }
21
+ | { readonly t: "param"; readonly name: string; readonly c?: ParamConstraint | undefined }
22
+
23
+ /**
24
+ * What a constrained parameter accepts, parsed from the `{...}` after its name.
25
+ *
26
+ * A constraint is one of two things:
27
+ * - a character class with an optional count: `[0-9]+`, `[a-z0-9-]{3,32}`, `\d{4}`, `\w+`. The class
28
+ * is `[...]` holding characters, `x-y` ranges, `\d` and `\w`, or a bare `\d` / `\w`. The count is
29
+ * `+` (one or more), `{n}`, `{n,}` or `{n,m}` with `n` at least 1; with no count the value is
30
+ * exactly one character.
31
+ * - a list of two or more literal values: `en|fr|de`.
32
+ *
33
+ * Anything else in braces is not a constraint, and the braces stay the literal text they always were.
34
+ *
35
+ * A value is tested as it appears in the request path, before percent-decoding, and only ASCII can
36
+ * satisfy a constraint. `%` is never accepted, so a value that passes holds no escape and reaches the
37
+ * handler as exactly the text that was tested.
38
+ */
39
+ export interface ParamConstraint {
40
+ /** The text between the braces, as written. */
41
+ readonly source: string
42
+ /** The accepted values, for a list (`en|fr|de`), in the order written. */
43
+ readonly oneOf?: readonly string[] | undefined
44
+ /** The same values as the keys of an object with no prototype, which is what a value is looked up in. */
45
+ readonly index?: Readonly<Record<string, 1>> | undefined
46
+ /**
47
+ * For a class: which ASCII codes it holds. 128 characters, the one at index `code` being `1` when
48
+ * the class holds that code and `0` when it does not. All `0` for a list.
49
+ */
50
+ readonly mask: string
51
+ /** For a class: the fewest and the most characters a value may have. */
52
+ readonly min: number
53
+ readonly max: number
54
+ /** How many characters the class holds, or how many values the list has. */
55
+ readonly size: number
56
+ /**
57
+ * What the constraint accepts, spelled one way: `\d+` and `[0-9]+` share a key, and so do `a|b`
58
+ * and `b|a`. Two parameters with one key are the same parameter to the router.
59
+ */
60
+ readonly key: string
61
+ }
18
62
 
19
63
  /**
20
64
  * Compiled route grammar shared by runtime routers, browser navigation, mocks, and adapters.
@@ -34,32 +78,128 @@ export type RoutePatternMatch =
34
78
  | { readonly matched: true; readonly params: Record<string, string> }
35
79
  | { readonly matched: false; readonly reason: "not-found" | "malformed" }
36
80
 
81
+ /** What {@link matchRoutePattern} needs per compiled pattern: the whole-path regex, and for each mixed
82
+ * segment its shape and, when it has a constraint, its parts (both indexed like `segments`,
83
+ * `undefined` elsewhere). */
84
+ interface PatternMatcher {
85
+ readonly regex: RegExp
86
+ readonly shapes: readonly (MixedSegmentShape | undefined)[]
87
+ readonly checked: readonly (readonly MixedPart[] | undefined)[]
88
+ }
89
+
37
90
  /**
38
- * Regex per compiled pattern, derived on first use. The core trie matches by descending segments and
39
- * never asks for one, so building it during {@link compileRoutePattern} would charge every server's
40
- * boot for the browser/mock adapters alone. Keyed by the frozen pattern, so the cache dies with it.
91
+ * Matcher per compiled pattern, derived on first use. The core trie matches by descending segments
92
+ * and never asks for one, so building it during {@link compileRoutePattern} would charge every
93
+ * server's boot for the browser/mock adapters alone. Keyed by the frozen pattern, so the cache dies
94
+ * with it.
41
95
  */
42
- const REGEX_CACHE = new WeakMap<CompiledRoutePattern, RegExp>()
96
+ const MATCHER_CACHE = new WeakMap<CompiledRoutePattern, PatternMatcher>()
43
97
 
44
- function regexOf(compiled: CompiledRoutePattern): RegExp {
45
- let regex = REGEX_CACHE.get(compiled)
46
- if (regex === undefined) {
98
+ function matcherOf(compiled: CompiledRoutePattern): PatternMatcher {
99
+ let matcher = MATCHER_CACHE.get(compiled)
100
+ if (matcher === undefined) {
47
101
  if (compiled[COMPILED_ROUTE_PATTERN] !== true) {
48
102
  throw new TypeError("route pattern was not produced by compileRoutePattern()")
49
103
  }
104
+ // A mixed segment is captured WHOLE here and taken apart by `matchMixedSegment` afterwards. Its
105
+ // parameters never become separate lazy groups in this regex: several of those in one segment
106
+ // make the engine retry every split of the text between them, and the text is the request's.
50
107
  const parts = compiled.segments.map((segment) =>
51
108
  segment.kind === "static"
52
109
  ? escapeRegex(segment.value)
53
- : segment.kind === "param"
54
- ? "([^/]+)"
55
- : segment.kind === "mixed"
56
- ? mixedSegmentSource(segment.parts)
57
- : "(.+)",
110
+ : segment.kind === "wildcard"
111
+ ? "(.+)"
112
+ : "([^/]+)",
58
113
  )
59
- regex = new RegExp(parts.length === 0 ? "^/$" : `^/${parts.join("/")}$`)
60
- REGEX_CACHE.set(compiled, regex)
114
+ matcher = {
115
+ regex: new RegExp(parts.length === 0 ? "^/$" : `^/${parts.join("/")}$`),
116
+ shapes: compiled.segments.map((segment) =>
117
+ segment.kind === "mixed" ? mixedSegmentShape(segment.parts) : undefined,
118
+ ),
119
+ checked: compiled.segments.map((segment) =>
120
+ segment.kind === "mixed" ? constrainedParts(segment.parts) : undefined,
121
+ ),
122
+ }
123
+ MATCHER_CACHE.set(compiled, matcher)
61
124
  }
62
- return regex
125
+ return matcher
126
+ }
127
+
128
+ // No pattern text ever becomes a RegExp. This recognises the constraint grammar, anchored at a `{`:
129
+ // group 1 is a class, 2 its count (3 and 4 the bounds), 5 a list of values.
130
+ const CONSTRAINT =
131
+ /^\{(?:(\[(?:[\w.~!$&'()+,;=@-]|\\[dw])+]|\\[dw])(\+|\{(\d+)(?:,(\d*))?})?|([\w.~-]+(?:\|[\w.~-]+)+))}/
132
+
133
+ /**
134
+ * Parse the constraint `text` starts with, or `undefined` when it starts with none. `text` begins at
135
+ * the `{` that follows a parameter name; the constraint read is `source.length + 2` characters long.
136
+ */
137
+ export function paramConstraint(text: string): ParamConstraint | undefined {
138
+ const found = CONSTRAINT.exec(text)
139
+ if (found === null) return undefined
140
+ const oneOf = found[5]?.split("|")
141
+ const table: number[] = new Array(128).fill(0)
142
+ let min = 1
143
+ let max = 1
144
+ const set = (from: number, to: number): unknown => table.fill(1, from, to + 1)
145
+ // A list has no class to read: `body` is empty for it, and so is the count.
146
+ const body = found[1] ?? ""
147
+ const members = body.length > 2 ? body.slice(1, -1) : body
148
+ for (let i = 0; i < members.length; i++) {
149
+ const from = members.charCodeAt(i)
150
+ let to = from
151
+ if (from === 92 /* \ */) {
152
+ if (members[++i] === "w") {
153
+ set(65, 90)
154
+ set(95, 95)
155
+ set(97, 122)
156
+ }
157
+ set(48, 57)
158
+ continue
159
+ }
160
+ // `x-y` is a range when something follows the hyphen; a hyphen at either end is itself.
161
+ if (members[i + 1] === "-" && i + 2 < members.length) {
162
+ i += 2
163
+ to = members.charCodeAt(i)
164
+ // A range that takes in `%` would let a percent-escape through, and the handler would then
165
+ // see a value other than the one tested.
166
+ if (to === 92 || to < from || (from < 37 && to > 37)) return undefined
167
+ }
168
+ set(from, to)
169
+ }
170
+ if (found[2] !== undefined) {
171
+ min = Number(found[3] ?? 1)
172
+ max = found[2] === "+" || found[4] === "" ? Infinity : Number(found[4] ?? found[3])
173
+ if (min < 1 || max < min) return undefined
174
+ }
175
+ const mask = table.join("")
176
+ let index: Record<string, 1> | undefined
177
+ if (oneOf) {
178
+ index = Object.create(null) as Record<string, 1>
179
+ for (const value of oneOf) index[value] = 1
180
+ }
181
+ return Object.freeze({
182
+ source: found[0].slice(1, -1),
183
+ oneOf: oneOf && Object.freeze(oneOf),
184
+ index: index && Object.freeze(index),
185
+ mask,
186
+ min,
187
+ max,
188
+ size: oneOf ? oneOf.length : mask.split("1").length - 1,
189
+ key: oneOf ? [...oneOf].sort().join("|") : `${mask},${min},${max}`,
190
+ })
191
+ }
192
+
193
+ // Runs per request: lookups hit a string or a frozen object, as a frozen array reads several times slower.
194
+ function satisfies(constraint: ParamConstraint | undefined, value: string): boolean {
195
+ if (!constraint) return true
196
+ if (constraint.index) return constraint.index[value] === 1
197
+ let i = value.length
198
+ if (i < constraint.min || i > constraint.max) return false
199
+ const mask = constraint.mask
200
+ // A code past 127 reads outside the mask, which is `NaN` and so not a `1`.
201
+ while (i-- > 0) if (mask.charCodeAt(value.charCodeAt(i)) !== 49 /* 1 */) return false
202
+ return true
63
203
  }
64
204
 
65
205
  function validParamName(name: string): boolean {
@@ -131,16 +271,26 @@ function splitMixed(value: string): MixedPart[] | undefined {
131
271
  parts.push({ t: "lit", v: literal })
132
272
  literal = ""
133
273
  }
134
- parts.push({ t: "param", name })
274
+ // A constraint belongs to the parameter; braces that are not one stay literal text.
275
+ const constraint = paramConstraint(value.slice(paramEnd))
276
+ parts.push({ t: "param", name, c: constraint })
135
277
  sawParam = true
136
- i += name.length
278
+ i = paramEnd - 1 + (constraint ? constraint.source.length + 2 : 0)
137
279
  }
138
280
  if (!sawParam) return undefined
139
281
  if (literal !== "") parts.push({ t: "lit", v: literal })
140
282
  return parts
141
283
  }
142
284
 
143
- /** The regex source matching one segment's worth of a mixed pattern, with a capture per parameter. */
285
+ /**
286
+ * A canonical string for one mixed segment's shape: the anchored-regex source that describes what the
287
+ * segment accepts, with a capture per parameter. Two segments with the same source are the same shape.
288
+ *
289
+ * It is an identity and an ordering key. Matching goes through {@link matchMixedSegment}, which accepts
290
+ * exactly what this source describes without compiling it - for a segment with no constraint. A
291
+ * constrained parameter's capture is its constraint, and there the scanner accepts less than the
292
+ * source describes: it places the literals as if nothing were constrained and then tests each value.
293
+ */
144
294
  export function mixedSegmentSource(parts: readonly MixedPart[]): string {
145
295
  let source = ""
146
296
  for (const part of parts) {
@@ -148,11 +298,150 @@ export function mixedSegmentSource(parts: readonly MixedPart[]): string {
148
298
  // `/abc.txt` would capture `abc.txt` and then fail to match `\.txt`. With `^…$` anchoring, the
149
299
  // lazy form still yields `abc.txt` for `/abc.txt.txt` - the anchor forces the LAST `.txt` to be
150
300
  // the literal. `+?` and not `*?`: an empty capture must not match (see the empty-segment rule).
151
- source += part.t === "lit" ? escapeRegex(part.v) : "([^/]+?)"
301
+ source +=
302
+ part.t === "lit"
303
+ ? escapeRegex(part.v)
304
+ : part.c === undefined
305
+ ? "([^/]+?)"
306
+ : `(${part.c.oneOf?.map(escapeRegex).join("|") ?? part.c.source})`
152
307
  }
153
308
  return source
154
309
  }
155
310
 
311
+ /**
312
+ * A mixed segment laid out for matching: the literals around its parameters, in order. The first
313
+ * entry is the literal before the first parameter, and each entry after it is the literal following
314
+ * the next parameter, so a segment with N parameters has N + 1 entries. An entry is `""` where the
315
+ * segment has no literal in that position - at either end, or between two parameters that touch.
316
+ *
317
+ * `/v:major.:minor` is `["v", ".", ""]`; `/:name.json` is `["", ".json"]`.
318
+ */
319
+ export type MixedSegmentShape = readonly string[]
320
+
321
+ /** Lay a mixed segment's parts out as a {@link MixedSegmentShape}. Done once, at registration. */
322
+ export function mixedSegmentShape(parts: readonly MixedPart[]): MixedSegmentShape {
323
+ // Two literals are never adjacent, so a literal fills the open slot and a parameter opens the next.
324
+ const shape = [""]
325
+ for (const part of parts) {
326
+ if (part.t === "lit") shape[shape.length - 1] = part.v
327
+ else shape.push("")
328
+ }
329
+ return shape
330
+ }
331
+
332
+ /**
333
+ * The parts of a mixed segment when at least one of its parameters has a constraint, else
334
+ * `undefined`. It is the fourth argument to {@link matchMixedSegment}; done once, at registration.
335
+ */
336
+ export function constrainedParts(parts: readonly MixedPart[]): readonly MixedPart[] | undefined {
337
+ return parts.some((part) => part.t === "param" && part.c) ? parts : undefined
338
+ }
339
+
340
+ /**
341
+ * Match ONE path segment against a mixed shape, in a single left-to-right pass.
342
+ *
343
+ * On a match the captures are appended to `out` in parameter order and the result is `true`. On a miss
344
+ * `out` is left exactly as it was and the result is `false`.
345
+ *
346
+ * The rule: the leading literal must start the segment and the trailing literal must end it; every
347
+ * parameter takes at least one character; and each literal between two parameters is taken at its
348
+ * FIRST occurrence that leaves the parameter before it non-empty. The last parameter takes what
349
+ * remains. Two parameters with nothing between them give the first one a single character.
350
+ *
351
+ * That is the match an anchored pattern with lazy captures selects, reached without trying the
352
+ * alternatives: taking a literal earlier only ever hands more text to the parameter after it, so if
353
+ * any placement matches, the earliest one does. The work is therefore bounded by the segment's length
354
+ * however many parameters the shape has - a segment is request-controlled and must not be able to buy
355
+ * more than one pass.
356
+ *
357
+ * Constraints are tested last, on the values that rule produced: pass `checked` (from
358
+ * {@link constrainedParts}) and a value that fails its parameter's constraint is a miss. A failed
359
+ * test never moves a literal to a later occurrence to try again, so the cost stays one pass to place
360
+ * the literals and one over the values.
361
+ *
362
+ * `segment` must not contain `/`.
363
+ */
364
+ export function matchMixedSegment(
365
+ shape: MixedSegmentShape,
366
+ segment: string,
367
+ out: string[],
368
+ checked?: readonly MixedPart[],
369
+ ): boolean {
370
+ const last = shape.length - 1
371
+ const head = shape[0]!
372
+ const tail = shape[last]!
373
+ let at = head.length
374
+ const end = segment.length - tail.length
375
+ // The first parameter needs a character between the two end literals. This also refuses end
376
+ // literals that would overlap in the text (`a.:x.b` against `a.b`).
377
+ if (at >= end) return false
378
+ if (at !== 0 && !segment.startsWith(head)) return false
379
+ if (end !== segment.length && !segment.endsWith(tail)) return false
380
+ const base = out.length
381
+ for (let i = 1; i < last; i++) {
382
+ const separator = shape[i]!
383
+ // An empty separator is found right here, which gives the parameter before it one character.
384
+ const found = segment.indexOf(separator, at + 1)
385
+ const next = found + separator.length
386
+ // The parameter after this separator needs a character before the trailing literal. A first
387
+ // occurrence that leaves none means every later one does too.
388
+ if (found === -1 || next >= end) {
389
+ out.length = base
390
+ return false
391
+ }
392
+ out.push(segment.slice(at, found))
393
+ at = next
394
+ }
395
+ out.push(segment.slice(at, end))
396
+ if (checked !== undefined) {
397
+ let value = base
398
+ for (const part of checked) {
399
+ if (part.t === "param" && !satisfies(part.c, out[value++]!)) {
400
+ out.length = base
401
+ return false
402
+ }
403
+ }
404
+ }
405
+ return true
406
+ }
407
+
408
+ /** Whether a segment is `:name?` or `:name{constraint}?` and nothing else. The caller has seen
409
+ * that the pattern ends in `?`; a segment before the last one is tested for it here. */
410
+ function isOptionalParam(segment: string): boolean {
411
+ const parts = segment.endsWith("?") ? splitMixed(segment.slice(0, -1)) : undefined
412
+ return parts?.length === 1 && parts[0]!.t === "param"
413
+ }
414
+
415
+ /**
416
+ * The concrete patterns an optional-parameter pattern stands for, shortest first.
417
+ *
418
+ * A path may END in a run of whole-segment optional parameters, `:name?` or, with a constraint,
419
+ * `:name{[0-9]+}?`. It is shorthand for one
420
+ * route per prefix of that run: `/users/:id?` is `/users` and `/users/:id`, and `/a/:b?/:c?` is `/a`,
421
+ * `/a/:b`, and `/a/:b/:c`. A later parameter is only present when every earlier one is, so a run of
422
+ * `n` parameters is `n + 1` patterns, never `2^n`.
423
+ *
424
+ * Every other pattern comes back unchanged as the only element. That includes a `?` anywhere but the
425
+ * trailing run (`/a/:b?/c`) or inside a mixed segment (`/files/:name.:ext?`): there it is literal
426
+ * text, as it always was.
427
+ *
428
+ * Expansion happens before compilation, so each result is an ordinary pattern for
429
+ * {@link compileRoutePattern} and a matcher never learns that a parameter was optional.
430
+ */
431
+ export function expandOptionalParams(pattern: string): readonly string[] {
432
+ if (pattern.charCodeAt(pattern.length - 1) !== 63 /* ? */) return [pattern]
433
+ const segments = pattern.split("/")
434
+ let first = segments.length
435
+ while (first > 1 && isOptionalParam(segments[first - 1]!)) first--
436
+ let form = segments.slice(0, first).join("/")
437
+ const forms = [form || "/"]
438
+ for (let i = first; i < segments.length; i++) {
439
+ form += `/${segments[i]!.slice(0, -1)}`
440
+ forms.push(form)
441
+ }
442
+ return forms
443
+ }
444
+
156
445
  /** Parse and validate Nifra's strict route grammar once. Trailing slashes remain significant. */
157
446
  export function compileRoutePattern(pattern: string): CompiledRoutePattern {
158
447
  if (pattern.length === 0 || pattern.charCodeAt(0) !== 47 /* / */) {
@@ -272,6 +561,8 @@ export function compareMixedPartsSpecificity(
272
561
  const literalWeight = (parts: readonly MixedPart[]): number =>
273
562
  parts.reduce((sum, part) => (part.t === "lit" ? sum + part.v.length : sum), 0)
274
563
  const order = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
564
+ const rank = (constraint: ParamConstraint | undefined): number =>
565
+ constraint ? (constraint.oneOf ? 0 : 1) : 2
275
566
 
276
567
  const weightDifference = literalWeight(right) - literalWeight(left)
277
568
  if (weightDifference !== 0) return weightDifference
@@ -285,9 +576,23 @@ export function compareMixedPartsSpecificity(
285
576
  if (a.t === "lit" && b.t === "lit" && a.v !== b.v) {
286
577
  return a.v.length === b.v.length ? order(a.v, b.v) : b.v.length - a.v.length
287
578
  }
579
+ if (a.t === "param" && b.t === "param") {
580
+ // A constrained parameter before a free one, a list of values before a class. Two of a kind:
581
+ // the one that accepts less goes first, so a constraint contained in another is tried before
582
+ // it (`\d+` before `\w+`, whichever way each is spelled). The key settles the rest, and the
583
+ // result never depends on registration order.
584
+ const x = a.c
585
+ const y = b.c
586
+ const difference =
587
+ rank(x) - rank(y) ||
588
+ (x && y && (x.size - y.size || x.max - x.min - (y.max - y.min) || order(x.key, y.key)))
589
+ if (difference) return difference
590
+ }
288
591
  }
289
592
 
290
- return order(mixedSegmentSource(left), mixedSegmentSource(right))
593
+ // Same kinds in the same order with the same literals and constraints: the two are one shape.
594
+ // Parameter names do not enter into what a segment matches, so they do not order it either.
595
+ return 0
291
596
  }
292
597
 
293
598
  /** Core precedence: static > mixed > param > wildcard at the first differing segment, independent of
@@ -346,11 +651,30 @@ export function matchRoutePattern(
346
651
  compiled: CompiledRoutePattern,
347
652
  pathname: string,
348
653
  ): RoutePatternMatch {
349
- const match = regexOf(compiled).exec(pathname)
654
+ const matcher = matcherOf(compiled)
655
+ const match = matcher.regex.exec(pathname)
350
656
  if (match === null) return { matched: false, reason: "not-found" }
351
657
  const params: Record<string, string> = {}
352
- for (let i = 0; i < compiled.paramNames.length; i++) {
353
- params[compiled.paramNames[i]!] = match[i + 1] ?? ""
658
+ const names = compiled.paramNames
659
+ // One capture group per dynamic segment, in order. A mixed segment's group is the whole segment,
660
+ // which the scanner splits into that segment's parameters.
661
+ let group = 1
662
+ let name = 0
663
+ let captures: string[] | undefined
664
+ for (let i = 0; i < compiled.segments.length; i++) {
665
+ if (compiled.segments[i]!.kind === "static") continue
666
+ const value = match[group++] ?? ""
667
+ const shape = matcher.shapes[i]
668
+ if (shape === undefined) {
669
+ params[names[name++]!] = value
670
+ continue
671
+ }
672
+ captures ??= []
673
+ captures.length = 0
674
+ if (!matchMixedSegment(shape, value, captures, matcher.checked[i])) {
675
+ return { matched: false, reason: "not-found" }
676
+ }
677
+ for (const capture of captures) params[names[name++]!] = capture
354
678
  }
355
679
  const decoded = decodeRouteParams(params)
356
680
  return decoded === null
@@ -3,19 +3,35 @@ import {
3
3
  type CompiledRoutePattern,
4
4
  compareMixedPartsSpecificity,
5
5
  compileRoutePattern,
6
+ constrainedParts,
6
7
  type MixedPart,
7
- mixedSegmentSource,
8
+ type MixedSegmentShape,
9
+ matchMixedSegment,
10
+ mixedSegmentShape,
8
11
  } from "./pattern.ts"
9
12
 
10
- /** HTTP methods the router accepts. */
13
+ /** The standard HTTP methods: the ones with a builder on the server and a call on the typed client. */
11
14
  export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const
12
15
 
13
16
  export type Method = (typeof METHODS)[number]
14
17
 
15
- const METHOD_SET: ReadonlySet<string> = new Set(METHODS)
18
+ /**
19
+ * A method a route is registered under: a standard {@link Method}, or another token that
20
+ * {@link isRegistrableMethod} accepts (`PROPFIND`, `PURGE`, ...).
21
+ */
22
+ export type RouteMethod = Method | (string & {})
16
23
 
17
- function isMethod(value: string): value is Method {
18
- return METHOD_SET.has(value)
24
+ // A method token is 1-32 characters of `A-Z`, `0-9` and `-`, starting with a letter. TRACE, CONNECT
25
+ // and TRACK are left out: TRACE (and its alias TRACK) asks a server to echo the request back,
26
+ // headers included, and CONNECT asks for a tunnel. Neither is something a route handler answers.
27
+ const REGISTRABLE_METHOD = /^(?!(TRAC[EK]|CONNECT)$)[A-Z][A-Z\d-]{0,31}$/
28
+
29
+ /**
30
+ * Whether a route can be registered under `method` - one of {@link METHODS}, or another uppercase
31
+ * token such as `PROPFIND`. `TRACE`, `CONNECT` and `TRACK` never are.
32
+ */
33
+ export function isRegistrableMethod(method: string): boolean {
34
+ return REGISTRABLE_METHOD.test(method)
19
35
  }
20
36
 
21
37
  const SLASH = 47
@@ -92,18 +108,26 @@ interface RouteNode<T> {
92
108
  }
93
109
 
94
110
  interface MixedChild<T> {
95
- /** Anchored matcher for ONE segment, with a capture group per parameter. */
96
- readonly regex: RegExp
97
111
  /**
98
- * How many values this segment pushes. A mixed segment pushes N, not one, so a failed branch must
99
- * pop exactly N to keep `paramValues` aligned with `paramNames`.
112
+ * What a request segment is matched against: one pass, whatever the segment holds.
113
+ *
114
+ * It has one entry more than the segment has parameters. A match pushes a value per parameter -
115
+ * N values, not one - so a failed branch must pop exactly `shape.length - 1` to keep `paramValues`
116
+ * aligned with `paramNames`.
100
117
  */
101
- readonly arity: number
102
- /** Parsed shape used by the same total specificity comparator as the browser router. */
118
+ readonly shape: MixedSegmentShape
119
+ /** Parsed parts used by the same total specificity comparator as the browser router. */
103
120
  readonly parts: readonly MixedPart[]
121
+ /** `parts` when a parameter has a constraint to test after the scan, else `undefined`. */
122
+ readonly checked: readonly MixedPart[] | undefined
104
123
  readonly node: RouteNode<T>
105
- /** The pattern source, so re-registering the same shape reuses its node. */
106
- readonly source: string
124
+ /**
125
+ * The segment as one string, so re-registering the same one reuses its node: each literal and each
126
+ * parameter's constraint, tagged by kind. Joined on `/`, the one character neither can hold, so
127
+ * two different segments never share a key. Parameter names are left out: they do not decide what
128
+ * a segment matches.
129
+ */
130
+ readonly key: string
107
131
  }
108
132
 
109
133
  function createNode<T>(): RouteNode<T> {
@@ -162,19 +186,19 @@ function matchNode<T>(
162
186
  const mixedChildren = node.mixedChildren
163
187
  if (mixedChildren !== undefined && seg.length > 0) {
164
188
  for (const child of mixedChildren) {
165
- const matched = child.regex.exec(seg)
166
- if (matched === null) continue
167
- // Push left-to-right, matching the order `paramNames` was built in.
168
- for (let i = 1; i <= child.arity; i++) paramValues.push(matched[i] as string)
189
+ // Pushes this segment's captures left-to-right, matching the order `paramNames` was built in,
190
+ // and pushes nothing on a miss - a value that fails its constraint is a miss. One pass over
191
+ // the segment whatever the shape: see the scanner.
192
+ if (!matchMixedSegment(child.shape, seg, paramValues, child.checked)) continue
169
193
  if (isLast) {
170
194
  if (child.node.terminal !== undefined) return child.node.terminal
171
195
  } else {
172
196
  const found = matchNode(child.node, path, end + 1, len, paramValues)
173
197
  if (found !== undefined) return found
174
198
  }
175
- // Backtrack: a mixed segment pushed `arity` values, not one, so unwind exactly that many or
176
- // every later param reads a value belonging to an abandoned branch.
177
- for (let i = 0; i < child.arity; i++) paramValues.pop()
199
+ // Backtrack: a mixed segment pushed a value per parameter, not a single one, so unwind exactly
200
+ // that many or every later param reads a value belonging to an abandoned branch.
201
+ for (let i = 1; i < child.shape.length; i++) paramValues.pop()
178
202
  }
179
203
  }
180
204
 
@@ -220,43 +244,39 @@ function resolve<T>(
220
244
  params: Record<string, string>,
221
245
  ): RouterMatch<T> {
222
246
  if (terminal.staticMatches !== undefined && params === EMPTY_PARAMS) {
223
- // Real requests arrive pre-uppercased (HTTP methods are canonically uppercase; every runtime and
224
- // client normalizes this) - try the cache under the raw method first so that overwhelmingly common
225
- // case skips `toUpperCase()` entirely, matching the same already-uppercase fast path
226
- // `resolveDirect` takes below. Falls back to the uppercase key (computed once) for a lowercase or
227
- // mixed-case caller, so behavior is unchanged - only the common case gets cheaper.
228
247
  const direct = terminal.staticMatches.get(method)
229
248
  if (direct !== undefined) return direct
230
- const upper = method.toUpperCase()
231
- const cached = upper === method ? undefined : terminal.staticMatches.get(upper)
232
- if (cached !== undefined) return cached
249
+ // Only a hit is cached, and only under a method the terminal serves, so the cache is bounded by
250
+ // the registered methods and an arbitrary request token never grows it.
233
251
  const cacheable =
234
- terminal.handlers.has(upper) || (upper === "HEAD" && terminal.handlers.has("GET"))
252
+ terminal.handlers.has(method) || (method === "HEAD" && terminal.handlers.has("GET"))
235
253
  if (!cacheable) return resolveDirect(terminal, method, params)
236
254
 
237
255
  const res = resolveDirect(terminal, method, params)
238
- terminal.staticMatches.set(upper, res)
256
+ terminal.staticMatches.set(method, res)
239
257
  return res
240
258
  }
241
259
  return resolveDirect(terminal, method, params)
242
260
  }
243
261
 
262
+ /**
263
+ * Method tokens are case-sensitive (RFC 9110 §9.1), and this compares them exactly. A request whose
264
+ * token differs from a registered method only in case is a different method and answers 405. Folding
265
+ * case here would run a handler under a method string that a case-sensitive method check elsewhere -
266
+ * a CSRF guard, a body limit, a user hook comparing `c.req.method` - does not recognise, so the two
267
+ * would disagree about whether the request mutates.
268
+ */
244
269
  function resolveDirect<T>(
245
270
  terminal: Terminal<T>,
246
271
  method: string,
247
272
  params: Record<string, string>,
248
273
  ): RouterMatch<T> {
249
- if (terminal.handlers.has(method)) {
250
- return { found: true, payload: terminal.handlers.get(method)!, params }
251
- }
252
- const upper = method.toUpperCase()
253
- if (upper !== method && terminal.handlers.has(upper)) {
254
- return { found: true, payload: terminal.handlers.get(upper)!, params }
255
- }
274
+ const payload = terminal.handlers.get(method)
275
+ if (payload !== undefined) return { found: true, payload, params }
256
276
  // RFC 9110 §9.3.2: HEAD is GET without the body. A route registered for GET answers HEAD with
257
277
  // the GET handler; the serving runtime strips the body, so status + headers mirror GET exactly.
258
- // An explicitly registered HEAD handler takes precedence via the has() checks above.
259
- if (upper === "HEAD") {
278
+ // An explicitly registered HEAD handler takes precedence via the lookup above.
279
+ if (method === "HEAD") {
260
280
  const get = terminal.handlers.get("GET")
261
281
  if (get !== undefined) return { found: true, payload: get, params }
262
282
  }
@@ -301,13 +321,15 @@ export class Router<T> {
301
321
  * Register a payload for `method` + `path`. Throws {@link RouteConfigError}
302
322
  * (boot-time, L2) on a duplicate route, a malformed pattern, or conflicting
303
323
  * parameter names for the same path shape.
324
+ *
325
+ * One call is one route. A `?` in the pattern is literal text here: optional parameters
326
+ * (`/users/:id?`) are expanded by the caller, which adds each pattern `expandOptionalParams`
327
+ * (`@nifrajs/core/pattern`) gives for it.
304
328
  */
305
- add(method: Method, pattern: string | CompiledRoutePattern, payload: T): void {
329
+ add(method: string, pattern: string | CompiledRoutePattern, payload: T): void {
306
330
  DYNAMIC_MATCH_CACHES.delete(this)
307
331
  const upper = method.toUpperCase()
308
- // The `Method` parameter type is the compile-time guard; this re-checks at
309
- // runtime for JS callers and dynamically-computed methods.
310
- if (!isMethod(upper)) {
332
+ if (!isRegistrableMethod(upper)) {
311
333
  throw new RouteConfigError("INVALID_METHOD", `unsupported HTTP method "${method}"`)
312
334
  }
313
335
  const compiled = typeof pattern === "string" ? compileRoutePattern(pattern) : pattern
@@ -324,16 +346,19 @@ export class Router<T> {
324
346
  node.wildcardChild ??= createNode<T>()
325
347
  node = node.wildcardChild
326
348
  } else if (segment.kind === "mixed") {
327
- const source = mixedSegmentSource(segment.parts)
349
+ const parts = segment.parts
350
+ const key = parts
351
+ .map((part) => (part.t === "lit" ? `L${part.v}` : `P${part.c?.key ?? ""}`))
352
+ .join("/")
328
353
  node.mixedChildren ??= []
329
- let child = node.mixedChildren.find((entry) => entry.source === source)
354
+ let child = node.mixedChildren.find((entry) => entry.key === key)
330
355
  if (child === undefined) {
331
356
  child = {
332
- regex: new RegExp(`^${source}$`),
333
- arity: segment.parts.reduce((n, part) => (part.t === "param" ? n + 1 : n), 0),
334
- parts: segment.parts,
357
+ shape: mixedSegmentShape(parts),
358
+ parts,
359
+ checked: constrainedParts(parts),
335
360
  node: createNode<T>(),
336
- source,
361
+ key,
337
362
  }
338
363
  node.mixedChildren.push(child)
339
364
  // Most literal text first. Registration order must not decide which of `/:id.txt` and
@@ -388,8 +413,8 @@ export class Router<T> {
388
413
  }
389
414
 
390
415
  /**
391
- * Resolve `method` + `path`. Tolerant of a missing leading slash and of
392
- * method casing. Never throws.
416
+ * Resolve `method` + `path`. Tolerant of a missing leading slash. The method is compared exactly:
417
+ * tokens are case-sensitive, so `get` does not reach a `GET` route. Never throws.
393
418
  */
394
419
  find(method: string, path: string): RouterMatch<T> {
395
420
  const len = path.length