@nifrajs/core 3.5.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +26 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +24 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +168 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +40 -8
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +41 -37
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +53 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +191 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1206 -218
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +7 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +31 -6
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +237 -12
- package/src/idempotency.ts +72 -8
- package/src/index.ts +40 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +41 -36
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +59 -2
- package/src/server/server.ts +1613 -269
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +38 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
package/src/router/pattern.ts
CHANGED
|
@@ -11,10 +11,54 @@ export type RoutePatternSegment =
|
|
|
11
11
|
/** A segment that is part literal, part parameter: `:key.txt`, `feed.:format`, `v:major.:minor`. */
|
|
12
12
|
| { readonly kind: "mixed"; readonly parts: readonly MixedPart[] }
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* One piece of a {@link RoutePatternSegment} of kind `mixed`, in left-to-right order. A parameter
|
|
16
|
+
* written with a constraint (`:id{[0-9]+}`) carries it as `c`; a segment that is one constrained
|
|
17
|
+
* parameter and nothing else is a `mixed` segment with that single part.
|
|
18
|
+
*/
|
|
15
19
|
export type MixedPart =
|
|
16
20
|
| { readonly t: "lit"; readonly v: string }
|
|
17
|
-
| { readonly t: "param"; readonly name: string }
|
|
21
|
+
| { readonly t: "param"; readonly name: string; readonly c?: ParamConstraint | undefined }
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a constrained parameter accepts, parsed from the `{...}` after its name.
|
|
25
|
+
*
|
|
26
|
+
* A constraint is one of two things:
|
|
27
|
+
* - a character class with an optional count: `[0-9]+`, `[a-z0-9-]{3,32}`, `\d{4}`, `\w+`. The class
|
|
28
|
+
* is `[...]` holding characters, `x-y` ranges, `\d` and `\w`, or a bare `\d` / `\w`. The count is
|
|
29
|
+
* `+` (one or more), `{n}`, `{n,}` or `{n,m}` with `n` at least 1; with no count the value is
|
|
30
|
+
* exactly one character.
|
|
31
|
+
* - a list of two or more literal values: `en|fr|de`.
|
|
32
|
+
*
|
|
33
|
+
* Anything else in braces is not a constraint, and the braces stay the literal text they always were.
|
|
34
|
+
*
|
|
35
|
+
* A value is tested as it appears in the request path, before percent-decoding, and only ASCII can
|
|
36
|
+
* satisfy a constraint. `%` is never accepted, so a value that passes holds no escape and reaches the
|
|
37
|
+
* handler as exactly the text that was tested.
|
|
38
|
+
*/
|
|
39
|
+
export interface ParamConstraint {
|
|
40
|
+
/** The text between the braces, as written. */
|
|
41
|
+
readonly source: string
|
|
42
|
+
/** The accepted values, for a list (`en|fr|de`), in the order written. */
|
|
43
|
+
readonly oneOf?: readonly string[] | undefined
|
|
44
|
+
/** The same values as the keys of an object with no prototype, which is what a value is looked up in. */
|
|
45
|
+
readonly index?: Readonly<Record<string, 1>> | undefined
|
|
46
|
+
/**
|
|
47
|
+
* For a class: which ASCII codes it holds. 128 characters, the one at index `code` being `1` when
|
|
48
|
+
* the class holds that code and `0` when it does not. All `0` for a list.
|
|
49
|
+
*/
|
|
50
|
+
readonly mask: string
|
|
51
|
+
/** For a class: the fewest and the most characters a value may have. */
|
|
52
|
+
readonly min: number
|
|
53
|
+
readonly max: number
|
|
54
|
+
/** How many characters the class holds, or how many values the list has. */
|
|
55
|
+
readonly size: number
|
|
56
|
+
/**
|
|
57
|
+
* What the constraint accepts, spelled one way: `\d+` and `[0-9]+` share a key, and so do `a|b`
|
|
58
|
+
* and `b|a`. Two parameters with one key are the same parameter to the router.
|
|
59
|
+
*/
|
|
60
|
+
readonly key: string
|
|
61
|
+
}
|
|
18
62
|
|
|
19
63
|
/**
|
|
20
64
|
* Compiled route grammar shared by runtime routers, browser navigation, mocks, and adapters.
|
|
@@ -34,32 +78,128 @@ export type RoutePatternMatch =
|
|
|
34
78
|
| { readonly matched: true; readonly params: Record<string, string> }
|
|
35
79
|
| { readonly matched: false; readonly reason: "not-found" | "malformed" }
|
|
36
80
|
|
|
81
|
+
/** What {@link matchRoutePattern} needs per compiled pattern: the whole-path regex, and for each mixed
|
|
82
|
+
* segment its shape and, when it has a constraint, its parts (both indexed like `segments`,
|
|
83
|
+
* `undefined` elsewhere). */
|
|
84
|
+
interface PatternMatcher {
|
|
85
|
+
readonly regex: RegExp
|
|
86
|
+
readonly shapes: readonly (MixedSegmentShape | undefined)[]
|
|
87
|
+
readonly checked: readonly (readonly MixedPart[] | undefined)[]
|
|
88
|
+
}
|
|
89
|
+
|
|
37
90
|
/**
|
|
38
|
-
*
|
|
39
|
-
* never asks for one, so building it during {@link compileRoutePattern} would charge every
|
|
40
|
-
* boot for the browser/mock adapters alone. Keyed by the frozen pattern, so the cache dies
|
|
91
|
+
* Matcher per compiled pattern, derived on first use. The core trie matches by descending segments
|
|
92
|
+
* and never asks for one, so building it during {@link compileRoutePattern} would charge every
|
|
93
|
+
* server's boot for the browser/mock adapters alone. Keyed by the frozen pattern, so the cache dies
|
|
94
|
+
* with it.
|
|
41
95
|
*/
|
|
42
|
-
const
|
|
96
|
+
const MATCHER_CACHE = new WeakMap<CompiledRoutePattern, PatternMatcher>()
|
|
43
97
|
|
|
44
|
-
function
|
|
45
|
-
let
|
|
46
|
-
if (
|
|
98
|
+
function matcherOf(compiled: CompiledRoutePattern): PatternMatcher {
|
|
99
|
+
let matcher = MATCHER_CACHE.get(compiled)
|
|
100
|
+
if (matcher === undefined) {
|
|
47
101
|
if (compiled[COMPILED_ROUTE_PATTERN] !== true) {
|
|
48
102
|
throw new TypeError("route pattern was not produced by compileRoutePattern()")
|
|
49
103
|
}
|
|
104
|
+
// A mixed segment is captured WHOLE here and taken apart by `matchMixedSegment` afterwards. Its
|
|
105
|
+
// parameters never become separate lazy groups in this regex: several of those in one segment
|
|
106
|
+
// make the engine retry every split of the text between them, and the text is the request's.
|
|
50
107
|
const parts = compiled.segments.map((segment) =>
|
|
51
108
|
segment.kind === "static"
|
|
52
109
|
? escapeRegex(segment.value)
|
|
53
|
-
: segment.kind === "
|
|
54
|
-
? "(
|
|
55
|
-
:
|
|
56
|
-
? mixedSegmentSource(segment.parts)
|
|
57
|
-
: "(.+)",
|
|
110
|
+
: segment.kind === "wildcard"
|
|
111
|
+
? "(.+)"
|
|
112
|
+
: "([^/]+)",
|
|
58
113
|
)
|
|
59
|
-
|
|
60
|
-
|
|
114
|
+
matcher = {
|
|
115
|
+
regex: new RegExp(parts.length === 0 ? "^/$" : `^/${parts.join("/")}$`),
|
|
116
|
+
shapes: compiled.segments.map((segment) =>
|
|
117
|
+
segment.kind === "mixed" ? mixedSegmentShape(segment.parts) : undefined,
|
|
118
|
+
),
|
|
119
|
+
checked: compiled.segments.map((segment) =>
|
|
120
|
+
segment.kind === "mixed" ? constrainedParts(segment.parts) : undefined,
|
|
121
|
+
),
|
|
122
|
+
}
|
|
123
|
+
MATCHER_CACHE.set(compiled, matcher)
|
|
61
124
|
}
|
|
62
|
-
return
|
|
125
|
+
return matcher
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// No pattern text ever becomes a RegExp. This recognises the constraint grammar, anchored at a `{`:
|
|
129
|
+
// group 1 is a class, 2 its count (3 and 4 the bounds), 5 a list of values.
|
|
130
|
+
const CONSTRAINT =
|
|
131
|
+
/^\{(?:(\[(?:[\w.~!$&'()+,;=@-]|\\[dw])+]|\\[dw])(\+|\{(\d+)(?:,(\d*))?})?|([\w.~-]+(?:\|[\w.~-]+)+))}/
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse the constraint `text` starts with, or `undefined` when it starts with none. `text` begins at
|
|
135
|
+
* the `{` that follows a parameter name; the constraint read is `source.length + 2` characters long.
|
|
136
|
+
*/
|
|
137
|
+
export function paramConstraint(text: string): ParamConstraint | undefined {
|
|
138
|
+
const found = CONSTRAINT.exec(text)
|
|
139
|
+
if (found === null) return undefined
|
|
140
|
+
const oneOf = found[5]?.split("|")
|
|
141
|
+
const table: number[] = new Array(128).fill(0)
|
|
142
|
+
let min = 1
|
|
143
|
+
let max = 1
|
|
144
|
+
const set = (from: number, to: number): unknown => table.fill(1, from, to + 1)
|
|
145
|
+
// A list has no class to read: `body` is empty for it, and so is the count.
|
|
146
|
+
const body = found[1] ?? ""
|
|
147
|
+
const members = body.length > 2 ? body.slice(1, -1) : body
|
|
148
|
+
for (let i = 0; i < members.length; i++) {
|
|
149
|
+
const from = members.charCodeAt(i)
|
|
150
|
+
let to = from
|
|
151
|
+
if (from === 92 /* \ */) {
|
|
152
|
+
if (members[++i] === "w") {
|
|
153
|
+
set(65, 90)
|
|
154
|
+
set(95, 95)
|
|
155
|
+
set(97, 122)
|
|
156
|
+
}
|
|
157
|
+
set(48, 57)
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
// `x-y` is a range when something follows the hyphen; a hyphen at either end is itself.
|
|
161
|
+
if (members[i + 1] === "-" && i + 2 < members.length) {
|
|
162
|
+
i += 2
|
|
163
|
+
to = members.charCodeAt(i)
|
|
164
|
+
// A range that takes in `%` would let a percent-escape through, and the handler would then
|
|
165
|
+
// see a value other than the one tested.
|
|
166
|
+
if (to === 92 || to < from || (from < 37 && to > 37)) return undefined
|
|
167
|
+
}
|
|
168
|
+
set(from, to)
|
|
169
|
+
}
|
|
170
|
+
if (found[2] !== undefined) {
|
|
171
|
+
min = Number(found[3] ?? 1)
|
|
172
|
+
max = found[2] === "+" || found[4] === "" ? Infinity : Number(found[4] ?? found[3])
|
|
173
|
+
if (min < 1 || max < min) return undefined
|
|
174
|
+
}
|
|
175
|
+
const mask = table.join("")
|
|
176
|
+
let index: Record<string, 1> | undefined
|
|
177
|
+
if (oneOf) {
|
|
178
|
+
index = Object.create(null) as Record<string, 1>
|
|
179
|
+
for (const value of oneOf) index[value] = 1
|
|
180
|
+
}
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
source: found[0].slice(1, -1),
|
|
183
|
+
oneOf: oneOf && Object.freeze(oneOf),
|
|
184
|
+
index: index && Object.freeze(index),
|
|
185
|
+
mask,
|
|
186
|
+
min,
|
|
187
|
+
max,
|
|
188
|
+
size: oneOf ? oneOf.length : mask.split("1").length - 1,
|
|
189
|
+
key: oneOf ? [...oneOf].sort().join("|") : `${mask},${min},${max}`,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Runs per request: lookups hit a string or a frozen object, as a frozen array reads several times slower.
|
|
194
|
+
function satisfies(constraint: ParamConstraint | undefined, value: string): boolean {
|
|
195
|
+
if (!constraint) return true
|
|
196
|
+
if (constraint.index) return constraint.index[value] === 1
|
|
197
|
+
let i = value.length
|
|
198
|
+
if (i < constraint.min || i > constraint.max) return false
|
|
199
|
+
const mask = constraint.mask
|
|
200
|
+
// A code past 127 reads outside the mask, which is `NaN` and so not a `1`.
|
|
201
|
+
while (i-- > 0) if (mask.charCodeAt(value.charCodeAt(i)) !== 49 /* 1 */) return false
|
|
202
|
+
return true
|
|
63
203
|
}
|
|
64
204
|
|
|
65
205
|
function validParamName(name: string): boolean {
|
|
@@ -131,16 +271,26 @@ function splitMixed(value: string): MixedPart[] | undefined {
|
|
|
131
271
|
parts.push({ t: "lit", v: literal })
|
|
132
272
|
literal = ""
|
|
133
273
|
}
|
|
134
|
-
|
|
274
|
+
// A constraint belongs to the parameter; braces that are not one stay literal text.
|
|
275
|
+
const constraint = paramConstraint(value.slice(paramEnd))
|
|
276
|
+
parts.push({ t: "param", name, c: constraint })
|
|
135
277
|
sawParam = true
|
|
136
|
-
i
|
|
278
|
+
i = paramEnd - 1 + (constraint ? constraint.source.length + 2 : 0)
|
|
137
279
|
}
|
|
138
280
|
if (!sawParam) return undefined
|
|
139
281
|
if (literal !== "") parts.push({ t: "lit", v: literal })
|
|
140
282
|
return parts
|
|
141
283
|
}
|
|
142
284
|
|
|
143
|
-
/**
|
|
285
|
+
/**
|
|
286
|
+
* A canonical string for one mixed segment's shape: the anchored-regex source that describes what the
|
|
287
|
+
* segment accepts, with a capture per parameter. Two segments with the same source are the same shape.
|
|
288
|
+
*
|
|
289
|
+
* It is an identity and an ordering key. Matching goes through {@link matchMixedSegment}, which accepts
|
|
290
|
+
* exactly what this source describes without compiling it - for a segment with no constraint. A
|
|
291
|
+
* constrained parameter's capture is its constraint, and there the scanner accepts less than the
|
|
292
|
+
* source describes: it places the literals as if nothing were constrained and then tests each value.
|
|
293
|
+
*/
|
|
144
294
|
export function mixedSegmentSource(parts: readonly MixedPart[]): string {
|
|
145
295
|
let source = ""
|
|
146
296
|
for (const part of parts) {
|
|
@@ -148,11 +298,150 @@ export function mixedSegmentSource(parts: readonly MixedPart[]): string {
|
|
|
148
298
|
// `/abc.txt` would capture `abc.txt` and then fail to match `\.txt`. With `^…$` anchoring, the
|
|
149
299
|
// lazy form still yields `abc.txt` for `/abc.txt.txt` - the anchor forces the LAST `.txt` to be
|
|
150
300
|
// the literal. `+?` and not `*?`: an empty capture must not match (see the empty-segment rule).
|
|
151
|
-
source +=
|
|
301
|
+
source +=
|
|
302
|
+
part.t === "lit"
|
|
303
|
+
? escapeRegex(part.v)
|
|
304
|
+
: part.c === undefined
|
|
305
|
+
? "([^/]+?)"
|
|
306
|
+
: `(${part.c.oneOf?.map(escapeRegex).join("|") ?? part.c.source})`
|
|
152
307
|
}
|
|
153
308
|
return source
|
|
154
309
|
}
|
|
155
310
|
|
|
311
|
+
/**
|
|
312
|
+
* A mixed segment laid out for matching: the literals around its parameters, in order. The first
|
|
313
|
+
* entry is the literal before the first parameter, and each entry after it is the literal following
|
|
314
|
+
* the next parameter, so a segment with N parameters has N + 1 entries. An entry is `""` where the
|
|
315
|
+
* segment has no literal in that position - at either end, or between two parameters that touch.
|
|
316
|
+
*
|
|
317
|
+
* `/v:major.:minor` is `["v", ".", ""]`; `/:name.json` is `["", ".json"]`.
|
|
318
|
+
*/
|
|
319
|
+
export type MixedSegmentShape = readonly string[]
|
|
320
|
+
|
|
321
|
+
/** Lay a mixed segment's parts out as a {@link MixedSegmentShape}. Done once, at registration. */
|
|
322
|
+
export function mixedSegmentShape(parts: readonly MixedPart[]): MixedSegmentShape {
|
|
323
|
+
// Two literals are never adjacent, so a literal fills the open slot and a parameter opens the next.
|
|
324
|
+
const shape = [""]
|
|
325
|
+
for (const part of parts) {
|
|
326
|
+
if (part.t === "lit") shape[shape.length - 1] = part.v
|
|
327
|
+
else shape.push("")
|
|
328
|
+
}
|
|
329
|
+
return shape
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The parts of a mixed segment when at least one of its parameters has a constraint, else
|
|
334
|
+
* `undefined`. It is the fourth argument to {@link matchMixedSegment}; done once, at registration.
|
|
335
|
+
*/
|
|
336
|
+
export function constrainedParts(parts: readonly MixedPart[]): readonly MixedPart[] | undefined {
|
|
337
|
+
return parts.some((part) => part.t === "param" && part.c) ? parts : undefined
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Match ONE path segment against a mixed shape, in a single left-to-right pass.
|
|
342
|
+
*
|
|
343
|
+
* On a match the captures are appended to `out` in parameter order and the result is `true`. On a miss
|
|
344
|
+
* `out` is left exactly as it was and the result is `false`.
|
|
345
|
+
*
|
|
346
|
+
* The rule: the leading literal must start the segment and the trailing literal must end it; every
|
|
347
|
+
* parameter takes at least one character; and each literal between two parameters is taken at its
|
|
348
|
+
* FIRST occurrence that leaves the parameter before it non-empty. The last parameter takes what
|
|
349
|
+
* remains. Two parameters with nothing between them give the first one a single character.
|
|
350
|
+
*
|
|
351
|
+
* That is the match an anchored pattern with lazy captures selects, reached without trying the
|
|
352
|
+
* alternatives: taking a literal earlier only ever hands more text to the parameter after it, so if
|
|
353
|
+
* any placement matches, the earliest one does. The work is therefore bounded by the segment's length
|
|
354
|
+
* however many parameters the shape has - a segment is request-controlled and must not be able to buy
|
|
355
|
+
* more than one pass.
|
|
356
|
+
*
|
|
357
|
+
* Constraints are tested last, on the values that rule produced: pass `checked` (from
|
|
358
|
+
* {@link constrainedParts}) and a value that fails its parameter's constraint is a miss. A failed
|
|
359
|
+
* test never moves a literal to a later occurrence to try again, so the cost stays one pass to place
|
|
360
|
+
* the literals and one over the values.
|
|
361
|
+
*
|
|
362
|
+
* `segment` must not contain `/`.
|
|
363
|
+
*/
|
|
364
|
+
export function matchMixedSegment(
|
|
365
|
+
shape: MixedSegmentShape,
|
|
366
|
+
segment: string,
|
|
367
|
+
out: string[],
|
|
368
|
+
checked?: readonly MixedPart[],
|
|
369
|
+
): boolean {
|
|
370
|
+
const last = shape.length - 1
|
|
371
|
+
const head = shape[0]!
|
|
372
|
+
const tail = shape[last]!
|
|
373
|
+
let at = head.length
|
|
374
|
+
const end = segment.length - tail.length
|
|
375
|
+
// The first parameter needs a character between the two end literals. This also refuses end
|
|
376
|
+
// literals that would overlap in the text (`a.:x.b` against `a.b`).
|
|
377
|
+
if (at >= end) return false
|
|
378
|
+
if (at !== 0 && !segment.startsWith(head)) return false
|
|
379
|
+
if (end !== segment.length && !segment.endsWith(tail)) return false
|
|
380
|
+
const base = out.length
|
|
381
|
+
for (let i = 1; i < last; i++) {
|
|
382
|
+
const separator = shape[i]!
|
|
383
|
+
// An empty separator is found right here, which gives the parameter before it one character.
|
|
384
|
+
const found = segment.indexOf(separator, at + 1)
|
|
385
|
+
const next = found + separator.length
|
|
386
|
+
// The parameter after this separator needs a character before the trailing literal. A first
|
|
387
|
+
// occurrence that leaves none means every later one does too.
|
|
388
|
+
if (found === -1 || next >= end) {
|
|
389
|
+
out.length = base
|
|
390
|
+
return false
|
|
391
|
+
}
|
|
392
|
+
out.push(segment.slice(at, found))
|
|
393
|
+
at = next
|
|
394
|
+
}
|
|
395
|
+
out.push(segment.slice(at, end))
|
|
396
|
+
if (checked !== undefined) {
|
|
397
|
+
let value = base
|
|
398
|
+
for (const part of checked) {
|
|
399
|
+
if (part.t === "param" && !satisfies(part.c, out[value++]!)) {
|
|
400
|
+
out.length = base
|
|
401
|
+
return false
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return true
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Whether a segment is `:name?` or `:name{constraint}?` and nothing else. The caller has seen
|
|
409
|
+
* that the pattern ends in `?`; a segment before the last one is tested for it here. */
|
|
410
|
+
function isOptionalParam(segment: string): boolean {
|
|
411
|
+
const parts = segment.endsWith("?") ? splitMixed(segment.slice(0, -1)) : undefined
|
|
412
|
+
return parts?.length === 1 && parts[0]!.t === "param"
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The concrete patterns an optional-parameter pattern stands for, shortest first.
|
|
417
|
+
*
|
|
418
|
+
* A path may END in a run of whole-segment optional parameters, `:name?` or, with a constraint,
|
|
419
|
+
* `:name{[0-9]+}?`. It is shorthand for one
|
|
420
|
+
* route per prefix of that run: `/users/:id?` is `/users` and `/users/:id`, and `/a/:b?/:c?` is `/a`,
|
|
421
|
+
* `/a/:b`, and `/a/:b/:c`. A later parameter is only present when every earlier one is, so a run of
|
|
422
|
+
* `n` parameters is `n + 1` patterns, never `2^n`.
|
|
423
|
+
*
|
|
424
|
+
* Every other pattern comes back unchanged as the only element. That includes a `?` anywhere but the
|
|
425
|
+
* trailing run (`/a/:b?/c`) or inside a mixed segment (`/files/:name.:ext?`): there it is literal
|
|
426
|
+
* text, as it always was.
|
|
427
|
+
*
|
|
428
|
+
* Expansion happens before compilation, so each result is an ordinary pattern for
|
|
429
|
+
* {@link compileRoutePattern} and a matcher never learns that a parameter was optional.
|
|
430
|
+
*/
|
|
431
|
+
export function expandOptionalParams(pattern: string): readonly string[] {
|
|
432
|
+
if (pattern.charCodeAt(pattern.length - 1) !== 63 /* ? */) return [pattern]
|
|
433
|
+
const segments = pattern.split("/")
|
|
434
|
+
let first = segments.length
|
|
435
|
+
while (first > 1 && isOptionalParam(segments[first - 1]!)) first--
|
|
436
|
+
let form = segments.slice(0, first).join("/")
|
|
437
|
+
const forms = [form || "/"]
|
|
438
|
+
for (let i = first; i < segments.length; i++) {
|
|
439
|
+
form += `/${segments[i]!.slice(0, -1)}`
|
|
440
|
+
forms.push(form)
|
|
441
|
+
}
|
|
442
|
+
return forms
|
|
443
|
+
}
|
|
444
|
+
|
|
156
445
|
/** Parse and validate Nifra's strict route grammar once. Trailing slashes remain significant. */
|
|
157
446
|
export function compileRoutePattern(pattern: string): CompiledRoutePattern {
|
|
158
447
|
if (pattern.length === 0 || pattern.charCodeAt(0) !== 47 /* / */) {
|
|
@@ -272,6 +561,8 @@ export function compareMixedPartsSpecificity(
|
|
|
272
561
|
const literalWeight = (parts: readonly MixedPart[]): number =>
|
|
273
562
|
parts.reduce((sum, part) => (part.t === "lit" ? sum + part.v.length : sum), 0)
|
|
274
563
|
const order = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
|
|
564
|
+
const rank = (constraint: ParamConstraint | undefined): number =>
|
|
565
|
+
constraint ? (constraint.oneOf ? 0 : 1) : 2
|
|
275
566
|
|
|
276
567
|
const weightDifference = literalWeight(right) - literalWeight(left)
|
|
277
568
|
if (weightDifference !== 0) return weightDifference
|
|
@@ -285,9 +576,23 @@ export function compareMixedPartsSpecificity(
|
|
|
285
576
|
if (a.t === "lit" && b.t === "lit" && a.v !== b.v) {
|
|
286
577
|
return a.v.length === b.v.length ? order(a.v, b.v) : b.v.length - a.v.length
|
|
287
578
|
}
|
|
579
|
+
if (a.t === "param" && b.t === "param") {
|
|
580
|
+
// A constrained parameter before a free one, a list of values before a class. Two of a kind:
|
|
581
|
+
// the one that accepts less goes first, so a constraint contained in another is tried before
|
|
582
|
+
// it (`\d+` before `\w+`, whichever way each is spelled). The key settles the rest, and the
|
|
583
|
+
// result never depends on registration order.
|
|
584
|
+
const x = a.c
|
|
585
|
+
const y = b.c
|
|
586
|
+
const difference =
|
|
587
|
+
rank(x) - rank(y) ||
|
|
588
|
+
(x && y && (x.size - y.size || x.max - x.min - (y.max - y.min) || order(x.key, y.key)))
|
|
589
|
+
if (difference) return difference
|
|
590
|
+
}
|
|
288
591
|
}
|
|
289
592
|
|
|
290
|
-
|
|
593
|
+
// Same kinds in the same order with the same literals and constraints: the two are one shape.
|
|
594
|
+
// Parameter names do not enter into what a segment matches, so they do not order it either.
|
|
595
|
+
return 0
|
|
291
596
|
}
|
|
292
597
|
|
|
293
598
|
/** Core precedence: static > mixed > param > wildcard at the first differing segment, independent of
|
|
@@ -346,11 +651,30 @@ export function matchRoutePattern(
|
|
|
346
651
|
compiled: CompiledRoutePattern,
|
|
347
652
|
pathname: string,
|
|
348
653
|
): RoutePatternMatch {
|
|
349
|
-
const
|
|
654
|
+
const matcher = matcherOf(compiled)
|
|
655
|
+
const match = matcher.regex.exec(pathname)
|
|
350
656
|
if (match === null) return { matched: false, reason: "not-found" }
|
|
351
657
|
const params: Record<string, string> = {}
|
|
352
|
-
|
|
353
|
-
|
|
658
|
+
const names = compiled.paramNames
|
|
659
|
+
// One capture group per dynamic segment, in order. A mixed segment's group is the whole segment,
|
|
660
|
+
// which the scanner splits into that segment's parameters.
|
|
661
|
+
let group = 1
|
|
662
|
+
let name = 0
|
|
663
|
+
let captures: string[] | undefined
|
|
664
|
+
for (let i = 0; i < compiled.segments.length; i++) {
|
|
665
|
+
if (compiled.segments[i]!.kind === "static") continue
|
|
666
|
+
const value = match[group++] ?? ""
|
|
667
|
+
const shape = matcher.shapes[i]
|
|
668
|
+
if (shape === undefined) {
|
|
669
|
+
params[names[name++]!] = value
|
|
670
|
+
continue
|
|
671
|
+
}
|
|
672
|
+
captures ??= []
|
|
673
|
+
captures.length = 0
|
|
674
|
+
if (!matchMixedSegment(shape, value, captures, matcher.checked[i])) {
|
|
675
|
+
return { matched: false, reason: "not-found" }
|
|
676
|
+
}
|
|
677
|
+
for (const capture of captures) params[names[name++]!] = capture
|
|
354
678
|
}
|
|
355
679
|
const decoded = decodeRouteParams(params)
|
|
356
680
|
return decoded === null
|
package/src/router/router.ts
CHANGED
|
@@ -3,19 +3,35 @@ import {
|
|
|
3
3
|
type CompiledRoutePattern,
|
|
4
4
|
compareMixedPartsSpecificity,
|
|
5
5
|
compileRoutePattern,
|
|
6
|
+
constrainedParts,
|
|
6
7
|
type MixedPart,
|
|
7
|
-
|
|
8
|
+
type MixedSegmentShape,
|
|
9
|
+
matchMixedSegment,
|
|
10
|
+
mixedSegmentShape,
|
|
8
11
|
} from "./pattern.ts"
|
|
9
12
|
|
|
10
|
-
/** HTTP methods the
|
|
13
|
+
/** The standard HTTP methods: the ones with a builder on the server and a call on the typed client. */
|
|
11
14
|
export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const
|
|
12
15
|
|
|
13
16
|
export type Method = (typeof METHODS)[number]
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A method a route is registered under: a standard {@link Method}, or another token that
|
|
20
|
+
* {@link isRegistrableMethod} accepts (`PROPFIND`, `PURGE`, ...).
|
|
21
|
+
*/
|
|
22
|
+
export type RouteMethod = Method | (string & {})
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
// A method token is 1-32 characters of `A-Z`, `0-9` and `-`, starting with a letter. TRACE, CONNECT
|
|
25
|
+
// and TRACK are left out: TRACE (and its alias TRACK) asks a server to echo the request back,
|
|
26
|
+
// headers included, and CONNECT asks for a tunnel. Neither is something a route handler answers.
|
|
27
|
+
const REGISTRABLE_METHOD = /^(?!(TRAC[EK]|CONNECT)$)[A-Z][A-Z\d-]{0,31}$/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a route can be registered under `method` - one of {@link METHODS}, or another uppercase
|
|
31
|
+
* token such as `PROPFIND`. `TRACE`, `CONNECT` and `TRACK` never are.
|
|
32
|
+
*/
|
|
33
|
+
export function isRegistrableMethod(method: string): boolean {
|
|
34
|
+
return REGISTRABLE_METHOD.test(method)
|
|
19
35
|
}
|
|
20
36
|
|
|
21
37
|
const SLASH = 47
|
|
@@ -92,18 +108,26 @@ interface RouteNode<T> {
|
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
interface MixedChild<T> {
|
|
95
|
-
/** Anchored matcher for ONE segment, with a capture group per parameter. */
|
|
96
|
-
readonly regex: RegExp
|
|
97
111
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
112
|
+
* What a request segment is matched against: one pass, whatever the segment holds.
|
|
113
|
+
*
|
|
114
|
+
* It has one entry more than the segment has parameters. A match pushes a value per parameter -
|
|
115
|
+
* N values, not one - so a failed branch must pop exactly `shape.length - 1` to keep `paramValues`
|
|
116
|
+
* aligned with `paramNames`.
|
|
100
117
|
*/
|
|
101
|
-
readonly
|
|
102
|
-
/** Parsed
|
|
118
|
+
readonly shape: MixedSegmentShape
|
|
119
|
+
/** Parsed parts used by the same total specificity comparator as the browser router. */
|
|
103
120
|
readonly parts: readonly MixedPart[]
|
|
121
|
+
/** `parts` when a parameter has a constraint to test after the scan, else `undefined`. */
|
|
122
|
+
readonly checked: readonly MixedPart[] | undefined
|
|
104
123
|
readonly node: RouteNode<T>
|
|
105
|
-
/**
|
|
106
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The segment as one string, so re-registering the same one reuses its node: each literal and each
|
|
126
|
+
* parameter's constraint, tagged by kind. Joined on `/`, the one character neither can hold, so
|
|
127
|
+
* two different segments never share a key. Parameter names are left out: they do not decide what
|
|
128
|
+
* a segment matches.
|
|
129
|
+
*/
|
|
130
|
+
readonly key: string
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
function createNode<T>(): RouteNode<T> {
|
|
@@ -162,19 +186,19 @@ function matchNode<T>(
|
|
|
162
186
|
const mixedChildren = node.mixedChildren
|
|
163
187
|
if (mixedChildren !== undefined && seg.length > 0) {
|
|
164
188
|
for (const child of mixedChildren) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
189
|
+
// Pushes this segment's captures left-to-right, matching the order `paramNames` was built in,
|
|
190
|
+
// and pushes nothing on a miss - a value that fails its constraint is a miss. One pass over
|
|
191
|
+
// the segment whatever the shape: see the scanner.
|
|
192
|
+
if (!matchMixedSegment(child.shape, seg, paramValues, child.checked)) continue
|
|
169
193
|
if (isLast) {
|
|
170
194
|
if (child.node.terminal !== undefined) return child.node.terminal
|
|
171
195
|
} else {
|
|
172
196
|
const found = matchNode(child.node, path, end + 1, len, paramValues)
|
|
173
197
|
if (found !== undefined) return found
|
|
174
198
|
}
|
|
175
|
-
// Backtrack: a mixed segment pushed
|
|
176
|
-
// every later param reads a value belonging to an abandoned branch.
|
|
177
|
-
for (let i =
|
|
199
|
+
// Backtrack: a mixed segment pushed a value per parameter, not a single one, so unwind exactly
|
|
200
|
+
// that many or every later param reads a value belonging to an abandoned branch.
|
|
201
|
+
for (let i = 1; i < child.shape.length; i++) paramValues.pop()
|
|
178
202
|
}
|
|
179
203
|
}
|
|
180
204
|
|
|
@@ -220,43 +244,39 @@ function resolve<T>(
|
|
|
220
244
|
params: Record<string, string>,
|
|
221
245
|
): RouterMatch<T> {
|
|
222
246
|
if (terminal.staticMatches !== undefined && params === EMPTY_PARAMS) {
|
|
223
|
-
// Real requests arrive pre-uppercased (HTTP methods are canonically uppercase; every runtime and
|
|
224
|
-
// client normalizes this) - try the cache under the raw method first so that overwhelmingly common
|
|
225
|
-
// case skips `toUpperCase()` entirely, matching the same already-uppercase fast path
|
|
226
|
-
// `resolveDirect` takes below. Falls back to the uppercase key (computed once) for a lowercase or
|
|
227
|
-
// mixed-case caller, so behavior is unchanged - only the common case gets cheaper.
|
|
228
247
|
const direct = terminal.staticMatches.get(method)
|
|
229
248
|
if (direct !== undefined) return direct
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (cached !== undefined) return cached
|
|
249
|
+
// Only a hit is cached, and only under a method the terminal serves, so the cache is bounded by
|
|
250
|
+
// the registered methods and an arbitrary request token never grows it.
|
|
233
251
|
const cacheable =
|
|
234
|
-
terminal.handlers.has(
|
|
252
|
+
terminal.handlers.has(method) || (method === "HEAD" && terminal.handlers.has("GET"))
|
|
235
253
|
if (!cacheable) return resolveDirect(terminal, method, params)
|
|
236
254
|
|
|
237
255
|
const res = resolveDirect(terminal, method, params)
|
|
238
|
-
terminal.staticMatches.set(
|
|
256
|
+
terminal.staticMatches.set(method, res)
|
|
239
257
|
return res
|
|
240
258
|
}
|
|
241
259
|
return resolveDirect(terminal, method, params)
|
|
242
260
|
}
|
|
243
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Method tokens are case-sensitive (RFC 9110 §9.1), and this compares them exactly. A request whose
|
|
264
|
+
* token differs from a registered method only in case is a different method and answers 405. Folding
|
|
265
|
+
* case here would run a handler under a method string that a case-sensitive method check elsewhere -
|
|
266
|
+
* a CSRF guard, a body limit, a user hook comparing `c.req.method` - does not recognise, so the two
|
|
267
|
+
* would disagree about whether the request mutates.
|
|
268
|
+
*/
|
|
244
269
|
function resolveDirect<T>(
|
|
245
270
|
terminal: Terminal<T>,
|
|
246
271
|
method: string,
|
|
247
272
|
params: Record<string, string>,
|
|
248
273
|
): RouterMatch<T> {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
const upper = method.toUpperCase()
|
|
253
|
-
if (upper !== method && terminal.handlers.has(upper)) {
|
|
254
|
-
return { found: true, payload: terminal.handlers.get(upper)!, params }
|
|
255
|
-
}
|
|
274
|
+
const payload = terminal.handlers.get(method)
|
|
275
|
+
if (payload !== undefined) return { found: true, payload, params }
|
|
256
276
|
// RFC 9110 §9.3.2: HEAD is GET without the body. A route registered for GET answers HEAD with
|
|
257
277
|
// the GET handler; the serving runtime strips the body, so status + headers mirror GET exactly.
|
|
258
|
-
// An explicitly registered HEAD handler takes precedence via the
|
|
259
|
-
if (
|
|
278
|
+
// An explicitly registered HEAD handler takes precedence via the lookup above.
|
|
279
|
+
if (method === "HEAD") {
|
|
260
280
|
const get = terminal.handlers.get("GET")
|
|
261
281
|
if (get !== undefined) return { found: true, payload: get, params }
|
|
262
282
|
}
|
|
@@ -301,13 +321,15 @@ export class Router<T> {
|
|
|
301
321
|
* Register a payload for `method` + `path`. Throws {@link RouteConfigError}
|
|
302
322
|
* (boot-time, L2) on a duplicate route, a malformed pattern, or conflicting
|
|
303
323
|
* parameter names for the same path shape.
|
|
324
|
+
*
|
|
325
|
+
* One call is one route. A `?` in the pattern is literal text here: optional parameters
|
|
326
|
+
* (`/users/:id?`) are expanded by the caller, which adds each pattern `expandOptionalParams`
|
|
327
|
+
* (`@nifrajs/core/pattern`) gives for it.
|
|
304
328
|
*/
|
|
305
|
-
add(method:
|
|
329
|
+
add(method: string, pattern: string | CompiledRoutePattern, payload: T): void {
|
|
306
330
|
DYNAMIC_MATCH_CACHES.delete(this)
|
|
307
331
|
const upper = method.toUpperCase()
|
|
308
|
-
|
|
309
|
-
// runtime for JS callers and dynamically-computed methods.
|
|
310
|
-
if (!isMethod(upper)) {
|
|
332
|
+
if (!isRegistrableMethod(upper)) {
|
|
311
333
|
throw new RouteConfigError("INVALID_METHOD", `unsupported HTTP method "${method}"`)
|
|
312
334
|
}
|
|
313
335
|
const compiled = typeof pattern === "string" ? compileRoutePattern(pattern) : pattern
|
|
@@ -324,16 +346,19 @@ export class Router<T> {
|
|
|
324
346
|
node.wildcardChild ??= createNode<T>()
|
|
325
347
|
node = node.wildcardChild
|
|
326
348
|
} else if (segment.kind === "mixed") {
|
|
327
|
-
const
|
|
349
|
+
const parts = segment.parts
|
|
350
|
+
const key = parts
|
|
351
|
+
.map((part) => (part.t === "lit" ? `L${part.v}` : `P${part.c?.key ?? ""}`))
|
|
352
|
+
.join("/")
|
|
328
353
|
node.mixedChildren ??= []
|
|
329
|
-
let child = node.mixedChildren.find((entry) => entry.
|
|
354
|
+
let child = node.mixedChildren.find((entry) => entry.key === key)
|
|
330
355
|
if (child === undefined) {
|
|
331
356
|
child = {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
357
|
+
shape: mixedSegmentShape(parts),
|
|
358
|
+
parts,
|
|
359
|
+
checked: constrainedParts(parts),
|
|
335
360
|
node: createNode<T>(),
|
|
336
|
-
|
|
361
|
+
key,
|
|
337
362
|
}
|
|
338
363
|
node.mixedChildren.push(child)
|
|
339
364
|
// Most literal text first. Registration order must not decide which of `/:id.txt` and
|
|
@@ -388,8 +413,8 @@ export class Router<T> {
|
|
|
388
413
|
}
|
|
389
414
|
|
|
390
415
|
/**
|
|
391
|
-
* Resolve `method` + `path`. Tolerant of a missing leading slash
|
|
392
|
-
*
|
|
416
|
+
* Resolve `method` + `path`. Tolerant of a missing leading slash. The method is compared exactly:
|
|
417
|
+
* tokens are case-sensitive, so `get` does not reach a `GET` route. Never throws.
|
|
393
418
|
*/
|
|
394
419
|
find(method: string, path: string): RouterMatch<T> {
|
|
395
420
|
const len = path.length
|