Router Internals
How @nextrush/router's segment trie actually matches routes — node structure, priority order, and the audited edge cases.
This page explains how @nextrush/router works internally. For routing concepts and usage
patterns, see Routing. Every claim below is verified against
packages/router/src/segment-trie.ts (node structure, executor compilation) and the sibling
modules Router (router.ts) delegates to — registration.ts (route insertion, conflict
detection), match-route.ts/matching.ts (lookup, decoding), state.ts, group-router.ts,
composition.ts — plus the package's own
ROUTER_AUDIT.md,
a correctness audit backed by a 200-test suite. Router itself is a thin, chainable facade over
these focused modules, not a single file containing all matching logic.
Source & internals
It's a segment trie — the filename now says so too
The router's doc comments are explicit: "Routes are keyed by full path segments … this is a
segment-based trie, not a compressed radix tree." The implementation file was previously named
radix-tree.ts with an exported RadixNode type — a naming holdover from before the
terminology was corrected. Both have since been renamed: the file is segment-trie.ts and the
type is TrieNode, closing the gap between the doc comments and the source's own naming.
Node structure
Each node in the trie (packages/router/src/segment-trie.ts) holds:
// packages/router/src/segment-trie.ts
const enum NodeType {
STATIC = 0,
PARAM = 1,
WILDCARD = 2,
}
interface HandlerEntry {
handler: (ctx: unknown, next: () => Promise<void>) => unknown;
middleware?: unknown[];
}
interface TrieNode {
segment: string;
type: NodeType; // STATIC | PARAM | WILDCARD
children: Map<string, TrieNode>; // static children, keyed by segment string
paramName?: string;
handlers: Map<string, HandlerEntry>; // keyed by HttpMethod
wildcardChild?: TrieNode;
paramChild?: TrieNode;
}Static children are keyed by their full segment string ("users", "posts"), not by
individual characters — this is what makes it a segment trie rather than a character-level radix
tree. Each node has at most one paramChild and one wildcardChild, since a position in a path
can only bind to one parameter name or one wildcard across all routes registered at that
position.
Route registration walks segments, one node per path part
Router.addRoute() (in router.ts) validates the path is a string, normalizes it (prefix
handling, collapsing repeated slashes, trailing-slash stripping when not in strict mode), then
delegates trie insertion to addRoute() in registration.ts, which calls parseSegments() to
split the path into typed segments and walks the trie one segment at a time, creating nodes as
needed:
// packages/router/src/segment-trie.ts — parseSegments()
const enum NodeType {
STATIC = 0,
PARAM = 1,
WILDCARD = 2,
}
interface ParsedSegment {
segment: string;
type: NodeType;
paramName?: string;
}
function parseSegments(path: string, caseSensitive = true): ParsedSegment[] {
const parts = path.startsWith('/') ? path.slice(1).split('/') : path.split('/');
const segments: ParsedSegment[] = [];
for (const part of parts) {
if (part.startsWith(':')) {
segments.push({ segment: part, type: NodeType.PARAM, paramName: part.slice(1) });
} else if (part === '*') {
segments.push({ segment: '*', type: NodeType.WILDCARD });
break; // wildcard must be last
} else {
segments.push({ segment: caseSensitive ? part : part.toLowerCase(), type: NodeType.STATIC });
}
}
return segments;
}Exactly one dynamic syntax is supported: :name for a named parameter and * for a
catch-all wildcard that must be the final segment. There is no brace syntax ({id}), no regex
constraint (:id(\d+)), and no optional-segment syntax (:id?) — ROUTER_AUDIT.md §1
characterizes this explicitly: /n/:id(\d+) registers a param literally named id(\d+),
with no regex enforcement, and /users/{id} registers a literal static route matching only the
string /users/{id}.
Two lookup paths: a hash map fast path, and trie traversal
Router keeps a second structure alongside the trie purely for speed — a method-nested map, not
a single flat map keyed by a concatenated string:
// packages/router/src/router.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
interface HandlerEntry {
handler: (ctx: unknown, next: () => Promise<void>) => unknown;
}
type StaticRouteMap = Map<HttpMethod, Map<string, HandlerEntry>>;
class Router {
private readonly staticRoutes: StaticRouteMap = new Map();
}Any route with no :param or * segments is additionally stored here: the outer map selects an
inner map by HTTP method, and the inner map probes by the raw normalized path. This replaced an
earlier design keyed by a per-request `${method} ${path}` string — the method-nested shape
gives the same O(1) lookup for a purely static route without building that key string on
every request. Routes with dynamic segments only exist in the trie, which resolves in O(d)
where d is the number of path segments — not proportional to the total number of registered
routes.
Match priority: static beats param beats wildcard, with backtracking
matchNodeIndexed (in matching.ts, called from match-route.ts) tries children in this fixed
order at every level: static child (exact segment match) → param child → wildcard child,
backtracking to try the next option if a deeper match fails. ROUTER_AUDIT.md §3 verifies this
is deterministic regardless of registration order:
/users/me(static) wins over/users/:id(param) for the literal path/users/me./a/:id(param) wins over/a/*(wildcard).- If
/users/me/profileis the only route under the staticmebranch, a request for/users/me/postscorrectly backtracks off the static branch and falls through to/users/:id/posts.
Percent-decoding is on by default
Param and wildcard capture values are percent-decoded (decodeURIComponent) by default —
/u/hello%20world yields params.name === 'hello world', matching Express/Koa/Hono/find-my-way
behavior. Malformed encoding never throws; it falls back to the raw value. This is an explicit
RouterOptions, not a hidden default you can't see:
import { createRouter } from '@nextrush/router';
const router = createRouter({ decode: false }); // opt out of decodingROUTER_AUDIT.md §7 documents this as a fix applied during the audit — it used to diverge from
mainstream router behavior before decoding was added.
Query strings are stripped before matching
Router.match() slices the path at the first ? before walking the trie. ROUTER_AUDIT.md
§11.1 documents this as a fixed bug: before the fix, router.match('GET', '/users?page=5')
returned null because the raw query string leaked into the static-route hash key. In-framework
requests were never affected (the Node adapter passes a query-free ctx.path) — only the
standalone match() API used for testing/introspection was wrong.
Per-route middleware compiles to a dispatcher at registration time, not per request
compileExecutor() (in segment-trie.ts) builds the middleware-plus-handler dispatch function
once, when the route is registered, not on every incoming request:
// packages/router/src/segment-trie.ts
import type { Context } from '@nextrush/types';
const NOOP_NEXT = (): Promise<void> => Promise.resolve();
function compileExecutor(len: number, handler: (ctx: Context, next: () => Promise<void>) => unknown) {
// Fast path: no per-route middleware — skip the dispatcher entirely
if (len === 0) {
return async (ctx: Context) => {
if (ctx.setNext) ctx.setNext(NOOP_NEXT);
await handler(ctx, NOOP_NEXT);
};
}
// ...compiled dispatcher for the middleware+handler chain, when len > 0
}When middleware is present, the compiled dispatcher mirrors @nextrush/core's compose()
exactly (see Middleware Flow): ctx.next() and the
(ctx, next) argument both advance the same index-based chain, calling next() twice rejects
with an explicit error, and a synchronous throw inside a middleware is converted into a rejected
promise rather than crashing the dispatch loop. ROUTER_AUDIT.md §11.3 documents this as a fixed
bug — ctx.next() used to no-op in per-route middleware before ctx.setNext was wired into
compileExecutor.
Conflict detection at registration, not at request time
Two registration-time guards fail fast rather than silently misbehaving at runtime:
- Duplicate route: registering the same
METHOD pathtwice throwsRoute conflict: … is already registered. - Conflicting param name at the same trie position: registering
/a/:idand later/a/:userIdthrows, rather than silently losing one of the parameter names at request time. - Non-string path:
router.get(null, handler)throws aTypeErrorrather than being coerced into the literal route/null('' + null === 'null'was the original bug,ROUTER_AUDIT.md§11.2).
Introspection is a side structure, never read during dispatch
Every registered route is also recorded in routeDefinitions: RouteDefinition[], used by
getRoutes() for documentation/introspection tooling (e.g. OpenAPI generation). The router's own
comment is explicit about why this is a separate structure: "Introspection registry, kept
SEPARATE from the hot-path trie/staticRoutes" so request dispatch never reads metadata.
Introspection has zero cost on the request path.
What the audit calls out as a documented gap, not a bug
ROUTER_AUDIT.md's final verdict is production ready for the supported feature set (static,
:param, * wildcard, priority with backtracking, deep nesting, all HTTP methods, conflict
detection — backed by 200 tests). Two items are named as feature gaps rather than correctness
bugs, and would require an RFC to add:
- No regex constraints, brace syntax, or optional-segment syntax — unsupported syntax silently mis-parses instead of erroring at registration (a possible future fail-fast improvement).
- Strict-trailing-slash mode doesn't fully differentiate
/pathfrom/path/— both currently match even whenstrict: trueis set.
Next steps
Contracts
The Extension API contract and the Adapter contract — the two structural interfaces every long-lived service and every runtime adapter must satisfy.
Capability Composition
How middleware, registrars, Extensions, and adapters compose into one running NextRush application — and why the framework has four idioms instead of a single plugin interface.