@nextrush/router 4.0.0-beta.0 → 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/dist/index.d.ts +93 -1
- package/dist/index.js +294 -27
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/allowed-methods.test.ts +2 -2
- package/src/__tests__/audit-fixes.test.ts +0 -12
- 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 +0 -20
- package/src/__tests__/find-node-differential.test.ts +6 -6
- package/src/__tests__/head-auto-registration.test.ts +197 -0
- package/src/__tests__/helpers/differential-corpus.ts +1 -3
- package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +1 -3
- package/src/__tests__/match-prenormalized.test.ts +95 -0
- package/src/__tests__/match-safety.test.ts +53 -7
- package/src/__tests__/match-single-alloc.test.ts +1 -3
- package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
- package/src/__tests__/public-surface.test.ts +14 -1
- package/src/__tests__/registration-max-depth.test.ts +56 -0
- package/src/__tests__/router-audit.test.ts +0 -11
- package/src/__tests__/router-edge-cases.test.ts +0 -5
- package/src/__tests__/router.test.ts +0 -5
- package/src/__tests__/static-map-reset.test.ts +1 -3
- 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 +4 -0
- package/src/constants.ts +24 -6
- package/src/dispatch.ts +34 -6
- package/src/index.ts +6 -0
- package/src/match-route.ts +82 -19
- package/src/matching.ts +20 -14
- package/src/registration.ts +62 -10
- package/src/router.ts +79 -1
- package/src/segment-trie.ts +8 -0
- package/src/state.ts +1 -0
- package/src/walk-pool.ts +208 -0
package/src/registration.ts
CHANGED
|
@@ -39,12 +39,22 @@ import {
|
|
|
39
39
|
import { mergeContributions, readContribution } from './route-metadata';
|
|
40
40
|
import { createRedirectHandler, type RedirectStatus } from './redirect';
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Registration state `addRoute` reads and writes, threaded explicitly.
|
|
44
|
+
*
|
|
45
|
+
* `maxDepth` is the deepest registered route's segment count seen so far —
|
|
46
|
+
* used to size the reused walk-frame pool (`reduce-router-match-allocations`)
|
|
47
|
+
* without guessing a constant. It only ever grows: a request path can never
|
|
48
|
+
* make the tree walk descend past the trie's own real depth (a mismatched
|
|
49
|
+
* segment backtracks rather than pushing a new frame), so this stays a
|
|
50
|
+
* registration-time-only figure, never influenced by request traffic.
|
|
51
|
+
*/
|
|
43
52
|
export interface RegistrationState {
|
|
44
53
|
readonly root: TrieNode;
|
|
45
54
|
readonly caseSensitive: boolean;
|
|
46
55
|
readonly staticRoutes: StaticRouteMap;
|
|
47
56
|
readonly routeDefinitions: RouteDefinition[];
|
|
57
|
+
maxDepth: number;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
/**
|
|
@@ -81,6 +91,24 @@ export function normalizeRegistrationPath(path: string, prefix: string, strict:
|
|
|
81
91
|
return normalized.startsWith('/') ? normalized : '/' + normalized;
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Insert one entry into the method-nested static-route fast-path map,
|
|
96
|
+
* creating the per-method inner map on first use.
|
|
97
|
+
*/
|
|
98
|
+
function setStaticEntry(
|
|
99
|
+
staticRoutes: StaticRouteMap,
|
|
100
|
+
method: HttpMethod,
|
|
101
|
+
key: string,
|
|
102
|
+
entry: HandlerEntry
|
|
103
|
+
): void {
|
|
104
|
+
let methodMap = staticRoutes.get(method);
|
|
105
|
+
if (!methodMap) {
|
|
106
|
+
methodMap = new Map<string, HandlerEntry>();
|
|
107
|
+
staticRoutes.set(method, methodMap);
|
|
108
|
+
}
|
|
109
|
+
methodMap.set(key, entry);
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
/**
|
|
85
113
|
* Insert one route into the segment trie, updating every side structure
|
|
86
114
|
* (static-route fast-path map, introspection registry) that
|
|
@@ -115,6 +143,10 @@ export function addRoute(
|
|
|
115
143
|
): boolean {
|
|
116
144
|
const segments = parseSegments(normalized, state.caseSensitive);
|
|
117
145
|
|
|
146
|
+
if (segments.length > state.maxDepth) {
|
|
147
|
+
state.maxDepth = segments.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
118
150
|
let node = state.root;
|
|
119
151
|
|
|
120
152
|
for (const seg of segments) {
|
|
@@ -182,10 +214,14 @@ export function addRoute(
|
|
|
182
214
|
handler: finalHandler,
|
|
183
215
|
middleware: combinedMiddleware,
|
|
184
216
|
executor,
|
|
217
|
+
autoHead: false,
|
|
185
218
|
};
|
|
186
219
|
|
|
187
|
-
// Detect duplicate route registration
|
|
188
|
-
|
|
220
|
+
// Detect duplicate route registration. A derived HEAD entry is not a
|
|
221
|
+
// duplicate — an explicit `router.head()` replaces it, in either
|
|
222
|
+
// registration order.
|
|
223
|
+
const existing = node.handlers.get(method);
|
|
224
|
+
if (existing && !(method === 'HEAD' && existing.autoHead)) {
|
|
189
225
|
throw new Error(
|
|
190
226
|
`Route conflict: ${method} ${normalized} is already registered. ` +
|
|
191
227
|
'Remove the duplicate or use a different path.'
|
|
@@ -199,14 +235,30 @@ export function addRoute(
|
|
|
199
235
|
// case-sensitive) path — so matching selects the inner map by method and
|
|
200
236
|
// probes by the raw path with no per-request key-string concatenation.
|
|
201
237
|
const hasParams = segments.some((s) => s.type === NodeType.PARAM || s.type === NodeType.WILDCARD);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
238
|
+
const staticKey = hasParams
|
|
239
|
+
? undefined
|
|
240
|
+
: state.caseSensitive
|
|
241
|
+
? normalized
|
|
242
|
+
: normalized.toLowerCase();
|
|
243
|
+
if (staticKey !== undefined) {
|
|
244
|
+
setStaticEntry(state.staticRoutes, method, staticKey, handlerEntry);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// RFC 9110 §9.3.2: HEAD is GET without a body, so a GET registration answers
|
|
248
|
+
// HEAD too — matching Fastify/Express/Koa/Hono. Derived at registration time,
|
|
249
|
+
// so request dispatch is unchanged. An explicit HEAD already registered for
|
|
250
|
+
// this path wins and is never overwritten.
|
|
251
|
+
if (method === 'GET' && !node.handlers.has('HEAD')) {
|
|
252
|
+
const derived: HandlerEntry = {
|
|
253
|
+
handler: finalHandler,
|
|
254
|
+
middleware: combinedMiddleware,
|
|
255
|
+
executor,
|
|
256
|
+
autoHead: true,
|
|
257
|
+
};
|
|
258
|
+
node.handlers.set('HEAD', derived);
|
|
259
|
+
if (staticKey !== undefined) {
|
|
260
|
+
setStaticEntry(state.staticRoutes, 'HEAD', staticKey, derived);
|
|
208
261
|
}
|
|
209
|
-
methodMap.set(normalizedKey, handlerEntry);
|
|
210
262
|
}
|
|
211
263
|
|
|
212
264
|
// Record in the introspection registry (side structure — never touched by
|
package/src/router.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { clearNode, createNode, type StaticRouteMap, type TrieNode } from './seg
|
|
|
22
22
|
import { type RedirectStatus } from './redirect';
|
|
23
23
|
import { runRouteGroup, type RouteGroup } from './group-router';
|
|
24
24
|
import { resolveMatch, type MatchState } from './match-route';
|
|
25
|
+
import { createWalkPool } from './walk-pool';
|
|
25
26
|
import { copyRoutes } from './composition';
|
|
26
27
|
import { sealRouterMiddleware as sealRouterMiddlewareImpl } from './middleware-adapter';
|
|
27
28
|
import {
|
|
@@ -33,6 +34,10 @@ import {
|
|
|
33
34
|
} from './registration';
|
|
34
35
|
import { createAllowedMethodsMiddleware, createRoutesMiddleware } from './dispatch';
|
|
35
36
|
import { createRouterState, resolveRouterOptions } from './state';
|
|
37
|
+
import { canonicalizePath } from './canonicalize';
|
|
38
|
+
|
|
39
|
+
/** '/'.charCodeAt(0) — used by {@link Router.matchesMountPrefix}'s boundary check. */
|
|
40
|
+
const SLASH_CHAR_CODE = 0x2f;
|
|
36
41
|
|
|
37
42
|
/** Inline route metadata declaration — re-exported from its own module (RT-3). */
|
|
38
43
|
export { endpoint } from './route-metadata';
|
|
@@ -62,6 +67,14 @@ export class Router {
|
|
|
62
67
|
/** Whether router-level middleware has already been sealed into executors (audit RT-7) */
|
|
63
68
|
private _sealed = false;
|
|
64
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Single-entry memo for {@link matchesMountPrefix}'s canonicalization of the
|
|
72
|
+
* mount prefix, which is fixed at registration time. Declared as two fields
|
|
73
|
+
* rather than an object so a miss stores without allocating.
|
|
74
|
+
*/
|
|
75
|
+
private _prefixMemoRaw: string | undefined = undefined;
|
|
76
|
+
private _prefixMemoCanonical = '';
|
|
77
|
+
|
|
65
78
|
/** Memoized state the extracted registration/matching functions read (see {@link createRouterState}). */
|
|
66
79
|
private readonly state: RegistrationState & MatchState;
|
|
67
80
|
|
|
@@ -96,9 +109,15 @@ export class Router {
|
|
|
96
109
|
);
|
|
97
110
|
}
|
|
98
111
|
const normalized = normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict);
|
|
112
|
+
const depthBefore = this.state.maxDepth;
|
|
99
113
|
if (addRouteImpl(method, normalized, entries, middleware, this.state, recordIntrospection)) {
|
|
100
114
|
this.hasParamRoutes = true;
|
|
101
115
|
}
|
|
116
|
+
// Rebuild the pool only when maxDepth actually grew (F-02) — a cheap,
|
|
117
|
+
// registration-time-only check; never runs per-request.
|
|
118
|
+
if (this.state.maxDepth > depthBefore) {
|
|
119
|
+
this.state.walkPool = createWalkPool(this.state.maxDepth);
|
|
120
|
+
}
|
|
102
121
|
}
|
|
103
122
|
|
|
104
123
|
get(path: string, ...entries: RouteEntry[]): this {
|
|
@@ -215,6 +234,50 @@ export class Router {
|
|
|
215
234
|
return resolveMatch(this.state, this.hasParamRoutes, method, path);
|
|
216
235
|
}
|
|
217
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Test whether `path` falls under `prefix` using this router's OWN
|
|
239
|
+
* canonicalization (case folding per `caseSensitive`, structural
|
|
240
|
+
* normalization) — the mount-boundary counterpart to {@link match}, so a
|
|
241
|
+
* router mounted via `Application.route()` is tested with the identical
|
|
242
|
+
* rule it dispatches with (RFC-029, task 3.8). Implements the optional
|
|
243
|
+
* `Routable.matchesMountPrefix` contract from `@nextrush/core`.
|
|
244
|
+
*
|
|
245
|
+
* @param path - The full request path being tested for this mount.
|
|
246
|
+
* @param prefix - The normalized mount prefix (leading `/`, no trailing `/`).
|
|
247
|
+
* @returns The path's remainder past the prefix (e.g. `/users` for a
|
|
248
|
+
* `/ADMIN/Users` request mounted at `/admin`), or `undefined` when `path`
|
|
249
|
+
* is not under `prefix` per this router's canonicalization.
|
|
250
|
+
*/
|
|
251
|
+
matchesMountPrefix(path: string, prefix: string): string | undefined {
|
|
252
|
+
const canonical = canonicalizePath(path, this.opts.caseSensitive, this.opts.strict);
|
|
253
|
+
if (canonical.rejected) return undefined;
|
|
254
|
+
|
|
255
|
+
// The prefix is fixed at registration time, so canonicalizing it on every
|
|
256
|
+
// request is pure waste — it was ~150 ns of the ~557 ns each mounted router
|
|
257
|
+
// added to dispatch. Memoized rather than precomputed at `route()` time so
|
|
258
|
+
// the `Routable.matchesMountPrefix` contract still accepts any prefix
|
|
259
|
+
// string from any caller. One entry is enough: a router is normally mounted
|
|
260
|
+
// once, and a second prefix only costs a miss.
|
|
261
|
+
let canonicalPrefix: string;
|
|
262
|
+
if (this._prefixMemoRaw === prefix) {
|
|
263
|
+
canonicalPrefix = this._prefixMemoCanonical;
|
|
264
|
+
} else {
|
|
265
|
+
canonicalPrefix = canonicalizePath(prefix, this.opts.caseSensitive, this.opts.strict).path;
|
|
266
|
+
this._prefixMemoRaw = prefix;
|
|
267
|
+
this._prefixMemoCanonical = canonicalPrefix;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const prefixLen = canonicalPrefix.length;
|
|
271
|
+
if (!canonical.path.startsWith(canonicalPrefix)) return undefined;
|
|
272
|
+
|
|
273
|
+
const hasCharAfterPrefix = prefixLen < canonical.path.length;
|
|
274
|
+
if (hasCharAfterPrefix && canonical.path.charCodeAt(prefixLen) !== SLASH_CHAR_CODE) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return canonical.path.slice(prefixLen) || '/';
|
|
279
|
+
}
|
|
280
|
+
|
|
218
281
|
/**
|
|
219
282
|
* Return the router's dispatch middleware — mount this on the application.
|
|
220
283
|
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#routerroutes | README: router.routes()}
|
|
@@ -226,7 +289,17 @@ export class Router {
|
|
|
226
289
|
this._sealed = true;
|
|
227
290
|
sealRouterMiddlewareImpl(this.root, this.staticRoutes, this.routerMiddleware);
|
|
228
291
|
}
|
|
229
|
-
|
|
292
|
+
// F-10: the internal closure calls `resolveMatch` directly with
|
|
293
|
+
// `preNormalized: true` rather than going through the public `this.match()`
|
|
294
|
+
// — this is the one call path where the caller (`createRoutesMiddleware`)
|
|
295
|
+
// has already run `canonicalizePath()` on `path`, so `matchRoute`'s own
|
|
296
|
+
// fold+collapse re-derivation is skippable. `Router.match()` itself is
|
|
297
|
+
// untouched and still defaults to `false` for every other caller.
|
|
298
|
+
return createRoutesMiddleware(
|
|
299
|
+
(method, path) => resolveMatch(this.state, this.hasParamRoutes, method, path, true),
|
|
300
|
+
this.opts.caseSensitive,
|
|
301
|
+
this.opts.strict
|
|
302
|
+
);
|
|
230
303
|
}
|
|
231
304
|
|
|
232
305
|
/**
|
|
@@ -263,6 +336,11 @@ export class Router {
|
|
|
263
336
|
// Clear the introspection registry too, or getRoutes()/OpenAPI would emit
|
|
264
337
|
// ghost routes after a reset (audit RT-1).
|
|
265
338
|
this.routeDefinitions.length = 0;
|
|
339
|
+
// Reset the walk-frame pool sizing too (F-02) — otherwise maxDepth would
|
|
340
|
+
// keep reporting a since-cleared route's depth, and the pool would stay
|
|
341
|
+
// needlessly oversized for whatever gets registered next.
|
|
342
|
+
this.state.maxDepth = 0;
|
|
343
|
+
this.state.walkPool = undefined;
|
|
266
344
|
this._sealed = false;
|
|
267
345
|
}
|
|
268
346
|
|
package/src/segment-trie.ts
CHANGED
|
@@ -51,6 +51,14 @@ export interface HandlerEntry {
|
|
|
51
51
|
middleware: Middleware[];
|
|
52
52
|
/** Pre-compiled executor for fast dispatch (no closure per request) */
|
|
53
53
|
executor?: (ctx: Context) => Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* `true` only for a `HEAD` entry derived from a `GET` registration
|
|
56
|
+
* (RFC 9110 §9.3.2). A derived entry is replaced by an explicit `HEAD`
|
|
57
|
+
* registration instead of reporting a route conflict, is absent from
|
|
58
|
+
* `getRoutes()`, and is skipped when copying routes into a parent router —
|
|
59
|
+
* which re-derives it from the `GET` it copies.
|
|
60
|
+
*/
|
|
61
|
+
autoHead: boolean;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/**
|
package/src/state.ts
CHANGED
package/src/walk-pool.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Reused walk-frame pool (F-02, `reduce-router-match-allocations`)
|
|
3
|
+
*
|
|
4
|
+
* Split out of `matching.ts` once the pool implementation pushed it over the
|
|
5
|
+
* 300-line file cap — same reasoning `match-route.ts`'s own extraction used.
|
|
6
|
+
* Holds the pooled scratch structure and its indexed-cursor variant of the
|
|
7
|
+
* tree walk; `matching.ts`'s `matchNodeIndexed` delegates here when a pool is
|
|
8
|
+
* supplied and keeps its own fresh-allocation behavior otherwise.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HttpMethod } from '@nextrush/types';
|
|
15
|
+
import type { HandlerEntry, TrieNode } from './segment-trie';
|
|
16
|
+
import { decodeParam, segmentAt } from './matching';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One node in the iterative walk's explicit stack. `stage` is a small state
|
|
20
|
+
* machine (0 = extract + try static, 1 = try param, 2 = try wildcard/backtrack)
|
|
21
|
+
* so a single frame can be revisited on backtrack without recursion. `bound`
|
|
22
|
+
* records whether this frame pushed a deferred param binding, so backtracking
|
|
23
|
+
* can pop it without an object-property delete.
|
|
24
|
+
*/
|
|
25
|
+
export interface WalkFrame {
|
|
26
|
+
node: TrieNode;
|
|
27
|
+
pos: number;
|
|
28
|
+
stage: 0 | 1 | 2;
|
|
29
|
+
seg: string;
|
|
30
|
+
next: number;
|
|
31
|
+
bound: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-router-instance reused scratch space for the tree walk — the
|
|
36
|
+
* `WalkFrame[]` stack and the `bindNames`/`bindValues` binding arrays,
|
|
37
|
+
* pre-sized to the deepest currently registered route
|
|
38
|
+
* (`RegistrationState.maxDepth`) instead of allocated fresh on every
|
|
39
|
+
* `matchNodeIndexed` call.
|
|
40
|
+
*
|
|
41
|
+
* Safe under the router's synchronous-walk invariant only: a request path
|
|
42
|
+
* can never make the walk descend past `maxDepth` (a mismatched segment
|
|
43
|
+
* backtracks — the depth cursor decrements — rather than growing past the
|
|
44
|
+
* pool), and the walk never awaits mid-frame, so no two in-flight matches on
|
|
45
|
+
* the same router instance can observe each other's frames. See the
|
|
46
|
+
* `router` capability's "Reused internal walk state is never shared across
|
|
47
|
+
* concurrent in-flight matches" requirement.
|
|
48
|
+
*/
|
|
49
|
+
export interface WalkPool {
|
|
50
|
+
frames: WalkFrame[];
|
|
51
|
+
bindNames: string[];
|
|
52
|
+
bindValues: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a `WalkPool` sized to hold `maxDepth` matched segments. The walk's
|
|
57
|
+
* frame at index 0 always represents the trie ROOT itself (mirroring the
|
|
58
|
+
* unpooled walk's `stack[0] = { node: root, ... }`), so a route with
|
|
59
|
+
* `maxDepth` segments needs `maxDepth + 1` frames — one for the root plus one
|
|
60
|
+
* per descended segment. Called once when a router's `maxDepth` grows
|
|
61
|
+
* (registration time), never per-request.
|
|
62
|
+
*/
|
|
63
|
+
export function createWalkPool(maxDepth: number): WalkPool {
|
|
64
|
+
const frames: WalkFrame[] = [];
|
|
65
|
+
for (let i = 0; i < maxDepth + 1; i++) {
|
|
66
|
+
frames.push({
|
|
67
|
+
node: undefined as unknown as TrieNode,
|
|
68
|
+
pos: 0,
|
|
69
|
+
stage: 0,
|
|
70
|
+
seg: '',
|
|
71
|
+
next: 0,
|
|
72
|
+
bound: false,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { frames, bindNames: [], bindValues: [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pooled variant of `matchNodeIndexed` — same stage-machine, same precedence,
|
|
80
|
+
* same backtrack semantics, byte-identical results — but indexes into
|
|
81
|
+
* `pool.frames` by depth (mutating each pre-allocated frame in place) instead
|
|
82
|
+
* of `push`/`pop`-ing fresh frame objects onto a fresh array. `depth` is a
|
|
83
|
+
* local cursor, never shared across calls; only the underlying frame OBJECTS
|
|
84
|
+
* are reused. Safe only because the walk never awaits mid-frame and a
|
|
85
|
+
* mismatched segment decrements `depth` (backtrack) rather than growing past
|
|
86
|
+
* `pool.frames.length` (bounded by the router's `maxDepth` at registration
|
|
87
|
+
* time — see {@link createWalkPool}).
|
|
88
|
+
*/
|
|
89
|
+
export function matchNodeIndexedPooled(
|
|
90
|
+
root: TrieNode,
|
|
91
|
+
path: string,
|
|
92
|
+
startPos: number,
|
|
93
|
+
bindNames: string[],
|
|
94
|
+
bindValues: string[],
|
|
95
|
+
method: HttpMethod,
|
|
96
|
+
decode: boolean,
|
|
97
|
+
pool: WalkPool,
|
|
98
|
+
originalPath?: string
|
|
99
|
+
): HandlerEntry | null {
|
|
100
|
+
const { frames } = pool;
|
|
101
|
+
let depth = 0;
|
|
102
|
+
const first = frames[0];
|
|
103
|
+
if (first === undefined) return null; // maxDepth 0 — no param routes registered, nothing to walk
|
|
104
|
+
first.node = root;
|
|
105
|
+
first.pos = startPos;
|
|
106
|
+
first.stage = 0;
|
|
107
|
+
first.seg = '';
|
|
108
|
+
first.next = 0;
|
|
109
|
+
first.bound = false;
|
|
110
|
+
|
|
111
|
+
while (depth >= 0) {
|
|
112
|
+
const frame = frames[depth];
|
|
113
|
+
if (frame === undefined) break;
|
|
114
|
+
|
|
115
|
+
if (frame.stage === 0) {
|
|
116
|
+
if (frame.pos >= path.length) {
|
|
117
|
+
const handler = frame.node.handlers.get(method);
|
|
118
|
+
if (handler) return handler;
|
|
119
|
+
depth--;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const slashPos = path.indexOf('/', frame.pos);
|
|
123
|
+
if (slashPos === -1) {
|
|
124
|
+
frame.seg = path.slice(frame.pos);
|
|
125
|
+
frame.next = path.length;
|
|
126
|
+
} else {
|
|
127
|
+
frame.seg = path.slice(frame.pos, slashPos);
|
|
128
|
+
frame.next = slashPos + 1;
|
|
129
|
+
}
|
|
130
|
+
if (frame.seg === '') {
|
|
131
|
+
const handler = frame.node.handlers.get(method);
|
|
132
|
+
if (handler) return handler;
|
|
133
|
+
depth--;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
frame.stage = 1;
|
|
137
|
+
const staticChild = frame.node.children.get(frame.seg);
|
|
138
|
+
if (staticChild) {
|
|
139
|
+
depth++;
|
|
140
|
+
const next = frames[depth];
|
|
141
|
+
if (next === undefined) {
|
|
142
|
+
// Should be unreachable — pool sized to maxDepth — but fail closed
|
|
143
|
+
// (treat as a miss) rather than write past the pooled array.
|
|
144
|
+
depth--;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
next.node = staticChild;
|
|
148
|
+
next.pos = frame.next;
|
|
149
|
+
next.stage = 0;
|
|
150
|
+
next.seg = '';
|
|
151
|
+
next.next = 0;
|
|
152
|
+
next.bound = false;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (frame.stage === 1) {
|
|
158
|
+
frame.stage = 2;
|
|
159
|
+
const paramChild = frame.node.paramChild;
|
|
160
|
+
if (paramChild) {
|
|
161
|
+
const paramName = paramChild.paramName;
|
|
162
|
+
if (paramName === undefined) return null;
|
|
163
|
+
const value =
|
|
164
|
+
originalPath !== undefined
|
|
165
|
+
? decodeParam(segmentAt(originalPath, frame.pos), decode)
|
|
166
|
+
: decodeParam(frame.seg, decode);
|
|
167
|
+
bindNames.push(paramName);
|
|
168
|
+
bindValues.push(value);
|
|
169
|
+
frame.bound = true;
|
|
170
|
+
depth++;
|
|
171
|
+
const next = frames[depth];
|
|
172
|
+
if (next === undefined) {
|
|
173
|
+
bindNames.pop();
|
|
174
|
+
bindValues.pop();
|
|
175
|
+
frame.bound = false;
|
|
176
|
+
depth--;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
next.node = paramChild;
|
|
180
|
+
next.pos = frame.next;
|
|
181
|
+
next.stage = 0;
|
|
182
|
+
next.seg = '';
|
|
183
|
+
next.next = 0;
|
|
184
|
+
next.bound = false;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (frame.bound) {
|
|
190
|
+
bindNames.pop();
|
|
191
|
+
bindValues.pop();
|
|
192
|
+
frame.bound = false;
|
|
193
|
+
}
|
|
194
|
+
const wildcardChild = frame.node.wildcardChild;
|
|
195
|
+
if (wildcardChild) {
|
|
196
|
+
const src = originalPath ?? path;
|
|
197
|
+
bindNames.push('*');
|
|
198
|
+
bindValues.push(decodeParam(src.slice(frame.pos), decode));
|
|
199
|
+
const handler = wildcardChild.handlers.get(method);
|
|
200
|
+
if (handler) return handler;
|
|
201
|
+
bindNames.pop();
|
|
202
|
+
bindValues.pop();
|
|
203
|
+
}
|
|
204
|
+
depth--;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
}
|