@orkestrel/router 0.0.1

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