@nextrush/router 3.0.7 → 4.0.0-beta.0

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.
Files changed (40) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +142 -185
  3. package/dist/index.js +816 -665
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -6
  6. package/src/__tests__/allowed-methods.test.ts +144 -0
  7. package/src/__tests__/audit-fixes.test.ts +71 -0
  8. package/src/__tests__/dispatch-deasync.test.ts +312 -0
  9. package/src/__tests__/find-node-differential.test.ts +220 -0
  10. package/src/__tests__/fixtures/match-golden.json +556 -0
  11. package/src/__tests__/helpers/differential-corpus.ts +316 -0
  12. package/src/__tests__/match-differential.test.ts +46 -0
  13. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  14. package/src/__tests__/match-normalize-fastpath.test.ts +66 -0
  15. package/src/__tests__/match-safety.test.ts +177 -0
  16. package/src/__tests__/match-single-alloc.test.ts +84 -0
  17. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  18. package/src/__tests__/param-decoding.test.ts +78 -0
  19. package/src/__tests__/public-surface.test.ts +59 -0
  20. package/src/__tests__/route-metadata.test.ts +172 -0
  21. package/src/__tests__/router-audit.test.ts +326 -0
  22. package/src/__tests__/router-edge-cases.test.ts +2 -2
  23. package/src/__tests__/router.test.ts +148 -2
  24. package/src/__tests__/static-map-reset.test.ts +50 -0
  25. package/src/composition.ts +75 -0
  26. package/src/constants.ts +25 -0
  27. package/src/dispatch.ts +117 -0
  28. package/src/find-node.ts +125 -0
  29. package/src/group-router.ts +208 -0
  30. package/src/index.ts +8 -5
  31. package/src/match-route.ts +178 -0
  32. package/src/matching.ts +240 -0
  33. package/src/middleware-adapter.ts +59 -0
  34. package/src/redirect.ts +97 -0
  35. package/src/registration.ts +291 -0
  36. package/src/route-metadata.ts +68 -0
  37. package/src/router.ts +150 -881
  38. package/src/segment-trie.ts +219 -0
  39. package/src/state.ts +52 -0
  40. package/src/radix-tree.ts +0 -184
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @nextrush/router - Dispatch Middleware Generation
3
+ *
4
+ * The two app-facing `Middleware` factories extracted from the `Router` class
5
+ * (design.md D2 — finishing T014's split along the same seam: the composition,
6
+ * matching-engine, and sealing clusters were already extracted; this is the
7
+ * dispatch/allowed-methods generation cluster).
8
+ *
9
+ * Both closures are structurally pure — every value they read (the `match`
10
+ * function, the trie root, the case-sensitivity/strict flags) is passed in
11
+ * explicitly rather than captured off `this`, so they carry no hidden
12
+ * dependency on `Router` beyond their parameters (same principle as the
13
+ * matching-engine extraction, design.md D1). `Router.routes()` and
14
+ * `Router.allowedMethods()` stay as thin public methods that supply that
15
+ * state and return these closures.
16
+ *
17
+ * @packageDocumentation
18
+ * @internal
19
+ */
20
+
21
+ import type { Context, HttpMethod, Middleware, RouteMatch } from '@nextrush/types';
22
+ import { NOOP_NEXT, type TrieNode } from './segment-trie';
23
+ import { findAllowedMethods } from './find-node';
24
+
25
+ /**
26
+ * Shared resolved promise for the no-`next` miss path (NF-1). Reused rather than
27
+ * allocating a fresh `Promise.resolve()` per miss, mirroring the router's
28
+ * existing `NOOP_NEXT`/`RESOLVED_PROMISE` sentinels.
29
+ */
30
+ const RESOLVED: Promise<void> = Promise.resolve();
31
+
32
+ /**
33
+ * Build the router's primary dispatch middleware.
34
+ *
35
+ * On each request it resolves the route via the injected `match` function,
36
+ * sets `ctx.params`, and runs the pre-compiled executor (which already bakes
37
+ * in any router-level middleware). A miss sets `ctx.status = 404` and yields
38
+ * to the next middleware so `allowedMethods()`/a 404 handler can act.
39
+ *
40
+ * @param match - Route resolver, supplied by `Router.match` so this factory
41
+ * never touches `Router` internals directly.
42
+ */
43
+ export function createRoutesMiddleware(
44
+ match: (method: HttpMethod, path: string) => RouteMatch | null
45
+ ): Middleware {
46
+ return (ctx: Context, next?: () => Promise<void>): Promise<void> => {
47
+ const routeMatch = match(ctx.method, ctx.path);
48
+
49
+ if (!routeMatch) {
50
+ // No route matched — set 404 so allowedMethods()/notFoundHandler() can act,
51
+ // then forward to the next middleware (the allowedMethods fall-through).
52
+ ctx.status = 404;
53
+ return next ? next() : RESOLVED;
54
+ }
55
+
56
+ ctx.params = routeMatch.params;
57
+
58
+ // NF-1: forward the executor's promise DIRECTLY instead of `await`-ing it in
59
+ // an extra `async` frame. The executor already returns a `Promise<void>`,
60
+ // converts synchronous throws to rejections, and terminates the chain at the
61
+ // handler, so ordering, rejection propagation, and the `setNext(NOOP_NEXT)`
62
+ // guard are unchanged — one state machine + one microtask hop removed. A
63
+ // synchronous throw from `match()` itself is still converted to a rejection
64
+ // by the composer's `try/catch` that wraps this middleware call.
65
+ return routeMatch.executor
66
+ ? routeMatch.executor(ctx)
67
+ : // Fallback (no pre-compiled executor — shouldn't happen): wrap so a void
68
+ // or thenable return still yields a Promise<void> and never a sync throw.
69
+ Promise.resolve(routeMatch.handler(ctx, NOOP_NEXT));
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Build the allowed-methods middleware.
75
+ *
76
+ * Runs after the dispatch middleware: if the request was a 404, it does a
77
+ * single tree walk to collect every method registered for the path. An
78
+ * `OPTIONS` request gets a `200` with an `Allow` header; any other method
79
+ * gets a `405` with `Allow`. If no method is registered for the path it
80
+ * leaves the 404 untouched.
81
+ *
82
+ * @param root - Trie root to walk for allowed methods.
83
+ * @param caseSensitive - Router case-sensitivity option.
84
+ * @param strict - Router strict-trailing-slash option.
85
+ */
86
+ export function createAllowedMethodsMiddleware(
87
+ root: TrieNode,
88
+ caseSensitive: boolean,
89
+ strict: boolean
90
+ ): Middleware {
91
+ return async (ctx: Context, next?: () => Promise<void>): Promise<void> => {
92
+ if (next) {
93
+ await next();
94
+ }
95
+
96
+ if (ctx.status !== 404) return;
97
+
98
+ // Single tree walk to find all allowed methods instead of N×match()
99
+ const allowed = findAllowedMethods(ctx.path, root, caseSensitive, strict);
100
+
101
+ if (allowed.length === 0) return;
102
+
103
+ const allowHeader = allowed.join(', ');
104
+
105
+ // If OPTIONS request, respond with allowed methods
106
+ if (ctx.method === 'OPTIONS') {
107
+ ctx.status = 200;
108
+ ctx.set('Allow', allowHeader);
109
+ ctx.body = '';
110
+ return;
111
+ }
112
+
113
+ // Otherwise, return 405 Method Not Allowed
114
+ ctx.status = 405;
115
+ ctx.set('Allow', allowHeader);
116
+ };
117
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @nextrush/router - Method-agnostic trie walk (allowed-methods / 405 path)
3
+ *
4
+ * `findNode` + `findAllowedMethods` were split out of `matching.ts` when the
5
+ * iterative rewrite of `findNode` (HP-17, OpenSpec change
6
+ * `router-context-final-cleanup`) pushed that file past the 300-line ceiling.
7
+ * They form one cohesive concern — resolving the *node* for a path regardless
8
+ * of HTTP method, to answer OPTIONS/405 — distinct from `matching.ts`'s
9
+ * method-aware handler match (`matchNodeIndexed`) and its normalization
10
+ * primitives, which are reused here via import (`segmentAt`,
11
+ * `normalizePathForMatch`).
12
+ *
13
+ * @packageDocumentation
14
+ * @internal
15
+ */
16
+
17
+ import type { HttpMethod } from '@nextrush/types';
18
+ import { normalizePathForMatch, segmentAt } from './matching';
19
+ import type { TrieNode } from './segment-trie';
20
+
21
+ /**
22
+ * One node in {@link findNode}'s explicit-stack walk. `stage` is a small state
23
+ * machine (0 = extract segment + try static, 1 = try param, 2 = try
24
+ * wildcard/backtrack) so a single frame can be revisited on backtrack without
25
+ * recursion. `next` is the start position of the following segment, captured
26
+ * once in stage 0 and reused when descending into the param branch.
27
+ */
28
+ interface FindFrame {
29
+ node: TrieNode;
30
+ pos: number;
31
+ stage: 0 | 1 | 2;
32
+ next: number;
33
+ }
34
+
35
+ /**
36
+ * Walk the trie to find the node matching a path (ignoring HTTP method), the
37
+ * method-agnostic walker used by {@link findAllowedMethods} for the 405/OPTIONS
38
+ * path (design.md D3 / HP-17).
39
+ *
40
+ * Walks with an EXPLICIT stack instead of recursion — mirroring
41
+ * `matchNodeIndexed` — so a pathological segment count cannot overflow the call
42
+ * stack (the same DoS class HP-11 closed for the match path). Behavior is
43
+ * byte-identical to the former recursive walker: precedence is static > param >
44
+ * wildcard at each node, a partially-matching branch backtracks cleanly, the
45
+ * wildcard child is a terminal (it captures the remainder), and the first
46
+ * accepted terminal wins. The scalar {@link segmentAt} scan is reused so the
47
+ * traversal shares one segment-extraction helper rather than duplicating it.
48
+ *
49
+ * `path` is the already-normalized lookup path; `startPos` skips the leading
50
+ * `/` (callers pass `1`, matching `matchNodeIndexed`).
51
+ */
52
+ export function findNode(root: TrieNode, path: string, startPos: number): TrieNode | null {
53
+ const stack: FindFrame[] = [{ node: root, pos: startPos, stage: 0, next: 0 }];
54
+
55
+ while (stack.length > 0) {
56
+ const frame = stack[stack.length - 1];
57
+ if (frame === undefined) break;
58
+
59
+ // Stage 0 — first visit: terminal check, then try the static child.
60
+ if (frame.stage === 0) {
61
+ // Whole path consumed at this node → it is the matching node.
62
+ if (frame.pos >= path.length) {
63
+ return frame.node;
64
+ }
65
+ const seg = segmentAt(path, frame.pos);
66
+ // An empty segment means the path is exhausted here (e.g. a strict-mode
67
+ // trailing slash) — treat this node as the terminal, as the recursive
68
+ // walk did after `split('/').filter(Boolean)` dropped empty segments.
69
+ if (seg === '') {
70
+ return frame.node;
71
+ }
72
+ // `segmentAt` already scanned to the next `/`; derive the following
73
+ // position from the segment length (no second indexOf) — a slash follows
74
+ // iff the segment ended before the path did.
75
+ const segEnd = frame.pos + seg.length;
76
+ frame.next = segEnd < path.length ? segEnd + 1 : path.length;
77
+ frame.stage = 1;
78
+ const staticChild = frame.node.children.get(seg);
79
+ if (staticChild) {
80
+ stack.push({ node: staticChild, pos: frame.next, stage: 0, next: 0 });
81
+ }
82
+ continue;
83
+ }
84
+
85
+ // Stage 1 — static child (if any) failed: try the param child.
86
+ if (frame.stage === 1) {
87
+ frame.stage = 2;
88
+ if (frame.node.paramChild) {
89
+ stack.push({ node: frame.node.paramChild, pos: frame.next, stage: 0, next: 0 });
90
+ }
91
+ continue;
92
+ }
93
+
94
+ // Stage 2 — static and param branches exhausted: the wildcard child is a
95
+ // terminal (matches the remainder). Otherwise this branch fails; backtrack.
96
+ if (frame.node.wildcardChild) {
97
+ return frame.node.wildcardChild;
98
+ }
99
+ stack.pop();
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Find all HTTP methods registered for a given path via a single tree walk.
107
+ * `caseSensitive`/`strict` (formerly `this.opts.*`) and `root` (formerly
108
+ * `this.root`) are threaded explicitly.
109
+ */
110
+ export function findAllowedMethods(
111
+ path: string,
112
+ root: TrieNode,
113
+ caseSensitive: boolean,
114
+ strict: boolean
115
+ ): HttpMethod[] {
116
+ const normalized = normalizePathForMatch(path, caseSensitive, strict);
117
+
118
+ // Walk from position 1 to skip the leading '/', matching `matchNodeIndexed`'s
119
+ // start offset. The iterative `findNode` scans segments off the path in place,
120
+ // so no `split('/')` array is allocated here.
121
+ const node = findNode(root, normalized, 1);
122
+ if (!node || node.handlers.size === 0) return [];
123
+
124
+ return Array.from(node.handlers.keys());
125
+ }
@@ -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 radix tree based router with:
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,14 @@
13
13
  */
14
14
 
15
15
  // Router
16
- export { createRouter, Router } from './router';
16
+ export { createRouter, endpoint, Router } from './router';
17
17
 
18
- // Radix tree internals (for advanced usage)
19
- export { createNode, NodeType, parseSegments } from './radix-tree';
20
- export type { HandlerEntry, ParsedSegment, RadixNode } from './radix-tree';
18
+ // Route groups
19
+ export type { RouteGroup } from './group-router';
20
+
21
+ // Segment trie internals (for advanced usage)
22
+ export { createNode, NodeType, parseSegments } from './segment-trie';
23
+ export type { HandlerEntry, ParsedSegment, TrieNode } from './segment-trie';
21
24
 
22
25
  // Re-export relevant types
23
26
  export type {
@@ -0,0 +1,178 @@
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 } from './constants';
18
+ import { matchNodeIndexed, collapseAndStrip, isProvablyLowerAscii } from './matching';
19
+ import type { StaticRouteMap, TrieNode } from './segment-trie';
20
+
21
+ /**
22
+ * Match a route and return the full {@link RouteMatch} in a SINGLE allocation
23
+ * (design.md D1 / HP-10).
24
+ *
25
+ * `matchRoute` builds the final `RouteMatch` — including `middleware:
26
+ * routerMiddleware` — directly at each return site, so `Router.match()` gets
27
+ * one object per matched request instead of a bare result later re-wrapped by
28
+ * `resolveMatch`. `routerMiddleware` is threaded in as a parameter (its
29
+ * contents are never read here — only attached by reference), preserving the
30
+ * "no implicit `Router` state" property of the earlier extraction while
31
+ * removing the duplicate wrapper object.
32
+ *
33
+ * Every other piece of `Router` state it reads (`root`, `staticRoutes`,
34
+ * `hasParamRoutes`, the option flags) is read-only here (no mutation, unlike
35
+ * `addRoute` in `registration.ts`), so it is threaded as plain parameters.
36
+ */
37
+ export function matchRoute(
38
+ method: HttpMethod,
39
+ rawPath: string,
40
+ root: TrieNode,
41
+ staticRoutes: StaticRouteMap,
42
+ hasParamRoutes: boolean,
43
+ caseSensitive: boolean,
44
+ strict: boolean,
45
+ decode: boolean,
46
+ routerMiddleware: Middleware[]
47
+ ): RouteMatch | null {
48
+ let path = rawPath;
49
+ // Query string must not affect path matching (RFC 3986 §3.4). Strip it here,
50
+ // before normalization, so both the lookup path and extracted param values
51
+ // exclude it. This strip is caller-specific: `findAllowedMethods` receives an
52
+ // already query-free `ctx.path`, so the shared `normalizePathForMatch` does
53
+ // not strip — only `matchRoute` does.
54
+ const queryIdx = path.indexOf('?');
55
+ if (queryIdx !== -1) path = path.slice(0, queryIdx);
56
+
57
+ // HP-12: decide case-stability ONCE. When the path is provably case-stable
58
+ // (case-sensitive router, or an all-lowercase-ASCII path), folding is a no-op
59
+ // and the original-case path equals the normalized one — so we skip both the
60
+ // `toLowerCase()` allocation and the second original-case normalize pass.
61
+ const caseStable = caseSensitive || isProvablyLowerAscii(path);
62
+ const folded = caseStable ? path : path.toLowerCase();
63
+ const normalized = collapseAndStrip(folded, strict);
64
+
65
+ // FAST PATH: O(1) static route lookup (no tree traversal). Method-nested map
66
+ // (HP-9): select the inner map by method, then probe by the normalized path —
67
+ // no per-request `${method} ${path}` key-string allocation. For static routes
68
+ // a trailing slash is irrelevant, so always strip it for the probe.
69
+ const methodMap = staticRoutes.get(method);
70
+ if (methodMap) {
71
+ const staticKey =
72
+ normalized.length > 1 && normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;
73
+ const staticEntry = methodMap.get(staticKey);
74
+ if (staticEntry) {
75
+ return {
76
+ handler: staticEntry.handler,
77
+ params: EMPTY_PARAMS,
78
+ middleware: routerMiddleware,
79
+ executor: staticEntry.executor,
80
+ };
81
+ }
82
+ }
83
+
84
+ // Only walk tree if we have param/wildcard routes
85
+ if (!hasParamRoutes) return null;
86
+
87
+ // Original-case (query-stripped) path so extracted param values keep their
88
+ // casing while lookup uses the lowercased `normalized`. Needed ONLY when a
89
+ // fold actually happened (HP-12): when case-stable, `normalized` already IS
90
+ // the original-case structure, so the walk extracts from it and no second
91
+ // normalize pass runs.
92
+ const originalPath = caseStable ? undefined : collapseAndStrip(path, strict);
93
+
94
+ // Deferred param binding (HP-11): the walk records the accepted path's
95
+ // `:param`/`*` bindings onto these parallel stacks (pushed on descent, popped
96
+ // on backtrack) so params are materialized ONCE here on the accepted terminal
97
+ // — no eager bind + backtrack `Reflect.deleteProperty`.
98
+ const bindNames: string[] = [];
99
+ const bindValues: string[] = [];
100
+
101
+ const entry = matchNodeIndexed(
102
+ root,
103
+ normalized,
104
+ 1, // Start after leading '/'
105
+ bindNames,
106
+ bindValues,
107
+ method,
108
+ decode,
109
+ originalPath
110
+ );
111
+ if (!entry) return null;
112
+
113
+ // Materialize params once on a null-prototype object (design.md D8): a param
114
+ // named `__proto__`/`constructor`/`prototype` binds as an OWN key with no
115
+ // prototype mutation, and no inherited member is visible on `ctx.params`. The
116
+ // bind count replaces the former `Object.keys` post-loop (HP-13); zero binds
117
+ // returns the shared frozen `EMPTY_PARAMS`.
118
+ const count = bindNames.length;
119
+ let params: Record<string, string>;
120
+ if (count === 0) {
121
+ params = EMPTY_PARAMS;
122
+ } else {
123
+ params = Object.create(null) as Record<string, string>;
124
+ for (let i = 0; i < count; i++) {
125
+ const name = bindNames[i];
126
+ const value = bindValues[i];
127
+ if (name !== undefined && value !== undefined) params[name] = value;
128
+ }
129
+ }
130
+
131
+ return {
132
+ handler: entry.handler,
133
+ params,
134
+ middleware: routerMiddleware,
135
+ executor: entry.executor,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Stable router state `resolveMatch` reads on every request. All fields are
141
+ * fixed references for the router's lifetime, so the caller memoizes this once
142
+ * rather than rebuilding it per request.
143
+ */
144
+ export interface MatchState {
145
+ readonly root: TrieNode;
146
+ readonly staticRoutes: StaticRouteMap;
147
+ readonly caseSensitive: boolean;
148
+ readonly strict: boolean;
149
+ readonly decode: boolean;
150
+ readonly routerMiddleware: Middleware[];
151
+ }
152
+
153
+ /**
154
+ * Resolve a request to a full {@link RouteMatch}. Thin delegator to
155
+ * {@link matchRoute}, which now builds the final `RouteMatch` (incl.
156
+ * `state.routerMiddleware`) in one allocation — so this no longer wraps a
157
+ * result in a second object (HP-10). `Router.match()` is a one-line delegator
158
+ * to this. `hasParamRoutes` is passed separately from `state` because it is
159
+ * the one piece of router state that flips after construction.
160
+ */
161
+ export function resolveMatch(
162
+ state: MatchState,
163
+ hasParamRoutes: boolean,
164
+ method: HttpMethod,
165
+ path: string
166
+ ): RouteMatch | null {
167
+ return matchRoute(
168
+ method,
169
+ path,
170
+ state.root,
171
+ state.staticRoutes,
172
+ hasParamRoutes,
173
+ state.caseSensitive,
174
+ state.strict,
175
+ state.decode,
176
+ state.routerMiddleware
177
+ );
178
+ }