@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/dist/index.d.ts
CHANGED
|
@@ -106,6 +106,13 @@ declare class Router {
|
|
|
106
106
|
private hasParamRoutes;
|
|
107
107
|
/** Whether router-level middleware has already been sealed into executors (audit RT-7) */
|
|
108
108
|
private _sealed;
|
|
109
|
+
/**
|
|
110
|
+
* Single-entry memo for {@link matchesMountPrefix}'s canonicalization of the
|
|
111
|
+
* mount prefix, which is fixed at registration time. Declared as two fields
|
|
112
|
+
* rather than an object so a miss stores without allocating.
|
|
113
|
+
*/
|
|
114
|
+
private _prefixMemoRaw;
|
|
115
|
+
private _prefixMemoCanonical;
|
|
109
116
|
/** Memoized state the extracted registration/matching functions read (see {@link createRouterState}). */
|
|
110
117
|
private readonly state;
|
|
111
118
|
constructor(options?: RouterOptions);
|
|
@@ -149,6 +156,21 @@ declare class Router {
|
|
|
149
156
|
private mountRouter;
|
|
150
157
|
/** Match a request to a route — delegates to {@link resolveMatch} (design.md D1). */
|
|
151
158
|
match(method: HttpMethod, path: string): RouteMatch | null;
|
|
159
|
+
/**
|
|
160
|
+
* Test whether `path` falls under `prefix` using this router's OWN
|
|
161
|
+
* canonicalization (case folding per `caseSensitive`, structural
|
|
162
|
+
* normalization) — the mount-boundary counterpart to {@link match}, so a
|
|
163
|
+
* router mounted via `Application.route()` is tested with the identical
|
|
164
|
+
* rule it dispatches with (RFC-029, task 3.8). Implements the optional
|
|
165
|
+
* `Routable.matchesMountPrefix` contract from `@nextrush/core`.
|
|
166
|
+
*
|
|
167
|
+
* @param path - The full request path being tested for this mount.
|
|
168
|
+
* @param prefix - The normalized mount prefix (leading `/`, no trailing `/`).
|
|
169
|
+
* @returns The path's remainder past the prefix (e.g. `/users` for a
|
|
170
|
+
* `/ADMIN/Users` request mounted at `/admin`), or `undefined` when `path`
|
|
171
|
+
* is not under `prefix` per this router's canonicalization.
|
|
172
|
+
*/
|
|
173
|
+
matchesMountPrefix(path: string, prefix: string): string | undefined;
|
|
152
174
|
/**
|
|
153
175
|
* Return the router's dispatch middleware — mount this on the application.
|
|
154
176
|
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#routerroutes | README: router.routes()}
|
|
@@ -184,6 +206,68 @@ declare class Router {
|
|
|
184
206
|
*/
|
|
185
207
|
declare function createRouter(options?: RouterOptions): Router;
|
|
186
208
|
|
|
209
|
+
/**
|
|
210
|
+
* @nextrush/router - Canonical Request Path
|
|
211
|
+
*
|
|
212
|
+
* The single normalization owner (RFC-029): every consumer that needs to know
|
|
213
|
+
* "what path does the router treat this request as" — the router's own match,
|
|
214
|
+
* a mounted-router prefix test, a CSRF exclude-path match, an adapter's
|
|
215
|
+
* published `ctx.path` — calls {@link canonicalizePath} instead of hand-rolling
|
|
216
|
+
* its own fold/collapse/strip, so there is exactly one definition of "the same
|
|
217
|
+
* path" across the framework (SEC-02, SEC-09, SEC-15).
|
|
218
|
+
*
|
|
219
|
+
* @see docs/RFC/request-data/029-canonical-request-path.md
|
|
220
|
+
* @packageDocumentation
|
|
221
|
+
*/
|
|
222
|
+
/** Result of {@link canonicalizePath}. */
|
|
223
|
+
interface CanonicalPathResult {
|
|
224
|
+
/**
|
|
225
|
+
* `true` when the path contains a `.`/`..` path segment. A dot segment is
|
|
226
|
+
* rejected outright (400), never resolved — resolving it locally would
|
|
227
|
+
* diverge from how a front-end proxy already resolved (or didn't resolve)
|
|
228
|
+
* the same segment before forwarding, reopening the desync this RFC closes.
|
|
229
|
+
*/
|
|
230
|
+
readonly rejected: boolean;
|
|
231
|
+
/**
|
|
232
|
+
* The canonical path: query-stripped, dot-segment-free, case-folded (unless
|
|
233
|
+
* `caseSensitive`), slash-collapsed, trailing-slash-stripped per `strict`.
|
|
234
|
+
* Meaningless when {@link rejected} is `true`.
|
|
235
|
+
*/
|
|
236
|
+
readonly path: string;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* True when `path` (already query-stripped) contains a `.` or `..` segment —
|
|
240
|
+
* a component that is exactly `.` or `..` between slash boundaries (or at the
|
|
241
|
+
* start/end of the string). A single linear scan, no backtracking regex
|
|
242
|
+
* (task 3.5): each character is visited at most once, so a pathological input
|
|
243
|
+
* cannot degrade to worse than O(n).
|
|
244
|
+
*
|
|
245
|
+
* A dot as filename content (`archive.tar.gz`, `..hidden.txt`) is NOT a dot
|
|
246
|
+
* segment — only a component whose entire text between slashes is `.` or `..`
|
|
247
|
+
* counts.
|
|
248
|
+
*/
|
|
249
|
+
declare function hasDotSegment(path: string): boolean;
|
|
250
|
+
/**
|
|
251
|
+
* Canonicalize a raw request target into the one path value every consumer
|
|
252
|
+
* (router match, mount-prefix test, CSRF exclude match, published `ctx.path`)
|
|
253
|
+
* treats as "this request's path" (RFC-029).
|
|
254
|
+
*
|
|
255
|
+
* Order: strip the query string, reject a dot segment before any further
|
|
256
|
+
* normalization (a dot segment is a request-shape violation, not something to
|
|
257
|
+
* fold case on first), then fold case (unless `caseSensitive`) and collapse
|
|
258
|
+
* structure exactly as {@link import('./matching').normalizePathForMatch} does
|
|
259
|
+
* — this function IS that normalization, extended with dot-segment rejection
|
|
260
|
+
* and made a public, adapter-facing entry point.
|
|
261
|
+
*
|
|
262
|
+
* The most recent result is memoized; see the note above the memo fields for
|
|
263
|
+
* why that is sound. Treat the returned object as immutable.
|
|
264
|
+
*
|
|
265
|
+
* @param rawTarget - The raw request target, may include a query string.
|
|
266
|
+
* @param caseSensitive - Router case-sensitivity option.
|
|
267
|
+
* @param strict - Router strict-trailing-slash option.
|
|
268
|
+
*/
|
|
269
|
+
declare function canonicalizePath(rawTarget: string, caseSensitive: boolean, strict: boolean): CanonicalPathResult;
|
|
270
|
+
|
|
187
271
|
/**
|
|
188
272
|
* @nextrush/router - Segment Trie Node
|
|
189
273
|
*
|
|
@@ -233,6 +317,14 @@ interface HandlerEntry {
|
|
|
233
317
|
middleware: Middleware[];
|
|
234
318
|
/** Pre-compiled executor for fast dispatch (no closure per request) */
|
|
235
319
|
executor?: (ctx: Context) => Promise<void>;
|
|
320
|
+
/**
|
|
321
|
+
* `true` only for a `HEAD` entry derived from a `GET` registration
|
|
322
|
+
* (RFC 9110 §9.3.2). A derived entry is replaced by an explicit `HEAD`
|
|
323
|
+
* registration instead of reporting a route conflict, is absent from
|
|
324
|
+
* `getRoutes()`, and is skipped when copying routes into a parent router —
|
|
325
|
+
* which re-derives it from the `GET` it copies.
|
|
326
|
+
*/
|
|
327
|
+
autoHead: boolean;
|
|
236
328
|
}
|
|
237
329
|
/**
|
|
238
330
|
* Create a new segment trie node
|
|
@@ -255,4 +347,4 @@ interface ParsedSegment {
|
|
|
255
347
|
paramName?: string;
|
|
256
348
|
}
|
|
257
349
|
|
|
258
|
-
export { type HandlerEntry, NodeType, type ParsedSegment, type RouteGroup, Router, type TrieNode, createNode, createRouter, endpoint, parseSegments };
|
|
350
|
+
export { type CanonicalPathResult, type HandlerEntry, NodeType, type ParsedSegment, type RouteGroup, Router, type TrieNode, canonicalizePath, createNode, createRouter, endpoint, hasDotSegment, parseSegments };
|
package/dist/index.js
CHANGED
|
@@ -271,7 +271,130 @@ function runRouteGroup(host, prefix, middlewareOrCallback, callback, inheritedMi
|
|
|
271
271
|
__name(runRouteGroup, "runRouteGroup");
|
|
272
272
|
|
|
273
273
|
// src/constants.ts
|
|
274
|
-
var
|
|
274
|
+
var NULL_PROTO = /* @__PURE__ */ Object.create(null);
|
|
275
|
+
var EMPTY_PARAMS = Object.freeze(Object.create(NULL_PROTO));
|
|
276
|
+
|
|
277
|
+
// src/walk-pool.ts
|
|
278
|
+
function createWalkPool(maxDepth) {
|
|
279
|
+
const frames = [];
|
|
280
|
+
for (let i = 0; i < maxDepth + 1; i++) {
|
|
281
|
+
frames.push({
|
|
282
|
+
node: void 0,
|
|
283
|
+
pos: 0,
|
|
284
|
+
stage: 0,
|
|
285
|
+
seg: "",
|
|
286
|
+
next: 0,
|
|
287
|
+
bound: false
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
frames,
|
|
292
|
+
bindNames: [],
|
|
293
|
+
bindValues: []
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
__name(createWalkPool, "createWalkPool");
|
|
297
|
+
function matchNodeIndexedPooled(root, path, startPos, bindNames, bindValues, method, decode, pool, originalPath) {
|
|
298
|
+
const { frames } = pool;
|
|
299
|
+
let depth = 0;
|
|
300
|
+
const first = frames[0];
|
|
301
|
+
if (first === void 0) return null;
|
|
302
|
+
first.node = root;
|
|
303
|
+
first.pos = startPos;
|
|
304
|
+
first.stage = 0;
|
|
305
|
+
first.seg = "";
|
|
306
|
+
first.next = 0;
|
|
307
|
+
first.bound = false;
|
|
308
|
+
while (depth >= 0) {
|
|
309
|
+
const frame = frames[depth];
|
|
310
|
+
if (frame === void 0) break;
|
|
311
|
+
if (frame.stage === 0) {
|
|
312
|
+
if (frame.pos >= path.length) {
|
|
313
|
+
const handler = frame.node.handlers.get(method);
|
|
314
|
+
if (handler) return handler;
|
|
315
|
+
depth--;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const slashPos = path.indexOf("/", frame.pos);
|
|
319
|
+
if (slashPos === -1) {
|
|
320
|
+
frame.seg = path.slice(frame.pos);
|
|
321
|
+
frame.next = path.length;
|
|
322
|
+
} else {
|
|
323
|
+
frame.seg = path.slice(frame.pos, slashPos);
|
|
324
|
+
frame.next = slashPos + 1;
|
|
325
|
+
}
|
|
326
|
+
if (frame.seg === "") {
|
|
327
|
+
const handler = frame.node.handlers.get(method);
|
|
328
|
+
if (handler) return handler;
|
|
329
|
+
depth--;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
frame.stage = 1;
|
|
333
|
+
const staticChild = frame.node.children.get(frame.seg);
|
|
334
|
+
if (staticChild) {
|
|
335
|
+
depth++;
|
|
336
|
+
const next = frames[depth];
|
|
337
|
+
if (next === void 0) {
|
|
338
|
+
depth--;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
next.node = staticChild;
|
|
342
|
+
next.pos = frame.next;
|
|
343
|
+
next.stage = 0;
|
|
344
|
+
next.seg = "";
|
|
345
|
+
next.next = 0;
|
|
346
|
+
next.bound = false;
|
|
347
|
+
}
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (frame.stage === 1) {
|
|
351
|
+
frame.stage = 2;
|
|
352
|
+
const paramChild = frame.node.paramChild;
|
|
353
|
+
if (paramChild) {
|
|
354
|
+
const paramName = paramChild.paramName;
|
|
355
|
+
if (paramName === void 0) return null;
|
|
356
|
+
const value = originalPath !== void 0 ? decodeParam(segmentAt(originalPath, frame.pos), decode) : decodeParam(frame.seg, decode);
|
|
357
|
+
bindNames.push(paramName);
|
|
358
|
+
bindValues.push(value);
|
|
359
|
+
frame.bound = true;
|
|
360
|
+
depth++;
|
|
361
|
+
const next = frames[depth];
|
|
362
|
+
if (next === void 0) {
|
|
363
|
+
bindNames.pop();
|
|
364
|
+
bindValues.pop();
|
|
365
|
+
frame.bound = false;
|
|
366
|
+
depth--;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
next.node = paramChild;
|
|
370
|
+
next.pos = frame.next;
|
|
371
|
+
next.stage = 0;
|
|
372
|
+
next.seg = "";
|
|
373
|
+
next.next = 0;
|
|
374
|
+
next.bound = false;
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (frame.bound) {
|
|
379
|
+
bindNames.pop();
|
|
380
|
+
bindValues.pop();
|
|
381
|
+
frame.bound = false;
|
|
382
|
+
}
|
|
383
|
+
const wildcardChild = frame.node.wildcardChild;
|
|
384
|
+
if (wildcardChild) {
|
|
385
|
+
const src = originalPath ?? path;
|
|
386
|
+
bindNames.push("*");
|
|
387
|
+
bindValues.push(decodeParam(src.slice(frame.pos), decode));
|
|
388
|
+
const handler = wildcardChild.handlers.get(method);
|
|
389
|
+
if (handler) return handler;
|
|
390
|
+
bindNames.pop();
|
|
391
|
+
bindValues.pop();
|
|
392
|
+
}
|
|
393
|
+
depth--;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
__name(matchNodeIndexedPooled, "matchNodeIndexedPooled");
|
|
275
398
|
|
|
276
399
|
// src/matching.ts
|
|
277
400
|
function decodeParam(value, decode) {
|
|
@@ -312,7 +435,10 @@ function normalizePathForMatch(path, caseSensitive, strict) {
|
|
|
312
435
|
return collapseAndStrip(folded, strict);
|
|
313
436
|
}
|
|
314
437
|
__name(normalizePathForMatch, "normalizePathForMatch");
|
|
315
|
-
function matchNodeIndexed(root, path, startPos, bindNames, bindValues, method, decode, originalPath) {
|
|
438
|
+
function matchNodeIndexed(root, path, startPos, bindNames, bindValues, method, decode, originalPath, pool) {
|
|
439
|
+
if (pool) {
|
|
440
|
+
return matchNodeIndexedPooled(root, path, startPos, bindNames, bindValues, method, decode, pool, originalPath);
|
|
441
|
+
}
|
|
316
442
|
const stack = [
|
|
317
443
|
{
|
|
318
444
|
node: root,
|
|
@@ -404,13 +530,22 @@ function matchNodeIndexed(root, path, startPos, bindNames, bindValues, method, d
|
|
|
404
530
|
__name(matchNodeIndexed, "matchNodeIndexed");
|
|
405
531
|
|
|
406
532
|
// src/match-route.ts
|
|
407
|
-
function matchRoute(method, rawPath, root, staticRoutes, hasParamRoutes, caseSensitive, strict, decode, routerMiddleware) {
|
|
533
|
+
function matchRoute(method, rawPath, root, staticRoutes, hasParamRoutes, caseSensitive, strict, decode, routerMiddleware, preNormalized = false, walkPool) {
|
|
408
534
|
let path = rawPath;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
535
|
+
if (!preNormalized) {
|
|
536
|
+
const queryIdx = path.indexOf("?");
|
|
537
|
+
if (queryIdx !== -1) path = path.slice(0, queryIdx);
|
|
538
|
+
}
|
|
539
|
+
let normalized;
|
|
540
|
+
let caseStable;
|
|
541
|
+
if (preNormalized) {
|
|
542
|
+
normalized = path;
|
|
543
|
+
caseStable = true;
|
|
544
|
+
} else {
|
|
545
|
+
caseStable = caseSensitive || isProvablyLowerAscii(path);
|
|
546
|
+
const folded = caseStable ? path : path.toLowerCase();
|
|
547
|
+
normalized = collapseAndStrip(folded, strict);
|
|
548
|
+
}
|
|
414
549
|
const methodMap = staticRoutes.get(method);
|
|
415
550
|
if (methodMap) {
|
|
416
551
|
const staticKey = normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
@@ -426,16 +561,20 @@ function matchRoute(method, rawPath, root, staticRoutes, hasParamRoutes, caseSen
|
|
|
426
561
|
}
|
|
427
562
|
if (!hasParamRoutes) return null;
|
|
428
563
|
const originalPath = caseStable ? void 0 : collapseAndStrip(path, strict);
|
|
429
|
-
const bindNames = [];
|
|
430
|
-
const bindValues = [];
|
|
431
|
-
|
|
564
|
+
const bindNames = walkPool ? walkPool.bindNames : [];
|
|
565
|
+
const bindValues = walkPool ? walkPool.bindValues : [];
|
|
566
|
+
if (walkPool) {
|
|
567
|
+
bindNames.length = 0;
|
|
568
|
+
bindValues.length = 0;
|
|
569
|
+
}
|
|
570
|
+
const entry = matchNodeIndexed(root, normalized, 1, bindNames, bindValues, method, decode, originalPath, walkPool);
|
|
432
571
|
if (!entry) return null;
|
|
433
572
|
const count = bindNames.length;
|
|
434
573
|
let params;
|
|
435
574
|
if (count === 0) {
|
|
436
575
|
params = EMPTY_PARAMS;
|
|
437
576
|
} else {
|
|
438
|
-
params =
|
|
577
|
+
params = Object.create(NULL_PROTO);
|
|
439
578
|
for (let i = 0; i < count; i++) {
|
|
440
579
|
const name = bindNames[i];
|
|
441
580
|
const value = bindValues[i];
|
|
@@ -450,14 +589,15 @@ function matchRoute(method, rawPath, root, staticRoutes, hasParamRoutes, caseSen
|
|
|
450
589
|
};
|
|
451
590
|
}
|
|
452
591
|
__name(matchRoute, "matchRoute");
|
|
453
|
-
function resolveMatch(state, hasParamRoutes, method, path) {
|
|
454
|
-
return matchRoute(method, path, state.root, state.staticRoutes, hasParamRoutes, state.caseSensitive, state.strict, state.decode, state.routerMiddleware);
|
|
592
|
+
function resolveMatch(state, hasParamRoutes, method, path, preNormalized = false) {
|
|
593
|
+
return matchRoute(method, path, state.root, state.staticRoutes, hasParamRoutes, state.caseSensitive, state.strict, state.decode, state.routerMiddleware, preNormalized, state.walkPool);
|
|
455
594
|
}
|
|
456
595
|
__name(resolveMatch, "resolveMatch");
|
|
457
596
|
|
|
458
597
|
// src/composition.ts
|
|
459
598
|
function copyRoutes(node, prefix, segments, subRouterMiddleware, addRoute2) {
|
|
460
599
|
for (const [method, entry] of node.handlers) {
|
|
600
|
+
if (entry.autoHead) continue;
|
|
461
601
|
const path = prefix + "/" + segments.join("/");
|
|
462
602
|
const combined = subRouterMiddleware.length > 0 ? [
|
|
463
603
|
...subRouterMiddleware,
|
|
@@ -575,8 +715,20 @@ function normalizeRegistrationPath(path, prefix, strict) {
|
|
|
575
715
|
return normalized.startsWith("/") ? normalized : "/" + normalized;
|
|
576
716
|
}
|
|
577
717
|
__name(normalizeRegistrationPath, "normalizeRegistrationPath");
|
|
718
|
+
function setStaticEntry(staticRoutes, method, key, entry) {
|
|
719
|
+
let methodMap = staticRoutes.get(method);
|
|
720
|
+
if (!methodMap) {
|
|
721
|
+
methodMap = /* @__PURE__ */ new Map();
|
|
722
|
+
staticRoutes.set(method, methodMap);
|
|
723
|
+
}
|
|
724
|
+
methodMap.set(key, entry);
|
|
725
|
+
}
|
|
726
|
+
__name(setStaticEntry, "setStaticEntry");
|
|
578
727
|
function addRoute(method, normalized, entries, middleware, state, recordIntrospection = true) {
|
|
579
728
|
const segments = parseSegments(normalized, state.caseSensitive);
|
|
729
|
+
if (segments.length > state.maxDepth) {
|
|
730
|
+
state.maxDepth = segments.length;
|
|
731
|
+
}
|
|
580
732
|
let node = state.root;
|
|
581
733
|
for (const seg of segments) {
|
|
582
734
|
if (seg.type === NodeType.PARAM) {
|
|
@@ -623,21 +775,30 @@ function addRoute(method, normalized, entries, middleware, state, recordIntrospe
|
|
|
623
775
|
const handlerEntry = {
|
|
624
776
|
handler: finalHandler,
|
|
625
777
|
middleware: combinedMiddleware,
|
|
626
|
-
executor
|
|
778
|
+
executor,
|
|
779
|
+
autoHead: false
|
|
627
780
|
};
|
|
628
|
-
|
|
781
|
+
const existing = node.handlers.get(method);
|
|
782
|
+
if (existing && !(method === "HEAD" && existing.autoHead)) {
|
|
629
783
|
throw new Error(`Route conflict: ${method} ${normalized} is already registered. Remove the duplicate or use a different path.`);
|
|
630
784
|
}
|
|
631
785
|
node.handlers.set(method, handlerEntry);
|
|
632
786
|
const hasParams = segments.some((s) => s.type === NodeType.PARAM || s.type === NodeType.WILDCARD);
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
787
|
+
const staticKey = hasParams ? void 0 : state.caseSensitive ? normalized : normalized.toLowerCase();
|
|
788
|
+
if (staticKey !== void 0) {
|
|
789
|
+
setStaticEntry(state.staticRoutes, method, staticKey, handlerEntry);
|
|
790
|
+
}
|
|
791
|
+
if (method === "GET" && !node.handlers.has("HEAD")) {
|
|
792
|
+
const derived = {
|
|
793
|
+
handler: finalHandler,
|
|
794
|
+
middleware: combinedMiddleware,
|
|
795
|
+
executor,
|
|
796
|
+
autoHead: true
|
|
797
|
+
};
|
|
798
|
+
node.handlers.set("HEAD", derived);
|
|
799
|
+
if (staticKey !== void 0) {
|
|
800
|
+
setStaticEntry(state.staticRoutes, "HEAD", staticKey, derived);
|
|
639
801
|
}
|
|
640
|
-
methodMap.set(normalizedKey, handlerEntry);
|
|
641
802
|
}
|
|
642
803
|
if (recordIntrospection) {
|
|
643
804
|
state.routeDefinitions.push({
|
|
@@ -747,11 +908,67 @@ function findAllowedMethods(path, root, caseSensitive, strict) {
|
|
|
747
908
|
}
|
|
748
909
|
__name(findAllowedMethods, "findAllowedMethods");
|
|
749
910
|
|
|
911
|
+
// src/canonicalize.ts
|
|
912
|
+
var DOT = 46;
|
|
913
|
+
var SLASH = 47;
|
|
914
|
+
function hasDotSegment(path) {
|
|
915
|
+
const len = path.length;
|
|
916
|
+
let segStart = 0;
|
|
917
|
+
for (let i = 0; i <= len; i++) {
|
|
918
|
+
const atBoundary = i === len || path.charCodeAt(i) === SLASH;
|
|
919
|
+
if (!atBoundary) continue;
|
|
920
|
+
const segLen = i - segStart;
|
|
921
|
+
if (segLen === 1 && path.charCodeAt(segStart) === DOT) return true;
|
|
922
|
+
if (segLen === 2 && path.charCodeAt(segStart) === DOT && path.charCodeAt(segStart + 1) === DOT) {
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
segStart = i + 1;
|
|
926
|
+
}
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
__name(hasDotSegment, "hasDotSegment");
|
|
930
|
+
var memoTarget = void 0;
|
|
931
|
+
var memoCaseSensitive = false;
|
|
932
|
+
var memoStrict = false;
|
|
933
|
+
var memoResult = {
|
|
934
|
+
rejected: false,
|
|
935
|
+
path: "/"
|
|
936
|
+
};
|
|
937
|
+
function canonicalizePath(rawTarget, caseSensitive, strict) {
|
|
938
|
+
if (rawTarget === memoTarget && caseSensitive === memoCaseSensitive && strict === memoStrict) {
|
|
939
|
+
return memoResult;
|
|
940
|
+
}
|
|
941
|
+
const queryIdx = rawTarget.indexOf("?");
|
|
942
|
+
const path = queryIdx === -1 ? rawTarget : rawTarget.slice(0, queryIdx);
|
|
943
|
+
const result = hasDotSegment(path) ? {
|
|
944
|
+
rejected: true,
|
|
945
|
+
path
|
|
946
|
+
} : {
|
|
947
|
+
rejected: false,
|
|
948
|
+
path: collapseAndStrip(caseSensitive || isProvablyLowerAscii(path) ? path : path.toLowerCase(), strict)
|
|
949
|
+
};
|
|
950
|
+
memoTarget = rawTarget;
|
|
951
|
+
memoCaseSensitive = caseSensitive;
|
|
952
|
+
memoStrict = strict;
|
|
953
|
+
memoResult = result;
|
|
954
|
+
return result;
|
|
955
|
+
}
|
|
956
|
+
__name(canonicalizePath, "canonicalizePath");
|
|
957
|
+
|
|
750
958
|
// src/dispatch.ts
|
|
751
959
|
var RESOLVED = Promise.resolve();
|
|
752
|
-
function createRoutesMiddleware(match) {
|
|
960
|
+
function createRoutesMiddleware(match, caseSensitive, strict) {
|
|
753
961
|
return (ctx, next) => {
|
|
754
|
-
const
|
|
962
|
+
const originalPath = ctx.path;
|
|
963
|
+
const canonical = canonicalizePath(originalPath, caseSensitive, strict);
|
|
964
|
+
if (canonical.rejected) {
|
|
965
|
+
ctx.status = 400;
|
|
966
|
+
ctx.originalPath = originalPath;
|
|
967
|
+
return RESOLVED;
|
|
968
|
+
}
|
|
969
|
+
ctx.path = canonical.path;
|
|
970
|
+
ctx.originalPath = originalPath;
|
|
971
|
+
const routeMatch = match(ctx.method, canonical.path);
|
|
755
972
|
if (!routeMatch) {
|
|
756
973
|
ctx.status = 404;
|
|
757
974
|
return next ? next() : RESOLVED;
|
|
@@ -803,12 +1020,14 @@ function createRouterState(root, opts, staticRoutes, routeDefinitions, routerMid
|
|
|
803
1020
|
caseSensitive: opts.caseSensitive,
|
|
804
1021
|
strict: opts.strict,
|
|
805
1022
|
decode: opts.decode,
|
|
806
|
-
routerMiddleware
|
|
1023
|
+
routerMiddleware,
|
|
1024
|
+
maxDepth: 0
|
|
807
1025
|
};
|
|
808
1026
|
}
|
|
809
1027
|
__name(createRouterState, "createRouterState");
|
|
810
1028
|
|
|
811
1029
|
// src/router.ts
|
|
1030
|
+
var SLASH_CHAR_CODE = 47;
|
|
812
1031
|
var Router = class _Router {
|
|
813
1032
|
static {
|
|
814
1033
|
__name(this, "Router");
|
|
@@ -827,6 +1046,13 @@ var Router = class _Router {
|
|
|
827
1046
|
hasParamRoutes = false;
|
|
828
1047
|
/** Whether router-level middleware has already been sealed into executors (audit RT-7) */
|
|
829
1048
|
_sealed = false;
|
|
1049
|
+
/**
|
|
1050
|
+
* Single-entry memo for {@link matchesMountPrefix}'s canonicalization of the
|
|
1051
|
+
* mount prefix, which is fixed at registration time. Declared as two fields
|
|
1052
|
+
* rather than an object so a miss stores without allocating.
|
|
1053
|
+
*/
|
|
1054
|
+
_prefixMemoRaw = void 0;
|
|
1055
|
+
_prefixMemoCanonical = "";
|
|
830
1056
|
/** Memoized state the extracted registration/matching functions read (see {@link createRouterState}). */
|
|
831
1057
|
state;
|
|
832
1058
|
constructor(options = {}) {
|
|
@@ -844,9 +1070,13 @@ var Router = class _Router {
|
|
|
844
1070
|
throw new TypeError(`Route path must be a string, received ${rawPath === null ? "null" : typeof rawPath}.`);
|
|
845
1071
|
}
|
|
846
1072
|
const normalized = normalizeRegistrationPath(path, this.opts.prefix, this.opts.strict);
|
|
1073
|
+
const depthBefore = this.state.maxDepth;
|
|
847
1074
|
if (addRoute(method, normalized, entries, middleware, this.state, recordIntrospection)) {
|
|
848
1075
|
this.hasParamRoutes = true;
|
|
849
1076
|
}
|
|
1077
|
+
if (this.state.maxDepth > depthBefore) {
|
|
1078
|
+
this.state.walkPool = createWalkPool(this.state.maxDepth);
|
|
1079
|
+
}
|
|
850
1080
|
}
|
|
851
1081
|
get(path, ...entries) {
|
|
852
1082
|
this.addRoute("GET", path, entries);
|
|
@@ -939,6 +1169,39 @@ var Router = class _Router {
|
|
|
939
1169
|
return resolveMatch(this.state, this.hasParamRoutes, method, path);
|
|
940
1170
|
}
|
|
941
1171
|
/**
|
|
1172
|
+
* Test whether `path` falls under `prefix` using this router's OWN
|
|
1173
|
+
* canonicalization (case folding per `caseSensitive`, structural
|
|
1174
|
+
* normalization) — the mount-boundary counterpart to {@link match}, so a
|
|
1175
|
+
* router mounted via `Application.route()` is tested with the identical
|
|
1176
|
+
* rule it dispatches with (RFC-029, task 3.8). Implements the optional
|
|
1177
|
+
* `Routable.matchesMountPrefix` contract from `@nextrush/core`.
|
|
1178
|
+
*
|
|
1179
|
+
* @param path - The full request path being tested for this mount.
|
|
1180
|
+
* @param prefix - The normalized mount prefix (leading `/`, no trailing `/`).
|
|
1181
|
+
* @returns The path's remainder past the prefix (e.g. `/users` for a
|
|
1182
|
+
* `/ADMIN/Users` request mounted at `/admin`), or `undefined` when `path`
|
|
1183
|
+
* is not under `prefix` per this router's canonicalization.
|
|
1184
|
+
*/
|
|
1185
|
+
matchesMountPrefix(path, prefix) {
|
|
1186
|
+
const canonical = canonicalizePath(path, this.opts.caseSensitive, this.opts.strict);
|
|
1187
|
+
if (canonical.rejected) return void 0;
|
|
1188
|
+
let canonicalPrefix;
|
|
1189
|
+
if (this._prefixMemoRaw === prefix) {
|
|
1190
|
+
canonicalPrefix = this._prefixMemoCanonical;
|
|
1191
|
+
} else {
|
|
1192
|
+
canonicalPrefix = canonicalizePath(prefix, this.opts.caseSensitive, this.opts.strict).path;
|
|
1193
|
+
this._prefixMemoRaw = prefix;
|
|
1194
|
+
this._prefixMemoCanonical = canonicalPrefix;
|
|
1195
|
+
}
|
|
1196
|
+
const prefixLen = canonicalPrefix.length;
|
|
1197
|
+
if (!canonical.path.startsWith(canonicalPrefix)) return void 0;
|
|
1198
|
+
const hasCharAfterPrefix = prefixLen < canonical.path.length;
|
|
1199
|
+
if (hasCharAfterPrefix && canonical.path.charCodeAt(prefixLen) !== SLASH_CHAR_CODE) {
|
|
1200
|
+
return void 0;
|
|
1201
|
+
}
|
|
1202
|
+
return canonical.path.slice(prefixLen) || "/";
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
942
1205
|
* Return the router's dispatch middleware — mount this on the application.
|
|
943
1206
|
* @see {@link https://github.com/0xTanzim/nextRush/blob/main/packages/router/README.md#routerroutes | README: router.routes()}
|
|
944
1207
|
*/
|
|
@@ -947,7 +1210,7 @@ var Router = class _Router {
|
|
|
947
1210
|
this._sealed = true;
|
|
948
1211
|
sealRouterMiddleware(this.root, this.staticRoutes, this.routerMiddleware);
|
|
949
1212
|
}
|
|
950
|
-
return createRoutesMiddleware((method, path) => this.
|
|
1213
|
+
return createRoutesMiddleware((method, path) => resolveMatch(this.state, this.hasParamRoutes, method, path, true), this.opts.caseSensitive, this.opts.strict);
|
|
951
1214
|
}
|
|
952
1215
|
/**
|
|
953
1216
|
* Generate allowed-methods middleware. Responds to OPTIONS with an `Allow`
|
|
@@ -975,6 +1238,8 @@ var Router = class _Router {
|
|
|
975
1238
|
this.routerMiddleware.length = 0;
|
|
976
1239
|
this.hasParamRoutes = false;
|
|
977
1240
|
this.routeDefinitions.length = 0;
|
|
1241
|
+
this.state.maxDepth = 0;
|
|
1242
|
+
this.state.walkPool = void 0;
|
|
978
1243
|
this._sealed = false;
|
|
979
1244
|
}
|
|
980
1245
|
/** Register a route on behalf of a {@link RouteGroup} (group context). @internal */
|
|
@@ -996,9 +1261,11 @@ __name(createRouter, "createRouter");
|
|
|
996
1261
|
export {
|
|
997
1262
|
NodeType,
|
|
998
1263
|
Router,
|
|
1264
|
+
canonicalizePath,
|
|
999
1265
|
createNode,
|
|
1000
1266
|
createRouter,
|
|
1001
1267
|
endpoint,
|
|
1268
|
+
hasDotSegment,
|
|
1002
1269
|
parseSegments
|
|
1003
1270
|
};
|
|
1004
1271
|
//# sourceMappingURL=index.js.map
|