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