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