@orkestrel/router 0.0.1 → 0.0.2

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.
@@ -0,0 +1,893 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_emitter = require("@orkestrel/emitter");
3
+ let _orkestrel_contract = require("@orkestrel/contract");
4
+ //#region src/core/constants.ts
5
+ /**
6
+ * The complete set of HTTP methods a {@link import('./types.js').Dispatcher}
7
+ * registers routes under — backs the registration guard (`add` rejects any
8
+ * `method` outside this set) and the auto-`OPTIONS` `Allow` derivation.
9
+ *
10
+ * @remarks
11
+ * A `ReadonlySet` of the seven {@link import('./types.js').Method} literals:
12
+ * `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. `HEAD` is
13
+ * included even though it is never required at registration (a `GET` route
14
+ * auto-answers `HEAD`) — it is still a valid method to register explicitly.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * METHODS.has('GET') // true
19
+ * METHODS.has('TRACE') // false
20
+ * ```
21
+ */
22
+ var METHODS = Object.freeze(/* @__PURE__ */ new Set([
23
+ "GET",
24
+ "POST",
25
+ "PUT",
26
+ "PATCH",
27
+ "DELETE",
28
+ "HEAD",
29
+ "OPTIONS"
30
+ ]));
31
+ /**
32
+ * Specificity tier for a **literal** path segment (`/users`) — the highest
33
+ * tier, always outranking a param or wildcard segment at the same position.
34
+ *
35
+ * @remarks
36
+ * Consumed by `computeSpecificity` (U1 `helpers.ts`) when ranking candidate
37
+ * matches left-to-right at the earliest differing segment (§4 precedence).
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * TIER_LITERAL > TIER_PARAM // true
42
+ * ```
43
+ */
44
+ var TIER_LITERAL = 2;
45
+ /**
46
+ * Specificity tier for a **param** path segment (`:name`) — ranks below a
47
+ * literal segment and above a wildcard segment at the same position.
48
+ *
49
+ * @remarks
50
+ * Consumed by `computeSpecificity` (U1 `helpers.ts`) alongside {@link TIER_LITERAL}
51
+ * and {@link TIER_WILDCARD}.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * TIER_PARAM > TIER_WILDCARD // true
56
+ * ```
57
+ */
58
+ var TIER_PARAM = 1;
59
+ /**
60
+ * Specificity tier for a **wildcard** path segment (`*name`) — the lowest
61
+ * tier; a wildcard only ever wins against another wildcard shape (an
62
+ * equal-specificity tie resolved by registration order).
63
+ *
64
+ * @remarks
65
+ * Consumed by `computeSpecificity` (U1 `helpers.ts`).
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * TIER_WILDCARD // 0
70
+ * ```
71
+ */
72
+ var TIER_WILDCARD = 0;
73
+ //#endregion
74
+ //#region src/core/helpers.ts
75
+ /**
76
+ * Escape every regex metacharacter in a literal string so it can be embedded
77
+ * inside a larger `RegExp` source without being interpreted as syntax.
78
+ *
79
+ * @remarks
80
+ * {@link compilePath} escapes the literal segments of a route pattern with this
81
+ * before splicing in `:name` / `*name` capture groups, so a path like
82
+ * `/files/:name.json` matches the `.` literally rather than as "any character".
83
+ * Pure and total — never throws.
84
+ *
85
+ * @param value - The literal string to escape
86
+ * @returns `value` with every regex metacharacter backslash-escaped
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * escapeRegExp('a.b+c') // 'a\\.b\\+c'
91
+ * new RegExp(`^${escapeRegExp('a.b')}$`).test('a.b') // true
92
+ * new RegExp(`^${escapeRegExp('a.b')}$`).test('axb') // false
93
+ * ```
94
+ */
95
+ function escapeRegExp(value) {
96
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
97
+ }
98
+ /**
99
+ * Canonicalize a route path for REGISTRY IDENTITY — strip a single trailing
100
+ * slash, except the root `/` (and the empty pattern). The trailing-slash fold
101
+ * {@link compilePath} normalizes a pattern through, so identity agrees with the
102
+ * matcher.
103
+ *
104
+ * @remarks
105
+ * Mirrors {@link compilePath}'s trailing-slash folding: `/users/` canonicalizes
106
+ * to `/users` (the two compile to the same regex and match the same
107
+ * pathnames), while the root `/` and the empty `''` are EXEMPT (a bare `/`
108
+ * already matches `/`; stripping it would break that). Pure and total — a path
109
+ * without a trailing slash returns unchanged.
110
+ *
111
+ * @param path - The route path pattern
112
+ * @returns The canonical path (one trailing slash removed, except `/` and `''`)
113
+ *
114
+ * @example
115
+ * ```ts
116
+ * canonicalizePath('/users/') // '/users'
117
+ * canonicalizePath('/users') // '/users'
118
+ * canonicalizePath('/') // '/'
119
+ * canonicalizePath('') // ''
120
+ * ```
121
+ */
122
+ function canonicalizePath(path) {
123
+ return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
124
+ }
125
+ /**
126
+ * Compile a route path pattern into an anchored regex and its ordered param
127
+ * names.
128
+ *
129
+ * @remarks
130
+ * Splits the CANONICALIZED path into segments. Each `:name` segment becomes a
131
+ * `([^/]+)` capture group; the FINAL segment may instead be `*name`, which
132
+ * becomes a `(.+)` capture spanning the REST of the path including slashes — a
133
+ * wildcard segment anywhere but last is a registration-time programmer error
134
+ * and throws `TypeError` (§14 construction/registration boundary). Every regex
135
+ * metacharacter in a literal segment is escaped first ({@link escapeRegExp}),
136
+ * so a path like `/files/:name.json` matches the `.` literally apart from the
137
+ * param. The regex is anchored (`^…$`), so it matches the whole pathname, not
138
+ * a prefix.
139
+ *
140
+ * **Trailing slash is INSENSITIVE** (Express's `strict: false` default): a
141
+ * single trailing slash on the request path is OPTIONAL, so `/users` matches
142
+ * both `/users` and `/users/`, and `/users/:id` matches both `/users/me` and
143
+ * `/users/me/`. This is NOT prefix matching — a deeper path is still a
144
+ * distinct segment, so `/api` does not match `/api/users`. The ROOT `/` (and
145
+ * the empty pattern `''`) are EXEMPT — they are not stripped, so `/` stays
146
+ * `^/$` and `''` stays `^$`.
147
+ *
148
+ * `sensitive` (default `true`) controls case folding: `false` adds the `i`
149
+ * regex flag, so `/Users` matches `/users`. The pattern's own casing is never
150
+ * altered — only the matching behavior.
151
+ *
152
+ * @param path - The route path pattern (e.g. `/users/:id`, `/files/*rest`)
153
+ * @param sensitive - Case-sensitive matching (default `true`)
154
+ * @returns The {@link CompiledPath} — its `regex` + ordered `params`
155
+ * @throws {TypeError} When a `*name` wildcard segment is not the FINAL segment
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * const { regex, params } = compilePath('/users/:id/posts/:slug')
160
+ * params // ['id', 'slug']
161
+ * regex.exec('/users/7/posts/hello') // ['…', '7', 'hello']
162
+ * regex.test('/users/7/posts/hello/') // true — the trailing slash is optional
163
+ *
164
+ * compilePath('/files/*rest').regex.test('/files/a/b.png') // true
165
+ * compilePath('/Users', false).regex.test('/users') // true — case-insensitive
166
+ * ```
167
+ */
168
+ function compilePath(path, sensitive = true) {
169
+ const params = [];
170
+ const normalized = canonicalizePath(path);
171
+ const segments = normalized.split("/");
172
+ const pattern = segments.map((segment, index) => {
173
+ const isFinal = index === segments.length - 1;
174
+ 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}"`);
175
+ const tier = classifySegment(segment, isFinal);
176
+ if (tier === 0) {
177
+ params.push(segment.slice(1));
178
+ return "(.+)";
179
+ }
180
+ if (tier === 1) {
181
+ const name = /^:([A-Za-z_]\w*)/.exec(segment)?.[1] ?? "";
182
+ params.push(name);
183
+ return `([^/]+)${escapeRegExp(segment.slice(1 + name.length))}`;
184
+ }
185
+ return escapeRegExp(segment);
186
+ }).join("/");
187
+ const suffix = normalized === "/" || normalized === "" ? "" : "/?";
188
+ const flags = sensitive ? "" : "i";
189
+ return {
190
+ regex: new RegExp(`^${pattern}${suffix}$`, flags),
191
+ params
192
+ };
193
+ }
194
+ /**
195
+ * URL-decode one captured param value, tolerating a malformed percent-escape —
196
+ * the decode {@link matchPath} applies to each captured group.
197
+ *
198
+ * @remarks
199
+ * A bad `%` sequence is not a reason to reject an otherwise-matching route, so
200
+ * a `decodeURIComponent` that would throw falls back to the raw value
201
+ * (mirroring the cookie / token boundary readers, AGENTS §14). Total — never
202
+ * throws.
203
+ *
204
+ * @param value - The raw captured param value
205
+ * @returns The URL-decoded value, or the raw value when decoding would throw
206
+ *
207
+ * @example
208
+ * ```ts
209
+ * decodeParam('a%2Fb') // 'a/b'
210
+ * decodeParam('100%25') // '100%'
211
+ * decodeParam('%') // '%' — malformed escape stays literal
212
+ * ```
213
+ */
214
+ function decodeParam(value) {
215
+ try {
216
+ return decodeURIComponent(value);
217
+ } catch {
218
+ return value;
219
+ }
220
+ }
221
+ /**
222
+ * Extract the URL-decoded params a compiled path captures from a concrete
223
+ * pathname, or `undefined` when the pathname does not match.
224
+ *
225
+ * @remarks
226
+ * Runs the {@link CompiledPath} `regex` against `pathname` (a single `exec`); a
227
+ * miss returns `undefined`. On a hit it walks `params` POSITIONALLY — the
228
+ * `n`-th param name pairs with the `n`-th capture group — and URL-decodes each
229
+ * value with {@link decodeParam}. Returns a frozen `name → value` record (empty
230
+ * for a parameterless path). Total — never throws.
231
+ *
232
+ * @param compiled - The {@link CompiledPath} from {@link compilePath}
233
+ * @param pathname - The concrete request pathname to match (e.g. `/users/7`)
234
+ * @returns The decoded params on a hit, or `undefined` on a miss
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * const compiled = compilePath('/users/:id')
239
+ * matchPath(compiled, '/users/7') // { id: '7' }
240
+ * matchPath(compiled, '/users/a%2Fb') // { id: 'a/b' } — decoded
241
+ * matchPath(compiled, '/posts/7') // undefined
242
+ * ```
243
+ */
244
+ function matchPath(compiled, pathname) {
245
+ const result = compiled.regex.exec(pathname);
246
+ if (result === null) return void 0;
247
+ const params = {};
248
+ for (let index = 0; index < compiled.params.length; index += 1) {
249
+ const name = compiled.params[index];
250
+ const value = result[index + 1];
251
+ if (name !== void 0 && value !== void 0) params[name] = decodeParam(value);
252
+ }
253
+ return Object.freeze(params);
254
+ }
255
+ /**
256
+ * Classify one path segment into its specificity TIER — the SAME syntax
257
+ * {@link compilePath} rewrites: a syntactically valid `:name` head is a PARAM
258
+ * segment, a final `*name` is a WILDCARD segment, everything else (including a
259
+ * literal segment that merely CONTAINS a `:` mid-string, e.g. `a:b`) is a
260
+ * LITERAL segment.
261
+ *
262
+ * @remarks
263
+ * This is the fix over the old engine's bug: the old classifier ranked any
264
+ * segment `includes(':')` as a param, so a literal segment like `a:b` was
265
+ * mis-tiered even though {@link compilePath} compiles it literally. Sharing one
266
+ * segment parser between compilation and classification keeps the two in
267
+ * agreement (§4 fixes). Pure and total.
268
+ *
269
+ * @param segment - One `/`-split path segment
270
+ * @param isFinal - Whether `segment` is the last segment of its path (only the
271
+ * final segment may be classified as a wildcard)
272
+ * @returns The segment's specificity tier — {@link import('./constants.js').TIER_LITERAL},
273
+ * {@link import('./constants.js').TIER_PARAM}, or
274
+ * {@link import('./constants.js').TIER_WILDCARD}
275
+ *
276
+ * @example
277
+ * ```ts
278
+ * classifySegment(':id', true) // 1 — TIER_PARAM
279
+ * classifySegment('*rest', true) // 0 — TIER_WILDCARD
280
+ * classifySegment('a:b', true) // 2 — TIER_LITERAL — the old bug's regression case
281
+ * classifySegment('users', false) // 2 — TIER_LITERAL
282
+ * ```
283
+ */
284
+ function classifySegment(segment, isFinal) {
285
+ if (isFinal && /^\*[A-Za-z_]\w*$/.test(segment)) return 0;
286
+ if (/^:[A-Za-z_]\w*/.test(segment)) return 1;
287
+ return 2;
288
+ }
289
+ /**
290
+ * Compute a route path's SPECIFICITY VECTOR — the per-segment type ranking
291
+ * that breaks a tie when several registered routes match the same concrete
292
+ * pathname.
293
+ *
294
+ * @remarks
295
+ * Splits the CANONICALIZED path into segments (on `/`) and maps each to its
296
+ * specificity tier via {@link classifySegment} — the same segment parser
297
+ * {@link compilePath} uses, so a literal segment that merely contains a `:`
298
+ * (e.g. `a:b`) is correctly tiered as literal rather than param (the old
299
+ * engine's bug, fixed here). The standard route-precedence rule compares two
300
+ * matching routes' vectors LEFT-TO-RIGHT: at the first index where the tiers
301
+ * differ, the HIGHER tier (a literal over a param over a wildcard) is MORE
302
+ * SPECIFIC and wins — so `/users/me` (`[2, 2]`) beats `/users/:id` (`[2, 1]`)
303
+ * beats `/users/*rest` (`[2, 0]`) regardless of registration order. Two routes
304
+ * that match the SAME concrete pathname necessarily have the same segment
305
+ * count in the common case; {@link compareSpecificity} handles the general
306
+ * case for totality.
307
+ *
308
+ * @param path - The route path pattern (e.g. `/users/:id`)
309
+ * @returns The per-segment specificity tiers, in order
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * computeSpecificity('/users/me') // [2, 2]
314
+ * computeSpecificity('/users/:id') // [2, 1]
315
+ * computeSpecificity('/files/*rest') // [2, 0]
316
+ * computeSpecificity('/a:b') // [2] — literal, not param — the classification fix
317
+ * ```
318
+ */
319
+ function computeSpecificity(path) {
320
+ const segments = canonicalizePath(path).split("/");
321
+ return segments.map((segment, index) => classifySegment(segment, index === segments.length - 1));
322
+ }
323
+ /**
324
+ * Compare two route paths by SPECIFICITY — the comparator that picks the
325
+ * most-specific matching route (literal-over-param-over-wildcard,
326
+ * registration-order-independent).
327
+ *
328
+ * @remarks
329
+ * Compares the two paths' {@link computeSpecificity} vectors LEFT-TO-RIGHT and
330
+ * returns a standard `Array.sort` ordering: a NEGATIVE number when `a` is MORE
331
+ * specific than `b` (so a descending-specificity sort puts `a` first),
332
+ * positive when `b` is more specific, `0` when neither out-ranks the other
333
+ * across the compared segments. At the first index where the tiers differ,
334
+ * the higher tier wins; if one vector is a prefix of the other (different
335
+ * segment counts), the LONGER, more-segmented path is treated as more
336
+ * specific (a missing segment ranks below any real one).
337
+ *
338
+ * @param a - The first route path
339
+ * @param b - The second route path
340
+ * @returns A negative number when `a` is more specific, positive when `b` is, else `0`
341
+ *
342
+ * @example
343
+ * ```ts
344
+ * compareSpecificity('/users/me', '/users/:id') // negative — literal wins
345
+ * compareSpecificity('/users/:id', '/users/*rest') // negative — param beats wildcard
346
+ * compareSpecificity('/users/:id', '/users/:id') // 0 — equal specificity
347
+ * ```
348
+ */
349
+ function compareSpecificity(a, b) {
350
+ const left = computeSpecificity(a);
351
+ const right = computeSpecificity(b);
352
+ const length = Math.max(left.length, right.length);
353
+ for (let index = 0; index < length; index += 1) {
354
+ const tierA = left[index] ?? -1;
355
+ const tierB = right[index] ?? -1;
356
+ if (tierA !== tierB) return tierB - tierA;
357
+ }
358
+ return 0;
359
+ }
360
+ /**
361
+ * Narrow a raw `request.method` string into a typed {@link Method} — total,
362
+ * never throws.
363
+ *
364
+ * @remarks
365
+ * Guarded via {@link import('./constants.js').METHODS} (the seven registrable
366
+ * HTTP methods); any other value (an unknown verb, non-uppercase casing)
367
+ * resolves to `undefined` rather than throwing (§14 guard totality). Pure
368
+ * leaf shared by the `Dispatcher`'s `handle` (§5.1 unknown-verb honesty) and
369
+ * anywhere else a raw method string needs narrowing.
370
+ *
371
+ * @param value - The raw `request.method` string to narrow
372
+ * @returns The matching {@link Method}, or `undefined` when `value` is not one
373
+ * of the seven registrable methods
374
+ *
375
+ * @example
376
+ * ```ts
377
+ * parseMethod('GET') // 'GET'
378
+ * parseMethod('PURGE') // undefined
379
+ * parseMethod('get') // undefined — case-sensitive
380
+ * ```
381
+ */
382
+ function parseMethod(value) {
383
+ if (value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE" || value === "HEAD" || value === "OPTIONS") return value;
384
+ }
385
+ /**
386
+ * Join a group prefix and a route path into one `/`-prefixed path, normalizing
387
+ * duplicate or missing joining slashes.
388
+ *
389
+ * @remarks
390
+ * {@link import('./types.js').GroupInterface} / {@link import('./types.js').DispatchGroupInterface}
391
+ * compose a prefix with each registered entry's path this way — pure string
392
+ * composition (§4.2.2), no independent state. Both a duplicated slash
393
+ * (`'/api/'` + `'/users'`) and a missing one (`'/api'` + `'users'`) normalize
394
+ * to a single joining slash. An empty `prefix` returns `path` unchanged (after
395
+ * ensuring a leading slash); an empty `path` returns `prefix` unchanged.
396
+ * Pure and total.
397
+ *
398
+ * @param prefix - The group prefix (e.g. `/api`)
399
+ * @param path - The route path being joined under the prefix (e.g. `/users`)
400
+ * @returns The joined `/`-prefixed path
401
+ *
402
+ * @example
403
+ * ```ts
404
+ * joinPaths('/api', '/users') // '/api/users'
405
+ * joinPaths('/api/', '/users') // '/api/users'
406
+ * joinPaths('/api', 'users') // '/api/users'
407
+ * joinPaths('', '/users') // '/users'
408
+ * joinPaths('/api', '') // '/api'
409
+ * ```
410
+ */
411
+ function joinPaths(prefix, path) {
412
+ if (prefix === "") return path.startsWith("/") ? path : `/${path}`;
413
+ if (path === "") return prefix;
414
+ return `${prefix.endsWith("/") ? prefix.slice(0, -1) : prefix}${path.startsWith("/") ? path : `/${path}`}`;
415
+ }
416
+ /**
417
+ * Identity pass-through for a {@link RouteInput} that pins its `Path` generic
418
+ * to the LITERAL registration-site string, so `context.params` types
419
+ * correctly through {@link PathParams} without an explicit type argument.
420
+ *
421
+ * @remarks
422
+ * A bare object literal handed straight to {@link import('./types.js').DispatcherInterface}'s
423
+ * `add` already infers `Path` as a literal at that call site — but the moment
424
+ * the object is built through an intermediate binding (a local `const route =
425
+ * { method, path, handler }`) TypeScript widens `path` to `string` unless the
426
+ * binding's own type is pinned. Wrapping the literal in `route(...)` supplies
427
+ * that pin: its `const Path extends string` type parameter infers the NARROW
428
+ * literal from the call, and the function returns its input completely
429
+ * unchanged (same reference, no cloning, no validation) — this is a
430
+ * compile-time typing aid only, not a construction step (contrast
431
+ * {@link import('./factories.js')} `create*` entity factories). A
432
+ * heterogeneous `RouteInput[]` built from several `route(...)` calls still
433
+ * widens each element's `Path` to `string` once collected into one array
434
+ * (§14) — the realistic ceiling this helper raises is PER-CALL typing at the
435
+ * registration site, not a stored, still-literal-typed record.
436
+ *
437
+ * @typeParam Path - The route path pattern literal (drives `context.params`
438
+ * via {@link PathParams})
439
+ * @typeParam TState - The consumer's opaque per-request state type
440
+ * @param input - The {@link RouteInput} to pass through unchanged
441
+ * @returns `input`, unchanged (same reference)
442
+ *
443
+ * @example
444
+ * ```ts
445
+ * const input = route({
446
+ * method: 'GET',
447
+ * path: '/users/:id',
448
+ * handler: (_request, context) => new Response(context.params.id), // typed string
449
+ * })
450
+ * dispatcher.add(input)
451
+ * ```
452
+ */
453
+ function route(input) {
454
+ return input;
455
+ }
456
+ //#endregion
457
+ //#region src/core/Group.ts
458
+ /**
459
+ * A prefix-scoped registration handle over a {@link import('./Router.js').Router} —
460
+ * pure string composition (AGENTS §4.2.2), no independent state or storage.
461
+ *
462
+ * @typeParam Meta - The entry payload type, matching the owning router
463
+ *
464
+ * @remarks
465
+ * Every `add` composes `entry.path` as `joinPaths(prefix, entry.path)` and
466
+ * forwards to the OWNING router, so grouped routes land in the SAME registry.
467
+ * `group(prefix)` nests, composing prefixes via {@link joinPaths}.
468
+ *
469
+ * @example
470
+ * ```ts
471
+ * import { Router } from '@src/core'
472
+ *
473
+ * const router = new Router<{ readonly page: string }>()
474
+ * const api = router.group('/api')
475
+ * api.add({ path: '/users', meta: { page: 'list' } })
476
+ * router.match('/api/users')?.path // '/api/users'
477
+ * ```
478
+ */
479
+ var Group = class Group {
480
+ prefix;
481
+ #parent;
482
+ constructor(parent, prefix) {
483
+ this.#parent = parent;
484
+ this.prefix = prefix;
485
+ }
486
+ add(input) {
487
+ const inputs = Array.isArray(input) ? input : [input];
488
+ this.#parent.add(inputs.map((entry) => ({
489
+ ...entry,
490
+ path: joinPaths(this.prefix, entry.path)
491
+ })));
492
+ }
493
+ group(prefix) {
494
+ return new Group(this.#parent, joinPaths(this.prefix, prefix));
495
+ }
496
+ };
497
+ //#endregion
498
+ //#region src/core/Router.ts
499
+ /**
500
+ * The path-matching + registry engine — registers `{ path, meta, name? }`
501
+ * entries (compiling each path once) and resolves a concrete pathname to the
502
+ * MOST SPECIFIC matching entry. The shared machine both the `Navigator`
503
+ * (browser) and the `Dispatcher` (core, method-dimensioned) compose.
504
+ *
505
+ * @typeParam Meta - The opaque payload each entry carries and a match returns
506
+ *
507
+ * @remarks
508
+ * - **Registration boundary guard (§14).** `add` validates each entry's
509
+ * `path` — `isString` plus a leading `/` — and throws `TypeError` on a
510
+ * malformed registration; `match` stays guard-free (the hot path).
511
+ * - **Compile-once.** Each path is compiled exactly once at registration into
512
+ * a parallel `#compiled` array, so `match` runs only a cached `exec` per
513
+ * candidate.
514
+ * - **Dedup via `key`.** When `options.key` is set, an entry whose computed
515
+ * key already exists REPLACES the prior one IN PLACE (both the `#entries`
516
+ * and `#compiled` arrays, at the existing index) — last write wins, no
517
+ * engine rebuild. Omitted ⇒ every entry is kept, even duplicate paths.
518
+ * - **Groups.** `group(prefix)` returns a {@link GroupInterface} that composes
519
+ * `prefix` onto every entry it registers, nesting via {@link joinPaths}.
520
+ *
521
+ * @example
522
+ * ```ts
523
+ * const router = new Router<{ readonly page: string }>()
524
+ * router.add({ path: '/users/:id', meta: { page: 'profile' } })
525
+ * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }
526
+ * ```
527
+ */
528
+ var Router = class {
529
+ #entries = [];
530
+ #compiled = [];
531
+ #sensitive;
532
+ #key;
533
+ #index = /* @__PURE__ */ new Map();
534
+ constructor(options) {
535
+ this.#sensitive = options?.sensitive ?? true;
536
+ this.#key = options?.key;
537
+ if (options?.entries !== void 0) this.add(options.entries);
538
+ }
539
+ get count() {
540
+ return this.#entries.length;
541
+ }
542
+ add(input) {
543
+ const inputs = Array.isArray(input) ? input : [input];
544
+ for (const entry of inputs) this.#register(entry);
545
+ }
546
+ match(pathname, answers) {
547
+ let best;
548
+ for (let index = 0; index < this.#entries.length; index += 1) {
549
+ const entry = this.#entries[index];
550
+ const compiled = this.#compiled[index];
551
+ if (entry === void 0 || compiled === void 0) continue;
552
+ if (answers !== void 0 && !answers(entry.meta)) continue;
553
+ const params = matchPath(compiled, pathname);
554
+ if (params === void 0) continue;
555
+ if (best === void 0 || compareSpecificity(entry.path, best.entry.path) < 0) best = {
556
+ entry,
557
+ params
558
+ };
559
+ }
560
+ if (best === void 0) return void 0;
561
+ return {
562
+ path: best.entry.path,
563
+ params: best.params,
564
+ meta: best.entry.meta,
565
+ name: best.entry.name
566
+ };
567
+ }
568
+ entries(pathname) {
569
+ if (pathname === void 0) return [...this.#entries];
570
+ const out = [];
571
+ for (let index = 0; index < this.#entries.length; index += 1) {
572
+ const entry = this.#entries[index];
573
+ const compiled = this.#compiled[index];
574
+ if (entry === void 0 || compiled === void 0) continue;
575
+ if (matchPath(compiled, pathname) !== void 0) out.push(entry);
576
+ }
577
+ return out;
578
+ }
579
+ group(prefix) {
580
+ return new Group(this, prefix);
581
+ }
582
+ clear() {
583
+ this.#entries.length = 0;
584
+ this.#compiled.length = 0;
585
+ this.#index.clear();
586
+ }
587
+ #register(entry) {
588
+ if (!(0, _orkestrel_contract.isString)(entry.path) || !entry.path.startsWith("/")) throw new TypeError(`a route path must be a string starting with "/", got ${JSON.stringify(entry.path)}`);
589
+ const compiled = compilePath(entry.path, this.#sensitive);
590
+ if (this.#key === void 0) {
591
+ this.#entries.push(entry);
592
+ this.#compiled.push(compiled);
593
+ return;
594
+ }
595
+ const key = this.#key(entry);
596
+ const existing = this.#index.get(key);
597
+ if (existing !== void 0) {
598
+ this.#entries[existing] = entry;
599
+ this.#compiled[existing] = compiled;
600
+ return;
601
+ }
602
+ this.#index.set(key, this.#entries.length);
603
+ this.#entries.push(entry);
604
+ this.#compiled.push(compiled);
605
+ }
606
+ };
607
+ //#endregion
608
+ //#region src/core/DispatchGroup.ts
609
+ /**
610
+ * A prefix-scoped registration handle over a
611
+ * {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned
612
+ * counterpart of `Group` (`Group.ts`).
613
+ *
614
+ * @typeParam TState - The consumer's opaque per-request state type, matching
615
+ * the owning dispatcher
616
+ *
617
+ * @remarks
618
+ * Every `add` composes `input.path` via {@link joinPaths} against
619
+ * `this.prefix` and forwards to the OWNING dispatcher's `add` (its own §14
620
+ * boundary guard still applies). Pure string composition (§4.2.2) — no
621
+ * independent state or storage.
622
+ *
623
+ * @example
624
+ * ```ts
625
+ * import { Dispatcher } from '@src/core'
626
+ *
627
+ * const dispatcher = new Dispatcher()
628
+ * const api = dispatcher.group('/api')
629
+ * api.add({ method: 'GET', path: '/users', handler: () => new Response('ok') })
630
+ * ```
631
+ */
632
+ var DispatchGroup = class DispatchGroup {
633
+ prefix;
634
+ #parent;
635
+ constructor(parent, prefix) {
636
+ this.#parent = parent;
637
+ this.prefix = prefix;
638
+ }
639
+ add(input) {
640
+ const inputs = Array.isArray(input) ? input : [input];
641
+ this.#parent.add(inputs.map((route) => ({
642
+ ...route,
643
+ path: joinPaths(this.prefix, route.path)
644
+ })));
645
+ }
646
+ group(prefix) {
647
+ return new DispatchGroup(this.#parent, joinPaths(this.prefix, prefix));
648
+ }
649
+ };
650
+ //#endregion
651
+ //#region src/core/Dispatcher.ts
652
+ /**
653
+ * The fetch-standard, method-dimensioned dispatch entity — layers HTTP method
654
+ * dispatch and web-standard `Request`/`Response` handling over one internal
655
+ * `Router<RouteRecord<TState>>`. The core machine the eventual server face
656
+ * (§7) and any fetch-native runtime consumes directly.
657
+ *
658
+ * @typeParam TState - The consumer's opaque per-request state type
659
+ *
660
+ * @remarks
661
+ * - **Dedup by `method + canonicalizePath`.** The underlying `Router` is
662
+ * constructed with a `key` function so registering the same method+path
663
+ * twice REPLACES the prior route in place (§5.1).
664
+ * - **Registration boundary guard (§14).** `add` validates each input's
665
+ * `handler` (`isFunction`) and `method` (must be in {@link METHODS}) —
666
+ * throws `TypeError` on a malformed registration; path validation is
667
+ * delegated to the underlying `Router`'s own guard. `match`/`handle` stay
668
+ * guard-free.
669
+ * - **Auto-`HEAD` / auto-`OPTIONS`.** A `HEAD` request with no registered
670
+ * `HEAD` route runs the matching `GET` handler and strips the response
671
+ * body; an `OPTIONS` request with no registered `OPTIONS` route answers
672
+ * `204` with a derived `Allow` header.
673
+ * - **Handler throws propagate.** `handle` never invents an error boundary —
674
+ * a handler throw reaches the caller uncaught (§5.1).
675
+ * - **Emitter (§13).** Owns a `#emitter` for {@link DispatcherEventMap};
676
+ * `match`/`miss` fire AFTER resolution, before the handler/responder runs.
677
+ *
678
+ * @example
679
+ * ```ts
680
+ * const dispatcher = new Dispatcher<{ readonly userId: string }>()
681
+ * dispatcher.add({
682
+ * method: 'GET',
683
+ * path: '/users/:id',
684
+ * handler: (request, context) => Response.json({ id: context.params.id }),
685
+ * })
686
+ * const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })
687
+ * ```
688
+ */
689
+ var Dispatcher = class {
690
+ router;
691
+ #emitter;
692
+ #unmatched;
693
+ #unmethoded;
694
+ constructor(options) {
695
+ this.router = new Router({
696
+ sensitive: options?.sensitive,
697
+ key: (entry) => `${entry.meta.method} ${canonicalizePath(entry.path)}`
698
+ });
699
+ this.#emitter = new _orkestrel_emitter.Emitter({
700
+ on: options?.on,
701
+ error: options?.error
702
+ });
703
+ this.#unmatched = options?.unmatched ?? ((_request) => new Response("Not Found", { status: 404 }));
704
+ this.#unmethoded = options?.unmethoded ?? ((_request, allow) => new Response("Method Not Allowed", {
705
+ status: 405,
706
+ headers: { Allow: allow.join(", ") }
707
+ }));
708
+ if (options?.routes !== void 0) this.add(options.routes);
709
+ }
710
+ get emitter() {
711
+ return this.#emitter;
712
+ }
713
+ add(input) {
714
+ const inputs = Array.isArray(input) ? input : [input];
715
+ for (const route of inputs) this.#register(route);
716
+ }
717
+ group(prefix) {
718
+ return new DispatchGroup(this, prefix);
719
+ }
720
+ match(method, pathname) {
721
+ const hit = this.router.match(pathname, (meta) => meta.method === method);
722
+ if (hit !== void 0) return {
723
+ status: "matched",
724
+ match: hit
725
+ };
726
+ if (method === "HEAD") {
727
+ const getHit = this.router.match(pathname, (meta) => meta.method === "GET");
728
+ if (getHit !== void 0) return {
729
+ status: "matched",
730
+ match: getHit
731
+ };
732
+ }
733
+ const allow = this.#allow(pathname);
734
+ if (allow.length === 0) return { status: "unmatched" };
735
+ return {
736
+ status: "unmethoded",
737
+ allow
738
+ };
739
+ }
740
+ async handle(request, state) {
741
+ const url = new URL(request.url);
742
+ const pathname = url.pathname;
743
+ const requested = request.method;
744
+ const method = parseMethod(requested);
745
+ if (method === void 0) {
746
+ const allow = this.#allow(pathname);
747
+ if (allow.length === 0) {
748
+ this.#emitter.emit("miss", requested, pathname, "unmatched");
749
+ return this.#unmatched(request);
750
+ }
751
+ this.#emitter.emit("miss", requested, pathname, "unmethoded");
752
+ return this.#unmethoded(request, allow);
753
+ }
754
+ const result = this.match(method, pathname);
755
+ if (result.status === "matched") return this.#respondMatched(request, state, method, result.match, url);
756
+ if (result.status === "unmethoded") {
757
+ if (method === "OPTIONS") return this.#respondAutoOptions(pathname, result.allow);
758
+ this.#emitter.emit("miss", method, pathname, "unmethoded");
759
+ return this.#unmethoded(request, result.allow);
760
+ }
761
+ this.#emitter.emit("miss", method, pathname, "unmatched");
762
+ return this.#unmatched(request);
763
+ }
764
+ destroy() {
765
+ this.#emitter.destroy();
766
+ }
767
+ #register(input) {
768
+ if (!(0, _orkestrel_contract.isFunction)(input.handler)) throw new TypeError(`a route handler must be a function, got ${JSON.stringify(input.handler)}`);
769
+ if (!(0, _orkestrel_contract.isString)(input.method) || !METHODS.has(input.method)) throw new TypeError(`a route method must be one of ${[...METHODS].join(", ")}, got ${JSON.stringify(input.method)}`);
770
+ this.router.add({
771
+ path: input.path,
772
+ name: input.name,
773
+ meta: {
774
+ method: input.method,
775
+ handler: input.handler,
776
+ name: input.name
777
+ }
778
+ });
779
+ }
780
+ #allow(pathname) {
781
+ const entries = this.router.entries(pathname);
782
+ const methods = /* @__PURE__ */ new Set();
783
+ for (const entry of entries) methods.add(entry.meta.method);
784
+ if (methods.has("GET")) methods.add("HEAD");
785
+ return [...methods];
786
+ }
787
+ async #respondMatched(request, state, method, match, url) {
788
+ this.#emitter.emit("match", method, match.path);
789
+ const context = {
790
+ params: match.params,
791
+ pattern: match.path,
792
+ url,
793
+ state
794
+ };
795
+ const response = await match.meta.handler(request, context);
796
+ if (method === "HEAD" && match.meta.method === "GET") return new Response(null, {
797
+ status: response.status,
798
+ statusText: response.statusText,
799
+ headers: response.headers
800
+ });
801
+ return response;
802
+ }
803
+ #respondAutoOptions(pathname, allow) {
804
+ this.#emitter.emit("match", "OPTIONS", pathname);
805
+ const headers = new Headers({ Allow: [...allow, "OPTIONS"].join(", ") });
806
+ return new Response(null, {
807
+ status: 204,
808
+ headers
809
+ });
810
+ }
811
+ };
812
+ //#endregion
813
+ //#region src/core/factories.ts
814
+ /**
815
+ * Create a {@link RouterInterface} — the pure path-matching + registry engine
816
+ * shared by the browser `Navigator` and the core `Dispatcher`.
817
+ *
818
+ * @remarks
819
+ * Prefer this over `new Router(...)` at call sites that only need the
820
+ * interface; an entity that OWNS a `Router` internally (like `Dispatcher`)
821
+ * still constructs `new Router(...)` directly.
822
+ *
823
+ * @typeParam Meta - The opaque payload each entry carries and a match returns
824
+ * @param options - Optional initial `entries`, the `sensitive` case toggle
825
+ * (default `true`), and a `key` dedup identity function
826
+ * @returns A {@link RouterInterface}
827
+ *
828
+ * @example
829
+ * ```ts
830
+ * import { createRouter } from '@src/core'
831
+ *
832
+ * const router = createRouter<{ readonly page: string }>()
833
+ * router.add({ path: '/users/:id', meta: { page: 'profile' } })
834
+ * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }
835
+ * ```
836
+ */
837
+ function createRouter(options) {
838
+ return new Router(options);
839
+ }
840
+ /**
841
+ * Create a {@link DispatcherInterface} — the fetch-standard, method-
842
+ * dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`.
843
+ *
844
+ * @remarks
845
+ * Prefer this over `new Dispatcher(...)` at call sites that only need the
846
+ * interface.
847
+ *
848
+ * @typeParam TState - The consumer's opaque per-request state type (default
849
+ * `undefined` for stateless use)
850
+ * @param options - Optional initial `routes`, the `sensitive` case toggle,
851
+ * the `unmatched`/`unmethoded` default-responder overrides, and the AGENTS
852
+ * §13 emitter `on`/`error` wiring
853
+ * @returns A {@link DispatcherInterface}
854
+ *
855
+ * @example
856
+ * ```ts
857
+ * import { createDispatcher } from '@src/core'
858
+ *
859
+ * const dispatcher = createDispatcher<{ readonly userId: string }>({
860
+ * routes: [
861
+ * { method: 'GET', path: '/health', handler: () => new Response('ok') },
862
+ * ],
863
+ * })
864
+ * const response = await dispatcher.handle(new Request('http://x/health'), { userId: 'me' })
865
+ * ```
866
+ */
867
+ function createDispatcher(options) {
868
+ return new Dispatcher(options);
869
+ }
870
+ //#endregion
871
+ exports.DispatchGroup = DispatchGroup;
872
+ exports.Dispatcher = Dispatcher;
873
+ exports.Group = Group;
874
+ exports.METHODS = METHODS;
875
+ exports.Router = Router;
876
+ exports.TIER_LITERAL = TIER_LITERAL;
877
+ exports.TIER_PARAM = TIER_PARAM;
878
+ exports.TIER_WILDCARD = TIER_WILDCARD;
879
+ exports.canonicalizePath = canonicalizePath;
880
+ exports.classifySegment = classifySegment;
881
+ exports.compareSpecificity = compareSpecificity;
882
+ exports.compilePath = compilePath;
883
+ exports.computeSpecificity = computeSpecificity;
884
+ exports.createDispatcher = createDispatcher;
885
+ exports.createRouter = createRouter;
886
+ exports.decodeParam = decodeParam;
887
+ exports.escapeRegExp = escapeRegExp;
888
+ exports.joinPaths = joinPaths;
889
+ exports.matchPath = matchPath;
890
+ exports.parseMethod = parseMethod;
891
+ exports.route = route;
892
+
893
+ //# sourceMappingURL=index.cjs.map