Skip to content

v0.11.0

Choose a tag to compare

@pi0 pi0 released this 29 Sep 16:27
· 32 commits to main since this release

⚠️ Migration guide

Most apps can upgrade with no changes. Route patterns now follow URLPattern more closely, so check the sections below that apply to how you use rou3.

A - ends a param name (#222, #230)

Param names are now [A-Za-z_]\w*, as in URLPattern. A - is no longer part of the name, so :year-:month is two params again.

Pattern Path v0.10 v0.11
/api/:test-id /api/abc { "test-id": "abc" } no match
/api/:test-id /api/abc-id { "test-id": "abc-id" } { test: "abc" }
/blog/:year-:month /blog/2024-05 { "year-": "2024-0", month: "5" } { year: "2024", month: "05" }
/blog/:id{-:title}? /blog/1-hi { "id-": "1-h", title: "i" } { id: "1", title: "hi" }
  • A { or } also ends a name: /:a{b}? is the param a followed by an optional b (it used to be one param named ab).
  • InferRouteParams reads names the same way, so the types change too (/blog/:year-:month gives { year, month }).

Migrate: rename params that contain -, e.g. :test-id → :test_id or :testId.

Several params in one segment: the first one takes as little as possible (#230)

When a segment holds more than one param, the first one now matches as little as it can, like URLPattern.

Pattern Path v0.10 v0.11
/:name.:ext /a.tar.gz { name: "a.tar", ext: "gz" } { name: "a", ext: "tar.gz" }
/:a-:b /x-y-z { "a-": "x-y-", b: "z" } { a: "x", b: "y-z" }

Migrate: to keep the old split, constrain the param that follows: /:name.:ext(\w+) gives { name: "a.tar", ext: "gz" }.

prefix-:param? makes only the param optional (#230)

A ? on a param that does not start its segment now makes only that param optional, not the whole segment.

Pattern Path v0.10 v0.11
/a/pre-:x? /a/pre-b { x: "b" } { x: "b" }
/a/pre-:x? /a/pre- no match match (no x)
/a/pre-:x? /a match no match

Migrate: to make the whole segment optional, write /a/{pre-:x}?.

Required params never match an empty segment (#230)

A :name, :name+ or **:name now needs a value, as in URLPattern. Handlers no longer need to guard against "" for these params.

Pattern Path v0.10 v0.11
/foo/:bar /foo// { bar: "" } no match
/foo/:bar/x /foo//x { bar: "" } no match
/foo/:bar+ /foo// { bar: "" } no match
/foo/**:rest /foo// { rest: "" } no match
/foo/*, /foo/**, /foo/:bar* /foo// match match (unchanged)

Migrate: if you need to accept an empty segment, use *, **, :name*, or a constraint that allows empty, such as :id(\d*).

Method-agnostic routes are no longer hidden by method routes (#223)

A route added with method "" could disappear when a route for a specific method landed on the same place in the tree, even if that method route didn't match. Now both are always considered, and the more specific route wins. On a tie, the method route still wins.

addRoute(router, "", "/u/*", "any");
addRoute(router, "GET", "/u/:id(\\d+)", "get");

findRoute(router, "GET", "/u/abc"); // v0.10: undefined          v0.11: "any"
findAllRoutes(router, "GET", "/u/42"); // v0.10: ["get"]         v0.11: ["any", "get"]
  • findAllRoutes (and compiled matchAll) now return both routes, method-agnostic first, so a "" middleware or auth route is no longer silently dropped.
  • A more specific "" route now beats a broader method route: with "" /u/:id(\d+) and GET /u/*, GET /u/1 returns the "" route (v0.10 returned GET /u/*).

Migrate: nothing to change unless you relied on a method route hiding a "" route on the same path. If so, expect the extra match in findAllRoutes.

Backslash escapes mean a literal character (#228)

Outside a regex constraint, any \x is now a literal x, as in URLPattern. Before, the router kept the backslash in static segments, while routeToRegExp already dropped it, so the two disagreed.

Pattern Path v0.10 v0.11
/foo\.bar /foo.bar no match match
/a\*b /a*b no match match
/a/\(:x /a/(1 no match { x: "1" }
  • \\ is a literal backslash.
  • A \/ or a trailing \ throws.

Migrate: if a route really needs a backslash in the path, write \\.

Regex constraints: no anchors, look-arounds, backreferences or capturing groups (#226, #228, #230)

These now throw, because the router tests a constraint against one segment while routeToRegExp puts it inside the whole-path regex, so they matched different paths.

Pattern v0.11 Write instead
/:slug((?!admin)\w+) throws (look-around) /:slug(\w+) plus a static /admin route, which always wins over a param
/:x(^\d+$) throws (anchor) /:x(\d+) (constraints already match the whole segment)
/:x((a)) throws (capturing group inside a constraint) /:x((?:a))
/a/(), /a/(?:a|b), /a/(?<n>x) throws (empty or (? group) /a/:x((?:a|b)), /a/:n(x)

Migrate: rewrite constraints as shown. Look-arounds have no direct replacement: register the excluded paths as their own routes or check the value in your handler.

Pattern syntax with no meaning now throws (#226, #230)

addRoute (and routeToRegExp) now throw rou3: <what> (<your pattern>) for syntax that used to be accepted with a surprising meaning. The error quotes the route, so failures show up at startup.

Pattern v0.10 Write instead
/a/:x(\d+)+, /a/pre-:x+ dropped the constraint or prefix (/a/:x(\d+)+ matched /a/b/c as { x: "b/c" }) + / * only after a whole-segment :name (/a/:x+)
/a/**?, /a/*+, /:x??, /p/**:x? accepted, with an unclear meaning /p/:x* for an optional catch-all
/foo? never matched (lookup paths have no query) /foo\? for a literal ?
/a**b read as two wildcards /a*b, or /a\*\*b for literal stars
/a/**:x.json, /a/**:x(\d+) param named "x.json" / "x(\d+)" a plain **:name as the whole last segment
/:0, /:1st, /:café, /:id$ accepted as names /:v1, /:_1, /:cafe, /:id\$
/a/:, /a/x: literal : /a/\:
/a/:x/:x, /a/:x.:x later value won, or a raw SyntaxError unique names
/a/{b, /a/b}, /a/{{b}} literal or mis-parsed braces \{ / \} for literal braces, no nested groups
/a/{b}+, /a{/b}* threw a non-rou3: error not supported (only {…} and {…}?)
U+FFFD–U+FFFF in a pattern accepted not allowed (used internally)

Migrate: fix each pattern the error points to, using the right-hand column.

regExpToRoute rejects unanchored regexes and u / v flags (#218)

An unanchored regex matches any path that contains it, so turning it into a whole-path route was wrong. The u / v flags change what a constraint means, so they can't be represented either.

Input v0.10 v0.11
/\/users\/(?<id>\d+)/ /users/:id(\d+) throws
/^\/users/ /users throws
/^\/a\/(?<x>\p{L}+)$/u /a/:x(\p{L}+) throws
/^\/users$/ /users /users
  • Regexes produced by routeToRegExp are always anchored and keep working. The one exception is the v0.10 output for :x(\d+)+-style routes, which now throws since that pattern itself is rejected.

Migrate: anchor hand-written regexes with ^…$ and drop the u / v flags.

compileRouterToString emits route data as plain JSON (#225)

Route data in the generated code now always goes through JSON.stringify, so the output is always valid code.

Route data v0.10 output v0.11 output
new Date(0) invalid code "1970-01-01T00:00:00.000Z"
{ toJSON: () => ({ a: 1 }) } invalid code { a: 1 }
{ toJSON: () => "code" } emitted code as raw JS emitted as the string "code"
a function, symbol or bigint (anywhere in the data) silently dropped, or a raw TypeError throws a rou3: error
  • compileRouterToString now takes an options object: compileRouterToString(router, { functionName, matchAll, serialize }). The old (router, "name", opts) form still works but is deprecated.
  • RouterCompilerOptions is deprecated in favor of CompileRouterToStringOptions and CompileRouterOptions.
  • compileRouter<T>(router, { matchAll: true }) is now typed as returning an array.

Migrate: if your data contains functions or used toJSON() to emit code, pass serialize: (data) => "<js expression>".

Node.js 20.19+ (#224)

package.json now declares "engines": { "node": ">=20.19.0" }.

No action needed

  • findRoute and findAllRoutes return a fresh { data, params } object on every call. Static matches and params: false results no longer expose internal fields, and mutating a result no longer affects later lookups (#219).
  • Falsy route data (0, false, "") is returned as-is instead of null. Missing data is still null (#219).
  • findOverlappingRoutes reports a route with optional parts once, and reports distinct routes separately even when they share the same data object (#220).

compare changes

🚀 Enhancements

  • compiler: ⚠️ Options object, matchAll overloads and JSON route data (#225)

🩹 Fixes

  • regexp-to-route: Reject unanchored regexes and unicode flags (#218)
  • router: End param names at a - no word char follows (#222)
  • router: ⚠️ Never let a method-scoped route hide a method-agnostic one (#223)
  • router: ⚠️ Reject pattern syntax with no meaning yet (#226)
  • overlap: Dedupe findOverlappingRoutes by registration, not data reference (#220)
  • find: Return fresh match objects from the static fast path (#219)
  • router: ⚠️ Read any \x as a literal x, reject anchors in constraints (#228)
  • ⚠️ Align route pattern syntax with URLPattern (#230)