Lightweight, high-performance JavaScript/TypeScript HTTP router. Zero runtime dependencies.
Important
Keep AGENTS.md updated with project status.
src/
index.ts # Public API re-exports
types.ts # TypeScript interfaces & param inference types
context.ts # createRouter() factory
object.ts # NullProtoObj (null-prototype object constructor)
_escape.ts # URLPattern backslash escape handling (placeholder approach)
_group-delimiters.ts# Non-capturing group ({...}) expansion helper
_group-names.ts # Capture-group name codec (param name <-> group name escaping)
_segment-wildcards.ts# Wildcard segment capture handling
_overlap.ts # Pattern-overlap shape model (tree entry -> RouteShape, shape intersection)
_subsume.ts # Shape subsumption/canonicalization (shapeSubsumes, mergeShapes, regex-key normalization)
route-node-keys.ts # routeNodeKeys() - radix-node identity keys for a pattern
regexp.ts # routeToRegExp() utility
regexp-to-route.ts # regExpToRoute() utility (inverse of routeToRegExp)
compiler.ts # JIT/AOT compiler (generates optimized match functions)
operations/
add.ts # addRoute() - insert routes into the radix tree
find.ts # findRoute() - single-match lookup
find-all.ts # findAllRoutes() - multi-match lookup
overlap.ts # routesOverlap() / compareRoutes() / findOverlappingRoutes() - pattern-vs-pattern relations
remove.ts # removeRoute() - remove routes from tree
_utils.ts # Shared utilities (escaping, path splitting, normalization)
test/
router.test.ts # Core router tests
find.test.ts # Route matching tests (interpreter vs compiled)
find-all.test.ts # Multi-match tests
overlap.test.ts # Pattern-overlap tests (routesOverlap / compareRoutes / findOverlappingRoutes)
route-node-keys.test.ts # routeNodeKeys() (fixtures + node-sharing property sweep + shadowing sweep)
group-names.test.ts # Capture-group name codec (round-trip + injectivity)
regexp.test.ts # RegExp conversion tests
regexp.pcre.test.ts # Cross-engine PCRE checks (runs routeToRegExp output through installed grep -P/rg -P/pcre2grep/perl/php)
_regexp-cases.ts # Shared route->regex fixtures (used by regexp.test.ts + regexp.pcre.test.ts)
types.test-d.ts # TypeScript type-level tests
bench/ # Performance benchmarks (mitata)
_utils.ts # Test helpers (createRouter, formatTree)
Two entry points: rou3 (main) and rou3/compiler.
// rou3
createRouter<T>(options?) -> RouterContext<T>
addRoute(ctx, method, path, data?) -> void
removeRoute(ctx, method, path) -> void
findRoute(ctx, method, path, opts?) -> MatchedRoute<T> | undefined
findAllRoutes(ctx, method, path, opts?) -> MatchedRoute<T>[]
routesOverlap(patternA, patternB) -> boolean
compareRoutes(patternA, patternB) -> "disjoint" | "equal" | "superset" | "subset" | "partial"
findOverlappingRoutes(ctx, method, pattern) -> MatchedRoute<T>[]
routeNodeKeys(pattern) -> string[]
routeToRegExp(route) -> RegExp
regExpToRoute(regexp) -> string
// rou3/compiler
compileRouter<T>(router, opts?) -> (method, path) => MatchedRoute<T> | undefined
compileRouterToString(router, functionName?, opts?) -> stringRadix tree with three node types: static (exact match), param (:id, *), wildcard (**).
interface Node<T> {
key: string;
static?: Record<string, Node<T>>;
param?: Node<T>;
wildcard?: Node<T>;
hasRegexParam?: boolean;
methods?: Record<string, MethodData<T>[]>;
}- Static child (exact segment match)
- Param child (single-segment dynamic)
- Wildcard (multi-segment catch-all)
Same-node siblings (multiple routes sharing one node's methods[method] array) resolve by one shared model in all three matchers (_selectMatcher in find.ts, pushSorted in find-all.ts, the compiled matcher): the highest specificity weight among fully-matching entries wins, ties go to the first-registered. Weight = one point per passing regex-constrained param + one for a required last param on a dynamic (param/wildcard) terminal. An entry whose regex fails is skipped β lookup falls through to less specific siblings, other node kinds, or the optional end-of-path fallback, never aborting (the old per-level greedy regex filter made findRoute miss routes that findAllRoutes found). Out-of-bounds segments[i] reads (undefined) must never coerce into a literal "undefined" static key β guard with index < segments.length before any node.static[...] lookup. Both pinned in find.test.ts "same-node sibling selection" and find-all.test.ts "out-of-bounds segment". _selectMatcher keeps a single-sibling/no-regex fast path β lookup perf is at parity with the pre-selection code; keep it when editing.
Results are ordered least β most specific, and the interpreter (findAllRoutes) and compiled matchAll must agree exactly (test/find-all.test.ts asserts toEqual). This is a public contract, documented in README ("Result ordering") and pinned by test/find-all.test.ts (matcher: ordering contract ties result order to compareRoutes subsumption order) β changing it is a breaking change. Two levels:
- Across node kinds: tree traversal order (wildcard β param β static β self) yields general β specific.
- Same-node siblings (multiple routes sharing one param/wildcard node, i.e. one
methods[method]array): ordered by specificity weight ascending, with insertion order preserved on ties (#187). Weight = one point per regex-constrained param + one for a required last param on a dynamic terminal (param/wildcard node; static terminals don't distinguish required from optional β mirrors the compiler'shasLastOptionalParam/currentIdx === -1gating).pushSorted()infind-all.tssorts each pushed array (stableArray#sort); the compiler reaches the same order by sorting matchers descending by weight, emittingr.push(...), and reversingronce at return (return r.reverse()β the final array is the reverse of emit order; per-matchunshiftwas O(n) each). In matchAll mode the matcher list is pre-.reverse()-d so equal-weight siblings keep insertion order despite the final reverse. Single-match mode must not pre-reverse: firstreturnwins there, so ties stay in insertion order to matchfindRoute(duplicate registrations resolve to the first-registered entry; an unconditional pre-reverse used to flip this). The weight models (pushSorted,_selectMatcherin find.ts, and the compiled emission) must order each node's siblings identically; the absolute values may differ by a per-node constant β when no sibling has an optional last param, the compiler skips the widened end-of-path check and its per-matcherl>cconditions entirely (hasOptionalLastParam()gate), shifting every sibling's condition count down by one uniformly. Insertion order and weight order coincided before #187, which masked the divergence.
Carve-out: optional syntax breaks pattern-level subsumption consistency (known, documented, not a bug to "fix"). findAllRoutes orders tree entries (post-expandModifiers, single-shape routes); compareRoutes compares patterns (the union of all their expansions). A pattern with :name? / :name* / {...}? registers several entries, and the expansion that makes the pattern the broader one often does not participate in the match at all β so no node-local weight function can observe it. compareRoutes is correct here and must not be changed (e.g. /api/*/:path* really does match /api β dropping :path* leaves a trailing bare *, zero-or-one β while /api/*/** does not, so compareRoutes("/api/*/**", "/api/*/:path*") === "subset" is true). A sweep over 61 patterns Γ 13 paths Γ both registration orders found 354 order-vs-containment violations and every one required the superset pattern to be multi-expansion; registering each expansion individually removes them all. :x+, **, **:rest, *, regex params and static routes never violate on their own, interpreter vs compiled matchAll never diverged (that contract is intact), and findRoute is unaffected. Three classes, pinned as known-divergent in test/find-all.test.ts (matcher: ordering contract: optional-syntax carve-out) and documented in README ("Result ordering" carve-out):
- A1 β an expansion is byte-identical to the other route, so the stable sort is a no-op and registration order decides (
/adminvs/admin/:page?on/admin). - A2 β same node, the optional pattern's matched entry is strictly narrower, so it sorts last (
/api/*/:path*β/api/*/**on/api/v1/x) β wrong in both registration orders. - A3 β the matched entries live in different nodes and traversal order decides (
/p/:id/:id*β/p/:id/*on/p/a) β wrong in both registration orders.
A1/A2 (same-node) are fixable by recording a per-pattern breadth rank at addRoute time and feeding it into the sibling weight; A3 is not β cross-node order is fixed by traversal, so a partial fix would not close the gap. A full fix means globally re-sorting the result set against a partial order (compareRoutes) after collection: a behavior + perf change, i.e. a major-version design change. Do not touch the weight functions expecting them to help.
compileRouter() generates an optimized function via new Function():
- Static routes dispatch is hybrid (
STATIC_CHAIN_MAX = 8): up to 8 static paths emit anelse ifchain ofp === "..."compares β faster there because repeated/interned path strings compare near pointer-speed and dynamic requests miss the chain via cheap length checks (an unconditional map cost the bench set ~1.3Γ). Above 8 they emit a single null-prototype map lookup ({path: {method: data}}, matchAll:{path: {method: data[]}}) held in a$Ndata slot β O(1) vs the chain's O(N) (map is ~2-3Γ faster at 20-50 routes with fresh per-request strings, ~6Γ at 50 interned). Method miss falls through to the tree lookup, mirroring the interpreter'sctx.staticfast path. Both map levels are null-proto so__proto__/constructorpath or method strings can't match (pinned infind.test.ts"prototype-key lookups" and "many static routes", which covers the >8 map codegen incl. duplicates and matchAll). Both modes also accept the trailing-slash form ("/a//"strips once to"/a/", whose segments equal the static route's β the interpreter matches it through the tree, but static-only routes are not emitted in the compiled tree code): the chain appends a secondaryp === "β¦/"chain behind acharCodeAtguard, the map retriesp.slice(0,-1)only on a miss β the hot paths pay nothing (an unconditional pre-normalized_pcost the static bench ~15%). Pinned infind.test.ts"doubled trailing slash". - Unrolls segment checks into
split("/")-based array access - Tree static siblings dispatch is hybrid too (
SEGMENT_CHAIN_MAX = 32): up to 32 static children of one node emit anelse if(s[i]==="...")chain; above that a hoisted null-proto{segment: index}map + dense integerswitchβ O(1) vs the chain's O(N) scan (measured crossover ~40 siblings; switch ~1.4Γ faster at 64, ~2Γ at 200 where the chain degrades to interpreter speed). The switch must keep itsl>ibound check even after anelse: an out-of-boundss[i]isundefined, which the map lookup would coerce to the key"undefined"(the chain's===was immune). AOT map emission uses a computed key for a literal__proto__segment (a plain"__proto__":property in an object literal would set the prototype). Pinned infind.test.ts"wide static fan-out". __proto__object-literal keys go throughpropKey(): it emits["__proto__"]for that one name and a plainJSON.stringifykey for every other (so ordinary routes' codegen is byte-identical). Used by the AOT segment map and everyparams:{β¦}emission site (plain/optional/repeat param names,**:nametails, and bothscanRegExpGroupsbranches β whole-segment.test()and.groups.<name>reads); a route param named__proto__used to hit the prototype setter and silently vanish from the compiled result while the interpreter (null-proto params object) returned it. The..._normalizeGroups()spread fallback needs no guard (spread creates own data properties). Pinned infind.test.ts"proto param names".- End-of-path widening (
l===c||l===c-1+ per-matcherl>cguards, the/xmatches/x/*rule) is emitted only when some matcher at the param node actually has an optional last param (hasOptionalLastParam()); all-required nodes (plain:id, the common case) get a singlel===c. Gating onnode.key === "*"alone used to emit a dead widened branch for every required-only param node and for static nodes of escaped\*segments. - Method keys are
JSON.stringify-ed intom===compares β methods are user input; a raw quote was a SyntaxError in JIT mode and code injection in AOT output (pinned infind.test.ts"unusual method names"). - JIT data slots are function parameters (
$N, ~10% faster per access than array reads) up toDATA_ARGS_MAX = 32_000; above thatcompileRouterrecompiles with a single array argument ($[N]) to stay under the engine's 65535 formal-parameter/spread-call limits (pinned infind.test.ts"data slots above the argument limit"). - Wildcard tails with an all-static prefix compile to
p.slice(K)at a constant byte offset (O(1) substring view, ~4.5Γ faster per match) instead ofs.slice(i).join('/')(compileNodethreadsstaticPrefixLen; a param segment resets it to -1 and falls back to slice/join). Requirespto stay in sync with the popped trailing empty segment: when anyp.slice(K)is emitted (ctx.pathSliced), the split prologue pops and stripsp({s.pop();p=p.slice(0,-1)}). Doubled-slash edge cases are pinned infind.test.ts"wildcard tail extraction". - matchAll accumulates with
r.push(...)+ a singlereturn r.reverse()(final array = reverse of emit order β identical to the old per-matchunshift, without the O(n) shifts). serializeData()dedupes$Ndata slots via aMap(ctx.dataMap), notArray#indexOf(compile-time O(NΒ²) on large routers).- Regex params are resolved at compile time by
scanRegExpGroups()(escape- and character-class-aware source scan): group names β including user named groups embedded in constraint bodies β are emitted as directparams:{name:_mN.groups.name}reads with the__rou3_unnamed_Nβ"N"renaming done at compile time, and the regex runs once ((_mN=re.exec(seg))!==nullin the condition). A whole-segment group (^(?<name>...)$, the common:id(\d+)shape) skipsexec()entirely:.test()+params:{name:seg}(the anchored group always equals the segment). Unparseable group names (e.g. unicode) fall back to the legacy exec +{...spread}+ runtime_normalizeGroupspath (also single-exec now, via a_mNtemp). This is ~3.6Γ faster end-to-end on regex routes than the old always-spread emission (the.groupsspread + runtime renaming dominated, not the double exec)._mNtemps are declared next tolet s=...viactx.regexTemps. Regexes live in$Ndata slots (serializeRegExp(), deduped by source inctx.regexpMapβ separate fromdataMapso a regex can't collide with an equal-looking string data value; JIT passes the RegExp object, AOT emits its literal): an inline literal would allocate a fresh RegExp per evaluation (ES2015+ semantics), measured ~2-6% per match. - Compare interpreter vs compiled output in tests
src/_group-delimiters.tsexpands non-capturing group delimiters before route insertion/removal/regexp generation.- Supported forms:
{...}and{...}?(plus single-segment{...}+/{...}*converted to(?:...)+/*regex fragments). - Limitation:
{...}+/{...}*are rejected when group body contains/(cross-segment repetition unsupported in radix tree). - Regexp inlining (
routeToRegExponly): the radix tree still expands{...}?into two full routes (add/remove need that), butrouteToRegExp()compiles a trailing single optional group inline as(?:...)?viainlineOptionalGroup()inregexp.ts, instead of OR-joining two full-route regexes. This avoids re-emitting params before the group in both branches β the duplicate named groups ((?<id>β¦)|(?<id>β¦)) that PCRE2-family engines reject. It handles the mid-segment case (/book{s}?β^\/book(?:s)?\/?$,/blog/:id(\d+){-:title}?ββ¦(?<id>\d+)(?:-(?<title>β¦))?β¦) and the cross-segment case (/foo{/bar}?β^\/foo(?:\/bar)?\/?$); it falls back to alternation (old behavior) for multi-group routes, mid-route optionals (non-emptysuf), unexpected segment shapes, or a mid-segment optional following a greedy open-ended capture (/media/*{.webp}?β inlining would let[^/]*swallow.webpand change the captured value, so it stays as alternation; these are the only fixtures that still emit duplicate names). The group scanner (scanFirstGroup()) is shared: it lives in_group-delimiters.ts(already in the core bundle via add/remove) and is used by bothexpandGroupDelimiters()andinlineOptionalGroup(), so the two paths classify groups identically. It returns a[pre, body, suf, mod]tuple (not an object) to avoid enlarging the size-budgeted core bundle.
Two separate escape systems handle \x in route patterns:
-
Router escape encoding (
_utils.ts):encodeEscapes()converts\:,\(,\),\{,\}to\uFFFD+ single-char placeholders (A-E) before segment splitting, preventing these chars from being interpreted as route syntax.decodeEscaped()converts them back for static node keys. Other\x(like\*) are left for existingsegment === "\\*"handling. -
Regex escape handling (
_escape.ts):replaceEscapesOutsideGroups()replaces\xoutside(...)groups with\uFFFEplaceholder, preserving regex syntax inside groups (e.g.,\din(\d+)).resolveEscapePlaceholders()then converts placeholders to regex-safe literals. Used byrouteToRegExp()andgetParamRegexp()inadd.ts.
Key invariant: \uFFFD (U+FFFD) is used for router-level escaping, \uFFFE (U+FFFE) for regex-level escaping β they must not collide.
Perf: addRoute's pre-processing helpers each bail out early when the input lacks their trigger char β encodeEscapes (\), decodeEscaped (\uFFFD), expandGroupDelimiters ({), expandModifiers (trailing ?/+/* charCode check). Plain routes skip all the regex/scanner machinery (~2x faster add); keep the guards when editing these helpers.
src/regexp-to-route.tsis the inverse ofrouteToRegExp(): it parses an anchored, PCRE-compatibleRegExp(or itssourcestring) back into a rou3 route pattern. Tree-shakeable β zero impact on the core bundle (only pulled in when imported).- Targets the dialect
routeToRegExp()emits. The parser strips^/$and the trailing\/?, then walks the body recognizing: static separators (\/) + segments, catch-alls (\/?(?<_>.*)β**,\/?(?<name>.+)β**:name), and optional-group units ((?:\/β¦)?). Segment-internal parsing maps(?<name>[^/]+)β:name,(?<_N>[^/]*)/ bare([^/]*)β*,(?<_N>pat)/ bare(pat)β(pat),(?<name>pat)β:name(pat), and re-escapes literal route-syntax chars (: ( ) { } * \). Bare (unnamed) capturing groups map like their(?<_N>β¦)counterparts. - Optional units classify by inner shape: a whole-segment param β
:name?/:name*/:name(pat)?|*(repeat formpat(?:\/pat)*β+/*); a literal/mixed inner β{β¦}?merged onto the previous segment ((?:s)?β{s}?,(?:\/bar)?β{/bar}?). Whole-segment.+β:name+. - Reject-by-default (no silent corruption):
reverseSegment()is a whitelist parser β only named/bare groups, escaped-punctuation literals, and plain literal chars are accepted. Anything outside the dialect throws arou3:error instead of being literalized into a wrong route: structural look-arounds ((?=(?!(?<=(?<!) and other(?β¦)group constructs, backreferences and metaclass escapes outside a constraint (\k<x>,\1,\d,\w,\b), and bare (unescaped) regex operators at segment level (. ^ $ * + ? | [ ] { }). Constraint bodies ((β¦)) stay opaque β arbitrary regex inside them (quantifiers, non-greedy, look-arounds, nested groups) is preserved verbatim.constraint()still rejects bodies containing/(unrepresentable after path splitting). Match-affecting regexp flags (i/m/s) throw (routes carry none, so honoring them silently is impossible);g/y/u/v/ddon't affect a fully-anchored match and are ignored. - Round-trip:
routeToRegExp(regExpToRoute(regexp)).source === regexp.sourceholds for every non-fallback fixture (test/regexp-to-route.test.tsasserts this over_regexp-cases.ts). The alternation fallback forms (PCRE2_DUPLICATE_NAME_ROUTES, e.g./media/*{.webp}?β^(?:β¦|β¦)$) are not reversible and throw.
- The feature is tree-shakeable (the core bundle is unaffected; see
test/bench/bundle.test.ts):src/_overlap.tsholds the shape model,src/_subsume.tsthe subsumption/canonicalization layer,src/operations/overlap.tsthe public API + tree traversal. A route is modeled as aRouteShape: an array of fixed single-segment matchers (stringliteral |RegExpconstraint |undefined= any) plus a variable-length tail[tailMin, tailMax](trailing*->[0,1],**->[0,β],**:name->[1,β], none ->[0,0]). The tail matches any values, so it constrains only segment count, never contents. - Shapes are built from radix-tree entries (
shapeOf): kind-tagged edges (static key | param | wildcard) plus the entry's ownparamsMap. Carrying the node kind keeps escaped-literal static keys (\*-> static"*") distinguishable from dynamic segments. Per-entry shapes are cached in aWeakMap. - Query patterns are inserted into a throwaway router via the real
addRoute(routeToShapes), so queries and registered routes are classified by the exact same pipeline (expandGroupDelimiters->encodeEscapes/splitPath->expandModifiers-> regex params). A pattern with optional/group syntax yields several shapes; patterns overlap when any shape pair overlaps.routeToShapesmemoizes per pattern string (boundedMap, reset at 1024 entries) β pairwise consumers (compareRoutesover N patterns) pay N parses, not NΒ²; callers must treat returned shapes as immutable. shapesOverlap(): check the shared fixed prefix then test that total-length ranges[fixed+tailMin, fixed+tailMax]intersect. Value check is length-independent because any valid common length β₯max(fixedA, fixedB).- Overlap = "β concrete path matched by both," not subset containment.
static/staticandstatic/regexare precise;any-vs-anything andregex/regexare over-approximated to overlap (conservative default β regex intersection is undecidable). - Shape canonicalization:
_computeShape(_overlap.ts) folds trailingundefined(any-value) fixed matchers into the tail (/a/:x->["a"] [1,1]), androuteToShapesmerges shapes with identical fixed prefixes (_segmentEqualβ strict identity, never mutual-subsumption proofs) and contiguous length ranges (/a/:x?->["a"] [0,0]+["a"] [1,1]->["a"] [0,1]) viamergeShapes(_subsume.ts). Both are match-set-preserving; they make containment see through optional-syntax expansion (/a/:x?==/a/*). compareRoutes(a, b)classifies match-set relations:disjoint|equal|superset(strict unless equality is undecidable) |subset|partial(no containment proven, sets may intersect). Verdict names follow the ES2025 Set methods (isSupersetOf/isSubsetOf/isDisjointFrom);superset/subsetare directional (compareRoutes(a, b) === "superset"meansaβb). Built onshapeSubsumes()(_subsume.ts):b's total-length range insidea's + per-position matcher containment (anyβ all; literals by equality; anchored regex β literal viatest(); regex β regex only by source equality modulo named-group names β_regExpKeystrips(?<name>so param names never affect the verdict,/u/:id(\d+)==/u/:x(\d+)) +a's fixed positions underb's tail must beany. Pattern-level containment is proven shape-by-shape (eachbshape inside a singleashape) β sufficient, not necessary, so union-split coverage degrades topartial. Containment claims are proofs; undecidable directions degrade to a weaker verdict, never a wrong claim. Two caveats are inherent: strictness ofsubsumes/subsumedis best-effort (an actually-equal pair whose equality is only provable one way β/u/:id(42)vs/u/42β reports the proven containment, notequal), andpartial's intersection half is over-approximated (apartialregex-vs-regex pair may in fact be disjoint). Both are pinned intest/overlap.test.ts.findOverlappingRoutestraverses the tree infindAllRoutesorder (wildcard, param, static, self) so results are leastβmost specific, prunes static subtrees the query can't reach, and collapses only genuine reference-duplicates (a route with optional/group syntax expands into several entries sharing onedatareference); distinct routes with equal-or-absent primitivedataare all reported. Matches carrydataonly (a scope has no single concrete path β noparams).
src/route-node-keys.ts exposes the radix-node identity of a pattern β a syntactic tree property, deliberately separate from the semantic match-set relations compareRoutes answers. Tree-shakeable: it only reuses createRouter/addRoute (already in the core bundle) and is dropped entirely when unimported, so test/bench/bundle.test.ts is unaffected (measured byte-identical with and without the src/index.ts re-export).
- Why it exists: rou3 buckets registrations by node, and lookup resolves a node with
methods[method] || methods[""], so a method-scoped entry on a node hides that node's method-agnostic ("") entry. Consumers that bucket their own per-route metadata by pattern text (h3 PR #1524 did) see/users/*andGET /users/:idas two entries where rou3 has one bucket β thebasicAuthgate is silently deleted, fail-open.compareRoutescannot close this gap: over 18,336 pattern pairs,=== "equal"had 13% recall on node collisions and 51 of 338"equal"verdicts did not collide (/a/:x/**vs/a/:x+are equal but live on/a/*/**and/a/**). Keep the two APIs separate. - Soundness contract:
routeNodeKeys(A) β© routeNodeKeys(B) β ββΊ A and B share a radix node (hence onemethods[]bucket). Sound in both directions as a statement about nodes, and explicitly not a statement about match sets β the key erases regex constraints and widens**:nameβ**, so/u/:id(\d+)and/u/:slug([a-z]+)share/u/*while matching disjoint paths. Over-merging is the fail-closed direction and the correct bias here. It is not namedcanonicalRoutePatternfor exactly that reason: the key is not an equivalent pattern. - Implementation constraint (non-negotiable): never write a second pattern parser.
routeNodeKeysinserts into a throwaway router via the realaddRoute(exactly likerouteToShapesin_overlap.ts) and DFS-walks the tree emittingprefix || "/"at every node withmethodsβ which dedupes for free. A parser that drifts fromaddRoutewould report node identities the router doesn't use, recreating the very bug this exists to prevent. It must not import_overlap.ts(that pullsmergeShapesfrom_subsume.tsinto the graph). - Node-identity rules the key encodes: param edge (
node.param, key*) is taken by:name,*,:id(\d+),(\d+)and mid-segment captures (*.png,pre-:id-suf,file-*-*.png) β name and constraint live in the entry, not the node; wildcard edge (node.wildcard, key**) by**,**:rest,:x+, and it is terminal (the add loop breaks, so/a/**/anythingis/a/**); static otherwise viadecodeEscapedwith\*β*and\*\*β**. Trailing empty segments are all popped (/aβ‘/a/β‘/a//, #193) but middle empties are a real static""key (/a//b). - Key encoding: segments joined by
/; param β*, wildcard β**, static keykemitted with*β\*,**β\*\*and: ( ) { }backslash-escaped, so a literal can never be read as a marker and every key is itself a route pattern reaching exactly its own node (routeNodeKeys(k) === [k]). Weaker encodings fail: escaping only\/*breaks idempotence (a static key may legitimately contain a raw\, e.g.a\*), and leaving)/}unescaped breaks it for keys like\). - Memo: bounded
Mapcleared at 1024 entries (same policy asrouteToShapes); returnskeys.slice()so callers cannot mutate the memo. - Tests (
test/route-node-keys.test.ts) β the property sweep is what keeps this honest. Over a 141-pattern corpus (segment alphabet Γ depth β€ 2 Γ tails, plus optional/group/escape extras) it assertskeysIntersect(a,b) === sharesNode(a,b)for all 19,881 ordered pairs against ground truth read off the tree (0 mismatches). The security sweep then takes every disjoint-key pair (11,515), registers A under""and B underGET, and asserts A still comes back fromfindAllRoutesand compiledmatchAllon every path A covers alone β 44,916 checks, 0 shadowed. A false negative in the key model surfaces there as a dropped""entry. Encoding injectivity is brute-forced over the adversarial alphabet{a \ * : ( ) { } .}up to length 3 (820 segments, 247 reachable static keys, 0 collisions,*/**reserved), plus idempotence and an explicit non-goal guard that equal keys do not imply equal (or overlapping) match sets.
Param names accept [\w-]+, but a named capture group must be a valid identifier (no -, no leading digit) in JS and in PCRE. src/_group-names.ts is the codec every regex-emitting path goes through:
toGroupName()passes valid identifiers through unchanged (the common case, so existing output is byte-identical) and escapes the rest behindESCAPED_GROUP_PREFIX(__rou3_esc_), encoding_->__and-->_h(:test-id->__rou3_esc_test_hid,:0->__rou3_esc_0). Names in the reserved__rou3_space and_N-shaped ones (the unnamed formrouteToRegExpemits) are escaped too, so a group name maps back to exactly one param name. The escape is a prefix code β every_in the output opens a two-char escape β hence injective: distinct names can't collide and decoding is exact. This is the whole point of the two-char-escape; the tempting-->_sanitize is not injective (a--bdecodes back asa_b, anda-_b/a_-bcollide onto one group name, which is a duplicate-groupSyntaxErrorat registration). Pinned bytest/group-names.test.ts, which brute-forces round-trip + injectivity over an adversarial alphabet. The output stays within[A-Za-z0-9_], so it is a legal PCRE group name (pinned byregexp.pcre.test.tsvia the shared fixtures).fromGroupName()is the inverse and also stripsUNNAMED_GROUP_PREFIX; it is the single read-back point (getMatchParamsinoperations/_utils.ts, the compiler'sscanRegExpGroupspath, andmatchNamedGroupinregexp-to-route.ts). Params therefore always surface under the original route name (params["test-id"]), andregExpToRoute()restores:test-idrather than leaking the escaped form.- Emission sites:
getParamRegexp()(operations/add.ts) and all four inregexp.ts(plain, mixed-segment,:name?/+/*modifier,**:name). Before this, any param with-or a leading digit threwSyntaxError: Invalid capture group namefromaddRoute()/routeToRegExp()as soon as it appeared in a regex-compiled position β whole-segment:test-idworked only because it stores the name as a plain string key (#8dfaa48 widened the name class to[\w-]but left group emission raw). Pinned infind.test.ts"param names that are not valid capture-group names" and the_regexp-cases.tsfixtures. - The compiler emits
.groups.<name>property accesses, which are only valid because every emitted name is now an identifier; its runtime_normalizeGroupsfallback (used whenscanRegExpGroupscan't parse a name, e.g. a unicode group inside a constraint body) inlines a copy of the same decode and must stay in sync withfromGroupName(). - Known (pre-existing) ambiguity, unrelated to the escape: unnamed captures key on
"0","1", β¦ , so a route that mixes a digit-named param with an unnamed capture (/w/:0/*) has two params claiming key"0"and the later one wins. Same onmainbefore the escape existed β the numeric unnamed-key space simply overlaps digit-only param names.
normalizePath() in _utils.ts resolves . and .. segments in lookup paths (fast-path: skip if no /. found). Both findRoute() and findAllRoutes() normalize before matching. The compiler inlines equivalent logic in generated code.
Two different rules, and they must not be conflated:
- Trailing empties are canonicalized away at registration:
add/removesplit patterns withsplitRoute()(splitPath()+ pop all remaining trailing empties), so/a//β‘/a/β‘/a(#193).splitPath()alone pops only one, which used to leave/a//as segments["a", ""]β the radix tree then matched only/a///, thectx.statickey was/a/, and the compiled static dispatch stripped to/a: three matchers, three answers. This mirrors the trailing-slash policy already documented for routes (/aβ‘/a/). Pinned infind.test.ts"route patterns with trailing empty segments (#193)". - Lookup paths are unchanged:
findRoute/findAllRoutesslice one trailing/andsplitPathpops one empty segment, so/a//reaches a/aroute but/a///does not (a real empty segment remains). This bound is deliberate βfind.test.ts"matches the static route for path//, not beyond" and the compiled static chain/map (which retriesp.slice(0,-1)exactly once) both pin it. Do not turnsplitPath'sifinto awhile. - Middle empties are meaningful and preserved everywhere:
/a//bis a static segment""in the tree and matches only the doubled-slash path, never/a/b(request paths keep their empty segments too, so collapsing would create the classic normalization-mismatch bypass; URLPattern also treats//literally).routeToRegExpSegments()used tocontinueon every empty segment, sorouteToRegExp("/a//b")emitted^\/a\/b\/?$β matching the one path the router won't and missing the one it will. It now splits withsplitRoute()and re-emits middle empties. Pinned by the/path//sub+/path//:idfixtures in_regexp-cases.ts(which assert tree and regex agree) andfind.test.ts"routes with an empty middle segment". - Known residual: an empty segment directly before a wildcard (
/a//**,/a//*) still has the regex over-matching relative to the tree β the**emission makes its preceding separator optional (\/\/?), and lookup-side trailing normalization erases the empty segment in that position anyway. Pathological; not modeled.
- Breaking change: unnamed captures now use URLPattern-style numeric keys (
"0","1", ...) instead of legacy_0,_1, ... - Unescaped
*inside a segment is treated as an unnamed capture ("0","1", ...), including mid-pattern forms like/*.pngand/file-*-*.png. - Wildcard capture indexing is shared with unnamed regex groups in the same route.
removeRoute()now treats wildcard-segment patterns as dynamic segments (same classification as add/find/regexp).
- Builder:
obuild(config inbuild.config.mjs) - Entries:
src/index.ts,src/compiler.ts - Output: ESM +
.d.mtsdeclarations
pnpm build # Build with obuild
pnpm dev # Vitest watch mode
pnpm lint # ESLint + Prettier
pnpm lint:fix # Auto-fix
pnpm test # Full test suite + coverage
pnpm test:types # TypeScript type checking
pnpm bench:node # Benchmarks (node)
pnpm bench:bun # Benchmarks (bun)
pnpm bench:deno # Benchmarks (deno)- Framework: Vitest (config in
vitest.config.mjs) - Dual validation: Tests compare
findRoute()results againstcompileRouter()output - Snapshots: Tree structure and compiled code snapshots
- Type tests:
vitest typecheckviatypes.test-d.ts - Run a single test:
pnpm vitest run test/<file>.test.ts - WPT compat tests:
test/wpt.test.tsvalidates URLPattern compatibility using Web Platform Test data. Known diffs are tracked inKNOWN_DIFFS,REGEXP_ONLY_KNOWN_DIFFS, andROUTER_KNOWN_DIFFSsets with reason comments. - PCRE cross-engine tests:
test/regexp.pcre.test.tsrunsrouteToRegExp()output through whichever real PCRE-compatible CLIs are installed (grep -P,rg -P,pcre2grep,pcregrep,perl,php). Each candidate is included only after a(?<name>...)sanity probe, so missing/non-PCRE tools are auto-skipped (suite is a no-op if none are present). All fixtures are asserted to compile and match on every detected engine.PCRE2_DUPLICATE_NAME_ROUTESflags any route whose output reuses a named group across alternation branches (valid in JS/Perl, rejected by PCRE2 withoutPCRE2_DUPNAMES); it contains the non-inlinable cases (e.g./media/*{.webp}?, a mid-segment optional after a greedy capture) β for those the suite asserts strict PCRE2 engines reject the output while Perl accepts and matches it.test/regexp.test.tsasserts every other fixture emits no duplicate named groups, and conversely that eachPCRE2_DUPLICATE_NAME_ROUTESentry really does (so the set can't go silently stale).
- Performance-first:
charCodeAt()over.startsWith(), traditionalforloops, null-prototype objects,.concat()over spread - Abbreviated hot-path vars:
m(method),p(path),s(segments),l(length) - Internal files: Prefixed with
_(e.g.,_utils.ts) - ESM only, explicit
.tsextensions in imports - ESLint:
eslint-config-unjswith custom overrides - Formatter: Prettier
- Prefer ESM over CommonJS
- Use explicit extensions (
.ts/.js) in import statements - For
.jsonimports, usewith { "type": "json" } - Avoid barrel files (
index.tsre-exports); import directly from specific modules - Place non-exported/internal helpers at the end of the file
- For multi-arg functions, use an options object as the second parameter for extensibility
- Split logic across files; avoid long single-file modules (>200 LoC). Use
_*for naming internal files
- Write the regression test first β reproduce the exact bug
- Run it and confirm it fails β MUST fail before touching implementation
- Fix the implementation β minimal change
- Run the test again β confirm it passes
- Run the broader test suite β ensure no regressions
Never skip step 2. A regression test that wasn't proven to fail first has no value.
- Commits: Semantic, lower-case (e.g.,
perf: ...,fix(compiler): ...), include scope, add short description on second line - If not on
main, alsogit pushafter committing - Use
ghCLI for GitHub operations