Skip to content

v0.12.0

Latest

Choose a tag to compare

@pi0x pi0x released this 02 Oct 21:03
· 4 commits to main since this release

⚠️ 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, /*.png becomes /([^\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 routeToRegExp output, the ** group is _0, _1, … instead of _.
  • In InferRouteParams, the ** key is string | 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, routeNodeKeys and 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 matches pre- and pre-<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.
  • removeRoute resolves 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* / **:name use [^/]+(?:/[^/]+)* (#241).
    • On engines without duplicate named groups (Node 20 / 22), a group right before or after a trailing * that can't be inlined now throws rou3: 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.
  • 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 typed string | undefined. **<text> is one key.

No action needed

  • findAllRoutes lists each addRoute call once. Before, /a/:x?/:y? on /a/b returned the route twice ({ x } and { y }). Now it returns it once, with the variant findRoute picks (1c8aaa3).
  • Constrained and literal in-segment params now rank above plain ones on the same node, whatever the registration order. /f/:name.png and /f/:name.:ext(png|jpg) both beat /f/:name.:ext on /f/a.png (0f437bc).
  • Segments with several params (/blog/:year-:month-:day.html) now match in linear time in findRoute and the compiled matchers. routeToRegExp output for them no longer backtracks polynomially, which closes the ReDoS class of CVE-2024-45296 (e3e9a25, 0b8fde4).
  • RouterContext properties are marked @internal (c0f6015).

compare changes

🩹 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 RouterContext props 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)