@nextrush/router 3.0.7 → 4.0.0-beta.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/README.md +250 -492
- package/dist/index.d.ts +227 -178
- package/dist/index.js +1075 -657
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/allowed-methods.test.ts +144 -0
- package/src/__tests__/audit-fixes.test.ts +59 -0
- package/src/__tests__/canonical-path-security.test.ts +198 -0
- package/src/__tests__/canonicalize-memo.test.ts +108 -0
- package/src/__tests__/dispatch-deasync.test.ts +292 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/head-auto-registration.test.ts +197 -0
- package/src/__tests__/helpers/differential-corpus.ts +314 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
- package/src/__tests__/match-prenormalized.test.ts +95 -0
- package/src/__tests__/match-safety.test.ts +223 -0
- package/src/__tests__/match-single-alloc.test.ts +82 -0
- package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
- package/src/__tests__/middleware-pipeline.test.ts +306 -0
- package/src/__tests__/param-decoding.test.ts +78 -0
- package/src/__tests__/public-surface.test.ts +72 -0
- package/src/__tests__/registration-max-depth.test.ts +56 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +315 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -7
- package/src/__tests__/router.test.ts +148 -7
- package/src/__tests__/static-map-reset.test.ts +48 -0
- package/src/__tests__/walk-pool-sizing.test.ts +72 -0
- package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
- package/src/canonicalize.ts +137 -0
- package/src/composition.ts +79 -0
- package/src/constants.ts +43 -0
- package/src/dispatch.ts +145 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +14 -5
- package/src/match-route.ts +241 -0
- package/src/matching.ts +246 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +343 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +219 -872
- package/src/segment-trie.ts +227 -0
- package/src/state.ts +53 -0
- package/src/walk-pool.ts +208 -0
- package/src/radix-tree.ts +0 -184
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Route Groups
|
|
3
|
+
*
|
|
4
|
+
* A route group applies a shared path prefix and middleware to a set of routes
|
|
5
|
+
* registered inside a callback. Extracted from `router.ts` (audit RT-3) and
|
|
6
|
+
* given a real public type (audit RT-6): `router.group()` callbacks receive a
|
|
7
|
+
* {@link RouteGroup}, not a mis-cast `Router`.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { HTTP_METHODS, type HttpMethod, type Middleware, type RouteHandler } from '@nextrush/types';
|
|
13
|
+
import { createRedirectHandler, type RedirectStatus } from './redirect';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The subset of the parent router a group needs to register routes into.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Declared as an interface (rather than importing `Router`) so `group-router.ts`
|
|
20
|
+
* and `router.ts` don't form an import cycle. `Router` satisfies it structurally
|
|
21
|
+
* via its `_addGroupRoute` method.
|
|
22
|
+
*/
|
|
23
|
+
export interface GroupRouterHost {
|
|
24
|
+
_addGroupRoute(
|
|
25
|
+
method: HttpMethod,
|
|
26
|
+
path: string,
|
|
27
|
+
handlers: RouteHandler[],
|
|
28
|
+
groupMiddleware: Middleware[],
|
|
29
|
+
recordIntrospection?: boolean
|
|
30
|
+
): void;
|
|
31
|
+
/** Record a single any-method introspection row (T016) — see `Router._pushAnyMethodRouteDefinition`. */
|
|
32
|
+
_pushAnyMethodRouteDefinition(path: string): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The object passed to a `router.group(prefix, callback)` callback.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* Exposes only the route-registration surface that is valid inside a group —
|
|
40
|
+
* intentionally NOT the full {@link Router} (no `mount`/`use`/`reset`), which is
|
|
41
|
+
* why the previous `as unknown as Router` cast was a lie (audit RT-6).
|
|
42
|
+
*/
|
|
43
|
+
export interface RouteGroup {
|
|
44
|
+
get(path: string, ...handlers: RouteHandler[]): this;
|
|
45
|
+
post(path: string, ...handlers: RouteHandler[]): this;
|
|
46
|
+
put(path: string, ...handlers: RouteHandler[]): this;
|
|
47
|
+
delete(path: string, ...handlers: RouteHandler[]): this;
|
|
48
|
+
patch(path: string, ...handlers: RouteHandler[]): this;
|
|
49
|
+
head(path: string, ...handlers: RouteHandler[]): this;
|
|
50
|
+
options(path: string, ...handlers: RouteHandler[]): this;
|
|
51
|
+
all(path: string, ...handlers: RouteHandler[]): this;
|
|
52
|
+
redirect(from: string, to: string, status?: RedirectStatus): this;
|
|
53
|
+
group(
|
|
54
|
+
prefix: string,
|
|
55
|
+
middlewareOrCallback: Middleware[] | ((router: RouteGroup) => void),
|
|
56
|
+
callback?: (router: RouteGroup) => void
|
|
57
|
+
): this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Collects routes under a shared prefix + middleware and forwards them to the
|
|
62
|
+
* parent router. Implements {@link RouteGroup}.
|
|
63
|
+
*/
|
|
64
|
+
export class GroupRouter implements RouteGroup {
|
|
65
|
+
private readonly parent: GroupRouterHost;
|
|
66
|
+
private readonly prefix: string;
|
|
67
|
+
private readonly middleware: Middleware[];
|
|
68
|
+
|
|
69
|
+
constructor(parent: GroupRouterHost, prefix: string, middleware: Middleware[]) {
|
|
70
|
+
this.parent = parent;
|
|
71
|
+
this.prefix = prefix;
|
|
72
|
+
this.middleware = middleware;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private fullPath(path: string): string {
|
|
76
|
+
// Handle root path in group
|
|
77
|
+
if (path === '/' || path === '') {
|
|
78
|
+
return this.prefix;
|
|
79
|
+
}
|
|
80
|
+
// Combine prefix and path
|
|
81
|
+
const cleanPrefix = this.prefix.endsWith('/') ? this.prefix.slice(0, -1) : this.prefix;
|
|
82
|
+
const cleanPath = path.startsWith('/') ? path : '/' + path;
|
|
83
|
+
return cleanPrefix + cleanPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get(path: string, ...handlers: RouteHandler[]): this {
|
|
87
|
+
this.parent._addGroupRoute('GET', this.fullPath(path), handlers, this.middleware);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
post(path: string, ...handlers: RouteHandler[]): this {
|
|
92
|
+
this.parent._addGroupRoute('POST', this.fullPath(path), handlers, this.middleware);
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
put(path: string, ...handlers: RouteHandler[]): this {
|
|
97
|
+
this.parent._addGroupRoute('PUT', this.fullPath(path), handlers, this.middleware);
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
delete(path: string, ...handlers: RouteHandler[]): this {
|
|
102
|
+
this.parent._addGroupRoute('DELETE', this.fullPath(path), handlers, this.middleware);
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
patch(path: string, ...handlers: RouteHandler[]): this {
|
|
107
|
+
this.parent._addGroupRoute('PATCH', this.fullPath(path), handlers, this.middleware);
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
head(path: string, ...handlers: RouteHandler[]): this {
|
|
112
|
+
this.parent._addGroupRoute('HEAD', this.fullPath(path), handlers, this.middleware);
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
options(path: string, ...handlers: RouteHandler[]): this {
|
|
117
|
+
this.parent._addGroupRoute('OPTIONS', this.fullPath(path), handlers, this.middleware);
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Register a route matching every HTTP method under a single introspection
|
|
123
|
+
* entry, mirroring `Router.all()` (T016) — a group's `.all()` must not
|
|
124
|
+
* regress to the pre-T016 one-row-per-method shape just because
|
|
125
|
+
* registration is routed through `_addGroupRoute` instead of `addRoute`
|
|
126
|
+
* directly.
|
|
127
|
+
*/
|
|
128
|
+
all(path: string, ...handlers: RouteHandler[]): this {
|
|
129
|
+
for (const method of HTTP_METHODS) {
|
|
130
|
+
this.parent._addGroupRoute(method, this.fullPath(path), handlers, this.middleware, false);
|
|
131
|
+
}
|
|
132
|
+
this.parent._pushAnyMethodRouteDefinition(this.fullPath(path));
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Register a redirect within the group (uses the shared redirect handler,
|
|
138
|
+
* audit RT-4 — no more naive replaceAll param substitution).
|
|
139
|
+
*/
|
|
140
|
+
redirect(from: string, to: string, status: RedirectStatus = 301): this {
|
|
141
|
+
const redirectHandler = createRedirectHandler(to, status);
|
|
142
|
+
|
|
143
|
+
this.parent._addGroupRoute('GET', this.fullPath(from), [redirectHandler], this.middleware);
|
|
144
|
+
this.parent._addGroupRoute('HEAD', this.fullPath(from), [redirectHandler], this.middleware);
|
|
145
|
+
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Nested group support — combines this group's prefix + middleware with the
|
|
151
|
+
* nested group's.
|
|
152
|
+
*/
|
|
153
|
+
group(
|
|
154
|
+
prefix: string,
|
|
155
|
+
middlewareOrCallback: Middleware[] | ((router: RouteGroup) => void),
|
|
156
|
+
callback?: (router: RouteGroup) => void
|
|
157
|
+
): this {
|
|
158
|
+
runRouteGroup(
|
|
159
|
+
this.parent,
|
|
160
|
+
this.fullPath(prefix),
|
|
161
|
+
middlewareOrCallback,
|
|
162
|
+
callback,
|
|
163
|
+
this.middleware
|
|
164
|
+
);
|
|
165
|
+
return this;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve a `group()` call's `(middleware[], callback)` | `(callback)` overload
|
|
171
|
+
* and run the callback against a fresh {@link GroupRouter} bound to `host`.
|
|
172
|
+
*
|
|
173
|
+
* @remarks
|
|
174
|
+
* Shared by `Router.group()` and the nested `GroupRouter.group()` so the
|
|
175
|
+
* overload handling and the "throw when a middleware array has no callback"
|
|
176
|
+
* guard have a single definition.
|
|
177
|
+
*
|
|
178
|
+
* @param host - Parent router the group registers routes into.
|
|
179
|
+
* @param prefix - Fully-resolved path prefix for the group.
|
|
180
|
+
* @param middlewareOrCallback - Either the group middleware array or the callback.
|
|
181
|
+
* @param callback - The callback, required when a middleware array is passed.
|
|
182
|
+
* @param inheritedMiddleware - Middleware from an enclosing group, prepended
|
|
183
|
+
* ahead of this group's own (empty for a top-level `Router.group()`).
|
|
184
|
+
*/
|
|
185
|
+
export function runRouteGroup(
|
|
186
|
+
host: GroupRouterHost,
|
|
187
|
+
prefix: string,
|
|
188
|
+
middlewareOrCallback: Middleware[] | ((router: RouteGroup) => void),
|
|
189
|
+
callback: ((router: RouteGroup) => void) | undefined,
|
|
190
|
+
inheritedMiddleware: Middleware[] = []
|
|
191
|
+
): void {
|
|
192
|
+
let middleware: Middleware[] = [];
|
|
193
|
+
let cb: (router: RouteGroup) => void;
|
|
194
|
+
|
|
195
|
+
if (Array.isArray(middlewareOrCallback)) {
|
|
196
|
+
middleware = middlewareOrCallback;
|
|
197
|
+
if (!callback) {
|
|
198
|
+
throw new Error('Callback function is required when providing middleware array');
|
|
199
|
+
}
|
|
200
|
+
cb = callback;
|
|
201
|
+
} else {
|
|
202
|
+
cb = middlewareOrCallback;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const combined =
|
|
206
|
+
inheritedMiddleware.length > 0 ? [...inheritedMiddleware, ...middleware] : middleware;
|
|
207
|
+
cb(new GroupRouter(host, prefix, combined));
|
|
208
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @nextrush/router - High-Performance Router for NextRush
|
|
3
3
|
*
|
|
4
|
-
* This package provides a
|
|
4
|
+
* This package provides a segment trie based router with:
|
|
5
5
|
* - O(k) route matching where k is path length
|
|
6
6
|
* - Named parameters (/users/:id)
|
|
7
7
|
* - Wildcard routes (/files/*)
|
|
@@ -13,11 +13,20 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
// Router
|
|
16
|
-
export { createRouter, Router } from './router';
|
|
16
|
+
export { createRouter, endpoint, Router } from './router';
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// Canonical request path (RFC-029) — the single normalization owner shared by
|
|
19
|
+
// the router's own match, mounted-router prefix tests, and any consumer that
|
|
20
|
+
// needs to know "what path does the router treat this request as".
|
|
21
|
+
export { canonicalizePath, hasDotSegment } from './canonicalize';
|
|
22
|
+
export type { CanonicalPathResult } from './canonicalize';
|
|
23
|
+
|
|
24
|
+
// Route groups
|
|
25
|
+
export type { RouteGroup } from './group-router';
|
|
26
|
+
|
|
27
|
+
// Segment trie internals (for advanced usage)
|
|
28
|
+
export { createNode, NodeType, parseSegments } from './segment-trie';
|
|
29
|
+
export type { HandlerEntry, ParsedSegment, TrieNode } from './segment-trie';
|
|
21
30
|
|
|
22
31
|
// Re-export relevant types
|
|
23
32
|
export type {
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Top-Level Route Match Orchestration
|
|
3
|
+
*
|
|
4
|
+
* `matchRoute` was originally part of `matching.ts` (the lookup-primitives
|
|
5
|
+
* module: `decodeParam`/`extractSegment`/`findNode`/`findAllowedMethods`/
|
|
6
|
+
* `matchNodeIndexed`), but `matching.ts` was approaching the 300-line ceiling
|
|
7
|
+
* itself once `matchRoute` moved in — this file separates the top-level
|
|
8
|
+
* "match a request" orchestration (query-strip, normalize, static fast-path,
|
|
9
|
+
* tree-walk delegation, param post-processing) from the lower-level lookup
|
|
10
|
+
* primitives it calls into.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { HttpMethod, Middleware, RouteMatch } from '@nextrush/types';
|
|
17
|
+
import { EMPTY_PARAMS, NULL_PROTO } from './constants';
|
|
18
|
+
import { matchNodeIndexed, collapseAndStrip, isProvablyLowerAscii } from './matching';
|
|
19
|
+
import type { WalkPool } from './walk-pool';
|
|
20
|
+
import type { StaticRouteMap, TrieNode } from './segment-trie';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Match a route and return the full {@link RouteMatch} in a SINGLE allocation
|
|
24
|
+
* (design.md D1 / HP-10).
|
|
25
|
+
*
|
|
26
|
+
* `matchRoute` builds the final `RouteMatch` — including `middleware:
|
|
27
|
+
* routerMiddleware` — directly at each return site, so `Router.match()` gets
|
|
28
|
+
* one object per matched request instead of a bare result later re-wrapped by
|
|
29
|
+
* `resolveMatch`. `routerMiddleware` is threaded in as a parameter (its
|
|
30
|
+
* contents are never read here — only attached by reference), preserving the
|
|
31
|
+
* "no implicit `Router` state" property of the earlier extraction while
|
|
32
|
+
* removing the duplicate wrapper object.
|
|
33
|
+
*
|
|
34
|
+
* Every other piece of `Router` state it reads (`root`, `staticRoutes`,
|
|
35
|
+
* `hasParamRoutes`, the option flags) is read-only here (no mutation, unlike
|
|
36
|
+
* `addRoute` in `registration.ts`), so it is threaded as plain parameters.
|
|
37
|
+
*/
|
|
38
|
+
export function matchRoute(
|
|
39
|
+
method: HttpMethod,
|
|
40
|
+
rawPath: string,
|
|
41
|
+
root: TrieNode,
|
|
42
|
+
staticRoutes: StaticRouteMap,
|
|
43
|
+
hasParamRoutes: boolean,
|
|
44
|
+
caseSensitive: boolean,
|
|
45
|
+
strict: boolean,
|
|
46
|
+
decode: boolean,
|
|
47
|
+
routerMiddleware: Middleware[],
|
|
48
|
+
/**
|
|
49
|
+
* When `true`, `rawPath` is trusted as already the output of
|
|
50
|
+
* `canonicalizePath()` (same `caseSensitive`/`strict` options) — the fold
|
|
51
|
+
* and structural-collapse steps below are skipped entirely rather than
|
|
52
|
+
* re-derived. Only valid when the caller has actually run
|
|
53
|
+
* `canonicalizePath()` on this exact input first (F-10); the router's own
|
|
54
|
+
* `routes()` dispatch path is the only caller that does. Defaults to
|
|
55
|
+
* `false`, preserving every other caller's existing behavior — including
|
|
56
|
+
* `Router.match()`, which never canonicalizes first.
|
|
57
|
+
*/
|
|
58
|
+
preNormalized = false,
|
|
59
|
+
/**
|
|
60
|
+
* When supplied, the param walk reuses this router instance's pooled
|
|
61
|
+
* `WalkFrame[]`/binding-array scratch space instead of allocating fresh
|
|
62
|
+
* arrays on this call (F-02, `reduce-router-match-allocations`). Omitted,
|
|
63
|
+
* `matchRoute` behaves exactly as before.
|
|
64
|
+
*/
|
|
65
|
+
walkPool?: WalkPool
|
|
66
|
+
): RouteMatch | null {
|
|
67
|
+
let path = rawPath;
|
|
68
|
+
// Query string must not affect path matching (RFC 3986 §3.4). Strip it here,
|
|
69
|
+
// before normalization, so both the lookup path and extracted param values
|
|
70
|
+
// exclude it. This strip is caller-specific: `findAllowedMethods` receives an
|
|
71
|
+
// already query-free `ctx.path`, so the shared `normalizePathForMatch` does
|
|
72
|
+
// not strip — only `matchRoute` does.
|
|
73
|
+
if (!preNormalized) {
|
|
74
|
+
const queryIdx = path.indexOf('?');
|
|
75
|
+
if (queryIdx !== -1) path = path.slice(0, queryIdx);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// HP-12: decide case-stability ONCE. When the path is provably case-stable
|
|
79
|
+
// (case-sensitive router, or an all-lowercase-ASCII path), folding is a no-op
|
|
80
|
+
// and the original-case path equals the normalized one — so we skip both the
|
|
81
|
+
// `toLowerCase()` allocation and the second original-case normalize pass.
|
|
82
|
+
//
|
|
83
|
+
// F-10: when `preNormalized` is true, the caller already ran this exact
|
|
84
|
+
// fold+collapse via `canonicalizePath()` — trust `path` as both the
|
|
85
|
+
// normalized AND original-case string, skipping the fold+collapse
|
|
86
|
+
// re-derivation entirely rather than repeating work already done upstream.
|
|
87
|
+
let normalized: string;
|
|
88
|
+
let caseStable: boolean;
|
|
89
|
+
if (preNormalized) {
|
|
90
|
+
normalized = path;
|
|
91
|
+
caseStable = true;
|
|
92
|
+
} else {
|
|
93
|
+
caseStable = caseSensitive || isProvablyLowerAscii(path);
|
|
94
|
+
const folded = caseStable ? path : path.toLowerCase();
|
|
95
|
+
normalized = collapseAndStrip(folded, strict);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// FAST PATH: O(1) static route lookup (no tree traversal). Method-nested map
|
|
99
|
+
// (HP-9): select the inner map by method, then probe by the normalized path —
|
|
100
|
+
// no per-request `${method} ${path}` key-string allocation. For static routes
|
|
101
|
+
// a trailing slash is irrelevant, so always strip it for the probe.
|
|
102
|
+
const methodMap = staticRoutes.get(method);
|
|
103
|
+
if (methodMap) {
|
|
104
|
+
const staticKey =
|
|
105
|
+
normalized.length > 1 && normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;
|
|
106
|
+
const staticEntry = methodMap.get(staticKey);
|
|
107
|
+
if (staticEntry) {
|
|
108
|
+
return {
|
|
109
|
+
handler: staticEntry.handler,
|
|
110
|
+
params: EMPTY_PARAMS,
|
|
111
|
+
middleware: routerMiddleware,
|
|
112
|
+
executor: staticEntry.executor,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Only walk tree if we have param/wildcard routes
|
|
118
|
+
if (!hasParamRoutes) return null;
|
|
119
|
+
|
|
120
|
+
// Original-case (query-stripped) path so extracted param values keep their
|
|
121
|
+
// casing while lookup uses the lowercased `normalized`. Needed ONLY when a
|
|
122
|
+
// fold actually happened (HP-12): when case-stable, `normalized` already IS
|
|
123
|
+
// the original-case structure, so the walk extracts from it and no second
|
|
124
|
+
// normalize pass runs.
|
|
125
|
+
const originalPath = caseStable ? undefined : collapseAndStrip(path, strict);
|
|
126
|
+
|
|
127
|
+
// Deferred param binding (HP-11): the walk records the accepted path's
|
|
128
|
+
// `:param`/`*` bindings onto these parallel stacks (pushed on descent, popped
|
|
129
|
+
// on backtrack) so params are materialized ONCE here on the accepted terminal
|
|
130
|
+
// — no eager bind + backtrack `Reflect.deleteProperty`. Reused from the
|
|
131
|
+
// router's pool when supplied (F-02) instead of allocated fresh.
|
|
132
|
+
const bindNames: string[] = walkPool ? walkPool.bindNames : [];
|
|
133
|
+
const bindValues: string[] = walkPool ? walkPool.bindValues : [];
|
|
134
|
+
// A pooled bindNames/bindValues array persists across calls — clear any
|
|
135
|
+
// leftover entries from a prior match before this walk starts (the walk
|
|
136
|
+
// itself pops everything it pushes on a clean miss or match, but a defensive
|
|
137
|
+
// clear here costs nothing on the common empty case and closes any future
|
|
138
|
+
// edit that might leave a stale entry behind).
|
|
139
|
+
if (walkPool) {
|
|
140
|
+
bindNames.length = 0;
|
|
141
|
+
bindValues.length = 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const entry = matchNodeIndexed(
|
|
145
|
+
root,
|
|
146
|
+
normalized,
|
|
147
|
+
1, // Start after leading '/'
|
|
148
|
+
bindNames,
|
|
149
|
+
bindValues,
|
|
150
|
+
method,
|
|
151
|
+
decode,
|
|
152
|
+
originalPath,
|
|
153
|
+
walkPool
|
|
154
|
+
);
|
|
155
|
+
if (!entry) return null;
|
|
156
|
+
|
|
157
|
+
// Materialize params once on a bag whose prototype chain excludes
|
|
158
|
+
// `Object.prototype` (design.md D8): a param named
|
|
159
|
+
// `__proto__`/`constructor`/`prototype` binds as an OWN key with no prototype
|
|
160
|
+
// mutation, and no inherited member is visible on `ctx.params`. Derived from
|
|
161
|
+
// `NULL_PROTO` rather than `Object.create(null)` so the object keeps V8 fast
|
|
162
|
+
// properties and handler reads stay inline-cacheable. The bind count replaces
|
|
163
|
+
// the former `Object.keys` post-loop (HP-13); zero binds returns the shared
|
|
164
|
+
// frozen `EMPTY_PARAMS`.
|
|
165
|
+
const count = bindNames.length;
|
|
166
|
+
let params: Record<string, string>;
|
|
167
|
+
if (count === 0) {
|
|
168
|
+
params = EMPTY_PARAMS;
|
|
169
|
+
} else {
|
|
170
|
+
params = Object.create(NULL_PROTO) as Record<string, string>;
|
|
171
|
+
for (let i = 0; i < count; i++) {
|
|
172
|
+
const name = bindNames[i];
|
|
173
|
+
const value = bindValues[i];
|
|
174
|
+
if (name !== undefined && value !== undefined) params[name] = value;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
handler: entry.handler,
|
|
180
|
+
params,
|
|
181
|
+
middleware: routerMiddleware,
|
|
182
|
+
executor: entry.executor,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Stable router state `resolveMatch` reads on every request. All fields are
|
|
188
|
+
* fixed references for the router's lifetime, so the caller memoizes this once
|
|
189
|
+
* rather than rebuilding it per request.
|
|
190
|
+
*
|
|
191
|
+
* `walkPool` is the one field that is itself mutable (its contents, not the
|
|
192
|
+
* reference) — the reused `WalkFrame[]`/binding-array scratch space (F-02,
|
|
193
|
+
* `reduce-router-match-allocations`). It is `undefined` until the router has
|
|
194
|
+
* at least one param/wildcard route (no pool needed for a static-only router,
|
|
195
|
+
* per `createWalkPool(0)` never being called) and is rebuilt whenever the
|
|
196
|
+
* router's `maxDepth` grows past what the current pool was sized for — see
|
|
197
|
+
* `Router`'s own wiring, not this interface, for when that rebuild happens.
|
|
198
|
+
*/
|
|
199
|
+
export interface MatchState {
|
|
200
|
+
readonly root: TrieNode;
|
|
201
|
+
readonly staticRoutes: StaticRouteMap;
|
|
202
|
+
readonly caseSensitive: boolean;
|
|
203
|
+
readonly strict: boolean;
|
|
204
|
+
readonly decode: boolean;
|
|
205
|
+
readonly routerMiddleware: Middleware[];
|
|
206
|
+
walkPool?: WalkPool;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve a request to a full {@link RouteMatch}. Thin delegator to
|
|
211
|
+
* {@link matchRoute}, which now builds the final `RouteMatch` (incl.
|
|
212
|
+
* `state.routerMiddleware`) in one allocation — so this no longer wraps a
|
|
213
|
+
* result in a second object (HP-10). `Router.match()` is a one-line delegator
|
|
214
|
+
* to this. `hasParamRoutes` is passed separately from `state` because it is
|
|
215
|
+
* the one piece of router state that flips after construction.
|
|
216
|
+
*/
|
|
217
|
+
export function resolveMatch(
|
|
218
|
+
state: MatchState,
|
|
219
|
+
hasParamRoutes: boolean,
|
|
220
|
+
method: HttpMethod,
|
|
221
|
+
path: string,
|
|
222
|
+
/**
|
|
223
|
+
* Forwarded to {@link matchRoute} — see its own doc comment for the
|
|
224
|
+
* caller contract. Defaults to `false`.
|
|
225
|
+
*/
|
|
226
|
+
preNormalized = false
|
|
227
|
+
): RouteMatch | null {
|
|
228
|
+
return matchRoute(
|
|
229
|
+
method,
|
|
230
|
+
path,
|
|
231
|
+
state.root,
|
|
232
|
+
state.staticRoutes,
|
|
233
|
+
hasParamRoutes,
|
|
234
|
+
state.caseSensitive,
|
|
235
|
+
state.strict,
|
|
236
|
+
state.decode,
|
|
237
|
+
state.routerMiddleware,
|
|
238
|
+
preNormalized,
|
|
239
|
+
state.walkPool
|
|
240
|
+
);
|
|
241
|
+
}
|