@orkestrel/router 0.0.12 → 0.0.14

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.
@@ -1,24 +1,26 @@
1
+ import { ContractError, isFunction, isString, preview } from "@orkestrel/contract";
1
2
  import { Emitter } from "@orkestrel/emitter";
2
- import { isFunction, isString } from "@orkestrel/contract";
3
3
  //#region src/core/constants.ts
4
4
  /**
5
- * The complete set of HTTP methods a {@link import('./types.js').Dispatcher}
6
- * registers routes under — backs the registration guard (`add` rejects any
7
- * `method` outside this set) and the auto-`OPTIONS` `Allow` derivation.
5
+ * Lists the HTTP methods a {@link import('./types.js').DispatcherInterface} registers
6
+ * routes under, in canonical order a frozen literal tuple, and the single source the
7
+ * {@link import('./types.js').Method} type, {@link METHODS}, and `parseMethod` are all
8
+ * derived from.
8
9
  *
9
10
  * @remarks
10
- * A `ReadonlySet` of the seven {@link import('./types.js').Method} literals:
11
- * `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. `HEAD` is
12
- * included even though it is never required at registration (a `GET` route
13
- * auto-answers `HEAD`) it is still a valid method to register explicitly.
11
+ * A frozen tuple of the verbs: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`,
12
+ * `HEAD`, `OPTIONS`. Adding a verb here widens the `Method` type, the
13
+ * {@link METHODS} membership set, and the `parseMethod` narrowing together, so
14
+ * the method set cannot drift between them. Prefer {@link METHODS} for a
15
+ * membership test; use this tuple where order or literal typing matters.
14
16
  *
15
17
  * @example
16
18
  * ```ts
17
- * METHODS.has('GET') // true
18
- * METHODS.has('TRACE') // false
19
+ * METHOD_LIST[0] // 'GET'
20
+ * METHOD_LIST.includes('GET') // true
19
21
  * ```
20
22
  */
21
- var METHODS = Object.freeze(/* @__PURE__ */ new Set([
23
+ var METHOD_LIST = Object.freeze([
22
24
  "GET",
23
25
  "POST",
24
26
  "PUT",
@@ -26,14 +28,34 @@ var METHODS = Object.freeze(/* @__PURE__ */ new Set([
26
28
  "DELETE",
27
29
  "HEAD",
28
30
  "OPTIONS"
29
- ]));
31
+ ]);
30
32
  /**
31
- * Specificity tier for a **literal** path segment (`/users`) — the highest
33
+ * Holds every HTTP method a {@link import('./types.js').DispatcherInterface} registers
34
+ * routes under as a `ReadonlySet` — backs the registration guard (`add` rejects any
35
+ * `method` outside this set) and the auto-`OPTIONS` `Allow` derivation.
36
+ *
37
+ * @remarks
38
+ * A `ReadonlySet` built from {@link METHOD_LIST}, so it carries exactly the
39
+ * {@link import('./types.js').Method} literals: `GET`, `POST`, `PUT`,
40
+ * `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. `HEAD` is included even though it is
41
+ * never required at registration (a `GET` route auto-answers `HEAD`) — it is
42
+ * still a valid method to register explicitly. The element type stays `string`
43
+ * so a raw, unnarrowed `request.method` can be tested directly.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * METHODS.has('GET') // true
48
+ * METHODS.has('TRACE') // false
49
+ * ```
50
+ */
51
+ var METHODS = Object.freeze(new Set(METHOD_LIST));
52
+ /**
53
+ * Names the specificity tier for a **literal** path segment (`/users`) — the highest
32
54
  * tier, always outranking a param or wildcard segment at the same position.
33
55
  *
34
56
  * @remarks
35
- * Consumed by `computeSpecificity` (U1 `helpers.ts`) when ranking candidate
36
- * matches left-to-right at the earliest differing segment (§4 precedence).
57
+ * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`) when ranking candidate
58
+ * matches left-to-right at the earliest differing segment.
37
59
  *
38
60
  * @example
39
61
  * ```ts
@@ -42,11 +64,11 @@ var METHODS = Object.freeze(/* @__PURE__ */ new Set([
42
64
  */
43
65
  var TIER_LITERAL = 2;
44
66
  /**
45
- * Specificity tier for a **param** path segment (`:name`) — ranks below a
67
+ * Names the specificity tier for a **param** path segment (`:name`) — ranks below a
46
68
  * literal segment and above a wildcard segment at the same position.
47
69
  *
48
70
  * @remarks
49
- * Consumed by `computeSpecificity` (U1 `helpers.ts`) alongside {@link TIER_LITERAL}
71
+ * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`) alongside {@link TIER_LITERAL}
50
72
  * and {@link TIER_WILDCARD}.
51
73
  *
52
74
  * @example
@@ -56,12 +78,12 @@ var TIER_LITERAL = 2;
56
78
  */
57
79
  var TIER_PARAM = 1;
58
80
  /**
59
- * Specificity tier for a **wildcard** path segment (`*name`) — the lowest
81
+ * Names the specificity tier for a **wildcard** path segment (`*name`) — the lowest
60
82
  * tier; a wildcard only ever wins against another wildcard shape (an
61
83
  * equal-specificity tie resolved by registration order).
62
84
  *
63
85
  * @remarks
64
- * Consumed by `computeSpecificity` (U1 `helpers.ts`).
86
+ * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`).
65
87
  *
66
88
  * @example
67
89
  * ```ts
@@ -72,7 +94,7 @@ var TIER_WILDCARD = 0;
72
94
  //#endregion
73
95
  //#region src/core/helpers.ts
74
96
  /**
75
- * Escape every regex metacharacter in a literal string so it can be embedded
97
+ * Escapes every regex metacharacter in a literal string so it can be embedded
76
98
  * inside a larger `RegExp` source without being interpreted as syntax.
77
99
  *
78
100
  * @remarks
@@ -95,7 +117,7 @@ function escapeRegExp(value) {
95
117
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
96
118
  }
97
119
  /**
98
- * Canonicalize a route path for REGISTRY IDENTITYstrip a single trailing
120
+ * Canonicalizes a route path for registry identitystrips a single trailing
99
121
  * slash, except the root `/` (and the empty pattern). The trailing-slash fold
100
122
  * {@link compilePath} normalizes a pattern through, so identity agrees with the
101
123
  * matcher.
@@ -122,7 +144,8 @@ function canonicalizePath(path) {
122
144
  return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
123
145
  }
124
146
  /**
125
- * Compute the registry key for a method-dimensioned dispatcher route.
147
+ * Computes the canonical `METHOD /path` registry key for a method-dimensioned
148
+ * dispatcher route.
126
149
  *
127
150
  * @remarks
128
151
  * Combines the route record's HTTP method with the outer entry's canonical
@@ -141,7 +164,7 @@ function computeDispatchKey(entry) {
141
164
  return `${entry.meta.method} ${canonicalizePath(entry.path)}`;
142
165
  }
143
166
  /**
144
- * Compile a route path pattern into an anchored regex and its ordered param
167
+ * Compiles a route path pattern into an anchored regex and its ordered param
145
168
  * names.
146
169
  *
147
170
  * @remarks
@@ -149,7 +172,7 @@ function computeDispatchKey(entry) {
149
172
  * `([^/]+)` capture group; the FINAL segment may instead be `*name`, which
150
173
  * becomes a `(.+)` capture spanning the REST of the path including slashes — a
151
174
  * wildcard segment anywhere but last is a registration-time programmer error
152
- * and throws `TypeError` (§14 construction/registration boundary). Every regex
175
+ * and throws a `ContractError` at the construction/registration boundary. Every regex
153
176
  * metacharacter in a literal segment is escaped first ({@link escapeRegExp}),
154
177
  * so a path like `/files/:name.json` matches the `.` literally apart from the
155
178
  * param. The regex is anchored (`^…$`), so it matches the whole pathname, not
@@ -167,10 +190,12 @@ function computeDispatchKey(entry) {
167
190
  * regex flag, so `/Users` matches `/users`. The pattern's own casing is never
168
191
  * altered — only the matching behavior.
169
192
  *
170
- * @param path - The route path pattern (e.g. `/users/:id`, `/files/*rest`)
171
- * @param sensitive - Case-sensitive matching (default `true`)
193
+ * @param path - The route path pattern (for example `/users/:id`, `/files/*rest`)
194
+ * @param sensitive - If `true`, matching is case-sensitive; if `false`, case is
195
+ * folded during matching. Default: `true`
172
196
  * @returns The {@link CompiledPath} — its `regex` + ordered `params`
173
- * @throws {TypeError} When a `*name` wildcard segment is not the FINAL segment
197
+ * @throws {@link import('@orkestrel/contract').ContractError} Thrown when a
198
+ * `*name` wildcard segment is not the FINAL segment
174
199
  *
175
200
  * @example
176
201
  * ```ts
@@ -189,7 +214,14 @@ function compilePath(path, sensitive = true) {
189
214
  const segments = normalized.split("/");
190
215
  const pattern = segments.map((segment, index) => {
191
216
  const isFinal = index === segments.length - 1;
192
- if (!isFinal && /^\*[A-Za-z_]\w*/.test(segment)) throw new TypeError(`a wildcard segment ("${segment}") must be the final segment of a path pattern, got "${path}"`);
217
+ if (!isFinal && /^\*[A-Za-z_]\w*/.test(segment)) throw new ContractError("a wildcard segment must be the final segment of a path pattern", {
218
+ code: "placement",
219
+ context: {
220
+ path: ["path"],
221
+ limit: `a wildcard only in the final segment, not "${segment}"`,
222
+ received: preview(path)
223
+ }
224
+ });
193
225
  const tier = classifySegment(segment, isFinal);
194
226
  if (tier === 0) {
195
227
  params.push(segment.slice(1));
@@ -210,13 +242,13 @@ function compilePath(path, sensitive = true) {
210
242
  };
211
243
  }
212
244
  /**
213
- * URL-decode one captured param value, tolerating a malformed percent-escape —
245
+ * Decodes one captured param value from a URL, tolerating a malformed percent-escape —
214
246
  * the decode {@link matchPath} applies to each captured group.
215
247
  *
216
248
  * @remarks
217
249
  * A bad `%` sequence is not a reason to reject an otherwise-matching route, so
218
250
  * a `decodeURIComponent` that would throw falls back to the raw value
219
- * (mirroring the cookie / token boundary readers, AGENTS §14). Total — never
251
+ * (mirroring the cookie / token boundary readers). Total — never
220
252
  * throws.
221
253
  *
222
254
  * @param value - The raw captured param value
@@ -237,7 +269,7 @@ function decodeParam(value) {
237
269
  }
238
270
  }
239
271
  /**
240
- * Extract the URL-decoded params a compiled path captures from a concrete
272
+ * Extracts the URL-decoded params a compiled path captures from a concrete
241
273
  * pathname, or `undefined` when the pathname does not match.
242
274
  *
243
275
  * @remarks
@@ -248,7 +280,7 @@ function decodeParam(value) {
248
280
  * for a parameterless path). Total — never throws.
249
281
  *
250
282
  * @param compiled - The {@link CompiledPath} from {@link compilePath}
251
- * @param pathname - The concrete request pathname to match (e.g. `/users/7`)
283
+ * @param pathname - The concrete request pathname to match (for example `/users/7`)
252
284
  * @returns The decoded params on a hit, or `undefined` on a miss
253
285
  *
254
286
  * @example
@@ -271,22 +303,23 @@ function matchPath(compiled, pathname) {
271
303
  return Object.freeze(params);
272
304
  }
273
305
  /**
274
- * Classify one path segment into its specificity TIER — the SAME syntax
275
- * {@link compilePath} rewrites: a syntactically valid `:name` head is a PARAM
276
- * segment, a final `*name` is a WILDCARD segment, everything else (including a
277
- * literal segment that merely CONTAINS a `:` mid-string, e.g. `a:b`) is a
278
- * LITERAL segment.
306
+ * Classifies one path segment into its specificity tier — the same syntax
307
+ * {@link compilePath} rewrites: a syntactically valid `:name` head is a param
308
+ * segment, a final `*name` is a wildcard segment, and everything else (including a
309
+ * literal segment that merely contains a `:` mid-string, for example `a:b`) is a
310
+ * literal segment.
279
311
  *
280
312
  * @remarks
281
313
  * This is the fix over the old engine's bug: the old classifier ranked any
282
314
  * segment `includes(':')` as a param, so a literal segment like `a:b` was
283
315
  * mis-tiered even though {@link compilePath} compiles it literally. Sharing one
284
316
  * segment parser between compilation and classification keeps the two in
285
- * agreement (§4 fixes). Pure and total.
317
+ * agreement. Pure and total.
286
318
  *
287
319
  * @param segment - One `/`-split path segment
288
- * @param isFinal - Whether `segment` is the last segment of its path (only the
289
- * final segment may be classified as a wildcard)
320
+ * @param isFinal - If `true`, `segment` is the path's last segment and may
321
+ * classify as a wildcard; if `false`, a wildcard-shaped segment classifies as
322
+ * a literal
290
323
  * @returns The segment's specificity tier — {@link import('./constants.js').TIER_LITERAL},
291
324
  * {@link import('./constants.js').TIER_PARAM}, or
292
325
  * {@link import('./constants.js').TIER_WILDCARD}
@@ -305,15 +338,15 @@ function classifySegment(segment, isFinal) {
305
338
  return 2;
306
339
  }
307
340
  /**
308
- * Compute a route path's SPECIFICITY VECTOR — the per-segment type ranking
341
+ * Computes a route path's specificity vector — the per-segment type ranking
309
342
  * that breaks a tie when several registered routes match the same concrete
310
343
  * pathname.
311
344
  *
312
345
  * @remarks
313
346
  * Splits the CANONICALIZED path into segments (on `/`) and maps each to its
314
- * specificity tier via {@link classifySegment} — the same segment parser
347
+ * specificity tier through {@link classifySegment} — the same segment parser
315
348
  * {@link compilePath} uses, so a literal segment that merely contains a `:`
316
- * (e.g. `a:b`) is correctly tiered as literal rather than param (the old
349
+ * (for example `a:b`) is correctly tiered as literal rather than param (the old
317
350
  * engine's bug, fixed here). The standard route-precedence rule compares two
318
351
  * matching routes' vectors LEFT-TO-RIGHT: at the first index where the tiers
319
352
  * differ, the HIGHER tier (a literal over a param over a wildcard) is MORE
@@ -323,7 +356,7 @@ function classifySegment(segment, isFinal) {
323
356
  * count in the common case; {@link compareSpecificity} handles the general
324
357
  * case for totality.
325
358
  *
326
- * @param path - The route path pattern (e.g. `/users/:id`)
359
+ * @param path - The route path pattern (for example `/users/:id`)
327
360
  * @returns The per-segment specificity tiers, in order
328
361
  *
329
362
  * @example
@@ -339,7 +372,7 @@ function computeSpecificity(path) {
339
372
  return segments.map((segment, index) => classifySegment(segment, index === segments.length - 1));
340
373
  }
341
374
  /**
342
- * Compare two route paths by SPECIFICITY — the comparator that picks the
375
+ * Compares two route paths by specificity — the comparator that picks the
343
376
  * most-specific matching route (literal-over-param-over-wildcard,
344
377
  * registration-order-independent).
345
378
  *
@@ -376,45 +409,20 @@ function compareSpecificity(a, b) {
376
409
  return 0;
377
410
  }
378
411
  /**
379
- * Narrow a raw `request.method` string into a typed {@link Method} total,
380
- * never throws.
381
- *
382
- * @remarks
383
- * Guarded via {@link import('./constants.js').METHODS} (the seven registrable
384
- * HTTP methods); any other value (an unknown verb, non-uppercase casing)
385
- * resolves to `undefined` rather than throwing (§14 guard totality). Pure
386
- * leaf shared by the `Dispatcher`'s `handle` (§5.1 unknown-verb honesty) and
387
- * anywhere else a raw method string needs narrowing.
388
- *
389
- * @param value - The raw `request.method` string to narrow
390
- * @returns The matching {@link Method}, or `undefined` when `value` is not one
391
- * of the seven registrable methods
392
- *
393
- * @example
394
- * ```ts
395
- * parseMethod('GET') // 'GET'
396
- * parseMethod('PURGE') // undefined
397
- * parseMethod('get') // undefined — case-sensitive
398
- * ```
399
- */
400
- function parseMethod(value) {
401
- if (value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE" || value === "HEAD" || value === "OPTIONS") return value;
402
- }
403
- /**
404
- * Join a group prefix and a route path into one `/`-prefixed path, normalizing
412
+ * Joins a group prefix and a route path into one `/`-prefixed path, normalizing
405
413
  * duplicate or missing joining slashes.
406
414
  *
407
415
  * @remarks
408
416
  * {@link import('./types.js').GroupInterface} / {@link import('./types.js').DispatchGroupInterface}
409
417
  * compose a prefix with each registered entry's path this way — pure string
410
- * composition (§4.2.2), no independent state. Both a duplicated slash
418
+ * composition, no independent state. Both a duplicated slash
411
419
  * (`'/api/'` + `'/users'`) and a missing one (`'/api'` + `'users'`) normalize
412
420
  * to a single joining slash. An empty `prefix` returns `path` unchanged (after
413
421
  * ensuring a leading slash); an empty `path` returns `prefix` unchanged.
414
422
  * Pure and total.
415
423
  *
416
- * @param prefix - The group prefix (e.g. `/api`)
417
- * @param path - The route path being joined under the prefix (e.g. `/users`)
424
+ * @param prefix - The group prefix (for example `/api`)
425
+ * @param path - The route path being joined under the prefix (for example `/users`)
418
426
  * @returns The joined `/`-prefixed path
419
427
  *
420
428
  * @example
@@ -432,8 +440,8 @@ function joinPaths(prefix, path) {
432
440
  return `${prefix.endsWith("/") ? prefix.slice(0, -1) : prefix}${path.startsWith("/") ? path : `/${path}`}`;
433
441
  }
434
442
  /**
435
- * Identity pass-through for a {@link RouteInput} that pins its `Path` generic
436
- * to the LITERAL registration-site string, so `context.params` types
443
+ * Provides an identity pass-through for a {@link RouteInput} that pins its `Path`
444
+ * generic to the literal registration-site string, so `context.params` types
437
445
  * correctly through {@link PathParams} without an explicit type argument.
438
446
  *
439
447
  * @remarks
@@ -441,26 +449,26 @@ function joinPaths(prefix, path) {
441
449
  * `add` already infers `Path` as a literal at that call site — but the moment
442
450
  * the object is built through an intermediate binding (a local `const route =
443
451
  * { method, path, handler }`) TypeScript widens `path` to `string` unless the
444
- * binding's own type is pinned. Wrapping the literal in `route(...)` supplies
445
- * that pin: its `const Path extends string` type parameter infers the NARROW
446
- * literal from the call, and the function returns its input completely
452
+ * binding's own type is pinned. Wrapping the literal in `defineRoute(...)`
453
+ * supplies that pin: its `const Path extends string` type parameter infers the
454
+ * NARROW literal from the call, and the function returns its input completely
447
455
  * unchanged (same reference, no cloning, no validation) — this is a
448
456
  * compile-time typing aid only, not a construction step (contrast
449
457
  * {@link import('./factories.js')} `create*` entity factories). A
450
- * heterogeneous `RouteInput[]` built from several `route(...)` calls still
451
- * widens each element's `Path` to `string` once collected into one array
452
- * (§14) — the realistic ceiling this helper raises is PER-CALL typing at the
458
+ * heterogeneous `RouteInput[]` built from several `defineRoute(...)` calls
459
+ * still widens each element's `Path` to `string` after collection into one array
460
+ * the realistic ceiling this helper raises is PER-CALL typing at the
453
461
  * registration site, not a stored, still-literal-typed record.
454
462
  *
455
463
  * @typeParam Path - The route path pattern literal (drives `context.params`
456
- * via {@link PathParams})
464
+ * through {@link PathParams})
457
465
  * @typeParam TState - The consumer's opaque per-request state type
458
466
  * @param input - The {@link RouteInput} to pass through unchanged
459
467
  * @returns `input`, unchanged (same reference)
460
468
  *
461
469
  * @example
462
470
  * ```ts
463
- * const input = route({
471
+ * const input = defineRoute({
464
472
  * method: 'GET',
465
473
  * path: '/users/:id',
466
474
  * handler: (_request, context) => new Response(context.params.id), // typed string
@@ -468,21 +476,49 @@ function joinPaths(prefix, path) {
468
476
  * dispatcher.add(input)
469
477
  * ```
470
478
  */
471
- function route(input) {
479
+ function defineRoute(input) {
472
480
  return input;
473
481
  }
474
482
  //#endregion
483
+ //#region src/core/parsers.ts
484
+ /**
485
+ * Narrows a raw `request.method` string into a typed {@link Method} — total,
486
+ * never throws.
487
+ *
488
+ * @remarks
489
+ * Consults {@link import('./constants.js').METHOD_LIST} (the one home for the
490
+ * registrable HTTP methods), so a verb added there narrows here without
491
+ * a second list to update; any other value (an unknown verb, non-uppercase
492
+ * casing) resolves to `undefined` rather than throwing (total guard behavior).
493
+ * Pure leaf shared by the `Dispatcher`'s `handle` (honest about an unknown verb)
494
+ * and anywhere else a raw method string needs narrowing.
495
+ *
496
+ * @param value - The raw `request.method` string to narrow
497
+ * @returns The matching {@link Method}, or `undefined` when `value` is not a
498
+ * registrable method
499
+ *
500
+ * @example
501
+ * ```ts
502
+ * parseMethod('GET') // 'GET'
503
+ * parseMethod('PURGE') // undefined
504
+ * parseMethod('get') // undefined — case-sensitive
505
+ * ```
506
+ */
507
+ function parseMethod(value) {
508
+ return METHOD_LIST.find((method) => method === value);
509
+ }
510
+ //#endregion
475
511
  //#region src/core/Group.ts
476
512
  /**
477
- * A prefix-scoped registration handle over a {@link import('./Router.js').Router} —
478
- * pure string composition (AGENTS §4.2.2), no independent state or storage.
513
+ * Represents a prefix-scoped registration handle over a {@link import('./Router.js').Router} —
514
+ * pure string composition, no independent state or storage.
479
515
  *
480
516
  * @typeParam Meta - The entry payload type, matching the owning router
481
517
  *
482
518
  * @remarks
483
519
  * Every `add` composes `entry.path` as `joinPaths(prefix, entry.path)` and
484
520
  * forwards to the OWNING router, so grouped routes land in the SAME registry.
485
- * `group(prefix)` nests, composing prefixes via {@link joinPaths}.
521
+ * `group(prefix)` nests, composing prefixes through {@link joinPaths}.
486
522
  *
487
523
  * @example
488
524
  * ```ts
@@ -515,26 +551,26 @@ var Group = class Group {
515
551
  //#endregion
516
552
  //#region src/core/Router.ts
517
553
  /**
518
- * The path-matching + registry engine — registers `{ path, meta, name? }`
519
- * entries (compiling each path once) and resolves a concrete pathname to the
520
- * MOST SPECIFIC matching entry. The shared machine both the `Navigator`
521
- * (browser) and the `Dispatcher` (core, method-dimensioned) compose.
554
+ * Represents the path-matching + registry engine — registers `{ path, meta, name? }`
555
+ * entries (compiling each path once) and resolves a concrete pathname to the most
556
+ * specific matching entry. The shared machine both the `Navigator` (browser) and
557
+ * the `Dispatcher` (core, method-dimensioned) compose.
522
558
  *
523
559
  * @typeParam Meta - The opaque payload each entry carries and a match returns
524
560
  *
525
561
  * @remarks
526
- * - **Registration boundary guard (§14).** `add` validates each entry's
527
- * `path` — `isString` plus a leading `/` — and throws `TypeError` on a
562
+ * - **Registration boundary guard.** `add` validates each entry's
563
+ * `path` — `isString` plus a leading `/` — and throws a `ContractError` on a
528
564
  * malformed registration; `match` stays guard-free (the hot path).
529
565
  * - **Compile-once.** Each path is compiled exactly once at registration into
530
566
  * a parallel `#compiled` array, so `match` runs only a cached `exec` per
531
567
  * candidate.
532
- * - **Dedup via `key`.** When `options.key` is set, an entry whose computed
568
+ * - **Dedup through `key`.** When `options.key` is set, an entry whose computed
533
569
  * key already exists REPLACES the prior one IN PLACE (both the `#entries`
534
570
  * and `#compiled` arrays, at the existing index) — last write wins, no
535
571
  * engine rebuild. Omitted ⇒ every entry is kept, even duplicate paths.
536
572
  * - **Groups.** `group(prefix)` returns a {@link GroupInterface} that composes
537
- * `prefix` onto every entry it registers, nesting via {@link joinPaths}.
573
+ * `prefix` onto every entry it registers, nesting through {@link joinPaths}.
538
574
  *
539
575
  * @example
540
576
  * ```ts
@@ -603,7 +639,22 @@ var Router = class {
603
639
  this.#index.clear();
604
640
  }
605
641
  #register(entry) {
606
- if (!isString(entry.path) || !entry.path.startsWith("/")) throw new TypeError(`a route path must be a string starting with "/", got ${JSON.stringify(entry.path)}`);
642
+ if (!isString(entry.path)) throw new ContractError("a route path must be a string", {
643
+ code: "literal",
644
+ context: {
645
+ path: ["entry", "path"],
646
+ limit: "string",
647
+ received: preview(entry.path)
648
+ }
649
+ });
650
+ if (!entry.path.startsWith("/")) throw new ContractError("a route path must start with \"/\"", {
651
+ code: "pattern",
652
+ context: {
653
+ path: ["entry", "path"],
654
+ limit: "a \"/\"-prefixed path pattern",
655
+ received: preview(entry.path)
656
+ }
657
+ });
607
658
  const compiled = compilePath(entry.path, this.#sensitive);
608
659
  if (this.#key === void 0) {
609
660
  this.#entries.push(entry);
@@ -625,17 +676,17 @@ var Router = class {
625
676
  //#endregion
626
677
  //#region src/core/DispatchGroup.ts
627
678
  /**
628
- * A prefix-scoped registration handle over a
629
- * {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned
630
- * counterpart of `Group` (`Group.ts`).
679
+ * Represents a prefix-scoped registration handle over a
680
+ * {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned counterpart of
681
+ * `Group`.
631
682
  *
632
683
  * @typeParam TState - The consumer's opaque per-request state type, matching
633
684
  * the owning dispatcher
634
685
  *
635
686
  * @remarks
636
- * Every `add` composes `input.path` via {@link joinPaths} against
637
- * `this.prefix` and forwards to the OWNING dispatcher's `add` (its own §14
638
- * boundary guard still applies). Pure string composition (§4.2.2) — no
687
+ * Every `add` composes `input.path` through {@link joinPaths} against
688
+ * `this.prefix` and forwards to the OWNING dispatcher's `add` (its own
689
+ * registration boundary guard still applies). Pure string composition — no
639
690
  * independent state or storage.
640
691
  *
641
692
  * @example
@@ -668,20 +719,20 @@ var DispatchGroup = class DispatchGroup {
668
719
  //#endregion
669
720
  //#region src/core/Dispatcher.ts
670
721
  /**
671
- * The fetch-standard, method-dimensioned dispatch entity — layers HTTP method
672
- * dispatch and web-standard `Request`/`Response` handling over one internal
673
- * `Router<RouteRecord<TState>>`. The core machine the eventual server face
674
- * (§7) and any fetch-native runtime consumes directly.
722
+ * Represents the fetch-standard, method-dimensioned dispatch entity — layers HTTP
723
+ * method dispatch and web-standard `Request`/`Response` handling over one internal
724
+ * `Router<RouteRecord<TState>>`. The core machine the server face and any
725
+ * fetch-native runtime consume directly.
675
726
  *
676
727
  * @typeParam TState - The consumer's opaque per-request state type
677
728
  *
678
729
  * @remarks
679
730
  * - **Dedup by `method + canonicalizePath`.** The underlying `Router` is
680
731
  * constructed with a `key` function so registering the same method+path
681
- * twice REPLACES the prior route in place (§5.1).
682
- * - **Registration boundary guard (§14).** `add` validates each input's
732
+ * twice REPLACES the prior route in place.
733
+ * - **Registration boundary guard.** `add` validates each input's
683
734
  * `handler` (`isFunction`) and `method` (must be in {@link METHODS}) —
684
- * throws `TypeError` on a malformed registration; path validation is
735
+ * throws a `ContractError` on a malformed registration; path validation is
685
736
  * delegated to the underlying `Router`'s own guard. `match`/`handle` stay
686
737
  * guard-free.
687
738
  * - **Auto-`HEAD` / auto-`OPTIONS`.** A `HEAD` request with no registered
@@ -689,8 +740,8 @@ var DispatchGroup = class DispatchGroup {
689
740
  * body; an `OPTIONS` request with no registered `OPTIONS` route answers
690
741
  * `204` with a derived `Allow` header.
691
742
  * - **Handler throws propagate.** `handle` never invents an error boundary —
692
- * a handler throw reaches the caller uncaught (§5.1).
693
- * - **Emitter (§13).** Owns a `#emitter` for {@link DispatcherEventMap};
743
+ * a handler throw reaches the caller uncaught.
744
+ * - **Emitter.** Owns a `#emitter` for {@link DispatcherEventMap};
694
745
  * `match`/`miss` fire AFTER resolution, before the handler/responder runs.
695
746
  *
696
747
  * @example
@@ -705,7 +756,7 @@ var DispatchGroup = class DispatchGroup {
705
756
  * ```
706
757
  */
707
758
  var Dispatcher = class {
708
- router;
759
+ #router;
709
760
  #emitter;
710
761
  #unmatched;
711
762
  #unmethoded;
@@ -713,7 +764,7 @@ var Dispatcher = class {
713
764
  const sensitive = options?.sensitive;
714
765
  const on = options?.on;
715
766
  const error = options?.error;
716
- this.router = new Router({
767
+ this.#router = new Router({
717
768
  ...sensitive === void 0 ? {} : { sensitive },
718
769
  key: computeDispatchKey
719
770
  });
@@ -725,6 +776,9 @@ var Dispatcher = class {
725
776
  this.#unmethoded = options?.unmethoded;
726
777
  if (options?.routes !== void 0) this.add(options.routes);
727
778
  }
779
+ get router() {
780
+ return this.#router;
781
+ }
728
782
  get emitter() {
729
783
  return this.#emitter;
730
784
  }
@@ -736,13 +790,13 @@ var Dispatcher = class {
736
790
  return new DispatchGroup(this, prefix);
737
791
  }
738
792
  match(method, pathname) {
739
- const hit = this.router.match(pathname, (meta) => meta.method === method);
793
+ const hit = this.#router.match(pathname, (meta) => meta.method === method);
740
794
  if (hit !== void 0) return {
741
795
  status: "matched",
742
796
  match: hit
743
797
  };
744
798
  if (method === "HEAD") {
745
- const getHit = this.router.match(pathname, (meta) => meta.method === "GET");
799
+ const getHit = this.#router.match(pathname, (meta) => meta.method === "GET");
746
800
  if (getHit !== void 0) return {
747
801
  status: "matched",
748
802
  match: getHit
@@ -772,7 +826,10 @@ var Dispatcher = class {
772
826
  const result = this.match(method, pathname);
773
827
  if (result.status === "matched") return this.#respondMatched(request, state, method, result.match, url);
774
828
  if (result.status === "unmethoded") {
775
- if (method === "OPTIONS") return this.#respondAutoOptions(pathname, result.allow);
829
+ if (method === "OPTIONS") {
830
+ const hit = this.#router.match(pathname);
831
+ if (hit !== void 0) return this.#respondAutoOptions(hit.path, result.allow);
832
+ }
776
833
  this.#emitter.emit("miss", method, pathname, "unmethoded");
777
834
  return this.#respondUnmethoded(request, result.allow);
778
835
  }
@@ -783,10 +840,24 @@ var Dispatcher = class {
783
840
  this.#emitter.destroy();
784
841
  }
785
842
  #register(input) {
786
- if (!isFunction(input.handler)) throw new TypeError(`a route handler must be a function, got ${JSON.stringify(input.handler)}`);
787
- if (!isString(input.method) || !METHODS.has(input.method)) throw new TypeError(`a route method must be one of ${[...METHODS].join(", ")}, got ${JSON.stringify(input.method)}`);
843
+ if (!isFunction(input.handler)) throw new ContractError("a route handler must be a function", {
844
+ code: "literal",
845
+ context: {
846
+ path: ["input", "handler"],
847
+ limit: "function",
848
+ received: preview(input.handler)
849
+ }
850
+ });
851
+ if (!isString(input.method) || !METHODS.has(input.method)) throw new ContractError("a route method must be a registrable HTTP method", {
852
+ code: "literal",
853
+ context: {
854
+ path: ["input", "method"],
855
+ limit: [...METHODS].join(", "),
856
+ received: preview(input.method)
857
+ }
858
+ });
788
859
  const name = input.name;
789
- this.router.add({
860
+ this.#router.add({
790
861
  path: input.path,
791
862
  ...name === void 0 ? {} : { name },
792
863
  meta: {
@@ -797,7 +868,7 @@ var Dispatcher = class {
797
868
  });
798
869
  }
799
870
  #allow(pathname) {
800
- const entries = this.router.entries(pathname);
871
+ const entries = this.#router.entries(pathname);
801
872
  const methods = /* @__PURE__ */ new Set();
802
873
  for (const entry of entries) methods.add(entry.meta.method);
803
874
  if (methods.has("GET")) methods.add("HEAD");
@@ -832,8 +903,8 @@ var Dispatcher = class {
832
903
  });
833
904
  return response;
834
905
  }
835
- #respondAutoOptions(pathname, allow) {
836
- this.#emitter.emit("match", "OPTIONS", pathname);
906
+ #respondAutoOptions(pattern, allow) {
907
+ this.#emitter.emit("match", "OPTIONS", pattern);
837
908
  const headers = new Headers({ Allow: [...allow, "OPTIONS"].join(", ") });
838
909
  return new Response(null, {
839
910
  status: 204,
@@ -844,7 +915,7 @@ var Dispatcher = class {
844
915
  //#endregion
845
916
  //#region src/core/factories.ts
846
917
  /**
847
- * Create a {@link RouterInterface} — the pure path-matching + registry engine
918
+ * Creates a {@link RouterInterface} — the pure path-matching + registry engine
848
919
  * shared by the browser `Navigator` and the core `Dispatcher`.
849
920
  *
850
921
  * @remarks
@@ -857,21 +928,32 @@ var Dispatcher = class {
857
928
  * (default `true`), and a `key` dedup identity function
858
929
  * @returns A {@link RouterInterface}
859
930
  *
860
- * @example
931
+ * @example Register and match
861
932
  * ```ts
862
- * import { createRouter } from '@src/core'
933
+ * import { createDispatcher, createRouter } from '@orkestrel/router'
863
934
  *
864
935
  * const router = createRouter<{ readonly page: string }>()
865
936
  * router.add({ path: '/users/:id', meta: { page: 'profile' } })
866
937
  * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }
938
+ *
939
+ * const dispatcher = createDispatcher<{ readonly userId: string }>({
940
+ * routes: [
941
+ * {
942
+ * method: 'GET',
943
+ * path: '/users/:id',
944
+ * handler: (_request, context) => Response.json(context.params),
945
+ * },
946
+ * ],
947
+ * })
948
+ * const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })
867
949
  * ```
868
950
  */
869
951
  function createRouter(options) {
870
952
  return new Router(options);
871
953
  }
872
954
  /**
873
- * Create a {@link DispatcherInterface} — the fetch-standard, method-
874
- * dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`.
955
+ * Creates a {@link DispatcherInterface} — the fetch-standard,
956
+ * method-dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`.
875
957
  *
876
958
  * @remarks
877
959
  * Prefer this over `new Dispatcher(...)` at call sites that only need the
@@ -880,8 +962,8 @@ function createRouter(options) {
880
962
  * @typeParam TState - The consumer's opaque per-request state type (default
881
963
  * `undefined` for stateless use)
882
964
  * @param options - Optional initial `routes`, the `sensitive` case toggle,
883
- * the `unmatched`/`unmethoded` default-responder overrides, and the AGENTS
884
- * §13 emitter `on`/`error` wiring
965
+ * the `unmatched`/`unmethoded` default-responder overrides, and the
966
+ * Emitter pattern's `on`/`error` wiring
885
967
  * @returns A {@link DispatcherInterface}
886
968
  *
887
969
  * @example
@@ -900,6 +982,6 @@ function createDispatcher(options) {
900
982
  return new Dispatcher(options);
901
983
  }
902
984
  //#endregion
903
- export { DispatchGroup, Dispatcher, Group, METHODS, Router, TIER_LITERAL, TIER_PARAM, TIER_WILDCARD, canonicalizePath, classifySegment, compareSpecificity, compilePath, computeDispatchKey, computeSpecificity, createDispatcher, createRouter, decodeParam, escapeRegExp, joinPaths, matchPath, parseMethod, route };
985
+ export { DispatchGroup, Dispatcher, Group, METHODS, METHOD_LIST, Router, TIER_LITERAL, TIER_PARAM, TIER_WILDCARD, canonicalizePath, classifySegment, compareSpecificity, compilePath, computeDispatchKey, computeSpecificity, createDispatcher, createRouter, decodeParam, defineRoute, escapeRegExp, joinPaths, matchPath, parseMethod };
904
986
 
905
987
  //# sourceMappingURL=index.js.map