npm.io
0.10.2 • Published 35m ago

rou3

Licence
MIT
Version
0.10.2
Deps
0
Size
129 kB
Vulns
0
Weekly
0
Stars
745

rou3

npm version npm downloads bundle size codecov

Lightweight and fast router for JavaScript.

Usage

Install:

# ✨ Auto-detect
npx nypm install rou3

Import:

ESM (Node.js, Bun, Deno)

import {
  createRouter,
  addRoute,
  findRoute,
  removeRoute,
  findAllRoutes,
  routesOverlap,
  compareRoutes,
  findOverlappingRoutes,
  routeNodeKeys,
  routeToRegExp,
  regExpToRoute,
  NullProtoObj,
} from "rou3";

CDN (Deno and Browsers)

import {
  createRouter,
  addRoute,
  findRoute,
  removeRoute,
  findAllRoutes,
  routesOverlap,
  compareRoutes,
  findOverlappingRoutes,
  routeNodeKeys,
  routeToRegExp,
  regExpToRoute,
  NullProtoObj,
} from "https://esm.sh/rou3";

Create a router instance and insert routes:

import { createRouter, addRoute } from "rou3";

const router = createRouter(/* options */);

addRoute(router, "GET", "/path", { payload: "this path" });
addRoute(router, "POST", "/path/:name", { payload: "named route" });
addRoute(router, "GET", "/path/foo/**", { payload: "wildcard route" });
addRoute(router, "GET", "/path/foo/**:name", {
  payload: "named wildcard route",
});

Match route to access matched data:

// Returns { payload: 'this path' }
findRoute(router, "GET", "/path");

// Returns { payload: 'named route', params: { name: 'fooval' } }
findRoute(router, "POST", "/path/fooval");

// Returns { payload: 'wildcard route' }
findRoute(router, "GET", "/path/foo/bar/baz");

// Returns undefined (no route matched for/)
findRoute(router, "GET", "/");

Match all routes, ordered least → most specific:

findAllRoutes(router, "GET", "/path/foo/bar/baz");
// [
//   { data: { payload: "wildcard route" } },
//   { data: { payload: "named wildcard route" }, params: { name: "bar/baz" } },
// ]

The result ordering is a documented contract — see Result ordering.

Paths should always begin with /.

Method should always be UPPERCASE.

If you need to register a pattern containing literal : or *, you can escape them with \\. For example, /static\\:path/\\*\\* matches only the static /static:path/** route.

Remove a route:

removeRoute(router, "GET", "/path/:name");

Removal is by registered pattern: it removes every entry that addRoute call created (including optional/group expansions and duplicate registrations) and leaves routes registered under other patterns alone, even ones that share a tree node (/path/:id vs /path/:name, /path/** vs /path/**:rest). Pass the pattern as it was registered — spellings the tree cannot tell apart (/a/ vs /a, /a/:x?/ vs /a/:x?, escaped statics, /**.md vs /**/*.md) are equivalent, but /path/* does not remove /path/:name and /ab does not remove /a{b}.

Route Patterns

rou3 supports URLPattern-compatible syntax.

Pattern Example Match Params
/path/to/resource /path/to/resource {}
/users/:name /users/foo { name: "foo" }
/path/** /path/foo/bar {}
/path/**:rest /path/foo/bar { rest: "foo/bar" }
/**/_payload.json /_payload.json or /a/b/_payload.json { _: "" } or { _: "a/b" }
/**.md /docs/intro.md { _: "docs", "0": "intro" }
/files/*.png /files/icon.png { "0": "icon" }
/files/file-*-*.png /files/file-a-b.png { "0": "a", "1": "b" }
/users/:id(\\d+) /users/123 { id: "123" }
/files/:ext(png|jpg) /files/png { ext: "png" }
/path/(\\d+) /path/123 { "0": "123" }
/users/:id? /users or /users/123 {} or { id: "123" }
/files/:path+ /files/a/b/c { path: "a/b/c" }
/files/:path* /files or /files/a/b {} or { path: "a/b" }
/book{s}? /book or /books {}
/blog/:id(\\d+){-:title}? /blog/123 or /blog/123-my-post { id: "123" } or { id: "123", title: "my-post" }
  • Named params (:name) match a single segment.
  • Single-segment wildcards (*) capture unnamed params (0, 1, ...) and can be used as full or mid-segment tokens (for example /* or /*.png).
  • Wildcards (**) match zero or more segments. Use **:name to capture (one or more segments).
  • Segments after a wildcard (/**/_payload.json, /blog/**:path/og.png) are matched from the end of the path; the ** takes whatever is between. **<rest> is short for **/*<rest>: /**.md matches any path whose last segment ends in .md. A route can have one ** (a :name+ / :name* before the last segment counts as one: /files/:path+/meta is /files/**:path/meta), and a * after it always takes a segment. On paths such a route matches, routes are ranked from the end of the path — see Result ordering.
  • Regex constraints (:name(regex)) restrict matching. Constrained and unconstrained params can coexist on the same node (constrained checked first). A constraint applies to one segment and cannot contain / (:id([^/]+) throws; :id(.+) already stops at /).
  • Literal parentheses: a ( always opens a group, so a literal one must be escaped (/files/\\(2024); an unclosed ( throws. A ) with no group to close is a literal.
  • Unnamed groups ((regex)) capture into auto-indexed keys 0, 1, etc.
  • Modifiers: :name? (optional), :name+ (one or more), :name* (zero or more). Can combine with regex: :id(\d+)?.
  • Non-capturing groups ({...}): supported with inline (/foo{bar}) and optional (/foo{bar}?) forms.
  • Current limitation: repeating non-capturing groups ({...}+, {...}*) are supported only within a single segment (no / inside the group body).
  • Backslash escaping (\): escape special characters like :, *, (, ), {, } with a backslash (e.g., /static\:path matches literal /static:path).
Differences from URLPattern

rou3 aims for URLPattern-compatible syntax but has intentional differences due to its radix-tree design:

Feature URLPattern rou3
* (single star) Greedy catch-all (.*) across / Single-segment unnamed param ([^/]*)
** (double star) Literal ** Catch-all wildcard (zero or more segments), one per route
(.*) in segment Greedy match across / Segment-scoped (does not cross /)
{...}+ / {...}* groups Cross-segment group repetition Only supported within a single segment (no / in group body)
Path normalization (./..) Resolves ./.. in input paths Not done by default (opt-in with { normalize: true })
Case sensitivity Can be case-insensitive Always case-sensitive
Non-/-prefixed paths Supported Paths must start with /
Unicode param names Supports Unicode identifiers Params use \w (ASCII word chars only)
Percent-encoding Normalizes %xx sequences Does not decode percent-encoded input
Trailing slashes and empty segments

Lookup ignores at most one trailing slash: /users/foo/ matches /users/:name, but /users/foo// does not. On the pattern side, trailing slashes are ignored (/users/ and /users// register the same route as /users).

Other empty segments are kept and are real segments: /a//b does not match /a/b. A param (:name, *) or a catch-all (**, **:name, :name+) takes an empty segment and captures "", both in the middle and at the end of the path:

addRoute(router, "GET", "/admin/:id", {});
addRoute(router, "GET", "/files/:path+", {});

findRoute(router, "GET", "/admin/"); // undefined (the one trailing slash is ignored)
findRoute(router, "GET", "/admin//"); // params: { id: "" }
findRoute(router, "GET", "/files//"); // params: { path: "" }

A required param or :name+ is not guaranteed to be non-empty. If a handler needs a value, check for "" or use a regex constraint (/admin/:id(.+), /users/:id(\\d+)).

Path normalization

By default, findRoute and findAllRoutes do not resolve ./.. segments in input paths. If your input paths may contain relative segments, enable normalization:

findRoute(router, "GET", "/foo/bar/../baz", { normalize: true });
// Matches "/foo/baz"

findAllRoutes(router, "GET", "/foo/./bar", { normalize: true });
// Matches "/foo/bar"

The compiled router also supports this via the normalize option:

const match = compileRouter(router, { normalize: true });
match("GET", "/foo/bar/../baz"); // Matches "/foo/baz"
Result ordering

findAllRoutes returns matches ordered least → most specific: the broadest scopes first, the most specific match last. The compiled matchAll (see Compiler) returns the exact same results in the exact same order. This ordering is a contract, not incidental behavior — merge/fold-style consumers (e.g. route rules resolved by merging all matched layers, taking the last as the most specific) can rely on it, and it is pinned by tests. Any intentional change to it would be a breaking change.

const router = createRouter();
addRoute(router, "GET", "/**", { name: "catch-all" });
addRoute(router, "GET", "/api/**", { name: "api" });
addRoute(router, "GET", "/api/:v/users/:id", { name: "user" });

findAllRoutes(router, "GET", "/api/v1/users/42").map((m) => m.data.name);
// ["catch-all", "api", "user"]

Precisely:

  • Across the tree: at each level, wildcard (**) matches are emitted first, then single-segment params (*, :name), then static segments — so wilder/shallower routes come before more-static/deeper ones.

  • Same-node siblings (multiple routes ending on the same dynamic node, e.g. /foo/* and /foo/:id(\d+)): ordered by ascending specificity — optional/unconstrained entries before required/regex-constrained ones — with insertion order preserved on ties.

  • Subsumption consistency (patterns without optional syntax): when the registered patterns use no optional syntax and are strictly ordered by containment (each a "superset" of the next per compareRoutes), the result order agrees with the subsumption order (broader first).

  • Carve-out — optional syntax: a pattern containing :name?, :name* or {...}? registers several entries (one per expansion), and results are ordered by the specificity of the entry that matched, not by the breadth of the whole pattern. A pattern that is a "superset" of another can therefore come last:

    const router = createRouter();
    addRoute(router, "GET", "/admin", { name: "admin" });
    addRoute(router, "GET", "/admin/:page?", { name: "admin-page" }); // superset of "/admin"
    
    findAllRoutes(router, "GET", "/admin").map((m) => m.data.name);
    // ["admin", "admin-page"] — the broader pattern is last

    If you need a true pattern-level containment order and your patterns may use optional syntax, re-sort the (small) result array yourself with compareRoutes.

  • Registration order never affects the result order, except as the tiebreaker between equally specific same-node entries — which, per the carve-out above, includes expansions of optional-syntax patterns (registering /admin/:page? before /admin swaps the two results in the example above).

  • Segments after a wildcard: a route like /**/_payload.json is anchored at the end of the path, so the tree order (which decides at the first segment) would let /blog/** or /blog/:slug win over it on /blog/_payload.json. Instead, on every path such a route matches, all matches are ranked from the last segment backwards — a literal segment beats a regex-constrained param, which beats a plain param or a segment taken by ** — with ties in the order above. findRoute returns the last one, and paths no such route matches are not affected:

    const router = createRouter();
    addRoute(router, "GET", "/blog/**", { name: "blog" });
    addRoute(router, "GET", "/blog/:slug", { name: "post" });
    addRoute(router, "GET", "/**/_payload.json", { name: "payload" });
    addRoute(router, "GET", "/blog/:slug/_payload.json", { name: "post-payload" });
    
    findAllRoutes(router, "GET", "/blog/_payload.json").map((m) => m.data.name);
    // ["blog", "post", "payload"]
    findAllRoutes(router, "GET", "/blog/hello/_payload.json").map((m) => m.data.name);
    // ["blog", "payload", "post-payload"]
    findRoute(router, "GET", "/blog/hello")?.data.name; // "post" (tree order)

    This keeps the subsumption consistency above for such routes too (a narrower route never loses to a broader one). Note that it also applies between the other routes on those paths: with /**/_payload.json registered, /:lang/_payload.json wins over /blog/:slug on /blog/_payload.json (it pins the last segment), while without it the tree order picks /blog/:slug.

findOverlappingRoutes follows the same least → most specific order (a route with segments after ** comes right after the bare ** it follows: a scope has no last segment to rank from).

Pattern overlap

findRoute/findAllRoutes match a concrete path against registered patterns. Sometimes you instead need to reason about patterns against patterns — e.g. to resolve an "effective" merged config over a whole scope, you need to know when two patterns can match a common concrete path.

Three utilities cover this:

import { createRouter, addRoute, routesOverlap, compareRoutes, findOverlappingRoutes } from "rou3";

// Do two patterns share at least one concrete path? (pure, router-free)
routesOverlap("/**", "/protected/feed/**"); // true
routesOverlap("/a/**", "/b/**"); // false

// How do two patterns' match-sets relate? (pure, router-free)
compareRoutes("/api/**", "/api/admin/**"); // "superset"
compareRoutes("/api/admin/**", "/api/**"); // "subset"
compareRoutes("/a/:x", "/a/:y"); // "equal" (names don't matter)
compareRoutes("/a/*/c", "/a/b/*"); // "partial" (ambiguous specificity)

// Every registered route whose match-set intersects a *pattern* (a scope),
// ordered least -> most specific like findAllRoutes.
const router = createRouter();
addRoute(router, "GET", "/**", { isr: true });
addRoute(router, "GET", "/protected/**", { basicAuth: true });
addRoute(router, "GET", "/protected/feed/**", { isr: 60 });

findOverlappingRoutes(router, "GET", "/protected/feed/**");
// [ { data: { isr: true } },        // /**
//   { data: { basicAuth: true } },  // /protected/**
//   { data: { isr: 60 } } ]         // /protected/feed/**
  • routesOverlap(patternA, patternB) — returns true if the two patterns' match-sets intersect (there exists a concrete path matched by both). This is overlap, not subset containment.

  • compareRoutes(patternA, patternB) — classifies the relation between the two match-sets. Verdicts follow the ES2025 Set-method vocabulary (isSupersetOf/isSubsetOf/isDisjointFrom) and are directional — read them as "patternA is a … of patternB":

    • "disjoint" — provably no common path.
    • "equal" — provably the same paths. Param names are ignored: /a/:x equals /a/:y, /u/:id(\d+) equals /u/:x(\d+), and /a/:x? equals /a/*.
    • "superset" — patternA provably matches every path patternB matches (strict unless equality is undecidable).
    • "subset" — the mirror image (patternA ⊆ patternB).
    • "partial" — no containment proven; the sets may intersect.

    Useful for ordering patterns by specificity and detecting ambiguous pairs where "most specific match" is undefined. Every verdict's containment claims are proofs, and undecidable cases degrade to a weaker verdict, never a wrong claim: containment between two different regex constraints falls back to "partial" (even when the sets are actually disjoint — see the over-approximation note below — or actually equal), and an actually-equal pair whose equality is only provable in one direction (e.g. /u/:id(42) vs /u/42) reports the proven containment instead of "equal".

  • findOverlappingRoutes(router, method, pattern) — like findAllRoutes, but the query is a pattern instead of a concrete path. Returns every registered route whose match-set intersects the pattern, ordered least → most specific, with the same method handling as findAllRoutes (falls back to the method-agnostic bucket). Matches carry only data — a scope has no single concrete path, so no params are resolved. A single route registered with optional/group syntax expands into several tree entries sharing one data reference and is reported once; distinct routes are always reported separately, even when they share an equal primitive data value (or none).

Overlap semantics are computed with rou3's own segment/radix rules, so they stay consistent with findRoute/findAllRoutes:

  • Patterns are expanded through the same pipeline as addRoute, so groups ({s}?), optional/repeat modifiers (:x?/:x+/:x*), and escaping (\:, \*) are all respected. A pattern with optional syntax expands to several shapes; two patterns overlap when any pair of shapes overlaps.
  • Segment counts: bare ** matches zero or more segments (so /a/** overlaps /a), **:name matches one or more, a trailing bare * matches zero or one, and mid-pattern * / :name match exactly one. Segments after a ** are aligned to the end of the path (compareRoutes("/**/_payload.json", "/blog/:slug/_payload.json") is "superset").
  • Regex constraints (:id(\d+), unnamed groups, *.png) are matched precisely against static literals (/user/:id(\d+) does not overlap /user/abc), but two dynamic segments where at least one is constrained are over-approximated to "overlaps" — routesOverlap("/user/:id(\d+)", "/user/:name([a-z]+)") returns true even though the sets are disjoint. Exact regex intersection is undecidable, and over-approximating toward "overlaps" is the safe conservative default.
Route node keys

Different patterns can end up on the same node of the radix tree — /users/:id and /users/* both become "any single segment under /users". routeNodeKeys(pattern) tells you which node(s) a pattern lands on:

import { routeNodeKeys } from "rou3";

routeNodeKeys("/users/:id"); // ["/users/*"]
routeNodeKeys("/users/*"); // ["/users/*"]   -> same node as /users/:id
routeNodeKeys("/admin/**:rest"); // ["/admin/**"]
routeNodeKeys("/**:path/og.png"); // ["/**/og.png"]
routeNodeKeys("/a/:x?"); // ["/a", "/a/*"]  -> optional syntax lands on two nodes

Why it matters: routes on the same node share one bucket of handlers, and lookup takes methods[method] first, falling back to methods[""] only if there is none. So a method-scoped route hides a method-agnostic one registered on the same node:

const router = createRouter();
addRoute(router, "", "/users/*", { basicAuth: true }); // method-agnostic gate
addRoute(router, "GET", "/users/:id", { handler }); // different text, same node

findAllRoutes(router, "GET", "/users/42").map((m) => m.data);
// [{ handler }] — the gate is gone

If you keep your own per-route metadata (route rules, middleware, auth gates) in a map keyed by pattern text, "/users/*" and "/users/:id" look like two entries while rou3 has only one — and one of them silently disappears. Key that map by routeNodeKeys instead, and merge entries that share a key. The guarantee runs both ways:

routeNodeKeys(a) and routeNodeKeys(b) intersect ⟺ a and b share a node (hence one bucket).

  • The result is an array because optional syntax (:x?, :x*, {...}?) registers on several nodes (/x{/a}?{/b}? registers 4). It is deduplicated.
  • Each key is itself a valid route pattern for exactly the node it names, so keys work directly as ids: routeNodeKeys(k) is [k].
  • Invalid patterns throw exactly as addRoute does.

Sharing a node does not mean matching the same paths. The key drops regex constraints and widens **:name to **, so /u/:id(\d+) and /u/:slug([a-z]+) share the key /u/* but match disjoint paths. Merging too much is the safe direction for metadata, but to ask which paths two patterns share, use compareRoutes — the two answers are independent in both directions ("equal" patterns need not share a node either).

Regular expressions

routeToRegExp(route) converts a route pattern into an anchored RegExp with named capture groups, useful outside the router (validation, codegen, matching in other tools):

import { routeToRegExp } from "rou3";

routeToRegExp("/users/:id(\\d+)");
// /^\/users\/(?<id>\d+)\/?$/  ->  "/users/123".match(re).groups // { id: "123" }

The regex matches the paths findRoute() matches for a router holding only that route, so it can stand in for the router as a guard or scope check. That includes the router's lookup tolerances: one optional trailing slash (/users/123/, but not /users/123//), empty segments for whole-segment :name and * params (/a//b matches /a/:x/b), an optional trailing * (/a matches /a/*), catch-alls (**, **:name, :name+, :name*) that match any character, line terminators included, and segments after a wildcard matched at the end of the path (/**/_payload.json matches /_payload.json and /blog/post/_payload.json/, not /_payload.jsonx). Paths are compared as-is, like findRoute() without { normalize: true }, so resolve ./.. segments first if the router normalizes them. A few cases are not modeled:

  • A constraint that can match / (:x(.+), :x([^.]+)) can span segments in the regex (/a/:x(.+) matches /a/b/c), while the router splits the path first.
  • The router drops the constraint of a repeated param (:id(\d+)+), the regex keeps it.
  • The empty path "": the router treats it as /, the regex matches it only for root routes whose first segment is optional (/**, /:x*, /:x?, /*), not for / itself.
  • In PCRE and Perl, $ also matches before a final \n, so there the regex also matches <path>\n.

The named groups hold the params. In two cases the regex leaves a group unset where the router reports "": a required segment that is empty (/a// on /a/:x, /a//b on /a/:x/:y?) and a ** that matches no segment (/a on /a/**, /a/b on /a/**/b; at the root the regex captures "" too: /_payload.json on /**/_payload.json). The first is the cost of avoiding look-behind: capturing "" there would need look-around, backreferences or the same named group twice. Separately, when optional segments meet a * or a constrained optional, the regex can give a segment to a different param than the router (/a/:x?/* on /a/b sets x, the router sets *), and so can several optional segments after a ** (see below).

The trailing-slash rule (at most one trailing slash, and exactly one when the path's last segment is empty) is built into the end of the regex:

Route Regex
/users/:id(\d+) ^\/users\/(?<id>\d+)\/?$
/path/:id? ^\/path(?:\/(?<id>[^/]*))??\/?$
/path/:param ^\/path\/(?:(?<param>[^/]+)\/?|\/)$
/users/:id/:tab? ^\/users\/(?:(?<id>[^/]+)(?:\/|$)|\/)(?:(?<tab>[^/]*)\/?)??$
/path/** ^\/path(?:\/(?<_>(?:[\s\S]*[^/])?\/*?))?\/?$
/base/**:path ^\/base\/(?:\/|(?<path>(?:[\s\S]*[^/]|\/)\/*?)\/?)$
/** ^\/?(?<_>(?:[\s\S]*[^/])?\/*?)\/?$
/**/_payload.json ^\/?(?<_>[\s\S]*)\/_payload\.json\/?$
/path/**/suffix ^\/path(?:\/(?<_>[\s\S]*))?\/suffix\/?$
/**/:file ^\/?(?<_>[\s\S]*)\/(?:(?<file>[^/]+)\/?|\/)$
/a/**/:page? ^\/a(?:\/(?:(?:(?<_>[\s\S]*)\/)?(?:(?<page>[^/]+)\/?|\/))?)?$
/ ^\/$
/path/:id(\d*) (look-behind) ^\/path\/(?<id>\d*)(?:(?<=\/)\/|(?<!\/)\/?)$

Most routes compile without look-behind, so the output also works in RE2-family engines (RE2, Go regexp, the Rust regex crate). The look-behind suffix (?:(?<=\/)\/|(?<!\/)\/?)$ remains only for:

  • a constraint at the end of the route whose match can end in / (:name([^.]+), :x(\S+)?, :x(.+)),
  • a required last segment whose constraint can match empty (/path/:id(\d*)),
  • optional segments side by side where an earlier one can be empty and a later one can't nest in it (/a/:x(\d*)?/:y?, /a/:x?{/b/c}?),
  • optional segments side by side after a required segment that can be empty (/a/:x/:y(\d+)?/:z?),
  • a required catch-all right before an optional last segment (/a/**:rest/:page?, /a/:rest+/:page?),
  • a segment that can be empty right before a catch-all followed by optional segments only (/a/:p/**/:n(\d+)?),
  • an optional group after a catch-all and an optional segment that can be empty (/a/**/:y?{/b}?).

Fixed-length look-behinds work in JavaScript, PCRE and Perl. RE2-family engines also reject the duplicate-name alternations in the note below (they have no DUPNAMES option) and constraints that use syntax they lack.

The output is PCRE-compatible: it uses (?<name>...) named groups and avoids JS-only constructs, so the generated .source also compiles in PCRE2 engines (grep -P, rg -P, pcre2grep, PHP preg_*) and Perl — not just JavaScript. In particular, a single optional group that ends a segment is compiled inline as (?:...)? instead of an alternation, also when more of the route follows it, so a param is never emitted twice as a duplicate named group (which PCRE2 rejects unless PCRE2_DUPNAMES is set, and V8 before 12.5, i.e. Node 22, rejects outright):

routeToRegExp("/blog/:id(\\d+){-:title}?");
// /^\/blog\/(?<id>\d+)(?:-(?<title>[^/]+))?\/?$/
routeToRegExp("/users{/:id}?/posts/:post");
// /^\/users(?:\/(?<id>[^/]*))?\/posts\/(?:(?<post>[^/]+)\/?|\/)$/

When the group extends the param that ends its segment (/files/:name{.:ext}?, /users/:id([\w.]+){.json}?/edit), a look-ahead gives the param the value the router gives it (archive.tar.gz → name: "archive.tar", ext: "gz"); RE2-family engines reject that output.

Other optionals (several groups, a group whose segment is followed by an optional one like /{b}?/*, a mid-segment group after a greedy capture like /media/*{.webp}?, a :name* before a * like /a/:rest*/b/*, a group right after a bare ** like /a/**{.png}?) fall back to an alternation and may contain duplicate named groups. That output is valid in JavaScript engines with duplicate named groups (V8 12.5+ / Node 24+, Firefox 129+, Safari 17+) and Perl, throws on Node 22, and requires PCRE2_DUPNAMES on strict PCRE2 engines.

A route that declares the same param name twice (/files/:path/**:path; a bare ** is the _ param) throws a rou3: error, since engines disagree on whether a duplicate named group compiles.

A route with more than one ** (a :name+ / :name* before the last segment counts as one) throws the same rou3: error as addRoute. With one optional segment right after a **, the regex takes the route the router picks: the ** is lazy where the optional segment wins the end of the path (/a/**/:n(\d+)? gives /a/b/1 to n), greedy otherwise. With several, the router ranks the routes they register per path, while the regex's ** can only be lazy or greedy as a whole: it matches the same paths but may take another of those routes (/docs/**/:page?/:lang(en|fr)? on /docs/en sets page, the router lang).

regExpToRoute(regexp) is the inverse: it parses an anchored, PCRE-compatible RegExp (or its source string) back into a route pattern. Pass either a RegExp or a source string:

import { regExpToRoute } from "rou3";

regExpToRoute(/^\/users\/(?<id>\d+)\/?$/); // "/users/:id(\\d+)"
regExpToRoute(/^\/path\/(?:(?<param>[^/]+)\/?|\/)$/); // "/path/:param"
regExpToRoute(/^\/path(?:\/(?<_>(?:[\s\S]*[^/])?\/*?))?\/?$/); // "/path/**"
regExpToRoute("^\\/files\\/(?<_0>[^/]*)\\.png\\/?$"); // "/files/*.png"
regExpToRoute(/^\/?(?<_>[\s\S]*)\/_payload\.json\/?$/); // "/**/_payload.json"

It targets the dialect routeToRegExp() emits — named groups (?<name>...), [^/]* segment matchers, [\s\S]* catch-alls, (?:/...)? optional groups and the endings above. Bare (unnamed) capturing groups such as (\d+) are accepted too, and arbitrary regex inside an inline constraint (...) is preserved verbatim. Regexes from rou3 0.9.x (a plain \/? ending, .*/.+ catch-alls, [^/]+ params) still convert, except those for a catch-all inside an optional group (/a{/:w*}?, /a{/:w+}?). Every reversible output round-trips exactly: routeToRegExp(regExpToRoute(regexp)).source === regexp.source. Routes that compile to the same regex come back in one spelling: /base/**:path and /base/:path+ both become /base/:path+ (also with segments after it), /**.md becomes /**/*.md, and /a/:x?/:y? becomes /a{/:x/:y?}?.

Anything outside that dialect throws a clear error rather than returning a corrupt pattern: structural look-arounds ((?=…), (?<=…)) and backreferences, bare regex operators outside a constraint (|, ., +, […], …), match-affecting flags (i/m/s), the non-reversible alternation fallback described above, and inline constraints that can't be expressed as a route (e.g. one containing /).

Compiler

compileRouter(router, opts?)

Compiles the router instance into a faster route-matching function.

IMPORTANT: compileRouter requires eval support with new Function() in the runtime for JIT compilation.

Example:

import { createRouter, addRoute } from "rou3";
import { compileRouter } from "rou3/compiler";
const router = createRouter();
// [add some routes]
const findRoute = compileRouter(router);
const matchAll = compileRouter(router, { matchAll: true });
findRoute("GET", "/path/foo/bar");
compileRouterToString(router, functionName?, opts?)

Compile the router instance into a compact runnable code.

IMPORTANT: Route data must be serializable to JSON (i.e., no functions or classes) or implement the toJSON() method to render custom code or you can pass custom serialize function in options.

Example:

import { createRouter, addRoute } from "rou3";
import { compileRouterToString } from "rou3/compiler";
const router = createRouter();
// [add some routes with serializable data]
const compilerCode = compileRouterToString(router, "findRoute");
// "const findRoute=(m, p) => {}"

License

Published under the MIT license. Made by @pi0 and community


auto updated with automd