⚠️ Migration guide
This release finishes aligning rou3's pattern syntax with URLPattern. The biggest change is that * is now a greedy catch-all that spans segments. Check the sections below that apply to how you use rou3.
* is a greedy catch-all (#240)
* used to match a single segment ([^/]*). It is now URLPattern's * ((.*)), so it also matches across /. A trailing /foo/* still matches /foo, as in v0.11.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
/foo/* |
/foo/a/b |
no match | { 0: "a/b" } |
/foo/* |
/foo/ |
{} |
{ 0: "" } |
/foo/* |
/foo |
{} |
{} |
/*/x |
/a/b/x |
no match | { 0: "a/b" } |
/*.png |
/a/b.png |
no match | { 0: "a/b" } |
/**.md |
/a/b.md |
{ _: "a", 0: "b" } |
{ 0: "a/b" } |
/a/:x?/* |
/a/b |
{ 0: "b" } |
{ x: "b" } |
- Rules keyed by a trailing
*now apply at every depth. For example,"/admin/*": { auth: false }now covers/admin/a/b, not just/admin/a. - A
*before more of the route (/*/x) needs at least one segment, so it does not match/x.
Migrate:
- To match one segment, use
/users/:id?. To get v0.11's exact behavior, use/users{/([^\x2f]*)}?. - Inside a segment, replace
*with([^\x2f]*). For example,/*.pngbecomes/([^\x2f]*).png.
One catch-all per route (#240, #242)
A route can now hold only one of *, **, :x+, :x*, (.*) or :x(.*). Routes with more than one throw rou3: a route can have only one ....
| Pattern | v0.11 | Write instead |
|---|---|---|
/**/*.png |
{ _: "a", 0: "b" } on /a/b.png |
/**/:file.png |
/file-*-*.png |
{ 0: "a", 1: "b" } |
/file-:a-*.png, or a constraint like ([^\x2f]*) for one of them |
/*/x/*, /*/**, /*/:p+ |
accepted | a named :param for all but one |
(.*) and :name(.*) are catch-alls (#242)
A written (.*) is now the same as *, and :name(.*) is a * keyed by name. Both match across segments, as in URLPattern. In v0.11 they were limited to one segment, while routeToRegExp already matched across /.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
/foo/(.*) |
/foo/a/b |
no match | { 0: "a/b" } |
/foo/:p(.*) |
/foo/a/b |
no match | { p: "a/b" } |
/foo/:p(.*) |
/foo/ |
no match | { p: "" } |
:p(.*)?,(.*)+and other modifiers on these groups now throw, as they do on*.- Other constraints that can match
/((.+),(.*?),([^x]*)) still stay within one segment.
Migrate: for a single-segment value, use :p or :p([^\x2f]*).
A bare ** has a numbered key (#234)
A bare ** is now an unnamed capture, as in URLPattern. It is keyed "0", "1", … together with * and unnamed (…) groups, in pattern order across the whole pattern.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
/foo/** |
/foo/a/b |
{ _: "a/b" } |
{ 0: "a/b", _: "a/b" } |
/foo/** |
/foo |
{ _: "" } |
{} |
/x{(\d+)}?/* |
/x/b |
{ 0: "b" } |
{ 1: "b" } |
params._still works, but it is deprecated.- A
**that matches zero segments now leaves its key out. It used to set_: "". - Unnamed keys after a
**, or after an optional group that holds unnamed captures, shift up. A left-out group still uses up its numbers. - In
routeToRegExpoutput, the**group is_0,_1, … instead of_. - In
InferRouteParams, the**key isstring | undefined, plus a deprecated_?: string.
Migrate: read params["0"] ?? "" instead of params._, or name the capture: /foo/**:path.
Literal pattern text is percent-encoded (#231)
addRoute now percent-encodes a pattern's literal text once, like URLPattern. Lookup paths are never decoded or encoded, so pass an encoded pathname such as new URL(req.url).pathname.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
/café |
/caf%C3%A9 |
no match | match |
/café |
/café |
match | no match |
/a b |
/a%20b |
no match | match |
routeToRegExp,routeNodeKeysand the compiled matchers also use the encoded text. For example,routeToRegExp("/café")is^\/caf%C3%A9\/?$.- Regex constraints are not encoded.
Migrate: look up routes with the raw, encoded pathname. Stop calling decodeURI() / decodeURIComponent() on it before findRoute.
Named catch-alls never take an empty segment (#232, #241)
:name+, :name* and **:name now behave like URLPattern's [^/]+(?:/[^/]+)*: each segment they take needs a value.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
/a/:x* |
/a// |
{ x: "" } |
no match |
/foo/:bar+ |
/foo/a//b |
{ bar: "a//b" } |
no match |
/foo/**:bar |
/foo//a |
{ bar: "/a" } |
no match |
/foo/:bar+ |
/foo/a/b/ |
{ bar: "a/b" } |
{ bar: "a/b" } (one trailing slash is still ignored) |
These paths now fall through to a less specific route, or to no match.
Migrate: to accept empty segments, use *, ** or :name(.*).
A greedy capture before an in-segment optional param takes what it can (#233, #238)
A ? param that follows a capture in the same segment is now matched in place, as in URLPattern. The capture before it is greedy, and the route that includes the param no longer wins.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
/:a(\d+):b? |
/12 |
{ a: "1", b: "2" } |
{ a: "12" } |
/*-:x? |
/-- |
{ 0: "", x: "-" } |
{ 0: "-" } |
- After plain text, nothing changes:
pre-:x?still matchespre-andpre-<value>. - A
{:name}?group that follows text and ends its segment is read as:name?. For example,/a/*{:x}?is/a/*:x?.
Migrate: if you need the param to take its share, constrain the capture before it, e.g. /:a(\d):b?.
} ends a param before a regex group (#236)
A regex group right after a {…} group that ends in a param is now an unnamed capture next to the param, as in URLPattern. It is no longer that param's constraint.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
/{:foo}(.*) |
/foobarbaz |
{ foo: "foobarbaz" } |
{ foo: "f", 0: "oobarbaz" } |
/{:foo}?(…),/:foo{}(…)and/:foo{(x)}?change the same way./{:foo}{*},/{:foo}?*,/{:foo}(x)?,/*{*}, and{?}/{+}after a param now throw.
Migrate: write the constraint directly on the param: /:foo(.*).
A leading {/…} group is absolute (#239)
A pattern that starts with a {/…} group no longer gets an extra / in front.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
{/:a}?/b |
/b |
no match | {} |
{/:a}?/b |
/x/b |
no match | { a: "x" } |
{/:a}?/b |
//b |
{} |
no match |
Text right after a leading {/…}? ({/a}?b, {/:a}?.png) now throws.
. / .. segments in patterns are resolved (#246)
. and .. segments, including %2e forms, are resolved when a route is added, the same way new URL() resolves a path.
| Pattern | Path | v0.11 | v0.12 |
|---|---|---|---|
/foo/../bar |
/bar |
no match | match |
/foo/../bar |
/foo/../bar |
match | no match |
/\.\./bar |
/../bar |
match | match (escaped dots stay literal) |
/:id/.. |
accepted | throws |
- A dot segment next to a param, catch-all or group throws
rou3: `.` / `..` segment next to a param, catch-all or group. removeRouteresolves dot segments too. For example,removeRoute(router, "GET", "/docs/../api")removes/api.- Lookup paths are unchanged. They are resolved only with
normalize: true.
Migrate: escape the dots (\.\.) to match a literal .. segment.
New pattern errors (#244, #245)
| Pattern | v0.12 | Write instead |
|---|---|---|
a raw tab, LF or CR anywhere ("/a\tb") |
throws | %09, %0A, %0D (or \t, \n, \r inside a constraint) |
-- / && in a constraint's character class (/:x([[a-z]--a])) |
throws (URLPattern reads these as v-flag set operations) |
escape them for literal chars: [a\-\-z], [a\&\&b] |
Derived APIs
routeToRegExp:**groups are named_0,_1, … (#234).- Literal text is percent-encoded (#231).
:name+/:name*/**:nameuse[^/]+(?:/[^/]+)*(#241).- On engines without duplicate named groups (Node 20 / 22), a group right before or after a trailing
*that can't be inlined now throwsrou3: the regex for "…" repeats a named group…. Examples:/{b}?/*,/files/*{.:ext}?/raw.
regExpToRoute:- Reads an unnamed catch-all back as
**(old_groups are still accepted). - Throws on a literal char that a route would encode, and on a raw tab/LF/CR or
--/&&inside a constraint class. - Throws on a hand-written trailing
\/([^/]*)\/?$, which no route now matches.
- Reads an unnamed catch-all back as
compareRoutes:("/a/*", "/a/**")is now"equal"(it was"subset")./a/:x*now equals/a{/**:y}?and is a subset of/a/**.routeNodeKeys:"/a/*"gives["/a/**"](it was["/a/*"]). Param segments are keyed:_0,:_1, ….normalize: true: a trailing./..keeps its trailing slash, as in WHATWG URL./a//.is now/a//and no longer matches/a.InferRouteParams: a trailing whole-segment*,(.*)or:name(.*)is typedstring | undefined.**<text>is one key.
No action needed
findAllRouteslists eachaddRoutecall once. Before,/a/:x?/:y?on/a/breturned the route twice ({ x }and{ y }). Now it returns it once, with the variantfindRoutepicks (1c8aaa3).- Constrained and literal in-segment params now rank above plain ones on the same node, whatever the registration order.
/f/:name.pngand/f/:name.:ext(png|jpg)both beat/f/:name.:exton/f/a.png(0f437bc). - Segments with several params (
/blog/:year-:month-:day.html) now match in linear time infindRouteand the compiled matchers.routeToRegExpoutput for them no longer backtracks polynomially, which closes the ReDoS class of CVE-2024-45296 (e3e9a25, 0b8fde4). RouterContextproperties are marked@internal(c0f6015).
🩹 Fixes
- router:
⚠️ Percent-encode literal pattern text like URLPattern (#231) - router:
⚠️ Never let:name*capture an empty value (#232) - router:
⚠️ Let a greedy capture before an optional param take what it can (#233) - router:
⚠️ Key a bare**capture like URLPattern (#234) - router:
⚠️ End a param at a}before a regex group (#236) ⚠️ Review follow-ups for #231, #232 and #233 (#238)- router:
⚠️ Don't prefix/to a pattern that starts with a{/…}group (#239) - router:
⚠️ Make*a greedy catch-all like URLPattern (#240) - router:
⚠️ Reject empty segments inside:name+/:name*like URLPattern (#241) - router:
⚠️ Read(.*)and:name(.*)as greedy catch-alls like URLPattern (#242) - router:
⚠️ Reject--/&&in a constraint class like URLPattern (#245) - router:
⚠️ Throw on a tab, LF or CR in a pattern (#244) - router:
⚠️ Resolve./..segments in patterns like URLPattern (#246) - router: List each route once in findAllRoutes (1c8aaa3)
- router: Match segments with several captures in linear time (e3e9a25)
- regexp: Avoid polynomial backtracking for params sharing a segment (0b8fde4)
- router: Rank constrained and literal in-segment params above plain ones (0f437bc)
💅 Refactors
- Reorganize src and split docs (aa4c478)
📖 Documentation
- Clarify encoded dots, lookup path input and tie order (746210e)
- Simplify pattern and matching docs (f00fba9)
- Simplify public api jsdocs and remaining readme sections (f066346)
🏡 Chore
- Mark
RouterContextprops as internal (c0f6015)
✅ Tests
- wpt: Sync urlpattern data with upstream and tighten harness (863bb6a)
- wpt: Compare groups strictly and pin rou3 results for known diffs (#235)
- find-all: Sweep that a containing route is listed first (#237)
- Raise timeout for empty-segments compiled parity sweep (2a658e3)
- scenarios: Add data-driven cross-matcher scenario suite (01f72fb)