The Principal Dev – Masterclass for Tech Leads

The Principal Dev – Masterclass for Tech Leads28-29 May

Join

🌳 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.

[!IMPORTANT] Paths should always begin with /.

[!IMPORTANT] Method should always be UPPERCASE.

[!TIP] 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.

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" }
/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" }

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)
(.*) 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

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:

findOverlappingRoutes follows the same least → most specific order.

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/**

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

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("/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).

[!IMPORTANT] 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 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, trailing optional groups are compiled inline as (?:...)? instead of an alternation, so a param is never emitted twice as a duplicate named group (which PCRE2 rejects unless PCRE2_DUPNAMES is set):

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

[!NOTE] Multi-group or mid-route optionals that cannot be inlined fall back to an alternation and may contain duplicate named groups. That output is valid in JavaScript (per the TC39 duplicate-named-groups proposal) and Perl, but requires PCRE2_DUPNAMES on strict PCRE2 engines.

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(/^\/base\/?(?<path>.+)\/?$/); // "/base/**:path"
regExpToRoute("^\\/files\\/(?<_0>[^/]*)\\.png\\/?$"); // "/files/*.png"

It targets the dialect routeToRegExp() emits — named groups (?<name>...), [^/]+/[^/]* segment matchers, .*/.+ catch-alls, and (?:/...)? optional groups. Bare (unnamed) capturing groups such as (\d+) are accepted too, and arbitrary regex inside an inline constraint (...) is preserved verbatim. Every reversible output round-trips exactly: routeToRegExp(regExpToRoute(regexp)).source === regexp.source.

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

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.