@orkestrel/router 0.0.13 → 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.
- package/README.md +14 -14
- package/dist/src/browser/index.d.ts +47 -29
- package/dist/src/browser/index.js +3 -2
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +44 -33
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +206 -108
- package/dist/src/core/index.d.ts +206 -108
- package/dist/src/core/index.js +44 -33
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +14 -5
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +17 -8
- package/dist/src/server/index.d.ts +17 -8
- package/dist/src/server/index.js +14 -5
- package/dist/src/server/index.js.map +1 -1
- package/package.json +10 -11
package/dist/src/core/index.js
CHANGED
|
@@ -2,10 +2,10 @@ import { ContractError, isFunction, isString, preview } from "@orkestrel/contrac
|
|
|
2
2
|
import { Emitter } from "@orkestrel/emitter";
|
|
3
3
|
//#region src/core/constants.ts
|
|
4
4
|
/**
|
|
5
|
-
* Lists the HTTP methods a {@link import('./types.js').DispatcherInterface}
|
|
6
|
-
*
|
|
7
|
-
* {@link import('./types.js').Method} type, {@link METHODS}, and
|
|
8
|
-
*
|
|
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.
|
|
9
9
|
*
|
|
10
10
|
* @remarks
|
|
11
11
|
* A frozen tuple of the verbs: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`,
|
|
@@ -30,10 +30,9 @@ var METHOD_LIST = Object.freeze([
|
|
|
30
30
|
"OPTIONS"
|
|
31
31
|
]);
|
|
32
32
|
/**
|
|
33
|
-
* Holds
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* and the auto-`OPTIONS` `Allow` derivation.
|
|
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.
|
|
37
36
|
*
|
|
38
37
|
* @remarks
|
|
39
38
|
* A `ReadonlySet` built from {@link METHOD_LIST}, so it carries exactly the
|
|
@@ -118,7 +117,7 @@ function escapeRegExp(value) {
|
|
|
118
117
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
118
|
}
|
|
120
119
|
/**
|
|
121
|
-
* Canonicalizes a route path for
|
|
120
|
+
* Canonicalizes a route path for registry identity — strips a single trailing
|
|
122
121
|
* slash, except the root `/` (and the empty pattern). The trailing-slash fold
|
|
123
122
|
* {@link compilePath} normalizes a pattern through, so identity agrees with the
|
|
124
123
|
* matcher.
|
|
@@ -145,7 +144,8 @@ function canonicalizePath(path) {
|
|
|
145
144
|
return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
146
145
|
}
|
|
147
146
|
/**
|
|
148
|
-
* Computes the registry key for a method-dimensioned
|
|
147
|
+
* Computes the canonical `METHOD /path` registry key for a method-dimensioned
|
|
148
|
+
* dispatcher route.
|
|
149
149
|
*
|
|
150
150
|
* @remarks
|
|
151
151
|
* Combines the route record's HTTP method with the outer entry's canonical
|
|
@@ -242,7 +242,7 @@ function compilePath(path, sensitive = true) {
|
|
|
242
242
|
};
|
|
243
243
|
}
|
|
244
244
|
/**
|
|
245
|
-
*
|
|
245
|
+
* Decodes one captured param value from a URL, tolerating a malformed percent-escape —
|
|
246
246
|
* the decode {@link matchPath} applies to each captured group.
|
|
247
247
|
*
|
|
248
248
|
* @remarks
|
|
@@ -303,11 +303,11 @@ function matchPath(compiled, pathname) {
|
|
|
303
303
|
return Object.freeze(params);
|
|
304
304
|
}
|
|
305
305
|
/**
|
|
306
|
-
* Classifies one path segment into its specificity
|
|
307
|
-
* {@link compilePath} rewrites: a syntactically valid `:name` head is a
|
|
308
|
-
* segment, a final `*name` is a
|
|
309
|
-
* literal segment that merely
|
|
310
|
-
*
|
|
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.
|
|
311
311
|
*
|
|
312
312
|
* @remarks
|
|
313
313
|
* This is the fix over the old engine's bug: the old classifier ranked any
|
|
@@ -338,7 +338,7 @@ function classifySegment(segment, isFinal) {
|
|
|
338
338
|
return 2;
|
|
339
339
|
}
|
|
340
340
|
/**
|
|
341
|
-
* Computes a route path's
|
|
341
|
+
* Computes a route path's specificity vector — the per-segment type ranking
|
|
342
342
|
* that breaks a tie when several registered routes match the same concrete
|
|
343
343
|
* pathname.
|
|
344
344
|
*
|
|
@@ -372,7 +372,7 @@ function computeSpecificity(path) {
|
|
|
372
372
|
return segments.map((segment, index) => classifySegment(segment, index === segments.length - 1));
|
|
373
373
|
}
|
|
374
374
|
/**
|
|
375
|
-
* Compares two route paths by
|
|
375
|
+
* Compares two route paths by specificity — the comparator that picks the
|
|
376
376
|
* most-specific matching route (literal-over-param-over-wildcard,
|
|
377
377
|
* registration-order-independent).
|
|
378
378
|
*
|
|
@@ -440,8 +440,8 @@ function joinPaths(prefix, path) {
|
|
|
440
440
|
return `${prefix.endsWith("/") ? prefix.slice(0, -1) : prefix}${path.startsWith("/") ? path : `/${path}`}`;
|
|
441
441
|
}
|
|
442
442
|
/**
|
|
443
|
-
* Provides an identity pass-through for a {@link RouteInput} that pins its `Path`
|
|
444
|
-
* to the
|
|
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
|
|
445
445
|
* correctly through {@link PathParams} without an explicit type argument.
|
|
446
446
|
*
|
|
447
447
|
* @remarks
|
|
@@ -552,9 +552,9 @@ var Group = class Group {
|
|
|
552
552
|
//#region src/core/Router.ts
|
|
553
553
|
/**
|
|
554
554
|
* Represents the path-matching + registry engine — registers `{ path, meta, name? }`
|
|
555
|
-
* entries (compiling each path once) and resolves a concrete pathname to the
|
|
556
|
-
*
|
|
557
|
-
*
|
|
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.
|
|
558
558
|
*
|
|
559
559
|
* @typeParam Meta - The opaque payload each entry carries and a match returns
|
|
560
560
|
*
|
|
@@ -677,8 +677,8 @@ var Router = class {
|
|
|
677
677
|
//#region src/core/DispatchGroup.ts
|
|
678
678
|
/**
|
|
679
679
|
* Represents a prefix-scoped registration handle over a
|
|
680
|
-
* {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned
|
|
681
|
-
*
|
|
680
|
+
* {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned counterpart of
|
|
681
|
+
* `Group`.
|
|
682
682
|
*
|
|
683
683
|
* @typeParam TState - The consumer's opaque per-request state type, matching
|
|
684
684
|
* the owning dispatcher
|
|
@@ -719,10 +719,10 @@ var DispatchGroup = class DispatchGroup {
|
|
|
719
719
|
//#endregion
|
|
720
720
|
//#region src/core/Dispatcher.ts
|
|
721
721
|
/**
|
|
722
|
-
* Represents the fetch-standard, method-dimensioned dispatch entity — layers HTTP
|
|
723
|
-
* dispatch and web-standard `Request`/`Response` handling over one internal
|
|
724
|
-
* `Router<RouteRecord<TState>>`. The core machine the
|
|
725
|
-
*
|
|
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.
|
|
726
726
|
*
|
|
727
727
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
728
728
|
*
|
|
@@ -928,21 +928,32 @@ var Dispatcher = class {
|
|
|
928
928
|
* (default `true`), and a `key` dedup identity function
|
|
929
929
|
* @returns A {@link RouterInterface}
|
|
930
930
|
*
|
|
931
|
-
* @example
|
|
931
|
+
* @example Register and match
|
|
932
932
|
* ```ts
|
|
933
|
-
* import { createRouter } from '@
|
|
933
|
+
* import { createDispatcher, createRouter } from '@orkestrel/router'
|
|
934
934
|
*
|
|
935
935
|
* const router = createRouter<{ readonly page: string }>()
|
|
936
936
|
* router.add({ path: '/users/:id', meta: { page: 'profile' } })
|
|
937
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' })
|
|
938
949
|
* ```
|
|
939
950
|
*/
|
|
940
951
|
function createRouter(options) {
|
|
941
952
|
return new Router(options);
|
|
942
953
|
}
|
|
943
954
|
/**
|
|
944
|
-
* Creates a {@link DispatcherInterface} — the fetch-standard,
|
|
945
|
-
* 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>>`.
|
|
946
957
|
*
|
|
947
958
|
* @remarks
|
|
948
959
|
* Prefer this over `new Dispatcher(...)` at call sites that only need the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["#parent","#entries","#compiled","#sensitive","#key","#index","#register","#parent","#router","#emitter","#unmatched","#unmethoded","#register","#allow","#respondUnmatched","#respondUnmethoded","#respondMatched","#respondAutoOptions"],"sources":["../../../src/core/constants.ts","../../../src/core/helpers.ts","../../../src/core/parsers.ts","../../../src/core/Group.ts","../../../src/core/Router.ts","../../../src/core/DispatchGroup.ts","../../../src/core/Dispatcher.ts","../../../src/core/factories.ts"],"sourcesContent":["// ============================================================================\n// Core constants — the centralized-file rule's home for module-scope data used by\n// the matching engine and the fetch dispatcher. Every declaration here is frozen\n// and `export`ed per the centralized-file rule.\n// ============================================================================\n\n/**\n * Lists the HTTP methods a {@link import('./types.js').DispatcherInterface}\n * registers routes under, in canonical order — the single source the\n * {@link import('./types.js').Method} type, {@link METHODS}, and\n * `parseMethod` are all derived from.\n *\n * @remarks\n * A frozen tuple of the verbs: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`,\n * `HEAD`, `OPTIONS`. Adding a verb here widens the `Method` type, the\n * {@link METHODS} membership set, and the `parseMethod` narrowing together, so\n * the method set cannot drift between them. Prefer {@link METHODS} for a\n * membership test; use this tuple where order or literal typing matters.\n *\n * @example\n * ```ts\n * METHOD_LIST[0] // 'GET'\n * METHOD_LIST.includes('GET') // true\n * ```\n */\nexport const METHOD_LIST = Object.freeze([\n\t'GET',\n\t'POST',\n\t'PUT',\n\t'PATCH',\n\t'DELETE',\n\t'HEAD',\n\t'OPTIONS',\n] as const)\n\n/**\n * Holds the complete set of HTTP methods a\n * {@link import('./types.js').DispatcherInterface} registers routes under —\n * backs the registration guard (`add` rejects any `method` outside this set)\n * and the auto-`OPTIONS` `Allow` derivation.\n *\n * @remarks\n * A `ReadonlySet` built from {@link METHOD_LIST}, so it carries exactly the\n * {@link import('./types.js').Method} literals: `GET`, `POST`, `PUT`,\n * `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. `HEAD` is included even though it is\n * never required at registration (a `GET` route auto-answers `HEAD`) — it is\n * still a valid method to register explicitly. The element type stays `string`\n * so a raw, unnarrowed `request.method` can be tested directly.\n *\n * @example\n * ```ts\n * METHODS.has('GET') // true\n * METHODS.has('TRACE') // false\n * ```\n */\nexport const METHODS: ReadonlySet<string> = Object.freeze(new Set<string>(METHOD_LIST))\n\n/**\n * Names the specificity tier for a **literal** path segment (`/users`) — the highest\n * tier, always outranking a param or wildcard segment at the same position.\n *\n * @remarks\n * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`) when ranking candidate\n * matches left-to-right at the earliest differing segment.\n *\n * @example\n * ```ts\n * TIER_LITERAL > TIER_PARAM // true\n * ```\n */\nexport const TIER_LITERAL = 2\n\n/**\n * Names the specificity tier for a **param** path segment (`:name`) — ranks below a\n * literal segment and above a wildcard segment at the same position.\n *\n * @remarks\n * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`) alongside {@link TIER_LITERAL}\n * and {@link TIER_WILDCARD}.\n *\n * @example\n * ```ts\n * TIER_PARAM > TIER_WILDCARD // true\n * ```\n */\nexport const TIER_PARAM = 1\n\n/**\n * Names the specificity tier for a **wildcard** path segment (`*name`) — the lowest\n * tier; a wildcard only ever wins against another wildcard shape (an\n * equal-specificity tie resolved by registration order).\n *\n * @remarks\n * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`).\n *\n * @example\n * ```ts\n * TIER_WILDCARD // 0\n * ```\n */\nexport const TIER_WILDCARD = 0\n","import type { CompiledPath, Method, RouteEntry, RouteInput } from './types.js'\nimport { ContractError, preview } from '@orkestrel/contract'\nimport { TIER_LITERAL, TIER_PARAM, TIER_WILDCARD } from './constants.js'\n\n// The PURE path-matching primitives (self-describing helper naming — module scope,\n// no entity context). Every one is exported (the centralized-file rule): a\n// consumer composes them directly, or reaches them through the `Router` engine.\n// They speak ONLY `string` / `RegExp` / `Record` and `decodeURIComponent` (a\n// platform global valid in Node and the browser alike) — NO DOM, NO `node:*` — so\n// the SAME engine drives a method-dimensioned server dispatcher and a method-less\n// browser navigator.\n\n/**\n * Escapes every regex metacharacter in a literal string so it can be embedded\n * inside a larger `RegExp` source without being interpreted as syntax.\n *\n * @remarks\n * {@link compilePath} escapes the literal segments of a route pattern with this\n * before splicing in `:name` / `*name` capture groups, so a path like\n * `/files/:name.json` matches the `.` literally rather than as \"any character\".\n * Pure and total — never throws.\n *\n * @param value - The literal string to escape\n * @returns `value` with every regex metacharacter backslash-escaped\n *\n * @example\n * ```ts\n * escapeRegExp('a.b+c') // 'a\\\\.b\\\\+c'\n * new RegExp(`^${escapeRegExp('a.b')}$`).test('a.b') // true\n * new RegExp(`^${escapeRegExp('a.b')}$`).test('axb') // false\n * ```\n */\nexport function escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/**\n * Canonicalizes a route path for REGISTRY IDENTITY — strips a single trailing\n * slash, except the root `/` (and the empty pattern). The trailing-slash fold\n * {@link compilePath} normalizes a pattern through, so identity agrees with the\n * matcher.\n *\n * @remarks\n * Mirrors {@link compilePath}'s trailing-slash folding: `/users/` canonicalizes\n * to `/users` (the two compile to the same regex and match the same\n * pathnames), while the root `/` and the empty `''` are EXEMPT (a bare `/`\n * already matches `/`; stripping it would break that). Pure and total — a path\n * without a trailing slash returns unchanged.\n *\n * @param path - The route path pattern\n * @returns The canonical path (one trailing slash removed, except `/` and `''`)\n *\n * @example\n * ```ts\n * canonicalizePath('/users/') // '/users'\n * canonicalizePath('/users') // '/users'\n * canonicalizePath('/') // '/'\n * canonicalizePath('') // ''\n * ```\n */\nexport function canonicalizePath(path: string): string {\n\treturn path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path\n}\n\n/**\n * Computes the registry key for a method-dimensioned dispatcher route.\n *\n * @remarks\n * Combines the route record's HTTP method with the outer entry's canonical\n * path, so registrations that differ only by one trailing slash replace each\n * other while registrations for different methods remain distinct.\n *\n * @param entry - The dispatcher route entry to identify\n * @returns The canonical `METHOD /path` registry key\n *\n * @example\n * ```ts\n * computeDispatchKey({ path: '/health/', meta: { method: 'GET' } }) // 'GET /health'\n * ```\n */\nexport function computeDispatchKey(entry: RouteEntry<{ readonly method: Method }>): string {\n\treturn `${entry.meta.method} ${canonicalizePath(entry.path)}`\n}\n\n/**\n * Compiles a route path pattern into an anchored regex and its ordered param\n * names.\n *\n * @remarks\n * Splits the CANONICALIZED path into segments. Each `:name` segment becomes a\n * `([^/]+)` capture group; the FINAL segment may instead be `*name`, which\n * becomes a `(.+)` capture spanning the REST of the path including slashes — a\n * wildcard segment anywhere but last is a registration-time programmer error\n * and throws a `ContractError` at the construction/registration boundary. Every regex\n * metacharacter in a literal segment is escaped first ({@link escapeRegExp}),\n * so a path like `/files/:name.json` matches the `.` literally apart from the\n * param. The regex is anchored (`^…$`), so it matches the whole pathname, not\n * a prefix.\n *\n * **Trailing slash is INSENSITIVE** (Express's `strict: false` default): a\n * single trailing slash on the request path is OPTIONAL, so `/users` matches\n * both `/users` and `/users/`, and `/users/:id` matches both `/users/me` and\n * `/users/me/`. This is NOT prefix matching — a deeper path is still a\n * distinct segment, so `/api` does not match `/api/users`. The ROOT `/` (and\n * the empty pattern `''`) are EXEMPT — they are not stripped, so `/` stays\n * `^/$` and `''` stays `^$`.\n *\n * `sensitive` (default `true`) controls case folding: `false` adds the `i`\n * regex flag, so `/Users` matches `/users`. The pattern's own casing is never\n * altered — only the matching behavior.\n *\n * @param path - The route path pattern (for example `/users/:id`, `/files/*rest`)\n * @param sensitive - If `true`, matching is case-sensitive; if `false`, case is\n * folded during matching. Default: `true`\n * @returns The {@link CompiledPath} — its `regex` + ordered `params`\n * @throws {@link import('@orkestrel/contract').ContractError} Thrown when a\n * `*name` wildcard segment is not the FINAL segment\n *\n * @example\n * ```ts\n * const { regex, params } = compilePath('/users/:id/posts/:slug')\n * params // ['id', 'slug']\n * regex.exec('/users/7/posts/hello') // ['…', '7', 'hello']\n * regex.test('/users/7/posts/hello/') // true — the trailing slash is optional\n *\n * compilePath('/files/*rest').regex.test('/files/a/b.png') // true\n * compilePath('/Users', false).regex.test('/users') // true — case-insensitive\n * ```\n */\nexport function compilePath(path: string, sensitive = true): CompiledPath {\n\tconst params: string[] = []\n\t// Normalize ONE trailing slash off the pattern so `/users/` compiles like `/users`\n\t// — except the root `/` (and the empty pattern), which must keep matching `/` / `''`.\n\tconst normalized = canonicalizePath(path)\n\tconst segments = normalized.split('/')\n\tconst compiledSegments = segments.map((segment, index) => {\n\t\tconst isFinal = index === segments.length - 1\n\t\t// A wildcard-shaped segment head (`*name`) is only valid as the FINAL segment — a\n\t\t// registration-time programmer error anywhere else, guarded at the boundary.\n\t\tif (!isFinal && /^\\*[A-Za-z_]\\w*/.test(segment))\n\t\t\tthrow new ContractError('a wildcard segment must be the final segment of a path pattern', {\n\t\t\t\tcode: 'placement',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['path'],\n\t\t\t\t\tlimit: `a wildcard only in the final segment, not \"${segment}\"`,\n\t\t\t\t\treceived: preview(path),\n\t\t\t\t},\n\t\t\t})\n\t\t// Classification and compilation share ONE segment parser — the tier\n\t\t// {@link classifySegment} assigns is exactly the shape compiled here.\n\t\tconst tier = classifySegment(segment, isFinal)\n\t\tif (tier === TIER_WILDCARD) {\n\t\t\tparams.push(segment.slice(1))\n\t\t\treturn '(.+)'\n\t\t}\n\t\tif (tier === TIER_PARAM) {\n\t\t\tconst match = /^:([A-Za-z_]\\w*)/.exec(segment)\n\t\t\tconst name = match?.[1] ?? ''\n\t\t\tparams.push(name)\n\t\t\treturn `([^/]+)${escapeRegExp(segment.slice(1 + name.length))}`\n\t\t}\n\t\treturn escapeRegExp(segment)\n\t})\n\tconst pattern = compiledSegments.join('/')\n\t// Allow ONE optional trailing slash before the `$` anchor (Express `strict: false`),\n\t// EXCEPT for the root `/` and the empty pattern — those anchor exactly so they keep\n\t// matching only `/` / `''` (a bare `/?` there would let `''` match `/` and vice versa).\n\tconst suffix = normalized === '/' || normalized === '' ? '' : '/?'\n\tconst flags = sensitive ? '' : 'i'\n\treturn { regex: new RegExp(`^${pattern}${suffix}$`, flags), params }\n}\n\n/**\n * URL-decodes one captured param value, tolerating a malformed percent-escape —\n * the decode {@link matchPath} applies to each captured group.\n *\n * @remarks\n * A bad `%` sequence is not a reason to reject an otherwise-matching route, so\n * a `decodeURIComponent` that would throw falls back to the raw value\n * (mirroring the cookie / token boundary readers). Total — never\n * throws.\n *\n * @param value - The raw captured param value\n * @returns The URL-decoded value, or the raw value when decoding would throw\n *\n * @example\n * ```ts\n * decodeParam('a%2Fb') // 'a/b'\n * decodeParam('100%25') // '100%'\n * decodeParam('%') // '%' — malformed escape stays literal\n * ```\n */\nexport function decodeParam(value: string): string {\n\ttry {\n\t\treturn decodeURIComponent(value)\n\t} catch {\n\t\t// A malformed `%` escape stays literal rather than throwing — the match still succeeds.\n\t\treturn value\n\t}\n}\n\n/**\n * Extracts the URL-decoded params a compiled path captures from a concrete\n * pathname, or `undefined` when the pathname does not match.\n *\n * @remarks\n * Runs the {@link CompiledPath} `regex` against `pathname` (a single `exec`); a\n * miss returns `undefined`. On a hit it walks `params` POSITIONALLY — the\n * `n`-th param name pairs with the `n`-th capture group — and URL-decodes each\n * value with {@link decodeParam}. Returns a frozen `name → value` record (empty\n * for a parameterless path). Total — never throws.\n *\n * @param compiled - The {@link CompiledPath} from {@link compilePath}\n * @param pathname - The concrete request pathname to match (for example `/users/7`)\n * @returns The decoded params on a hit, or `undefined` on a miss\n *\n * @example\n * ```ts\n * const compiled = compilePath('/users/:id')\n * matchPath(compiled, '/users/7') // { id: '7' }\n * matchPath(compiled, '/users/a%2Fb') // { id: 'a/b' } — decoded\n * matchPath(compiled, '/posts/7') // undefined\n * ```\n */\nexport function matchPath(\n\tcompiled: CompiledPath,\n\tpathname: string,\n): Readonly<Record<string, string>> | undefined {\n\tconst result = compiled.regex.exec(pathname)\n\tif (result === null) return undefined\n\tconst params: Record<string, string> = {}\n\tfor (let index = 0; index < compiled.params.length; index += 1) {\n\t\tconst name = compiled.params[index]\n\t\tconst value = result[index + 1]\n\t\tif (name !== undefined && value !== undefined) params[name] = decodeParam(value)\n\t}\n\treturn Object.freeze(params)\n}\n\n/**\n * Classifies one path segment into its specificity TIER — the SAME syntax\n * {@link compilePath} rewrites: a syntactically valid `:name` head is a PARAM\n * segment, a final `*name` is a WILDCARD segment, everything else (including a\n * literal segment that merely CONTAINS a `:` mid-string, for example `a:b`) is a\n * LITERAL segment.\n *\n * @remarks\n * This is the fix over the old engine's bug: the old classifier ranked any\n * segment `includes(':')` as a param, so a literal segment like `a:b` was\n * mis-tiered even though {@link compilePath} compiles it literally. Sharing one\n * segment parser between compilation and classification keeps the two in\n * agreement. Pure and total.\n *\n * @param segment - One `/`-split path segment\n * @param isFinal - If `true`, `segment` is the path's last segment and may\n * classify as a wildcard; if `false`, a wildcard-shaped segment classifies as\n * a literal\n * @returns The segment's specificity tier — {@link import('./constants.js').TIER_LITERAL},\n * {@link import('./constants.js').TIER_PARAM}, or\n * {@link import('./constants.js').TIER_WILDCARD}\n *\n * @example\n * ```ts\n * classifySegment(':id', true) // 1 — TIER_PARAM\n * classifySegment('*rest', true) // 0 — TIER_WILDCARD\n * classifySegment('a:b', true) // 2 — TIER_LITERAL — the old bug's regression case\n * classifySegment('users', false) // 2 — TIER_LITERAL\n * ```\n */\nexport function classifySegment(segment: string, isFinal: boolean): number {\n\tif (isFinal && /^\\*[A-Za-z_]\\w*$/.test(segment)) return TIER_WILDCARD\n\tif (/^:[A-Za-z_]\\w*/.test(segment)) return TIER_PARAM\n\treturn TIER_LITERAL\n}\n\n/**\n * Computes a route path's SPECIFICITY VECTOR — the per-segment type ranking\n * that breaks a tie when several registered routes match the same concrete\n * pathname.\n *\n * @remarks\n * Splits the CANONICALIZED path into segments (on `/`) and maps each to its\n * specificity tier through {@link classifySegment} — the same segment parser\n * {@link compilePath} uses, so a literal segment that merely contains a `:`\n * (for example `a:b`) is correctly tiered as literal rather than param (the old\n * engine's bug, fixed here). The standard route-precedence rule compares two\n * matching routes' vectors LEFT-TO-RIGHT: at the first index where the tiers\n * differ, the HIGHER tier (a literal over a param over a wildcard) is MORE\n * SPECIFIC and wins — so `/users/me` (`[2, 2]`) beats `/users/:id` (`[2, 1]`)\n * beats `/users/*rest` (`[2, 0]`) regardless of registration order. Two routes\n * that match the SAME concrete pathname necessarily have the same segment\n * count in the common case; {@link compareSpecificity} handles the general\n * case for totality.\n *\n * @param path - The route path pattern (for example `/users/:id`)\n * @returns The per-segment specificity tiers, in order\n *\n * @example\n * ```ts\n * computeSpecificity('/users/me') // [2, 2]\n * computeSpecificity('/users/:id') // [2, 1]\n * computeSpecificity('/files/*rest') // [2, 0]\n * computeSpecificity('/a:b') // [2] — literal, not param — the classification fix\n * ```\n */\nexport function computeSpecificity(path: string): readonly number[] {\n\tconst segments = canonicalizePath(path).split('/')\n\treturn segments.map((segment, index) => classifySegment(segment, index === segments.length - 1))\n}\n\n/**\n * Compares two route paths by SPECIFICITY — the comparator that picks the\n * most-specific matching route (literal-over-param-over-wildcard,\n * registration-order-independent).\n *\n * @remarks\n * Compares the two paths' {@link computeSpecificity} vectors LEFT-TO-RIGHT and\n * returns a standard `Array.sort` ordering: a NEGATIVE number when `a` is MORE\n * specific than `b` (so a descending-specificity sort puts `a` first),\n * positive when `b` is more specific, `0` when neither out-ranks the other\n * across the compared segments. At the first index where the tiers differ,\n * the higher tier wins; if one vector is a prefix of the other (different\n * segment counts), the LONGER, more-segmented path is treated as more\n * specific (a missing segment ranks below any real one).\n *\n * @param a - The first route path\n * @param b - The second route path\n * @returns A negative number when `a` is more specific, positive when `b` is, else `0`\n *\n * @example\n * ```ts\n * compareSpecificity('/users/me', '/users/:id') // negative — literal wins\n * compareSpecificity('/users/:id', '/users/*rest') // negative — param beats wildcard\n * compareSpecificity('/users/:id', '/users/:id') // 0 — equal specificity\n * ```\n */\nexport function compareSpecificity(a: string, b: string): number {\n\tconst left = computeSpecificity(a)\n\tconst right = computeSpecificity(b)\n\tconst length = Math.max(left.length, right.length)\n\tfor (let index = 0; index < length; index += 1) {\n\t\t// A missing segment ranks below any real one (the shorter path is less specific).\n\t\tconst tierA = left[index] ?? -1\n\t\tconst tierB = right[index] ?? -1\n\t\tif (tierA !== tierB) return tierB - tierA\n\t}\n\treturn 0\n}\n\n/**\n * Joins a group prefix and a route path into one `/`-prefixed path, normalizing\n * duplicate or missing joining slashes.\n *\n * @remarks\n * {@link import('./types.js').GroupInterface} / {@link import('./types.js').DispatchGroupInterface}\n * compose a prefix with each registered entry's path this way — pure string\n * composition, no independent state. Both a duplicated slash\n * (`'/api/'` + `'/users'`) and a missing one (`'/api'` + `'users'`) normalize\n * to a single joining slash. An empty `prefix` returns `path` unchanged (after\n * ensuring a leading slash); an empty `path` returns `prefix` unchanged.\n * Pure and total.\n *\n * @param prefix - The group prefix (for example `/api`)\n * @param path - The route path being joined under the prefix (for example `/users`)\n * @returns The joined `/`-prefixed path\n *\n * @example\n * ```ts\n * joinPaths('/api', '/users') // '/api/users'\n * joinPaths('/api/', '/users') // '/api/users'\n * joinPaths('/api', 'users') // '/api/users'\n * joinPaths('', '/users') // '/users'\n * joinPaths('/api', '') // '/api'\n * ```\n */\nexport function joinPaths(prefix: string, path: string): string {\n\tif (prefix === '') return path.startsWith('/') ? path : `/${path}`\n\tif (path === '') return prefix\n\tconst left = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix\n\tconst right = path.startsWith('/') ? path : `/${path}`\n\treturn `${left}${right}`\n}\n\n/**\n * Provides an identity pass-through for a {@link RouteInput} that pins its `Path` generic\n * to the LITERAL registration-site string, so `context.params` types\n * correctly through {@link PathParams} without an explicit type argument.\n *\n * @remarks\n * A bare object literal handed straight to {@link import('./types.js').DispatcherInterface}'s\n * `add` already infers `Path` as a literal at that call site — but the moment\n * the object is built through an intermediate binding (a local `const route =\n * { method, path, handler }`) TypeScript widens `path` to `string` unless the\n * binding's own type is pinned. Wrapping the literal in `defineRoute(...)`\n * supplies that pin: its `const Path extends string` type parameter infers the\n * NARROW literal from the call, and the function returns its input completely\n * unchanged (same reference, no cloning, no validation) — this is a\n * compile-time typing aid only, not a construction step (contrast\n * {@link import('./factories.js')} `create*` entity factories). A\n * heterogeneous `RouteInput[]` built from several `defineRoute(...)` calls\n * still widens each element's `Path` to `string` after collection into one array —\n * the realistic ceiling this helper raises is PER-CALL typing at the\n * registration site, not a stored, still-literal-typed record.\n *\n * @typeParam Path - The route path pattern literal (drives `context.params`\n * through {@link PathParams})\n * @typeParam TState - The consumer's opaque per-request state type\n * @param input - The {@link RouteInput} to pass through unchanged\n * @returns `input`, unchanged (same reference)\n *\n * @example\n * ```ts\n * const input = defineRoute({\n * \tmethod: 'GET',\n * \tpath: '/users/:id',\n * \thandler: (_request, context) => new Response(context.params.id), // typed string\n * })\n * dispatcher.add(input)\n * ```\n */\nexport function defineRoute<const Path extends string, TState = undefined>(\n\tinput: RouteInput<Path, TState>,\n): RouteInput<Path, TState> {\n\treturn input\n}\n","// ============================================================================\n// Core coercers — the centralized-file rule's home for `parse*` narrowing leaves that\n// turn a raw external string into a typed core value or `undefined`. Every\n// declaration here is total and `export`ed per the centralized-file rule.\n// ============================================================================\n\nimport type { Method } from './types.js'\nimport { METHOD_LIST } from './constants.js'\n\n/**\n * Narrows a raw `request.method` string into a typed {@link Method} — total,\n * never throws.\n *\n * @remarks\n * Consults {@link import('./constants.js').METHOD_LIST} (the one home for the\n * registrable HTTP methods), so a verb added there narrows here without\n * a second list to update; any other value (an unknown verb, non-uppercase\n * casing) resolves to `undefined` rather than throwing (total guard behavior).\n * Pure leaf shared by the `Dispatcher`'s `handle` (honest about an unknown verb)\n * and anywhere else a raw method string needs narrowing.\n *\n * @param value - The raw `request.method` string to narrow\n * @returns The matching {@link Method}, or `undefined` when `value` is not a\n * registrable method\n *\n * @example\n * ```ts\n * parseMethod('GET') // 'GET'\n * parseMethod('PURGE') // undefined\n * parseMethod('get') // undefined — case-sensitive\n * ```\n */\nexport function parseMethod(value: string): Method | undefined {\n\treturn METHOD_LIST.find((method) => method === value)\n}\n","import type { GroupInterface, RouteEntry, RouterInterface } from './types.js'\nimport { joinPaths } from './helpers.js'\n\n/**\n * Represents a prefix-scoped registration handle over a {@link import('./Router.js').Router} —\n * pure string composition, no independent state or storage.\n *\n * @typeParam Meta - The entry payload type, matching the owning router\n *\n * @remarks\n * Every `add` composes `entry.path` as `joinPaths(prefix, entry.path)` and\n * forwards to the OWNING router, so grouped routes land in the SAME registry.\n * `group(prefix)` nests, composing prefixes through {@link joinPaths}.\n *\n * @example\n * ```ts\n * import { Router } from '@src/core'\n *\n * const router = new Router<{ readonly page: string }>()\n * const api = router.group('/api')\n * api.add({ path: '/users', meta: { page: 'list' } })\n * router.match('/api/users')?.path // '/api/users'\n * ```\n */\nexport class Group<Meta> implements GroupInterface<Meta> {\n\treadonly prefix: string\n\treadonly #parent: RouterInterface<Meta>\n\n\tconstructor(parent: RouterInterface<Meta>, prefix: string) {\n\t\tthis.#parent = parent\n\t\tthis.prefix = prefix\n\t}\n\n\tadd(entry: RouteEntry<Meta>): void\n\tadd(entries: ReadonlyArray<RouteEntry<Meta>>): void\n\tadd(input: RouteEntry<Meta> | ReadonlyArray<RouteEntry<Meta>>): void {\n\t\tconst inputs = Array.isArray(input) ? input : [input]\n\t\tthis.#parent.add(\n\t\t\tinputs.map((entry) => ({ ...entry, path: joinPaths(this.prefix, entry.path) })),\n\t\t)\n\t}\n\n\tgroup(prefix: string): GroupInterface<Meta> {\n\t\treturn new Group<Meta>(this.#parent, joinPaths(this.prefix, prefix))\n\t}\n}\n","import type {\n\tAnswerHandler,\n\tCompiledPath,\n\tGroupInterface,\n\tRouteEntry,\n\tRouterInterface,\n\tRouterMatch,\n\tRouterOptions,\n} from './types.js'\nimport { ContractError, isString, preview } from '@orkestrel/contract'\nimport { compareSpecificity, compilePath, matchPath } from './helpers.js'\nimport { Group } from './Group.js'\n\n/**\n * Represents the path-matching + registry engine — registers `{ path, meta, name? }`\n * entries (compiling each path once) and resolves a concrete pathname to the\n * MOST SPECIFIC matching entry. The shared machine both the `Navigator`\n * (browser) and the `Dispatcher` (core, method-dimensioned) compose.\n *\n * @typeParam Meta - The opaque payload each entry carries and a match returns\n *\n * @remarks\n * - **Registration boundary guard.** `add` validates each entry's\n * `path` — `isString` plus a leading `/` — and throws a `ContractError` on a\n * malformed registration; `match` stays guard-free (the hot path).\n * - **Compile-once.** Each path is compiled exactly once at registration into\n * a parallel `#compiled` array, so `match` runs only a cached `exec` per\n * candidate.\n * - **Dedup through `key`.** When `options.key` is set, an entry whose computed\n * key already exists REPLACES the prior one IN PLACE (both the `#entries`\n * and `#compiled` arrays, at the existing index) — last write wins, no\n * engine rebuild. Omitted ⇒ every entry is kept, even duplicate paths.\n * - **Groups.** `group(prefix)` returns a {@link GroupInterface} that composes\n * `prefix` onto every entry it registers, nesting through {@link joinPaths}.\n *\n * @example\n * ```ts\n * const router = new Router<{ readonly page: string }>()\n * router.add({ path: '/users/:id', meta: { page: 'profile' } })\n * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }\n * ```\n */\nexport class Router<Meta> implements RouterInterface<Meta> {\n\treadonly #entries: Array<RouteEntry<Meta>> = []\n\treadonly #compiled: CompiledPath[] = []\n\treadonly #sensitive: boolean\n\treadonly #key: ((entry: RouteEntry<Meta>) => string) | undefined\n\treadonly #index: Map<string, number> = new Map()\n\n\tconstructor(options?: RouterOptions<Meta>) {\n\t\tthis.#sensitive = options?.sensitive ?? true\n\t\tthis.#key = options?.key\n\t\tif (options?.entries !== undefined) this.add(options.entries)\n\t}\n\n\tget count(): number {\n\t\treturn this.#entries.length\n\t}\n\n\tadd(entry: RouteEntry<Meta>): void\n\tadd(entries: ReadonlyArray<RouteEntry<Meta>>): void\n\tadd(input: RouteEntry<Meta> | ReadonlyArray<RouteEntry<Meta>>): void {\n\t\tconst inputs = Array.isArray(input) ? input : [input]\n\t\tfor (const entry of inputs) this.#register(entry)\n\t}\n\n\tmatch(pathname: string, answers?: AnswerHandler<Meta>): RouterMatch<Meta> | undefined {\n\t\tlet best: { entry: RouteEntry<Meta>; params: Readonly<Record<string, string>> } | undefined\n\t\tfor (let index = 0; index < this.#entries.length; index += 1) {\n\t\t\tconst entry = this.#entries[index]\n\t\t\tconst compiled = this.#compiled[index]\n\t\t\tif (entry === undefined || compiled === undefined) continue\n\t\t\tif (answers !== undefined && !answers(entry.meta)) continue\n\t\t\tconst params = matchPath(compiled, pathname)\n\t\t\tif (params === undefined) continue\n\t\t\tif (best === undefined || compareSpecificity(entry.path, best.entry.path) < 0)\n\t\t\t\tbest = { entry, params }\n\t\t}\n\t\tif (best === undefined) return undefined\n\t\treturn {\n\t\t\tpath: best.entry.path,\n\t\t\tparams: best.params,\n\t\t\tmeta: best.entry.meta,\n\t\t\t...(best.entry.name === undefined ? {} : { name: best.entry.name }),\n\t\t}\n\t}\n\n\tentries(): ReadonlyArray<RouteEntry<Meta>>\n\tentries(pathname: string): ReadonlyArray<RouteEntry<Meta>>\n\tentries(pathname?: string): ReadonlyArray<RouteEntry<Meta>> {\n\t\tif (pathname === undefined) return [...this.#entries]\n\t\tconst out: Array<RouteEntry<Meta>> = []\n\t\tfor (let index = 0; index < this.#entries.length; index += 1) {\n\t\t\tconst entry = this.#entries[index]\n\t\t\tconst compiled = this.#compiled[index]\n\t\t\tif (entry === undefined || compiled === undefined) continue\n\t\t\tif (matchPath(compiled, pathname) !== undefined) out.push(entry)\n\t\t}\n\t\treturn out\n\t}\n\n\tgroup(prefix: string): GroupInterface<Meta> {\n\t\treturn new Group<Meta>(this, prefix)\n\t}\n\n\tclear(): void {\n\t\tthis.#entries.length = 0\n\t\tthis.#compiled.length = 0\n\t\tthis.#index.clear()\n\t}\n\n\t// Validate the registration boundary (isString + leading '/'), then either replace an\n\t// existing entry IN PLACE (dedup through `#key`, last write wins) or append a new one — the\n\t// engine's compile-once invariant, kept in sync across the `#entries`/`#compiled` pair.\n\t#register(entry: RouteEntry<Meta>): void {\n\t\tif (!isString(entry.path))\n\t\t\tthrow new ContractError('a route path must be a string', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: { path: ['entry', 'path'], limit: 'string', received: preview(entry.path) },\n\t\t\t})\n\t\tif (!entry.path.startsWith('/'))\n\t\t\tthrow new ContractError('a route path must start with \"/\"', {\n\t\t\t\tcode: 'pattern',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['entry', 'path'],\n\t\t\t\t\tlimit: 'a \"/\"-prefixed path pattern',\n\t\t\t\t\treceived: preview(entry.path),\n\t\t\t\t},\n\t\t\t})\n\t\tconst compiled = compilePath(entry.path, this.#sensitive)\n\t\tif (this.#key === undefined) {\n\t\t\tthis.#entries.push(entry)\n\t\t\tthis.#compiled.push(compiled)\n\t\t\treturn\n\t\t}\n\t\tconst key = this.#key(entry)\n\t\tconst existing = this.#index.get(key)\n\t\tif (existing !== undefined) {\n\t\t\tthis.#entries[existing] = entry\n\t\t\tthis.#compiled[existing] = compiled\n\t\t\treturn\n\t\t}\n\t\tthis.#index.set(key, this.#entries.length)\n\t\tthis.#entries.push(entry)\n\t\tthis.#compiled.push(compiled)\n\t}\n}\n","import type { DispatchGroupInterface, DispatcherInterface, RouteInput } from './types.js'\nimport { joinPaths } from './helpers.js'\n\n/**\n * Represents a prefix-scoped registration handle over a\n * {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned\n * counterpart of `Group` (`Group.ts`).\n *\n * @typeParam TState - The consumer's opaque per-request state type, matching\n * the owning dispatcher\n *\n * @remarks\n * Every `add` composes `input.path` through {@link joinPaths} against\n * `this.prefix` and forwards to the OWNING dispatcher's `add` (its own\n * registration boundary guard still applies). Pure string composition — no\n * independent state or storage.\n *\n * @example\n * ```ts\n * import { Dispatcher } from '@src/core'\n *\n * const dispatcher = new Dispatcher()\n * const api = dispatcher.group('/api')\n * api.add({ method: 'GET', path: '/users', handler: () => new Response('ok') })\n * ```\n */\nexport class DispatchGroup<TState> implements DispatchGroupInterface<TState> {\n\treadonly prefix: string\n\treadonly #parent: DispatcherInterface<TState>\n\n\tconstructor(parent: DispatcherInterface<TState>, prefix: string) {\n\t\tthis.#parent = parent\n\t\tthis.prefix = prefix\n\t}\n\n\tadd<Path extends string>(input: RouteInput<Path, TState>): void\n\tadd(inputs: ReadonlyArray<RouteInput<string, TState>>): void\n\tadd(input: RouteInput<string, TState> | ReadonlyArray<RouteInput<string, TState>>): void {\n\t\tconst inputs = Array.isArray(input) ? input : [input]\n\t\tthis.#parent.add(\n\t\t\tinputs.map((route) => ({ ...route, path: joinPaths(this.prefix, route.path) })),\n\t\t)\n\t}\n\n\tgroup(prefix: string): DispatchGroupInterface<TState> {\n\t\treturn new DispatchGroup<TState>(this.#parent, joinPaths(this.prefix, prefix))\n\t}\n}\n","import type {\n\tDispatchGroupInterface,\n\tDispatcherEventMap,\n\tDispatcherInterface,\n\tDispatcherOptions,\n\tDispatchResult,\n\tMethod,\n\tRouteContext,\n\tRouteEntry,\n\tRouteInput,\n\tRouteRecord,\n\tRouterInterface,\n\tRouterMatch,\n} from './types.js'\nimport type { EmitterInterface } from '@orkestrel/emitter'\nimport { Emitter } from '@orkestrel/emitter'\nimport { ContractError, isFunction, isString, preview } from '@orkestrel/contract'\nimport { METHODS } from './constants.js'\nimport { computeDispatchKey } from './helpers.js'\nimport { parseMethod } from './parsers.js'\nimport { Router } from './Router.js'\nimport { DispatchGroup } from './DispatchGroup.js'\n\n/**\n * Represents the fetch-standard, method-dimensioned dispatch entity — layers HTTP method\n * dispatch and web-standard `Request`/`Response` handling over one internal\n * `Router<RouteRecord<TState>>`. The core machine the eventual server face\n * and any fetch-native runtime consumes directly.\n *\n * @typeParam TState - The consumer's opaque per-request state type\n *\n * @remarks\n * - **Dedup by `method + canonicalizePath`.** The underlying `Router` is\n * constructed with a `key` function so registering the same method+path\n * twice REPLACES the prior route in place.\n * - **Registration boundary guard.** `add` validates each input's\n * `handler` (`isFunction`) and `method` (must be in {@link METHODS}) —\n * throws a `ContractError` on a malformed registration; path validation is\n * delegated to the underlying `Router`'s own guard. `match`/`handle` stay\n * guard-free.\n * - **Auto-`HEAD` / auto-`OPTIONS`.** A `HEAD` request with no registered\n * `HEAD` route runs the matching `GET` handler and strips the response\n * body; an `OPTIONS` request with no registered `OPTIONS` route answers\n * `204` with a derived `Allow` header.\n * - **Handler throws propagate.** `handle` never invents an error boundary —\n * a handler throw reaches the caller uncaught.\n * - **Emitter.** Owns a `#emitter` for {@link DispatcherEventMap};\n * `match`/`miss` fire AFTER resolution, before the handler/responder runs.\n *\n * @example\n * ```ts\n * const dispatcher = new Dispatcher<{ readonly userId: string }>()\n * dispatcher.add({\n * \tmethod: 'GET',\n * \tpath: '/users/:id',\n * \thandler: (request, context) => Response.json({ id: context.params.id }),\n * })\n * const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })\n * ```\n */\nexport class Dispatcher<TState = undefined> implements DispatcherInterface<TState> {\n\treadonly #router: RouterInterface<RouteRecord<TState>>\n\treadonly #emitter: Emitter<DispatcherEventMap>\n\treadonly #unmatched: DispatcherOptions<TState>['unmatched']\n\treadonly #unmethoded: DispatcherOptions<TState>['unmethoded']\n\n\tconstructor(options?: DispatcherOptions<TState>) {\n\t\tconst sensitive = options?.sensitive\n\t\tconst on = options?.on\n\t\tconst error = options?.error\n\t\tthis.#router = new Router<RouteRecord<TState>>({\n\t\t\t...(sensitive === undefined ? {} : { sensitive }),\n\t\t\tkey: computeDispatchKey,\n\t\t})\n\t\tthis.#emitter = new Emitter<DispatcherEventMap>({\n\t\t\t...(on === undefined ? {} : { on }),\n\t\t\t...(error === undefined ? {} : { error }),\n\t\t})\n\t\tthis.#unmatched = options?.unmatched\n\t\tthis.#unmethoded = options?.unmethoded\n\t\tif (options?.routes !== undefined) this.add(options.routes)\n\t}\n\n\tget router(): RouterInterface<RouteRecord<TState>> {\n\t\treturn this.#router\n\t}\n\n\tget emitter(): EmitterInterface<DispatcherEventMap> {\n\t\treturn this.#emitter\n\t}\n\n\tadd<Path extends string>(input: RouteInput<Path, TState>): void\n\tadd(inputs: ReadonlyArray<RouteInput<string, TState>>): void\n\tadd(input: RouteInput<string, TState> | ReadonlyArray<RouteInput<string, TState>>): void {\n\t\tconst inputs = Array.isArray(input) ? input : [input]\n\t\tfor (const route of inputs) this.#register(route)\n\t}\n\n\tgroup(prefix: string): DispatchGroupInterface<TState> {\n\t\treturn new DispatchGroup<TState>(this, prefix)\n\t}\n\n\tmatch(method: Method, pathname: string): DispatchResult<TState> {\n\t\tconst hit = this.#router.match(pathname, (meta) => meta.method === method)\n\t\tif (hit !== undefined) return { status: 'matched', match: hit }\n\t\tif (method === 'HEAD') {\n\t\t\tconst getHit = this.#router.match(pathname, (meta) => meta.method === 'GET')\n\t\t\tif (getHit !== undefined) return { status: 'matched', match: getHit }\n\t\t}\n\t\tconst allow = this.#allow(pathname)\n\t\tif (allow.length === 0) return { status: 'unmatched' }\n\t\treturn { status: 'unmethoded', allow }\n\t}\n\n\tasync handle(request: Request, state: TState): Promise<Response> {\n\t\tconst url = new URL(request.url)\n\t\tconst pathname = url.pathname\n\t\tconst requested = request.method\n\t\tconst method = parseMethod(requested)\n\t\tif (method === undefined) {\n\t\t\tconst allow = this.#allow(pathname)\n\t\t\tif (allow.length === 0) {\n\t\t\t\tthis.#emitter.emit('miss', requested, pathname, 'unmatched')\n\t\t\t\treturn this.#respondUnmatched(request)\n\t\t\t}\n\t\t\tthis.#emitter.emit('miss', requested, pathname, 'unmethoded')\n\t\t\treturn this.#respondUnmethoded(request, allow)\n\t\t}\n\t\tconst result = this.match(method, pathname)\n\t\tif (result.status === 'matched')\n\t\t\treturn this.#respondMatched(request, state, method, result.match, url)\n\t\tif (result.status === 'unmethoded') {\n\t\t\tif (method === 'OPTIONS') {\n\t\t\t\tconst hit = this.#router.match(pathname)\n\t\t\t\tif (hit !== undefined) return this.#respondAutoOptions(hit.path, result.allow)\n\t\t\t}\n\t\t\tthis.#emitter.emit('miss', method, pathname, 'unmethoded')\n\t\t\treturn this.#respondUnmethoded(request, result.allow)\n\t\t}\n\t\tthis.#emitter.emit('miss', method, pathname, 'unmatched')\n\t\treturn this.#respondUnmatched(request)\n\t}\n\n\tdestroy(): void {\n\t\tthis.#emitter.destroy()\n\t}\n\n\t// Validate the registration boundary (handler function, known method), then delegate\n\t// path validation + dedup to the underlying `Router`'s own guard.\n\t#register(input: RouteInput<string, TState>): void {\n\t\tif (!isFunction(input.handler))\n\t\t\tthrow new ContractError('a route handler must be a function', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['input', 'handler'],\n\t\t\t\t\tlimit: 'function',\n\t\t\t\t\treceived: preview(input.handler),\n\t\t\t\t},\n\t\t\t})\n\t\tif (!isString(input.method) || !METHODS.has(input.method))\n\t\t\tthrow new ContractError('a route method must be a registrable HTTP method', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['input', 'method'],\n\t\t\t\t\tlimit: [...METHODS].join(', '),\n\t\t\t\t\treceived: preview(input.method),\n\t\t\t\t},\n\t\t\t})\n\t\tconst name = input.name\n\t\tthis.#router.add({\n\t\t\tpath: input.path,\n\t\t\t...(name === undefined ? {} : { name }),\n\t\t\tmeta: {\n\t\t\t\tmethod: input.method,\n\t\t\t\thandler: input.handler,\n\t\t\t\t...(name === undefined ? {} : { name }),\n\t\t\t},\n\t\t})\n\t}\n\n\t// The derived `Allow` set for a pathname — every distinct registered method, with `HEAD`\n\t// added whenever `GET` is present and `HEAD` is not explicitly registered.\n\t#allow(pathname: string): readonly Method[] {\n\t\tconst entries: ReadonlyArray<RouteEntry<RouteRecord<TState>>> = this.#router.entries(pathname)\n\t\tconst methods = new Set<Method>()\n\t\tfor (const entry of entries) methods.add(entry.meta.method)\n\t\tif (methods.has('GET')) methods.add('HEAD')\n\t\treturn [...methods]\n\t}\n\n\t#respondUnmatched(request: Request): Response | Promise<Response> {\n\t\tconst responder = this.#unmatched\n\t\tif (responder !== undefined) return responder(request)\n\t\treturn new Response('Not Found', { status: 404 })\n\t}\n\n\t#respondUnmethoded(request: Request, allow: readonly Method[]): Response | Promise<Response> {\n\t\tconst responder = this.#unmethoded\n\t\tif (responder !== undefined) return responder(request, allow)\n\t\treturn new Response('Method Not Allowed', {\n\t\t\tstatus: 405,\n\t\t\theaders: { Allow: allow.join(', ') },\n\t\t})\n\t}\n\n\t// A matched dispatch — either the winning handler runs directly, or (for a derived `HEAD`\n\t// with no explicit `HEAD` route) the `GET` handler runs and the response body is stripped.\n\tasync #respondMatched(\n\t\trequest: Request,\n\t\tstate: TState,\n\t\tmethod: Method,\n\t\tmatch: RouterMatch<RouteRecord<TState>>,\n\t\turl: URL,\n\t): Promise<Response> {\n\t\tthis.#emitter.emit('match', method, match.path)\n\t\tconst context: RouteContext<string, TState> = {\n\t\t\tparams: match.params,\n\t\t\tpattern: match.path,\n\t\t\turl,\n\t\t\tstate,\n\t\t}\n\t\tconst response = await match.meta.handler(request, context)\n\t\tif (method === 'HEAD' && match.meta.method === 'GET')\n\t\t\treturn new Response(null, {\n\t\t\t\tstatus: response.status,\n\t\t\t\tstatusText: response.statusText,\n\t\t\t\theaders: response.headers,\n\t\t\t})\n\t\treturn response\n\t}\n\n\t// Derived `OPTIONS` — no explicit `OPTIONS` route registered for this pathname: answer\n\t// `204` with the derived `Allow` set (adding `OPTIONS` itself, always answerable), emitting\n\t// `match` under the most-specific REGISTERED pattern the pathname resolved to, so a consumer\n\t// aggregating by pattern sees bounded cardinality rather than one label per request path.\n\t#respondAutoOptions(pattern: string, allow: readonly Method[]): Response {\n\t\tthis.#emitter.emit('match', 'OPTIONS', pattern)\n\t\tconst headers = new Headers({ Allow: [...allow, 'OPTIONS'].join(', ') })\n\t\treturn new Response(null, { status: 204, headers })\n\t}\n}\n","import type {\n\tDispatcherInterface,\n\tDispatcherOptions,\n\tRouterInterface,\n\tRouterOptions,\n} from './types.js'\nimport { Dispatcher } from './Dispatcher.js'\nimport { Router } from './Router.js'\n\n/**\n * Creates a {@link RouterInterface} — the pure path-matching + registry engine\n * shared by the browser `Navigator` and the core `Dispatcher`.\n *\n * @remarks\n * Prefer this over `new Router(...)` at call sites that only need the\n * interface; an entity that OWNS a `Router` internally (like `Dispatcher`)\n * still constructs `new Router(...)` directly.\n *\n * @typeParam Meta - The opaque payload each entry carries and a match returns\n * @param options - Optional initial `entries`, the `sensitive` case toggle\n * (default `true`), and a `key` dedup identity function\n * @returns A {@link RouterInterface}\n *\n * @example\n * ```ts\n * import { createRouter } from '@src/core'\n *\n * const router = createRouter<{ readonly page: string }>()\n * router.add({ path: '/users/:id', meta: { page: 'profile' } })\n * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }\n * ```\n */\nexport function createRouter<Meta>(options?: RouterOptions<Meta>): RouterInterface<Meta> {\n\treturn new Router<Meta>(options)\n}\n\n/**\n * Creates a {@link DispatcherInterface} — the fetch-standard, method-\n * dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`.\n *\n * @remarks\n * Prefer this over `new Dispatcher(...)` at call sites that only need the\n * interface.\n *\n * @typeParam TState - The consumer's opaque per-request state type (default\n * `undefined` for stateless use)\n * @param options - Optional initial `routes`, the `sensitive` case toggle,\n * the `unmatched`/`unmethoded` default-responder overrides, and the\n * Emitter pattern's `on`/`error` wiring\n * @returns A {@link DispatcherInterface}\n *\n * @example\n * ```ts\n * import { createDispatcher } from '@src/core'\n *\n * const dispatcher = createDispatcher<{ readonly userId: string }>({\n * \troutes: [\n * \t\t{ method: 'GET', path: '/health', handler: () => new Response('ok') },\n * \t],\n * })\n * const response = await dispatcher.handle(new Request('http://x/health'), { userId: 'me' })\n * ```\n */\nexport function createDispatcher<TState = undefined>(\n\toptions?: DispatcherOptions<TState>,\n): DispatcherInterface<TState> {\n\treturn new Dispatcher<TState>(options)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../src/core/constants.ts","../../../src/core/helpers.ts","../../../src/core/parsers.ts","../../../src/core/Group.ts","../../../src/core/Router.ts","../../../src/core/DispatchGroup.ts","../../../src/core/Dispatcher.ts","../../../src/core/factories.ts"],"sourcesContent":["// ============================================================================\n// Core constants — the centralized-file rule's home for module-scope data used by\n// the matching engine and the fetch dispatcher. Every declaration here is frozen\n// and `export`ed per the centralized-file rule.\n// ============================================================================\n\n/**\n * Lists the HTTP methods a {@link import('./types.js').DispatcherInterface} registers\n * routes under, in canonical order — a frozen literal tuple, and the single source the\n * {@link import('./types.js').Method} type, {@link METHODS}, and `parseMethod` are all\n * derived from.\n *\n * @remarks\n * A frozen tuple of the verbs: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`,\n * `HEAD`, `OPTIONS`. Adding a verb here widens the `Method` type, the\n * {@link METHODS} membership set, and the `parseMethod` narrowing together, so\n * the method set cannot drift between them. Prefer {@link METHODS} for a\n * membership test; use this tuple where order or literal typing matters.\n *\n * @example\n * ```ts\n * METHOD_LIST[0] // 'GET'\n * METHOD_LIST.includes('GET') // true\n * ```\n */\nexport const METHOD_LIST = Object.freeze([\n\t'GET',\n\t'POST',\n\t'PUT',\n\t'PATCH',\n\t'DELETE',\n\t'HEAD',\n\t'OPTIONS',\n] as const)\n\n/**\n * Holds every HTTP method a {@link import('./types.js').DispatcherInterface} registers\n * routes under as a `ReadonlySet` — backs the registration guard (`add` rejects any\n * `method` outside this set) and the auto-`OPTIONS` `Allow` derivation.\n *\n * @remarks\n * A `ReadonlySet` built from {@link METHOD_LIST}, so it carries exactly the\n * {@link import('./types.js').Method} literals: `GET`, `POST`, `PUT`,\n * `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. `HEAD` is included even though it is\n * never required at registration (a `GET` route auto-answers `HEAD`) — it is\n * still a valid method to register explicitly. The element type stays `string`\n * so a raw, unnarrowed `request.method` can be tested directly.\n *\n * @example\n * ```ts\n * METHODS.has('GET') // true\n * METHODS.has('TRACE') // false\n * ```\n */\nexport const METHODS: ReadonlySet<string> = Object.freeze(new Set<string>(METHOD_LIST))\n\n/**\n * Names the specificity tier for a **literal** path segment (`/users`) — the highest\n * tier, always outranking a param or wildcard segment at the same position.\n *\n * @remarks\n * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`) when ranking candidate\n * matches left-to-right at the earliest differing segment.\n *\n * @example\n * ```ts\n * TIER_LITERAL > TIER_PARAM // true\n * ```\n */\nexport const TIER_LITERAL = 2\n\n/**\n * Names the specificity tier for a **param** path segment (`:name`) — ranks below a\n * literal segment and above a wildcard segment at the same position.\n *\n * @remarks\n * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`) alongside {@link TIER_LITERAL}\n * and {@link TIER_WILDCARD}.\n *\n * @example\n * ```ts\n * TIER_PARAM > TIER_WILDCARD // true\n * ```\n */\nexport const TIER_PARAM = 1\n\n/**\n * Names the specificity tier for a **wildcard** path segment (`*name`) — the lowest\n * tier; a wildcard only ever wins against another wildcard shape (an\n * equal-specificity tie resolved by registration order).\n *\n * @remarks\n * Consumed by `computeSpecificity` (the path compiler in `helpers.ts`).\n *\n * @example\n * ```ts\n * TIER_WILDCARD // 0\n * ```\n */\nexport const TIER_WILDCARD = 0\n","import type { CompiledPath, Method, RouteEntry, RouteInput } from './types.js'\nimport { ContractError, preview } from '@orkestrel/contract'\nimport { TIER_LITERAL, TIER_PARAM, TIER_WILDCARD } from './constants.js'\n\n// The PURE path-matching primitives (self-describing helper naming — module scope,\n// no entity context). Every one is exported (the centralized-file rule): a\n// consumer composes them directly, or reaches them through the `Router` engine.\n// They speak ONLY `string` / `RegExp` / `Record` and `decodeURIComponent` (a\n// platform global valid in Node and the browser alike) — NO DOM, NO `node:*` — so\n// the SAME engine drives a method-dimensioned server dispatcher and a method-less\n// browser navigator.\n\n/**\n * Escapes every regex metacharacter in a literal string so it can be embedded\n * inside a larger `RegExp` source without being interpreted as syntax.\n *\n * @remarks\n * {@link compilePath} escapes the literal segments of a route pattern with this\n * before splicing in `:name` / `*name` capture groups, so a path like\n * `/files/:name.json` matches the `.` literally rather than as \"any character\".\n * Pure and total — never throws.\n *\n * @param value - The literal string to escape\n * @returns `value` with every regex metacharacter backslash-escaped\n *\n * @example\n * ```ts\n * escapeRegExp('a.b+c') // 'a\\\\.b\\\\+c'\n * new RegExp(`^${escapeRegExp('a.b')}$`).test('a.b') // true\n * new RegExp(`^${escapeRegExp('a.b')}$`).test('axb') // false\n * ```\n */\nexport function escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/**\n * Canonicalizes a route path for registry identity — strips a single trailing\n * slash, except the root `/` (and the empty pattern). The trailing-slash fold\n * {@link compilePath} normalizes a pattern through, so identity agrees with the\n * matcher.\n *\n * @remarks\n * Mirrors {@link compilePath}'s trailing-slash folding: `/users/` canonicalizes\n * to `/users` (the two compile to the same regex and match the same\n * pathnames), while the root `/` and the empty `''` are EXEMPT (a bare `/`\n * already matches `/`; stripping it would break that). Pure and total — a path\n * without a trailing slash returns unchanged.\n *\n * @param path - The route path pattern\n * @returns The canonical path (one trailing slash removed, except `/` and `''`)\n *\n * @example\n * ```ts\n * canonicalizePath('/users/') // '/users'\n * canonicalizePath('/users') // '/users'\n * canonicalizePath('/') // '/'\n * canonicalizePath('') // ''\n * ```\n */\nexport function canonicalizePath(path: string): string {\n\treturn path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path\n}\n\n/**\n * Computes the canonical `METHOD /path` registry key for a method-dimensioned\n * dispatcher route.\n *\n * @remarks\n * Combines the route record's HTTP method with the outer entry's canonical\n * path, so registrations that differ only by one trailing slash replace each\n * other while registrations for different methods remain distinct.\n *\n * @param entry - The dispatcher route entry to identify\n * @returns The canonical `METHOD /path` registry key\n *\n * @example\n * ```ts\n * computeDispatchKey({ path: '/health/', meta: { method: 'GET' } }) // 'GET /health'\n * ```\n */\nexport function computeDispatchKey(entry: RouteEntry<{ readonly method: Method }>): string {\n\treturn `${entry.meta.method} ${canonicalizePath(entry.path)}`\n}\n\n/**\n * Compiles a route path pattern into an anchored regex and its ordered param\n * names.\n *\n * @remarks\n * Splits the CANONICALIZED path into segments. Each `:name` segment becomes a\n * `([^/]+)` capture group; the FINAL segment may instead be `*name`, which\n * becomes a `(.+)` capture spanning the REST of the path including slashes — a\n * wildcard segment anywhere but last is a registration-time programmer error\n * and throws a `ContractError` at the construction/registration boundary. Every regex\n * metacharacter in a literal segment is escaped first ({@link escapeRegExp}),\n * so a path like `/files/:name.json` matches the `.` literally apart from the\n * param. The regex is anchored (`^…$`), so it matches the whole pathname, not\n * a prefix.\n *\n * **Trailing slash is INSENSITIVE** (Express's `strict: false` default): a\n * single trailing slash on the request path is OPTIONAL, so `/users` matches\n * both `/users` and `/users/`, and `/users/:id` matches both `/users/me` and\n * `/users/me/`. This is NOT prefix matching — a deeper path is still a\n * distinct segment, so `/api` does not match `/api/users`. The ROOT `/` (and\n * the empty pattern `''`) are EXEMPT — they are not stripped, so `/` stays\n * `^/$` and `''` stays `^$`.\n *\n * `sensitive` (default `true`) controls case folding: `false` adds the `i`\n * regex flag, so `/Users` matches `/users`. The pattern's own casing is never\n * altered — only the matching behavior.\n *\n * @param path - The route path pattern (for example `/users/:id`, `/files/*rest`)\n * @param sensitive - If `true`, matching is case-sensitive; if `false`, case is\n * folded during matching. Default: `true`\n * @returns The {@link CompiledPath} — its `regex` + ordered `params`\n * @throws {@link import('@orkestrel/contract').ContractError} Thrown when a\n * `*name` wildcard segment is not the FINAL segment\n *\n * @example\n * ```ts\n * const { regex, params } = compilePath('/users/:id/posts/:slug')\n * params // ['id', 'slug']\n * regex.exec('/users/7/posts/hello') // ['…', '7', 'hello']\n * regex.test('/users/7/posts/hello/') // true — the trailing slash is optional\n *\n * compilePath('/files/*rest').regex.test('/files/a/b.png') // true\n * compilePath('/Users', false).regex.test('/users') // true — case-insensitive\n * ```\n */\nexport function compilePath(path: string, sensitive = true): CompiledPath {\n\tconst params: string[] = []\n\t// Normalize ONE trailing slash off the pattern so `/users/` compiles like `/users`\n\t// — except the root `/` (and the empty pattern), which must keep matching `/` / `''`.\n\tconst normalized = canonicalizePath(path)\n\tconst segments = normalized.split('/')\n\tconst compiledSegments = segments.map((segment, index) => {\n\t\tconst isFinal = index === segments.length - 1\n\t\t// A wildcard-shaped segment head (`*name`) is only valid as the FINAL segment — a\n\t\t// registration-time programmer error anywhere else, guarded at the boundary.\n\t\tif (!isFinal && /^\\*[A-Za-z_]\\w*/.test(segment))\n\t\t\tthrow new ContractError('a wildcard segment must be the final segment of a path pattern', {\n\t\t\t\tcode: 'placement',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['path'],\n\t\t\t\t\tlimit: `a wildcard only in the final segment, not \"${segment}\"`,\n\t\t\t\t\treceived: preview(path),\n\t\t\t\t},\n\t\t\t})\n\t\t// Classification and compilation share ONE segment parser — the tier\n\t\t// {@link classifySegment} assigns is exactly the shape compiled here.\n\t\tconst tier = classifySegment(segment, isFinal)\n\t\tif (tier === TIER_WILDCARD) {\n\t\t\tparams.push(segment.slice(1))\n\t\t\treturn '(.+)'\n\t\t}\n\t\tif (tier === TIER_PARAM) {\n\t\t\tconst match = /^:([A-Za-z_]\\w*)/.exec(segment)\n\t\t\tconst name = match?.[1] ?? ''\n\t\t\tparams.push(name)\n\t\t\treturn `([^/]+)${escapeRegExp(segment.slice(1 + name.length))}`\n\t\t}\n\t\treturn escapeRegExp(segment)\n\t})\n\tconst pattern = compiledSegments.join('/')\n\t// Allow ONE optional trailing slash before the `$` anchor (Express `strict: false`),\n\t// EXCEPT for the root `/` and the empty pattern — those anchor exactly so they keep\n\t// matching only `/` / `''` (a bare `/?` there would let `''` match `/` and vice versa).\n\tconst suffix = normalized === '/' || normalized === '' ? '' : '/?'\n\tconst flags = sensitive ? '' : 'i'\n\treturn { regex: new RegExp(`^${pattern}${suffix}$`, flags), params }\n}\n\n/**\n * Decodes one captured param value from a URL, tolerating a malformed percent-escape —\n * the decode {@link matchPath} applies to each captured group.\n *\n * @remarks\n * A bad `%` sequence is not a reason to reject an otherwise-matching route, so\n * a `decodeURIComponent` that would throw falls back to the raw value\n * (mirroring the cookie / token boundary readers). Total — never\n * throws.\n *\n * @param value - The raw captured param value\n * @returns The URL-decoded value, or the raw value when decoding would throw\n *\n * @example\n * ```ts\n * decodeParam('a%2Fb') // 'a/b'\n * decodeParam('100%25') // '100%'\n * decodeParam('%') // '%' — malformed escape stays literal\n * ```\n */\nexport function decodeParam(value: string): string {\n\ttry {\n\t\treturn decodeURIComponent(value)\n\t} catch {\n\t\t// A malformed `%` escape stays literal rather than throwing — the match still succeeds.\n\t\treturn value\n\t}\n}\n\n/**\n * Extracts the URL-decoded params a compiled path captures from a concrete\n * pathname, or `undefined` when the pathname does not match.\n *\n * @remarks\n * Runs the {@link CompiledPath} `regex` against `pathname` (a single `exec`); a\n * miss returns `undefined`. On a hit it walks `params` POSITIONALLY — the\n * `n`-th param name pairs with the `n`-th capture group — and URL-decodes each\n * value with {@link decodeParam}. Returns a frozen `name → value` record (empty\n * for a parameterless path). Total — never throws.\n *\n * @param compiled - The {@link CompiledPath} from {@link compilePath}\n * @param pathname - The concrete request pathname to match (for example `/users/7`)\n * @returns The decoded params on a hit, or `undefined` on a miss\n *\n * @example\n * ```ts\n * const compiled = compilePath('/users/:id')\n * matchPath(compiled, '/users/7') // { id: '7' }\n * matchPath(compiled, '/users/a%2Fb') // { id: 'a/b' } — decoded\n * matchPath(compiled, '/posts/7') // undefined\n * ```\n */\nexport function matchPath(\n\tcompiled: CompiledPath,\n\tpathname: string,\n): Readonly<Record<string, string>> | undefined {\n\tconst result = compiled.regex.exec(pathname)\n\tif (result === null) return undefined\n\tconst params: Record<string, string> = {}\n\tfor (let index = 0; index < compiled.params.length; index += 1) {\n\t\tconst name = compiled.params[index]\n\t\tconst value = result[index + 1]\n\t\tif (name !== undefined && value !== undefined) params[name] = decodeParam(value)\n\t}\n\treturn Object.freeze(params)\n}\n\n/**\n * Classifies one path segment into its specificity tier — the same syntax\n * {@link compilePath} rewrites: a syntactically valid `:name` head is a param\n * segment, a final `*name` is a wildcard segment, and everything else (including a\n * literal segment that merely contains a `:` mid-string, for example `a:b`) is a\n * literal segment.\n *\n * @remarks\n * This is the fix over the old engine's bug: the old classifier ranked any\n * segment `includes(':')` as a param, so a literal segment like `a:b` was\n * mis-tiered even though {@link compilePath} compiles it literally. Sharing one\n * segment parser between compilation and classification keeps the two in\n * agreement. Pure and total.\n *\n * @param segment - One `/`-split path segment\n * @param isFinal - If `true`, `segment` is the path's last segment and may\n * classify as a wildcard; if `false`, a wildcard-shaped segment classifies as\n * a literal\n * @returns The segment's specificity tier — {@link import('./constants.js').TIER_LITERAL},\n * {@link import('./constants.js').TIER_PARAM}, or\n * {@link import('./constants.js').TIER_WILDCARD}\n *\n * @example\n * ```ts\n * classifySegment(':id', true) // 1 — TIER_PARAM\n * classifySegment('*rest', true) // 0 — TIER_WILDCARD\n * classifySegment('a:b', true) // 2 — TIER_LITERAL — the old bug's regression case\n * classifySegment('users', false) // 2 — TIER_LITERAL\n * ```\n */\nexport function classifySegment(segment: string, isFinal: boolean): number {\n\tif (isFinal && /^\\*[A-Za-z_]\\w*$/.test(segment)) return TIER_WILDCARD\n\tif (/^:[A-Za-z_]\\w*/.test(segment)) return TIER_PARAM\n\treturn TIER_LITERAL\n}\n\n/**\n * Computes a route path's specificity vector — the per-segment type ranking\n * that breaks a tie when several registered routes match the same concrete\n * pathname.\n *\n * @remarks\n * Splits the CANONICALIZED path into segments (on `/`) and maps each to its\n * specificity tier through {@link classifySegment} — the same segment parser\n * {@link compilePath} uses, so a literal segment that merely contains a `:`\n * (for example `a:b`) is correctly tiered as literal rather than param (the old\n * engine's bug, fixed here). The standard route-precedence rule compares two\n * matching routes' vectors LEFT-TO-RIGHT: at the first index where the tiers\n * differ, the HIGHER tier (a literal over a param over a wildcard) is MORE\n * SPECIFIC and wins — so `/users/me` (`[2, 2]`) beats `/users/:id` (`[2, 1]`)\n * beats `/users/*rest` (`[2, 0]`) regardless of registration order. Two routes\n * that match the SAME concrete pathname necessarily have the same segment\n * count in the common case; {@link compareSpecificity} handles the general\n * case for totality.\n *\n * @param path - The route path pattern (for example `/users/:id`)\n * @returns The per-segment specificity tiers, in order\n *\n * @example\n * ```ts\n * computeSpecificity('/users/me') // [2, 2]\n * computeSpecificity('/users/:id') // [2, 1]\n * computeSpecificity('/files/*rest') // [2, 0]\n * computeSpecificity('/a:b') // [2] — literal, not param — the classification fix\n * ```\n */\nexport function computeSpecificity(path: string): readonly number[] {\n\tconst segments = canonicalizePath(path).split('/')\n\treturn segments.map((segment, index) => classifySegment(segment, index === segments.length - 1))\n}\n\n/**\n * Compares two route paths by specificity — the comparator that picks the\n * most-specific matching route (literal-over-param-over-wildcard,\n * registration-order-independent).\n *\n * @remarks\n * Compares the two paths' {@link computeSpecificity} vectors LEFT-TO-RIGHT and\n * returns a standard `Array.sort` ordering: a NEGATIVE number when `a` is MORE\n * specific than `b` (so a descending-specificity sort puts `a` first),\n * positive when `b` is more specific, `0` when neither out-ranks the other\n * across the compared segments. At the first index where the tiers differ,\n * the higher tier wins; if one vector is a prefix of the other (different\n * segment counts), the LONGER, more-segmented path is treated as more\n * specific (a missing segment ranks below any real one).\n *\n * @param a - The first route path\n * @param b - The second route path\n * @returns A negative number when `a` is more specific, positive when `b` is, else `0`\n *\n * @example\n * ```ts\n * compareSpecificity('/users/me', '/users/:id') // negative — literal wins\n * compareSpecificity('/users/:id', '/users/*rest') // negative — param beats wildcard\n * compareSpecificity('/users/:id', '/users/:id') // 0 — equal specificity\n * ```\n */\nexport function compareSpecificity(a: string, b: string): number {\n\tconst left = computeSpecificity(a)\n\tconst right = computeSpecificity(b)\n\tconst length = Math.max(left.length, right.length)\n\tfor (let index = 0; index < length; index += 1) {\n\t\t// A missing segment ranks below any real one (the shorter path is less specific).\n\t\tconst tierA = left[index] ?? -1\n\t\tconst tierB = right[index] ?? -1\n\t\tif (tierA !== tierB) return tierB - tierA\n\t}\n\treturn 0\n}\n\n/**\n * Joins a group prefix and a route path into one `/`-prefixed path, normalizing\n * duplicate or missing joining slashes.\n *\n * @remarks\n * {@link import('./types.js').GroupInterface} / {@link import('./types.js').DispatchGroupInterface}\n * compose a prefix with each registered entry's path this way — pure string\n * composition, no independent state. Both a duplicated slash\n * (`'/api/'` + `'/users'`) and a missing one (`'/api'` + `'users'`) normalize\n * to a single joining slash. An empty `prefix` returns `path` unchanged (after\n * ensuring a leading slash); an empty `path` returns `prefix` unchanged.\n * Pure and total.\n *\n * @param prefix - The group prefix (for example `/api`)\n * @param path - The route path being joined under the prefix (for example `/users`)\n * @returns The joined `/`-prefixed path\n *\n * @example\n * ```ts\n * joinPaths('/api', '/users') // '/api/users'\n * joinPaths('/api/', '/users') // '/api/users'\n * joinPaths('/api', 'users') // '/api/users'\n * joinPaths('', '/users') // '/users'\n * joinPaths('/api', '') // '/api'\n * ```\n */\nexport function joinPaths(prefix: string, path: string): string {\n\tif (prefix === '') return path.startsWith('/') ? path : `/${path}`\n\tif (path === '') return prefix\n\tconst left = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix\n\tconst right = path.startsWith('/') ? path : `/${path}`\n\treturn `${left}${right}`\n}\n\n/**\n * Provides an identity pass-through for a {@link RouteInput} that pins its `Path`\n * generic to the literal registration-site string, so `context.params` types\n * correctly through {@link PathParams} without an explicit type argument.\n *\n * @remarks\n * A bare object literal handed straight to {@link import('./types.js').DispatcherInterface}'s\n * `add` already infers `Path` as a literal at that call site — but the moment\n * the object is built through an intermediate binding (a local `const route =\n * { method, path, handler }`) TypeScript widens `path` to `string` unless the\n * binding's own type is pinned. Wrapping the literal in `defineRoute(...)`\n * supplies that pin: its `const Path extends string` type parameter infers the\n * NARROW literal from the call, and the function returns its input completely\n * unchanged (same reference, no cloning, no validation) — this is a\n * compile-time typing aid only, not a construction step (contrast\n * {@link import('./factories.js')} `create*` entity factories). A\n * heterogeneous `RouteInput[]` built from several `defineRoute(...)` calls\n * still widens each element's `Path` to `string` after collection into one array —\n * the realistic ceiling this helper raises is PER-CALL typing at the\n * registration site, not a stored, still-literal-typed record.\n *\n * @typeParam Path - The route path pattern literal (drives `context.params`\n * through {@link PathParams})\n * @typeParam TState - The consumer's opaque per-request state type\n * @param input - The {@link RouteInput} to pass through unchanged\n * @returns `input`, unchanged (same reference)\n *\n * @example\n * ```ts\n * const input = defineRoute({\n * \tmethod: 'GET',\n * \tpath: '/users/:id',\n * \thandler: (_request, context) => new Response(context.params.id), // typed string\n * })\n * dispatcher.add(input)\n * ```\n */\nexport function defineRoute<const Path extends string, TState = undefined>(\n\tinput: RouteInput<Path, TState>,\n): RouteInput<Path, TState> {\n\treturn input\n}\n","// ============================================================================\n// Core coercers — the centralized-file rule's home for `parse*` narrowing leaves that\n// turn a raw external string into a typed core value or `undefined`. Every\n// declaration here is total and `export`ed per the centralized-file rule.\n// ============================================================================\n\nimport type { Method } from './types.js'\nimport { METHOD_LIST } from './constants.js'\n\n/**\n * Narrows a raw `request.method` string into a typed {@link Method} — total,\n * never throws.\n *\n * @remarks\n * Consults {@link import('./constants.js').METHOD_LIST} (the one home for the\n * registrable HTTP methods), so a verb added there narrows here without\n * a second list to update; any other value (an unknown verb, non-uppercase\n * casing) resolves to `undefined` rather than throwing (total guard behavior).\n * Pure leaf shared by the `Dispatcher`'s `handle` (honest about an unknown verb)\n * and anywhere else a raw method string needs narrowing.\n *\n * @param value - The raw `request.method` string to narrow\n * @returns The matching {@link Method}, or `undefined` when `value` is not a\n * registrable method\n *\n * @example\n * ```ts\n * parseMethod('GET') // 'GET'\n * parseMethod('PURGE') // undefined\n * parseMethod('get') // undefined — case-sensitive\n * ```\n */\nexport function parseMethod(value: string): Method | undefined {\n\treturn METHOD_LIST.find((method) => method === value)\n}\n","import type { GroupInterface, RouteEntry, RouterInterface } from './types.js'\nimport { joinPaths } from './helpers.js'\n\n/**\n * Represents a prefix-scoped registration handle over a {@link import('./Router.js').Router} —\n * pure string composition, no independent state or storage.\n *\n * @typeParam Meta - The entry payload type, matching the owning router\n *\n * @remarks\n * Every `add` composes `entry.path` as `joinPaths(prefix, entry.path)` and\n * forwards to the OWNING router, so grouped routes land in the SAME registry.\n * `group(prefix)` nests, composing prefixes through {@link joinPaths}.\n *\n * @example\n * ```ts\n * import { Router } from '@src/core'\n *\n * const router = new Router<{ readonly page: string }>()\n * const api = router.group('/api')\n * api.add({ path: '/users', meta: { page: 'list' } })\n * router.match('/api/users')?.path // '/api/users'\n * ```\n */\nexport class Group<Meta> implements GroupInterface<Meta> {\n\treadonly prefix: string\n\treadonly #parent: RouterInterface<Meta>\n\n\tconstructor(parent: RouterInterface<Meta>, prefix: string) {\n\t\tthis.#parent = parent\n\t\tthis.prefix = prefix\n\t}\n\n\tadd(entry: RouteEntry<Meta>): void\n\tadd(entries: ReadonlyArray<RouteEntry<Meta>>): void\n\tadd(input: RouteEntry<Meta> | ReadonlyArray<RouteEntry<Meta>>): void {\n\t\tconst inputs = Array.isArray(input) ? input : [input]\n\t\tthis.#parent.add(\n\t\t\tinputs.map((entry) => ({ ...entry, path: joinPaths(this.prefix, entry.path) })),\n\t\t)\n\t}\n\n\tgroup(prefix: string): GroupInterface<Meta> {\n\t\treturn new Group<Meta>(this.#parent, joinPaths(this.prefix, prefix))\n\t}\n}\n","import type {\n\tAnswerHandler,\n\tCompiledPath,\n\tGroupInterface,\n\tRouteEntry,\n\tRouterInterface,\n\tRouterMatch,\n\tRouterOptions,\n} from './types.js'\nimport { ContractError, isString, preview } from '@orkestrel/contract'\nimport { compareSpecificity, compilePath, matchPath } from './helpers.js'\nimport { Group } from './Group.js'\n\n/**\n * Represents the path-matching + registry engine — registers `{ path, meta, name? }`\n * entries (compiling each path once) and resolves a concrete pathname to the most\n * specific matching entry. The shared machine both the `Navigator` (browser) and\n * the `Dispatcher` (core, method-dimensioned) compose.\n *\n * @typeParam Meta - The opaque payload each entry carries and a match returns\n *\n * @remarks\n * - **Registration boundary guard.** `add` validates each entry's\n * `path` — `isString` plus a leading `/` — and throws a `ContractError` on a\n * malformed registration; `match` stays guard-free (the hot path).\n * - **Compile-once.** Each path is compiled exactly once at registration into\n * a parallel `#compiled` array, so `match` runs only a cached `exec` per\n * candidate.\n * - **Dedup through `key`.** When `options.key` is set, an entry whose computed\n * key already exists REPLACES the prior one IN PLACE (both the `#entries`\n * and `#compiled` arrays, at the existing index) — last write wins, no\n * engine rebuild. Omitted ⇒ every entry is kept, even duplicate paths.\n * - **Groups.** `group(prefix)` returns a {@link GroupInterface} that composes\n * `prefix` onto every entry it registers, nesting through {@link joinPaths}.\n *\n * @example\n * ```ts\n * const router = new Router<{ readonly page: string }>()\n * router.add({ path: '/users/:id', meta: { page: 'profile' } })\n * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }\n * ```\n */\nexport class Router<Meta> implements RouterInterface<Meta> {\n\treadonly #entries: Array<RouteEntry<Meta>> = []\n\treadonly #compiled: CompiledPath[] = []\n\treadonly #sensitive: boolean\n\treadonly #key: ((entry: RouteEntry<Meta>) => string) | undefined\n\treadonly #index: Map<string, number> = new Map()\n\n\tconstructor(options?: RouterOptions<Meta>) {\n\t\tthis.#sensitive = options?.sensitive ?? true\n\t\tthis.#key = options?.key\n\t\tif (options?.entries !== undefined) this.add(options.entries)\n\t}\n\n\tget count(): number {\n\t\treturn this.#entries.length\n\t}\n\n\tadd(entry: RouteEntry<Meta>): void\n\tadd(entries: ReadonlyArray<RouteEntry<Meta>>): void\n\tadd(input: RouteEntry<Meta> | ReadonlyArray<RouteEntry<Meta>>): void {\n\t\tconst inputs = Array.isArray(input) ? input : [input]\n\t\tfor (const entry of inputs) this.#register(entry)\n\t}\n\n\tmatch(pathname: string, answers?: AnswerHandler<Meta>): RouterMatch<Meta> | undefined {\n\t\tlet best: { entry: RouteEntry<Meta>; params: Readonly<Record<string, string>> } | undefined\n\t\tfor (let index = 0; index < this.#entries.length; index += 1) {\n\t\t\tconst entry = this.#entries[index]\n\t\t\tconst compiled = this.#compiled[index]\n\t\t\tif (entry === undefined || compiled === undefined) continue\n\t\t\tif (answers !== undefined && !answers(entry.meta)) continue\n\t\t\tconst params = matchPath(compiled, pathname)\n\t\t\tif (params === undefined) continue\n\t\t\tif (best === undefined || compareSpecificity(entry.path, best.entry.path) < 0)\n\t\t\t\tbest = { entry, params }\n\t\t}\n\t\tif (best === undefined) return undefined\n\t\treturn {\n\t\t\tpath: best.entry.path,\n\t\t\tparams: best.params,\n\t\t\tmeta: best.entry.meta,\n\t\t\t...(best.entry.name === undefined ? {} : { name: best.entry.name }),\n\t\t}\n\t}\n\n\tentries(): ReadonlyArray<RouteEntry<Meta>>\n\tentries(pathname: string): ReadonlyArray<RouteEntry<Meta>>\n\tentries(pathname?: string): ReadonlyArray<RouteEntry<Meta>> {\n\t\tif (pathname === undefined) return [...this.#entries]\n\t\tconst out: Array<RouteEntry<Meta>> = []\n\t\tfor (let index = 0; index < this.#entries.length; index += 1) {\n\t\t\tconst entry = this.#entries[index]\n\t\t\tconst compiled = this.#compiled[index]\n\t\t\tif (entry === undefined || compiled === undefined) continue\n\t\t\tif (matchPath(compiled, pathname) !== undefined) out.push(entry)\n\t\t}\n\t\treturn out\n\t}\n\n\tgroup(prefix: string): GroupInterface<Meta> {\n\t\treturn new Group<Meta>(this, prefix)\n\t}\n\n\tclear(): void {\n\t\tthis.#entries.length = 0\n\t\tthis.#compiled.length = 0\n\t\tthis.#index.clear()\n\t}\n\n\t// Validate the registration boundary (isString + leading '/'), then either replace an\n\t// existing entry IN PLACE (dedup through `#key`, last write wins) or append a new one — the\n\t// engine's compile-once invariant, kept in sync across the `#entries`/`#compiled` pair.\n\t#register(entry: RouteEntry<Meta>): void {\n\t\tif (!isString(entry.path))\n\t\t\tthrow new ContractError('a route path must be a string', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: { path: ['entry', 'path'], limit: 'string', received: preview(entry.path) },\n\t\t\t})\n\t\tif (!entry.path.startsWith('/'))\n\t\t\tthrow new ContractError('a route path must start with \"/\"', {\n\t\t\t\tcode: 'pattern',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['entry', 'path'],\n\t\t\t\t\tlimit: 'a \"/\"-prefixed path pattern',\n\t\t\t\t\treceived: preview(entry.path),\n\t\t\t\t},\n\t\t\t})\n\t\tconst compiled = compilePath(entry.path, this.#sensitive)\n\t\tif (this.#key === undefined) {\n\t\t\tthis.#entries.push(entry)\n\t\t\tthis.#compiled.push(compiled)\n\t\t\treturn\n\t\t}\n\t\tconst key = this.#key(entry)\n\t\tconst existing = this.#index.get(key)\n\t\tif (existing !== undefined) {\n\t\t\tthis.#entries[existing] = entry\n\t\t\tthis.#compiled[existing] = compiled\n\t\t\treturn\n\t\t}\n\t\tthis.#index.set(key, this.#entries.length)\n\t\tthis.#entries.push(entry)\n\t\tthis.#compiled.push(compiled)\n\t}\n}\n","import type { DispatchGroupInterface, DispatcherInterface, RouteInput } from './types.js'\nimport { joinPaths } from './helpers.js'\n\n/**\n * Represents a prefix-scoped registration handle over a\n * {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned counterpart of\n * `Group`.\n *\n * @typeParam TState - The consumer's opaque per-request state type, matching\n * the owning dispatcher\n *\n * @remarks\n * Every `add` composes `input.path` through {@link joinPaths} against\n * `this.prefix` and forwards to the OWNING dispatcher's `add` (its own\n * registration boundary guard still applies). Pure string composition — no\n * independent state or storage.\n *\n * @example\n * ```ts\n * import { Dispatcher } from '@src/core'\n *\n * const dispatcher = new Dispatcher()\n * const api = dispatcher.group('/api')\n * api.add({ method: 'GET', path: '/users', handler: () => new Response('ok') })\n * ```\n */\nexport class DispatchGroup<TState> implements DispatchGroupInterface<TState> {\n\treadonly prefix: string\n\treadonly #parent: DispatcherInterface<TState>\n\n\tconstructor(parent: DispatcherInterface<TState>, prefix: string) {\n\t\tthis.#parent = parent\n\t\tthis.prefix = prefix\n\t}\n\n\tadd<Path extends string>(input: RouteInput<Path, TState>): void\n\tadd(inputs: ReadonlyArray<RouteInput<string, TState>>): void\n\tadd(input: RouteInput<string, TState> | ReadonlyArray<RouteInput<string, TState>>): void {\n\t\tconst inputs = Array.isArray(input) ? input : [input]\n\t\tthis.#parent.add(\n\t\t\tinputs.map((route) => ({ ...route, path: joinPaths(this.prefix, route.path) })),\n\t\t)\n\t}\n\n\tgroup(prefix: string): DispatchGroupInterface<TState> {\n\t\treturn new DispatchGroup<TState>(this.#parent, joinPaths(this.prefix, prefix))\n\t}\n}\n","import type {\n\tDispatchGroupInterface,\n\tDispatcherEventMap,\n\tDispatcherInterface,\n\tDispatcherOptions,\n\tDispatchResult,\n\tMethod,\n\tRouteContext,\n\tRouteEntry,\n\tRouteInput,\n\tRouteRecord,\n\tRouterInterface,\n\tRouterMatch,\n} from './types.js'\nimport type { EmitterInterface } from '@orkestrel/emitter'\nimport { Emitter } from '@orkestrel/emitter'\nimport { ContractError, isFunction, isString, preview } from '@orkestrel/contract'\nimport { METHODS } from './constants.js'\nimport { computeDispatchKey } from './helpers.js'\nimport { parseMethod } from './parsers.js'\nimport { Router } from './Router.js'\nimport { DispatchGroup } from './DispatchGroup.js'\n\n/**\n * Represents the fetch-standard, method-dimensioned dispatch entity — layers HTTP\n * method dispatch and web-standard `Request`/`Response` handling over one internal\n * `Router<RouteRecord<TState>>`. The core machine the server face and any\n * fetch-native runtime consume directly.\n *\n * @typeParam TState - The consumer's opaque per-request state type\n *\n * @remarks\n * - **Dedup by `method + canonicalizePath`.** The underlying `Router` is\n * constructed with a `key` function so registering the same method+path\n * twice REPLACES the prior route in place.\n * - **Registration boundary guard.** `add` validates each input's\n * `handler` (`isFunction`) and `method` (must be in {@link METHODS}) —\n * throws a `ContractError` on a malformed registration; path validation is\n * delegated to the underlying `Router`'s own guard. `match`/`handle` stay\n * guard-free.\n * - **Auto-`HEAD` / auto-`OPTIONS`.** A `HEAD` request with no registered\n * `HEAD` route runs the matching `GET` handler and strips the response\n * body; an `OPTIONS` request with no registered `OPTIONS` route answers\n * `204` with a derived `Allow` header.\n * - **Handler throws propagate.** `handle` never invents an error boundary —\n * a handler throw reaches the caller uncaught.\n * - **Emitter.** Owns a `#emitter` for {@link DispatcherEventMap};\n * `match`/`miss` fire AFTER resolution, before the handler/responder runs.\n *\n * @example\n * ```ts\n * const dispatcher = new Dispatcher<{ readonly userId: string }>()\n * dispatcher.add({\n * \tmethod: 'GET',\n * \tpath: '/users/:id',\n * \thandler: (request, context) => Response.json({ id: context.params.id }),\n * })\n * const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })\n * ```\n */\nexport class Dispatcher<TState = undefined> implements DispatcherInterface<TState> {\n\treadonly #router: RouterInterface<RouteRecord<TState>>\n\treadonly #emitter: Emitter<DispatcherEventMap>\n\treadonly #unmatched: DispatcherOptions<TState>['unmatched']\n\treadonly #unmethoded: DispatcherOptions<TState>['unmethoded']\n\n\tconstructor(options?: DispatcherOptions<TState>) {\n\t\tconst sensitive = options?.sensitive\n\t\tconst on = options?.on\n\t\tconst error = options?.error\n\t\tthis.#router = new Router<RouteRecord<TState>>({\n\t\t\t...(sensitive === undefined ? {} : { sensitive }),\n\t\t\tkey: computeDispatchKey,\n\t\t})\n\t\tthis.#emitter = new Emitter<DispatcherEventMap>({\n\t\t\t...(on === undefined ? {} : { on }),\n\t\t\t...(error === undefined ? {} : { error }),\n\t\t})\n\t\tthis.#unmatched = options?.unmatched\n\t\tthis.#unmethoded = options?.unmethoded\n\t\tif (options?.routes !== undefined) this.add(options.routes)\n\t}\n\n\tget router(): RouterInterface<RouteRecord<TState>> {\n\t\treturn this.#router\n\t}\n\n\tget emitter(): EmitterInterface<DispatcherEventMap> {\n\t\treturn this.#emitter\n\t}\n\n\tadd<Path extends string>(input: RouteInput<Path, TState>): void\n\tadd(inputs: ReadonlyArray<RouteInput<string, TState>>): void\n\tadd(input: RouteInput<string, TState> | ReadonlyArray<RouteInput<string, TState>>): void {\n\t\tconst inputs = Array.isArray(input) ? input : [input]\n\t\tfor (const route of inputs) this.#register(route)\n\t}\n\n\tgroup(prefix: string): DispatchGroupInterface<TState> {\n\t\treturn new DispatchGroup<TState>(this, prefix)\n\t}\n\n\tmatch(method: Method, pathname: string): DispatchResult<TState> {\n\t\tconst hit = this.#router.match(pathname, (meta) => meta.method === method)\n\t\tif (hit !== undefined) return { status: 'matched', match: hit }\n\t\tif (method === 'HEAD') {\n\t\t\tconst getHit = this.#router.match(pathname, (meta) => meta.method === 'GET')\n\t\t\tif (getHit !== undefined) return { status: 'matched', match: getHit }\n\t\t}\n\t\tconst allow = this.#allow(pathname)\n\t\tif (allow.length === 0) return { status: 'unmatched' }\n\t\treturn { status: 'unmethoded', allow }\n\t}\n\n\tasync handle(request: Request, state: TState): Promise<Response> {\n\t\tconst url = new URL(request.url)\n\t\tconst pathname = url.pathname\n\t\tconst requested = request.method\n\t\tconst method = parseMethod(requested)\n\t\tif (method === undefined) {\n\t\t\tconst allow = this.#allow(pathname)\n\t\t\tif (allow.length === 0) {\n\t\t\t\tthis.#emitter.emit('miss', requested, pathname, 'unmatched')\n\t\t\t\treturn this.#respondUnmatched(request)\n\t\t\t}\n\t\t\tthis.#emitter.emit('miss', requested, pathname, 'unmethoded')\n\t\t\treturn this.#respondUnmethoded(request, allow)\n\t\t}\n\t\tconst result = this.match(method, pathname)\n\t\tif (result.status === 'matched')\n\t\t\treturn this.#respondMatched(request, state, method, result.match, url)\n\t\tif (result.status === 'unmethoded') {\n\t\t\tif (method === 'OPTIONS') {\n\t\t\t\tconst hit = this.#router.match(pathname)\n\t\t\t\tif (hit !== undefined) return this.#respondAutoOptions(hit.path, result.allow)\n\t\t\t}\n\t\t\tthis.#emitter.emit('miss', method, pathname, 'unmethoded')\n\t\t\treturn this.#respondUnmethoded(request, result.allow)\n\t\t}\n\t\tthis.#emitter.emit('miss', method, pathname, 'unmatched')\n\t\treturn this.#respondUnmatched(request)\n\t}\n\n\tdestroy(): void {\n\t\tthis.#emitter.destroy()\n\t}\n\n\t// Validate the registration boundary (handler function, known method), then delegate\n\t// path validation + dedup to the underlying `Router`'s own guard.\n\t#register(input: RouteInput<string, TState>): void {\n\t\tif (!isFunction(input.handler))\n\t\t\tthrow new ContractError('a route handler must be a function', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['input', 'handler'],\n\t\t\t\t\tlimit: 'function',\n\t\t\t\t\treceived: preview(input.handler),\n\t\t\t\t},\n\t\t\t})\n\t\tif (!isString(input.method) || !METHODS.has(input.method))\n\t\t\tthrow new ContractError('a route method must be a registrable HTTP method', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['input', 'method'],\n\t\t\t\t\tlimit: [...METHODS].join(', '),\n\t\t\t\t\treceived: preview(input.method),\n\t\t\t\t},\n\t\t\t})\n\t\tconst name = input.name\n\t\tthis.#router.add({\n\t\t\tpath: input.path,\n\t\t\t...(name === undefined ? {} : { name }),\n\t\t\tmeta: {\n\t\t\t\tmethod: input.method,\n\t\t\t\thandler: input.handler,\n\t\t\t\t...(name === undefined ? {} : { name }),\n\t\t\t},\n\t\t})\n\t}\n\n\t// The derived `Allow` set for a pathname — every distinct registered method, with `HEAD`\n\t// added whenever `GET` is present and `HEAD` is not explicitly registered.\n\t#allow(pathname: string): readonly Method[] {\n\t\tconst entries: ReadonlyArray<RouteEntry<RouteRecord<TState>>> = this.#router.entries(pathname)\n\t\tconst methods = new Set<Method>()\n\t\tfor (const entry of entries) methods.add(entry.meta.method)\n\t\tif (methods.has('GET')) methods.add('HEAD')\n\t\treturn [...methods]\n\t}\n\n\t#respondUnmatched(request: Request): Response | Promise<Response> {\n\t\tconst responder = this.#unmatched\n\t\tif (responder !== undefined) return responder(request)\n\t\treturn new Response('Not Found', { status: 404 })\n\t}\n\n\t#respondUnmethoded(request: Request, allow: readonly Method[]): Response | Promise<Response> {\n\t\tconst responder = this.#unmethoded\n\t\tif (responder !== undefined) return responder(request, allow)\n\t\treturn new Response('Method Not Allowed', {\n\t\t\tstatus: 405,\n\t\t\theaders: { Allow: allow.join(', ') },\n\t\t})\n\t}\n\n\t// A matched dispatch — either the winning handler runs directly, or (for a derived `HEAD`\n\t// with no explicit `HEAD` route) the `GET` handler runs and the response body is stripped.\n\tasync #respondMatched(\n\t\trequest: Request,\n\t\tstate: TState,\n\t\tmethod: Method,\n\t\tmatch: RouterMatch<RouteRecord<TState>>,\n\t\turl: URL,\n\t): Promise<Response> {\n\t\tthis.#emitter.emit('match', method, match.path)\n\t\tconst context: RouteContext<string, TState> = {\n\t\t\tparams: match.params,\n\t\t\tpattern: match.path,\n\t\t\turl,\n\t\t\tstate,\n\t\t}\n\t\tconst response = await match.meta.handler(request, context)\n\t\tif (method === 'HEAD' && match.meta.method === 'GET')\n\t\t\treturn new Response(null, {\n\t\t\t\tstatus: response.status,\n\t\t\t\tstatusText: response.statusText,\n\t\t\t\theaders: response.headers,\n\t\t\t})\n\t\treturn response\n\t}\n\n\t// Derived `OPTIONS` — no explicit `OPTIONS` route registered for this pathname: answer\n\t// `204` with the derived `Allow` set (adding `OPTIONS` itself, always answerable), emitting\n\t// `match` under the most-specific REGISTERED pattern the pathname resolved to, so a consumer\n\t// aggregating by pattern sees bounded cardinality rather than one label per request path.\n\t#respondAutoOptions(pattern: string, allow: readonly Method[]): Response {\n\t\tthis.#emitter.emit('match', 'OPTIONS', pattern)\n\t\tconst headers = new Headers({ Allow: [...allow, 'OPTIONS'].join(', ') })\n\t\treturn new Response(null, { status: 204, headers })\n\t}\n}\n","import type {\n\tDispatcherInterface,\n\tDispatcherOptions,\n\tRouterInterface,\n\tRouterOptions,\n} from './types.js'\nimport { Dispatcher } from './Dispatcher.js'\nimport { Router } from './Router.js'\n\n/**\n * Creates a {@link RouterInterface} — the pure path-matching + registry engine\n * shared by the browser `Navigator` and the core `Dispatcher`.\n *\n * @remarks\n * Prefer this over `new Router(...)` at call sites that only need the\n * interface; an entity that OWNS a `Router` internally (like `Dispatcher`)\n * still constructs `new Router(...)` directly.\n *\n * @typeParam Meta - The opaque payload each entry carries and a match returns\n * @param options - Optional initial `entries`, the `sensitive` case toggle\n * (default `true`), and a `key` dedup identity function\n * @returns A {@link RouterInterface}\n *\n * @example Register and match\n * ```ts\n * import { createDispatcher, createRouter } from '@orkestrel/router'\n *\n * const router = createRouter<{ readonly page: string }>()\n * router.add({ path: '/users/:id', meta: { page: 'profile' } })\n * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }\n *\n * const dispatcher = createDispatcher<{ readonly userId: string }>({\n * \troutes: [\n * \t\t{\n * \t\t\tmethod: 'GET',\n * \t\t\tpath: '/users/:id',\n * \t\t\thandler: (_request, context) => Response.json(context.params),\n * \t\t},\n * \t],\n * })\n * const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })\n * ```\n */\nexport function createRouter<Meta>(options?: RouterOptions<Meta>): RouterInterface<Meta> {\n\treturn new Router<Meta>(options)\n}\n\n/**\n * Creates a {@link DispatcherInterface} — the fetch-standard,\n * method-dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`.\n *\n * @remarks\n * Prefer this over `new Dispatcher(...)` at call sites that only need the\n * interface.\n *\n * @typeParam TState - The consumer's opaque per-request state type (default\n * `undefined` for stateless use)\n * @param options - Optional initial `routes`, the `sensitive` case toggle,\n * the `unmatched`/`unmethoded` default-responder overrides, and the\n * Emitter pattern's `on`/`error` wiring\n * @returns A {@link DispatcherInterface}\n *\n * @example\n * ```ts\n * import { createDispatcher } from '@src/core'\n *\n * const dispatcher = createDispatcher<{ readonly userId: string }>({\n * \troutes: [\n * \t\t{ method: 'GET', path: '/health', handler: () => new Response('ok') },\n * \t],\n * })\n * const response = await dispatcher.handle(new Request('http://x/health'), { userId: 'me' })\n * ```\n */\nexport function createDispatcher<TState = undefined>(\n\toptions?: DispatcherOptions<TState>,\n): DispatcherInterface<TState> {\n\treturn new Dispatcher<TState>(options)\n}\n"],"mappings":"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"}
|
|
@@ -237,13 +237,22 @@ async function handleListenerRequest(dispatcher, state, request, response) {
|
|
|
237
237
|
*
|
|
238
238
|
* @example
|
|
239
239
|
* ```ts
|
|
240
|
-
* import { createListener } from '@
|
|
241
|
-
* import { createDispatcher } from '@
|
|
240
|
+
* import { createListener } from '@orkestrel/router/server'
|
|
241
|
+
* import { createDispatcher } from '@orkestrel/router'
|
|
242
242
|
* import http from 'node:http'
|
|
243
243
|
*
|
|
244
|
-
* const dispatcher = createDispatcher()
|
|
245
|
-
* dispatcher.add({
|
|
246
|
-
*
|
|
244
|
+
* const dispatcher = createDispatcher<{ readonly requestId: string }>()
|
|
245
|
+
* dispatcher.add({
|
|
246
|
+
* method: 'GET',
|
|
247
|
+
* path: '/users/:id',
|
|
248
|
+
* handler: (_request, context) =>
|
|
249
|
+
* Response.json({ id: context.params.id, requestId: context.state.requestId }),
|
|
250
|
+
* })
|
|
251
|
+
*
|
|
252
|
+
* const server = http.createServer(
|
|
253
|
+
* createListener(dispatcher, () => ({ requestId: crypto.randomUUID() })),
|
|
254
|
+
* )
|
|
255
|
+
* server.listen(0)
|
|
247
256
|
* ```
|
|
248
257
|
*/
|
|
249
258
|
function createListener(dispatcher, state) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../../src/server/validators.ts","../../../src/server/helpers.ts","../../../src/server/handlers.ts"],"sourcesContent":["// ============================================================================\n// Server guards — the centralized-file rule's home for the total `is*` narrows the\n// `node:http` conversion seam applies to raw connection values. Every\n// declaration here is total and `export`ed per the centralized-file rule.\n// ============================================================================\n\nimport { isRecord } from '@orkestrel/contract'\n\n/**\n * Determines whether a `node:http` connection socket is TLS-encrypted — the\n * total, never-throwing narrow `buildRequest` uses to pick the\n * derived scheme (`https` vs `http`).\n *\n * @param socket - The connection value to test (typically `message.socket`)\n * @returns True if `socket` carries a truthy `encrypted` property (a\n * `tls.TLSSocket`); false otherwise, including for `undefined`\n *\n * @example\n * ```ts\n * import { isEncryptedSocket } from '@src/server'\n *\n * isEncryptedSocket({ encrypted: true }) // true\n * isEncryptedSocket({}) // false\n * ```\n */\nexport function isEncryptedSocket(socket: unknown): socket is { readonly encrypted: true } {\n\treturn isRecord(socket) && socket.encrypted === true\n}\n","// ============================================================================\n// Pure conversion between `node:http` and the fetch vocabulary the core\n// `Dispatcher` speaks — no lifecycle and no listener ownership. Every\n// function is exported per the centralized-file rule.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { RequestOptions } from './types.js'\nimport { once } from 'node:events'\nimport { createAbort } from '@orkestrel/abort'\nimport { isEncryptedSocket } from './validators.js'\n\n/**\n * Builds a fetch-standard `Request` from a `node:http` `IncomingMessage` — the\n * server-adapter half of the fetch/node conversion seam.\n *\n * @remarks\n * - `method` is carried over verbatim (defaulting to `GET` when absent).\n * - The URL is built against `options.origin` when given, otherwise a scheme\n * derived from the connection (`https` when {@link isEncryptedSocket}, else\n * `http`) plus the `Host` header (absent `Host` ⇒ `localhost`).\n * - Every request header is copied; multi-value headers are joined per fetch\n * semantics (`', '`-joined), except `set-cookie`, whose values are each\n * appended individually (fetch `Headers` preserves multiple `set-cookie`\n * entries distinctly).\n * - For a method that carries a body (anything but `GET`/`HEAD`), the message\n * is pumped chunk by chunk into a DOM-compatible `ReadableStream<Uint8Array>`\n * (reconciling the DOM + node type worlds under the root config), with\n * `duplex: 'half'` set as Node's fetch implementation requires for a\n * streamed request body.\n * - A fresh `@orkestrel/abort` handle backs `request.signal`. It aborts when\n * the request connection closes before the message finished\n * (`!message.complete`), or when the paired `options.response` closes before\n * its response finished (`!response.writableEnded`). A handler awaiting the\n * signal therefore observes either side of a client disconnect the\n * fetch-standard way, with zero router-specific API.\n *\n * @param message - The raw `node:http` request\n * @param options - Optional `origin` override and paired `response` for\n * response-side disconnect tracking ({@link RequestOptions})\n * @returns A fetch `Request` whose `signal` fires on an incomplete request, or\n * on a response-side client disconnect when `options.response` is provided\n *\n * @example\n * ```ts\n * import { buildRequest } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer((incoming, response) => {\n * \tconst request = buildRequest(incoming, { response })\n * \tconsole.log(request.method, request.url)\n * })\n * ```\n */\nexport function buildRequest(message: IncomingMessage, options?: RequestOptions): Request {\n\tconst method = message.method ?? 'GET'\n\tconst host = message.headers.host ?? 'localhost'\n\tconst scheme = isEncryptedSocket(message.socket) ? 'https' : 'http'\n\tconst origin = options?.origin ?? `${scheme}://${host}`\n\tconst url = new URL(message.url ?? '/', origin)\n\n\tconst headers = new Headers()\n\tfor (const [name, value] of Object.entries(message.headers)) {\n\t\tif (value === undefined) continue\n\t\tif (name === 'set-cookie') {\n\t\t\tfor (const cookie of Array.isArray(value) ? value : [value]) headers.append(name, cookie)\n\t\t\tcontinue\n\t\t}\n\t\theaders.set(name, Array.isArray(value) ? value.join(', ') : value)\n\t}\n\n\tconst abort = createAbort()\n\tmessage.once('close', () => {\n\t\tif (!message.complete)\n\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before completion`))\n\t})\n\tconst response = options?.response\n\tif (response !== undefined)\n\t\tresponse.once('close', () => {\n\t\t\tif (!response.writableEnded)\n\t\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before response completed`))\n\t\t})\n\n\tconst carriesBody = method !== 'GET' && method !== 'HEAD'\n\tconst init: RequestInit = { method, headers, signal: abort.signal }\n\tif (!carriesBody) return new Request(url, init)\n\n\tconst body = new ReadableStream<Uint8Array>({\n\t\tasync start(controller) {\n\t\t\ttry {\n\t\t\t\tfor await (const chunk of message) controller.enqueue(chunk)\n\t\t\t\tcontroller.close()\n\t\t\t} catch (error) {\n\t\t\t\tcontroller.error(error)\n\t\t\t}\n\t\t},\n\t})\n\tconst streamed: RequestInit & { readonly duplex: 'half' } = { ...init, body, duplex: 'half' }\n\treturn new Request(url, streamed)\n}\n\n/**\n * Writes a fetch-standard `Response` back to a `node:http` `ServerResponse` —\n * the reverse half of the fetch/node conversion seam.\n *\n * @remarks\n * Writes `status`/`statusText`, then every response header (`set-cookie`\n * written through {@link Headers.getSetCookie} so multiple cookies stay distinct\n * instead of collapsing into one comma-joined header), then streams the web\n * body to `target` chunk by chunk (`for await` over `response.body`), ending\n * `target` when the stream completes. When a write reports backpressure, the\n * body pump waits for `drain` before pulling again, unless the target closes,\n * errors, or is destroyed first. A `null` body ends `target` immediately with\n * no further writes. Total error posture: if `target` is destroyed mid-stream\n * (the client disconnected), the write loop stops and `target` is left as-is\n * rather than throwing an unhandled rejection — a destroyed target is not\n * this function's error to surface.\n *\n * @param response - The fetch `Response` to write\n * @param target - The `node:http` response to write it to\n * @returns A promise that resolves after `target` has been ended (or the\n * stream stopped because `target` was destroyed)\n *\n * @example\n * ```ts\n * import { sendResponse } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer(async (_incoming, target) => {\n * \tawait sendResponse(new Response('ok'), target)\n * })\n * ```\n */\nexport async function sendResponse(response: Response, target: ServerResponse): Promise<void> {\n\ttarget.statusCode = response.status\n\ttarget.statusMessage = response.statusText\n\tfor (const [name, value] of response.headers) {\n\t\tif (name === 'set-cookie') continue\n\t\ttarget.setHeader(name, value)\n\t}\n\tconst cookies = response.headers.getSetCookie()\n\tif (cookies.length > 0) target.setHeader('set-cookie', cookies)\n\n\tif (response.body === null) {\n\t\tif (!target.destroyed) target.end()\n\t\treturn\n\t}\n\ttry {\n\t\tfor await (const chunk of response.body) {\n\t\t\tif (target.destroyed) return\n\t\t\tif (!target.write(chunk)) {\n\t\t\t\tif (target.destroyed) return\n\t\t\t\tconst abort = new AbortController()\n\t\t\t\ttry {\n\t\t\t\t\tawait Promise.race([\n\t\t\t\t\t\tonce(target, 'drain', { signal: abort.signal }),\n\t\t\t\t\t\tonce(target, 'close', { signal: abort.signal }),\n\t\t\t\t\t])\n\t\t\t\t} finally {\n\t\t\t\t\tabort.abort()\n\t\t\t\t}\n\t\t\t\tif (target.destroyed) return\n\t\t\t}\n\t\t}\n\t\tif (!target.destroyed) target.end()\n\t} catch {\n\t\tif (!target.destroyed) target.end()\n\t}\n}\n","// ============================================================================\n// Server request handlers — the centralized-file rule's home for the functions that\n// run one `node:http` exchange through a core `Dispatcher`, plus the listener\n// the whole server face hands to `http.createServer`. Every function\n// is exported per the centralized-file rule.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { DispatcherInterface } from '@src/core'\nimport type { ListenerFunction, StateFunction } from './types.js'\nimport { buildRequest, sendResponse } from './helpers.js'\n\n/**\n * Handles one `node:http` request through a core dispatcher and writes its\n * fetch-standard response.\n *\n * @remarks\n * This is the named asynchronous orchestration behind {@link createListener}.\n * A rejected dispatch is treated only as a transport-level last resort: write\n * a bare `500` before headers, or destroy a response whose headers have\n * already started.\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run\n * @param state - Derives the consumer state from the incoming message\n * @param request - The raw `node:http` request\n * @param response - The raw `node:http` response\n * @returns A promise that settles after the response is written or closed\n *\n * @example\n * ```ts\n * const server = http.createServer((request, response) => {\n * \tvoid handleListenerRequest(dispatcher, () => undefined, request, response)\n * })\n * ```\n */\nexport async function handleListenerRequest<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n\trequest: IncomingMessage,\n\tresponse: ServerResponse,\n): Promise<void> {\n\ttry {\n\t\tconst converted = buildRequest(request, { response })\n\t\tconst result = await dispatcher.handle(converted, state(request))\n\t\tawait sendResponse(result, response)\n\t} catch (error) {\n\t\tif (!response.headersSent && !response.destroyed) {\n\t\t\tresponse.writeHead(500)\n\t\t\tresponse.end()\n\t\t} else if (!response.destroyed) {\n\t\t\tresponse.destroy(error instanceof Error ? error : new Error(String(error)))\n\t\t}\n\t}\n}\n\n/**\n * Creates a `node:http` request listener over a core {@link DispatcherInterface} —\n * the whole server face's entry point: converts the incoming message to\n * a fetch `Request`, hands it to the dispatcher with the consumer's per-request\n * `state`, and writes the resulting `Response` back.\n *\n * @remarks\n * A rejected `dispatcher.handle` (a route handler throw — the dispatcher\n * never invents an error boundary) is this listener's transport-level\n * LAST RESORT, distinct from an application error boundary: when nothing has\n * been sent yet, it destroys the connection with a bare `500` head (never\n * leaking a hanging socket); after headers are already sent, it destroys the\n * connection outright. The router still owns no error POLICY — a consumer\n * that wants mapped error responses installs its own boundary around\n * `dispatcher.handle` (the future `@orkestrel/server` seam).\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run each converted request through\n * @param state - Derives the consumer's per-request `state` from the raw message\n * @returns A `(request, response) => void` listener, passable directly to\n * `http.createServer`\n *\n * @example\n * ```ts\n * import { createListener } from '@src/server'\n * import { createDispatcher } from '@src/core'\n * import http from 'node:http'\n *\n * const dispatcher = createDispatcher()\n * dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })\n * http.createServer(createListener(dispatcher, () => undefined)).listen(0)\n * ```\n */\nexport function createListener<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n): ListenerFunction {\n\treturn (request, response) => {\n\t\tvoid handleListenerRequest(dispatcher, state, request, response)\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../../../src/server/validators.ts","../../../src/server/helpers.ts","../../../src/server/handlers.ts"],"sourcesContent":["// ============================================================================\n// Server guards — the centralized-file rule's home for the total `is*` narrows the\n// `node:http` conversion seam applies to raw connection values. Every\n// declaration here is total and `export`ed per the centralized-file rule.\n// ============================================================================\n\nimport { isRecord } from '@orkestrel/contract'\n\n/**\n * Determines whether a `node:http` connection socket is TLS-encrypted — the\n * total, never-throwing narrow `buildRequest` uses to pick the\n * derived scheme (`https` vs `http`).\n *\n * @param socket - The connection value to test (typically `message.socket`)\n * @returns True if `socket` carries a truthy `encrypted` property (a\n * `tls.TLSSocket`); false otherwise, including for `undefined`\n *\n * @example\n * ```ts\n * import { isEncryptedSocket } from '@src/server'\n *\n * isEncryptedSocket({ encrypted: true }) // true\n * isEncryptedSocket({}) // false\n * ```\n */\nexport function isEncryptedSocket(socket: unknown): socket is { readonly encrypted: true } {\n\treturn isRecord(socket) && socket.encrypted === true\n}\n","// ============================================================================\n// Pure conversion between `node:http` and the fetch vocabulary the core\n// `Dispatcher` speaks — no lifecycle and no listener ownership. Every\n// function is exported per the centralized-file rule.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { RequestOptions } from './types.js'\nimport { once } from 'node:events'\nimport { createAbort } from '@orkestrel/abort'\nimport { isEncryptedSocket } from './validators.js'\n\n/**\n * Builds a fetch-standard `Request` from a `node:http` `IncomingMessage` — the\n * server-adapter half of the fetch/node conversion seam.\n *\n * @remarks\n * - `method` is carried over verbatim (defaulting to `GET` when absent).\n * - The URL is built against `options.origin` when given, otherwise a scheme\n * derived from the connection (`https` when {@link isEncryptedSocket}, else\n * `http`) plus the `Host` header (absent `Host` ⇒ `localhost`).\n * - Every request header is copied; multi-value headers are joined per fetch\n * semantics (`', '`-joined), except `set-cookie`, whose values are each\n * appended individually (fetch `Headers` preserves multiple `set-cookie`\n * entries distinctly).\n * - For a method that carries a body (anything but `GET`/`HEAD`), the message\n * is pumped chunk by chunk into a DOM-compatible `ReadableStream<Uint8Array>`\n * (reconciling the DOM + node type worlds under the root config), with\n * `duplex: 'half'` set as Node's fetch implementation requires for a\n * streamed request body.\n * - A fresh `@orkestrel/abort` handle backs `request.signal`. It aborts when\n * the request connection closes before the message finished\n * (`!message.complete`), or when the paired `options.response` closes before\n * its response finished (`!response.writableEnded`). A handler awaiting the\n * signal therefore observes either side of a client disconnect the\n * fetch-standard way, with zero router-specific API.\n *\n * @param message - The raw `node:http` request\n * @param options - Optional `origin` override and paired `response` for\n * response-side disconnect tracking ({@link RequestOptions})\n * @returns A fetch `Request` whose `signal` fires on an incomplete request, or\n * on a response-side client disconnect when `options.response` is provided\n *\n * @example\n * ```ts\n * import { buildRequest } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer((incoming, response) => {\n * \tconst request = buildRequest(incoming, { response })\n * \tconsole.log(request.method, request.url)\n * })\n * ```\n */\nexport function buildRequest(message: IncomingMessage, options?: RequestOptions): Request {\n\tconst method = message.method ?? 'GET'\n\tconst host = message.headers.host ?? 'localhost'\n\tconst scheme = isEncryptedSocket(message.socket) ? 'https' : 'http'\n\tconst origin = options?.origin ?? `${scheme}://${host}`\n\tconst url = new URL(message.url ?? '/', origin)\n\n\tconst headers = new Headers()\n\tfor (const [name, value] of Object.entries(message.headers)) {\n\t\tif (value === undefined) continue\n\t\tif (name === 'set-cookie') {\n\t\t\tfor (const cookie of Array.isArray(value) ? value : [value]) headers.append(name, cookie)\n\t\t\tcontinue\n\t\t}\n\t\theaders.set(name, Array.isArray(value) ? value.join(', ') : value)\n\t}\n\n\tconst abort = createAbort()\n\tmessage.once('close', () => {\n\t\tif (!message.complete)\n\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before completion`))\n\t})\n\tconst response = options?.response\n\tif (response !== undefined)\n\t\tresponse.once('close', () => {\n\t\t\tif (!response.writableEnded)\n\t\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before response completed`))\n\t\t})\n\n\tconst carriesBody = method !== 'GET' && method !== 'HEAD'\n\tconst init: RequestInit = { method, headers, signal: abort.signal }\n\tif (!carriesBody) return new Request(url, init)\n\n\tconst body = new ReadableStream<Uint8Array>({\n\t\tasync start(controller) {\n\t\t\ttry {\n\t\t\t\tfor await (const chunk of message) controller.enqueue(chunk)\n\t\t\t\tcontroller.close()\n\t\t\t} catch (error) {\n\t\t\t\tcontroller.error(error)\n\t\t\t}\n\t\t},\n\t})\n\tconst streamed: RequestInit & { readonly duplex: 'half' } = { ...init, body, duplex: 'half' }\n\treturn new Request(url, streamed)\n}\n\n/**\n * Writes a fetch-standard `Response` back to a `node:http` `ServerResponse` —\n * the reverse half of the fetch/node conversion seam.\n *\n * @remarks\n * Writes `status`/`statusText`, then every response header (`set-cookie`\n * written through {@link Headers.getSetCookie} so multiple cookies stay distinct\n * instead of collapsing into one comma-joined header), then streams the web\n * body to `target` chunk by chunk (`for await` over `response.body`), ending\n * `target` when the stream completes. When a write reports backpressure, the\n * body pump waits for `drain` before pulling again, unless the target closes,\n * errors, or is destroyed first. A `null` body ends `target` immediately with\n * no further writes. Total error posture: if `target` is destroyed mid-stream\n * (the client disconnected), the write loop stops and `target` is left as-is\n * rather than throwing an unhandled rejection — a destroyed target is not\n * this function's error to surface.\n *\n * @param response - The fetch `Response` to write\n * @param target - The `node:http` response to write it to\n * @returns A promise that resolves after `target` has been ended (or the\n * stream stopped because `target` was destroyed)\n *\n * @example\n * ```ts\n * import { sendResponse } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer(async (_incoming, target) => {\n * \tawait sendResponse(new Response('ok'), target)\n * })\n * ```\n */\nexport async function sendResponse(response: Response, target: ServerResponse): Promise<void> {\n\ttarget.statusCode = response.status\n\ttarget.statusMessage = response.statusText\n\tfor (const [name, value] of response.headers) {\n\t\tif (name === 'set-cookie') continue\n\t\ttarget.setHeader(name, value)\n\t}\n\tconst cookies = response.headers.getSetCookie()\n\tif (cookies.length > 0) target.setHeader('set-cookie', cookies)\n\n\tif (response.body === null) {\n\t\tif (!target.destroyed) target.end()\n\t\treturn\n\t}\n\ttry {\n\t\tfor await (const chunk of response.body) {\n\t\t\tif (target.destroyed) return\n\t\t\tif (!target.write(chunk)) {\n\t\t\t\tif (target.destroyed) return\n\t\t\t\tconst abort = new AbortController()\n\t\t\t\ttry {\n\t\t\t\t\tawait Promise.race([\n\t\t\t\t\t\tonce(target, 'drain', { signal: abort.signal }),\n\t\t\t\t\t\tonce(target, 'close', { signal: abort.signal }),\n\t\t\t\t\t])\n\t\t\t\t} finally {\n\t\t\t\t\tabort.abort()\n\t\t\t\t}\n\t\t\t\tif (target.destroyed) return\n\t\t\t}\n\t\t}\n\t\tif (!target.destroyed) target.end()\n\t} catch {\n\t\tif (!target.destroyed) target.end()\n\t}\n}\n","// ============================================================================\n// Server request handlers — the centralized-file rule's home for the functions that\n// run one `node:http` exchange through a core `Dispatcher`, plus the listener\n// the whole server face hands to `http.createServer`. Every function\n// is exported per the centralized-file rule.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { DispatcherInterface } from '@src/core'\nimport type { ListenerFunction, StateFunction } from './types.js'\nimport { buildRequest, sendResponse } from './helpers.js'\n\n/**\n * Handles one `node:http` request through a core dispatcher and writes its\n * fetch-standard response.\n *\n * @remarks\n * This is the named asynchronous orchestration behind {@link createListener}.\n * A rejected dispatch is treated only as a transport-level last resort: write\n * a bare `500` before headers, or destroy a response whose headers have\n * already started.\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run\n * @param state - Derives the consumer state from the incoming message\n * @param request - The raw `node:http` request\n * @param response - The raw `node:http` response\n * @returns A promise that settles after the response is written or closed\n *\n * @example\n * ```ts\n * const server = http.createServer((request, response) => {\n * \tvoid handleListenerRequest(dispatcher, () => undefined, request, response)\n * })\n * ```\n */\nexport async function handleListenerRequest<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n\trequest: IncomingMessage,\n\tresponse: ServerResponse,\n): Promise<void> {\n\ttry {\n\t\tconst converted = buildRequest(request, { response })\n\t\tconst result = await dispatcher.handle(converted, state(request))\n\t\tawait sendResponse(result, response)\n\t} catch (error) {\n\t\tif (!response.headersSent && !response.destroyed) {\n\t\t\tresponse.writeHead(500)\n\t\t\tresponse.end()\n\t\t} else if (!response.destroyed) {\n\t\t\tresponse.destroy(error instanceof Error ? error : new Error(String(error)))\n\t\t}\n\t}\n}\n\n/**\n * Creates a `node:http` request listener over a core {@link DispatcherInterface} —\n * the whole server face's entry point: converts the incoming message to\n * a fetch `Request`, hands it to the dispatcher with the consumer's per-request\n * `state`, and writes the resulting `Response` back.\n *\n * @remarks\n * A rejected `dispatcher.handle` (a route handler throw — the dispatcher\n * never invents an error boundary) is this listener's transport-level\n * LAST RESORT, distinct from an application error boundary: when nothing has\n * been sent yet, it destroys the connection with a bare `500` head (never\n * leaking a hanging socket); after headers are already sent, it destroys the\n * connection outright. The router still owns no error POLICY — a consumer\n * that wants mapped error responses installs its own boundary around\n * `dispatcher.handle` (the future `@orkestrel/server` seam).\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run each converted request through\n * @param state - Derives the consumer's per-request `state` from the raw message\n * @returns A `(request, response) => void` listener, passable directly to\n * `http.createServer`\n *\n * @example\n * ```ts\n * import { createListener } from '@orkestrel/router/server'\n * import { createDispatcher } from '@orkestrel/router'\n * import http from 'node:http'\n *\n * const dispatcher = createDispatcher<{ readonly requestId: string }>()\n * dispatcher.add({\n * \tmethod: 'GET',\n * \tpath: '/users/:id',\n * \thandler: (_request, context) =>\n * \t\tResponse.json({ id: context.params.id, requestId: context.state.requestId }),\n * })\n *\n * const server = http.createServer(\n * \tcreateListener(dispatcher, () => ({ requestId: crypto.randomUUID() })),\n * )\n * server.listen(0)\n * ```\n */\nexport function createListener<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n): ListenerFunction {\n\treturn (request, response) => {\n\t\tvoid handleListenerRequest(dispatcher, state, request, response)\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { DispatcherInterface } from '@orkestrel/router';
|
|
2
|
-
import { IncomingMessage } from 'node:http';
|
|
3
|
-
import { ServerResponse } from 'node:http';
|
|
1
|
+
import type { DispatcherInterface } from '@orkestrel/router';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
import type { ServerResponse } from 'node:http';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Builds a fetch-standard `Request` from a `node:http` `IncomingMessage` — the
|
|
@@ -70,13 +70,22 @@ export declare function buildRequest(message: IncomingMessage, options?: Request
|
|
|
70
70
|
*
|
|
71
71
|
* @example
|
|
72
72
|
* ```ts
|
|
73
|
-
* import { createListener } from '@
|
|
74
|
-
* import { createDispatcher } from '@
|
|
73
|
+
* import { createListener } from '@orkestrel/router/server'
|
|
74
|
+
* import { createDispatcher } from '@orkestrel/router'
|
|
75
75
|
* import http from 'node:http'
|
|
76
76
|
*
|
|
77
|
-
* const dispatcher = createDispatcher()
|
|
78
|
-
* dispatcher.add({
|
|
79
|
-
*
|
|
77
|
+
* const dispatcher = createDispatcher<{ readonly requestId: string }>()
|
|
78
|
+
* dispatcher.add({
|
|
79
|
+
* method: 'GET',
|
|
80
|
+
* path: '/users/:id',
|
|
81
|
+
* handler: (_request, context) =>
|
|
82
|
+
* Response.json({ id: context.params.id, requestId: context.state.requestId }),
|
|
83
|
+
* })
|
|
84
|
+
*
|
|
85
|
+
* const server = http.createServer(
|
|
86
|
+
* createListener(dispatcher, () => ({ requestId: crypto.randomUUID() })),
|
|
87
|
+
* )
|
|
88
|
+
* server.listen(0)
|
|
80
89
|
* ```
|
|
81
90
|
*/
|
|
82
91
|
export declare function createListener<TState>(dispatcher: DispatcherInterface<TState>, state: StateFunction<TState>): ListenerFunction;
|