Architecture

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/profile is the only route under the static me branch, a request for /users/me/posts correctly 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 decoding

ROUTER_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 path twice throws Route conflict: … is already registered.
  • Conflicting param name at the same trie position: registering /a/:id and later /a/:userId throws, rather than silently losing one of the parameter names at request time.
  • Non-string path: router.get(null, handler) throws a TypeError rather 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 /path from /path/ — both currently match even when strict: true is set.

Next steps

Was this helpful?

On this page