diff --git a/CHANGES.md b/CHANGES.md
index a0fb2cad7..3c3e42419 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -113,7 +113,13 @@ To be released.
and other high-cardinality identifiers are deliberately excluded
from the fanout histogram. [[#316], [#742], [#770]]
+ - Replaced Fedify's internal federation routing with
+ *@fedify/uri-template* for stricter RFC 6570 URI Template expansion and
+ matching. The deprecated `Router` export from *@fedify/fedify* remains
+ available for compatibility. [[#418], [#758] by ChanHaeng Lee]
+
[#316]: https://github.com/fedify-dev/fedify/issues/316
+[#418]: https://github.com/fedify-dev/fedify/issues/418
[#619]: https://github.com/fedify-dev/fedify/issues/619
[#735]: https://github.com/fedify-dev/fedify/issues/735
[#736]: https://github.com/fedify-dev/fedify/issues/736
@@ -125,6 +131,7 @@ To be released.
[#753]: https://github.com/fedify-dev/fedify/pull/753
[#755]: https://github.com/fedify-dev/fedify/pull/755
[#757]: https://github.com/fedify-dev/fedify/pull/757
+[#758]: https://github.com/fedify-dev/fedify/pull/758
[#759]: https://github.com/fedify-dev/fedify/pull/759
[#769]: https://github.com/fedify-dev/fedify/pull/769
[#770]: https://github.com/fedify-dev/fedify/pull/770
@@ -140,6 +147,13 @@ To be released.
- Added a `meterProvider` option to `createFederation()` so mock contexts can
expose a test OpenTelemetry meter provider. [[#316], [#619], [#755]]
+### @fedify/uri-template
+
+ - Added *@fedify/uri-template*, a dependency-free RFC 6570 URI Template
+ implementation for expansion, variable extraction, and round-trip route
+ matching. This package replaces Fedify's previous direct use of
+ *url-template* and *uri-template-router*. [[#418], [#758] by ChanHaeng Lee]
+
### @fedify/amqp
- Added `AmqpMessageQueue.getDepth()` for reporting queued, ready, and
diff --git a/cspell.json b/cspell.json
index c7516d114..b6a11b6bd 100644
--- a/cspell.json
+++ b/cspell.json
@@ -30,9 +30,11 @@
"docloader",
"dotenvx",
"draft-cavage",
+ "duplicable",
"eddsa",
"elysia",
"elysiajs",
+ "explodable",
"fanout",
"federatable",
"Federatable",
@@ -79,6 +81,7 @@
"nodeinfo",
"nuxi",
"nuxt",
+ "operatables",
"optique",
"phensley",
"Pico",
@@ -88,6 +91,7 @@
"popd",
"poppanator",
"precommand",
+ "prefixable",
"proto",
"pushd",
"pwsh",
@@ -121,6 +125,11 @@
"urlpattern",
"uuidv7",
"valueparser",
+ "varspec",
+ "varname",
+ "varnames",
+ "varchar",
+ "varchars",
"Vinxi",
"vitepress",
"vtsls",
diff --git a/deno.json b/deno.json
index 13b302e8b..2740fe03e 100644
--- a/deno.json
+++ b/deno.json
@@ -25,6 +25,7 @@
"./packages/sqlite",
"./packages/sveltekit",
"./packages/testing",
+ "./packages/uri-template",
"./packages/vocab",
"./packages/vocab-runtime",
"./packages/vocab-tools",
diff --git a/deno.lock b/deno.lock
index 22671e40a..bef73340b 100644
--- a/deno.lock
+++ b/deno.lock
@@ -9467,9 +9467,7 @@
"npm:json-canon@^1.0.1",
"npm:jsonld@9",
"npm:pkijs@^3.3.3",
- "npm:structured-field-values@^2.0.4",
- "npm:uri-template-router@1",
- "npm:url-template@^3.1.1"
+ "npm:structured-field-values@^2.0.4"
],
"packageJson": {
"dependencies": [
@@ -9481,8 +9479,6 @@
"npm:miniflare@^4.20250523.0",
"npm:structured-field-values@^2.0.4",
"npm:tsx@^4.21.0",
- "npm:uri-template-router@1",
- "npm:url-template@^3.1.1",
"npm:wrangler@^4.17.0"
]
}
diff --git a/docs/manual/actor.md b/docs/manual/actor.md
index b5e7bf37a..8b273c8df 100644
--- a/docs/manual/actor.md
+++ b/docs/manual/actor.md
@@ -98,10 +98,13 @@ should fall through to the next middleware or `onNotFound` handler.
> [!NOTE]
> The URI Template syntax supports different expansion types like `{identifier}`
-> (simple expansion) and `{+identifier}` (reserved expansion). Choosing the
-> right expansion type is important to avoid encoding issues. See the
-> [*URI Template* guide](./uri-template.md) for details on when to use
-> each type.
+> (simple expansion) and `{+identifier}` (reserved expansion). Use the plain
+> `{identifier}` form for ordinary segment-bounded identifiers such as
+> `/users/{identifier}`. `{+identifier}` is an advanced choice reserved for
+> identifiers that themselves contain slashes (such as embedded URIs); because
+> it keeps `/` literal, it can consume extra path segments and overlap with
+> more specific routes. See the [*URI Template* guide](./uri-template.md) for
+> details on when to use each type.
[actors]: https://www.w3.org/TR/activitystreams-core/#actors
[activities]: https://www.w3.org/TR/activitystreams-core/#activities
diff --git a/docs/manual/collections.md b/docs/manual/collections.md
index 5947f3dca..06b019f96 100644
--- a/docs/manual/collections.md
+++ b/docs/manual/collections.md
@@ -53,9 +53,12 @@ follows the [URI Template] specification.
> [!NOTE]
> The URI Template syntax supports different expansion types like `{identifier}`
-> (simple expansion) and `{+identifier}` (reserved expansion). If your
-> identifiers contain URIs or special characters, you may need to use
-> `{+identifier}` to avoid double-encoding issues. See the
+> (simple expansion) and `{+identifier}` (reserved expansion). Use the plain
+> `{identifier}` form for ordinary segment-bounded identifiers; `{+identifier}`
+> is an advanced choice reserved for identifiers that themselves contain
+> slashes (such as embedded URIs), since it keeps `/` literal and can consume
+> extra path segments. Note that a writable outbox is further restricted to
+> the strict `{identifier}` shape and rejects `{+identifier}`. See the
> [*URI Template* guide](./uri-template.md) for details.
Since the outbox is a collection of activities, the outbox dispatcher should
diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md
index 218d794c6..3c436b44d 100644
--- a/docs/manual/inbox.md
+++ b/docs/manual/inbox.md
@@ -182,10 +182,13 @@ multiple inbox listeners for different activity types.
> [!NOTE]
> The URI Template syntax supports different expansion types like `{identifier}`
-> (simple expansion) and `{+identifier}` (reserved expansion). If your
-> identifiers contain URIs or special characters, you may need to use
-> `{+identifier}` to avoid double-encoding issues. See the
-> [*URI Template* guide](./uri-template.md) for details.
+> (simple expansion) and `{+identifier}` (reserved expansion). Use the plain
+> `{identifier}` form for ordinary segment-bounded identifiers such as
+> `/users/{identifier}/inbox`. `{+identifier}` is an advanced choice reserved
+> for identifiers that themselves contain slashes (such as embedded URIs);
+> because it keeps `/` literal, it can consume extra path segments and overlap
+> with more specific routes, so add explicit validation when you use it. See
+> the [*URI Template* guide](./uri-template.md) for details.
> [!WARNING]
> Activities of any type that are not registered with
diff --git a/docs/manual/outbox.md b/docs/manual/outbox.md
index ef93a084b..2d592ed7b 100644
--- a/docs/manual/outbox.md
+++ b/docs/manual/outbox.md
@@ -79,9 +79,14 @@ actor who owns the addressed outbox.
> [!NOTE]
> The URI Template syntax supports different expansion types like
> `{identifier}` (simple expansion) and `{+identifier}` (reserved expansion).
-> If your identifiers contain URIs or special characters, you may need to use
-> `{+identifier}` to avoid double-encoding issues. See the
-> [*URI Template* guide][uri-template-guide] for details.
+> Use the plain `{identifier}` form for ordinary segment-bounded identifiers;
+> `{+identifier}` is an advanced choice reserved for identifiers that
+> themselves contain slashes (such as embedded URIs). The outbox is
+> additionally restricted: both `~Federatable.setOutboxDispatcher()` and
+> `~Federatable.setOutboxListeners()` require the strict single-segment
+> `{identifier}` shape and reject `{+identifier}` at registration time, so a
+> writable (or read-only) outbox cannot use reserved expansion. See
+> the [*URI Template* guide][uri-template-guide] for details.
[uri-template-guide]: ./uri-template.md
diff --git a/docs/manual/uri-template.md b/docs/manual/uri-template.md
index beaf50286..37ad02c8f 100644
--- a/docs/manual/uri-template.md
+++ b/docs/manual/uri-template.md
@@ -13,7 +13,19 @@ listeners, object dispatchers, and more. Understanding the different expansion
types is crucial for handling identifiers correctly, especially when they
contain special characters or URIs.
+Fedify's URI Template engine is published as a standalone
+package—[`@fedify/uri-template`][@fedify/uri-template]—which you can use
+independently of Fedify. If you only need RFC 6570 expansion and round-trip
+matching, jump to [Standalone `@fedify/uri-template`
+package](#uri-template-package) below.
+
+
+
[RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570
+[@fedify/uri-template]: https://jsr.io/@fedify/uri-template
What are URI Templates?
@@ -108,8 +120,37 @@ federation.setActorDispatcher(
> If you're getting double-encoding issues (e.g., `%253A` instead of `%3A`),
> switch from `{identifier}` to `{+identifier}`.
+> [!CAUTION]
+> Reserved expansion is an *advanced* choice, not the general recommendation
+> for Fedify dispatcher paths. Because `{+identifier}` keeps `/` literal, it
+> does not stop at a path-segment boundary: it can consume extra segments and
+> overlap with more specific routes. For example, `/users/{+identifier}`
+> also matches `/users/alice/inbox` and binds `identifier` to the value
+> `alice/inbox`, shadowing a dedicated `/users/{identifier}/inbox` route.
+>
+> The common ActivityPub route families—`/users/{identifier}`,
+> `/users/{identifier}/inbox`, and `/users/{identifier}/outbox`—rely on
+> segment-bounded identifiers, so keep the plain `{identifier}` form for
+> them. Reach for `{+identifier}` only when the identifier itself genuinely
+> contains slashes (such as an embedded URI), and add explicit validation in
+> the dispatcher to reject unexpected path separators (see [Matching issues
+> with `{+identifier}`](#matching-issues-with-identifier)). Some APIs
+> additionally forbid reserved expansion: a writable outbox requires the
+> strict single-segment `{identifier}` shape (see the note under [Expansion
+> versus matching](#expansion-versus-matching) below).
+
### Path segment expansion: `{/var}`
+> [!CAUTION]
+> `{/var}`, `{?var}`, and `{&var}` below are general [RFC 6570] operators
+> that `@fedify/uri-template` supports for expansion and matching. They are
+> **not** appropriate for required Fedify dispatcher identifiers: a Fedify
+> dispatcher exposes a non-optional `identifier: string` (or `values`)
+> callback contract, and these operators can all match without binding a
+> concrete value. Use `{identifier}` or `{+identifier}` for dispatcher
+> paths—see [Expansion versus matching](#expansion-versus-matching) and the
+> [Decision guide](#decision-guide).
+
Path expansion automatically prefixes the value with a `/` character.
It's useful for optional path segments. When the variable is empty or
undefined, nothing is added to the path:
@@ -140,6 +181,46 @@ to add more:
| ---------------------- | ------- | -------------------------- |
| `/search?type=all{&q}` | `hello` | `/search?type=all&q=hello` |
+### Expansion versus matching
+
+The standalone `@fedify/uri-template` `Router` supports every RFC 6570
+operator above for both expansion and matching, with no registration-time
+operator constraints.
+
+Fedify's dispatcher routes are stricter, and the rule differs by API:
+
+ - **Fixed-identifier routes** (`setActorDispatcher()`,
+ `setInboxDispatcher/Listeners()`, `setFollowingDispatcher()`,
+ `setFollowersDispatcher()`, `setLikedDispatcher()`,
+ `setFeaturedDispatcher()`, `setFeaturedTagsDispatcher()`) accept only
+ `{identifier}` or `{+identifier}`. Any other operator
+ (`{/identifier}`, `{?identifier}`, `{;identifier}`, `{.identifier}`,
+ `{#identifier}`, `{&identifier}`) is rejected at registration time with
+ a `DisallowedOperatorError`. The explode (`{identifier*}`) and prefix
+ (`{identifier:3}`) modifiers keep the simple operator but change the
+ binding shape, so they are instead rejected with a
+ `DisallowedVarSpecModifierError`. Use segment-boundary `{identifier}`
+ for ordinary identifiers and `{+identifier}` only when the identifier
+ itself contains slashes.
+
+ - **Outbox routes** (`setOutboxDispatcher()` and `setOutboxListeners()`)
+ are stricter still: both register with the same options and accept only
+ the single segment-boundary `{identifier}`. Reserved expansion
+ (`{+identifier}`), path-style expansion (`{/identifier}`), optional
+ operators (`{?identifier}`, `{;identifier}`, `{.identifier}`), explode
+ (`{identifier*}`), and prefix (`{identifier:3}`) are all rejected at
+ registration time. This matches *[Outbox](./outbox.md)*, which notes
+ that a writable outbox cannot use reserved expansion.
+
+ - **Generic routes** whose variable name is not fixed
+ (`setObjectDispatcher()` and custom collection dispatchers) apply no
+ registration-time operator constraint, but every template variable
+ carries a default *non-empty* constraint. An optional-operator or
+ path-expansion route such as `/objects{/id}` or `/objects{?id}`
+ registers successfully but only matches when `id` is actually present
+ and non-empty; an empty or missing binding is a runtime *Not Found*
+ rather than a registration error.
+
Common use cases in Fedify
--------------------------
@@ -335,36 +416,43 @@ federation.setActorDispatcher(
Decision guide
--------------
-Use this guide to choose the right expansion type:
+This guide is for **Fedify dispatcher paths**, where the identifier is a
+required value that must be bound from the request path. `Federation.fetch()`
+routes against `URL.pathname`, so only two expansion types are valid choices
+here—`{identifier}` and `{+identifier}`:
~~~~ mermaid
flowchart TD
- Start[What kind of identifier?]
+ Start[Fedify dispatcher identifier]
Start --> Simple{Simple string?
e.g., username, UUID}
- Start --> URI{Contains URI?
e.g., https://...}
- Start --> Path{Path segment?}
- Start --> Query{Query parameter?}
+ Start --> URI{Contains a URI or slashes?
e.g., https://...}
Simple --> UseBraces["Use {identifier}"]
URI --> UsePlus["Use {+identifier}"]
- Path --> UseSlash["Use {/var}"]
- Query --> UseQuestion["Use {?var} or {&var}"]
UseBraces --> Example1["Example: /users/{identifier}"]
UsePlus --> Example2["Example: /users/{+identifier}"]
- UseSlash --> Example3["Example: /api{/version}"]
- UseQuestion --> Example4["Example: /search{?q}"]
~~~~
-Quick reference:
+Quick reference for dispatcher identifiers:
+
+| If your identifier contains… | Use |
+| ------------------------------ | --------------- |
+| Just letters, numbers, hyphens | `{identifier}` |
+| UUIDs | `{identifier}` |
+| URIs or URLs | `{+identifier}` |
+| Special chars like `:`, `/` | `{+identifier}` |
+| Path segments | `{+identifier}` |
-| If your identifier contains… | Use |
-| ------------------------------ | --------------------------- |
-| Just letters, numbers, hyphens | `{identifier}` |
-| UUIDs | `{identifier}` |
-| URIs or URLs | `{+identifier}` |
-| Special chars like `:`, `/` | `{+identifier}` |
-| Path segments | `{+identifier}` or `{/var}` |
+> [!NOTE]
+> The other RFC 6570 operators (`{/var}`, `{?var}`, `{&var}`, `{;var}`,
+> `{.var}`, `{#var}`) are fully supported by the standalone
+> `@fedify/uri-template` package for general expansion and matching, but
+> they are deliberately absent from this chart: a required dispatcher
+> identifier must never come from an optional path or query expansion that
+> can match without binding a value. See [Standalone
+> `@fedify/uri-template` package](#uri-template-package) if you need them
+> outside a Fedify dispatcher.
Troubleshooting
@@ -414,12 +502,214 @@ federation.setActorDispatcher(
~~~~
+Standalone `@fedify/uri-template` package {#uri-template-package}
+-----------------------------------------------------------------
+
+The routing engine described above is published on its own as the
+[`@fedify/uri-template`][@fedify/uri-template] package. It has zero runtime
+dependencies and works on Deno, Node.js, and Bun, so you can use it for plain
+[RFC 6570] URI Template expansion and matching even outside a Fedify
+application.
+
+Install it with your package manager:
+
+~~~~ bash
+deno add jsr:@fedify/uri-template # Deno
+npm add @fedify/uri-template # npm
+pnpm add @fedify/uri-template # pnpm
+yarn add @fedify/uri-template # Yarn
+bun add @fedify/uri-template # Bun
+~~~~
+
+### Expanding and matching with `Template`
+
+A `Template` parses a URI Template string once and can then be reused. Call
+`expand()` to turn variables into a URI, and `match()` to recover the variables
+from a URI (it returns `null` when the URI does not match):
+
+~~~~ typescript twoslash
+import { Template } from "@fedify/uri-template";
+// ---cut-before---
+const template = new Template("/users/{identifier}");
+
+template.expand({ identifier: "alice" });
+// → "/users/alice"
+
+template.match("/users/alice");
+// → { identifier: "alice" }
+
+template.match("/posts/42");
+// → null
+~~~~
+
+The standalone `Template` supports every RFC 6570 operator (`{var}`, `{+var}`,
+`{#var}`, `{.var}`, `{/var}`, `{;var}`, `{?var}`, and `{&var}`), so it is not
+limited to the patterns recommended for Fedify dispatchers.
+
+### Round-trip matching
+
+`match()` does not merely decode a URI—it returns variables only when expanding
+them again reproduces the *exact* input URI. This rejects URIs that look
+plausible after decoding but could never have been produced by the template:
+
+~~~~ typescript twoslash
+import { Template } from "@fedify/uri-template";
+// ---cut-before---
+const template = new Template("/users/{identifier}");
+
+// Simple expansion percent-encodes the slash:
+template.expand({ identifier: "a/b" });
+// → "/users/a%2Fb"
+
+// The encoded form round-trips, so it matches:
+template.match("/users/a%2Fb");
+// → { identifier: "a/b" }
+
+// A literal slash could never be produced here, so there is no match:
+template.match("/users/a/b");
+// → null
+~~~~
+
+This is the same guarantee Fedify relies on to map an incoming request path
+back to a dispatcher identifier, which is why the
+[expansion type](#expansion-types) you choose matters.
+
+### Strict vs. lenient parsing
+
+By default a `Template` is *strict*: the first parse or expansion error is
+reported and then thrown. Pass `strict: false` to collect diagnostics through
+a `report` callback without throwing. This is useful when you want to accept
+looser input or surface warnings through your own logger:
+
+~~~~ typescript twoslash
+import { Template } from "@fedify/uri-template";
+// ---cut-before---
+// Strict (the default): the unclosed expression throws.
+try {
+ new Template("/users/{identifier");
+} catch (error) {
+ console.error(error); // an UnclosedExpressionError
+}
+
+// Lenient: errors are reported but not thrown.
+const diagnostics: Error[] = [];
+const lenient = new Template("/users/{identifier", {
+ strict: false,
+ report: (error) => diagnostics.push(error),
+});
+lenient.expand({ identifier: "alice" });
+console.log(diagnostics); // contains the reported parse error
+~~~~
+
+### Routing with `Router`
+
+`Router` maps many templates to names. Register routes, resolve a URI to a
+route with `route()`, and reverse the mapping with `build()`:
+
+~~~~ typescript twoslash
+import { Router } from "@fedify/uri-template";
+// ---cut-before---
+const router = new Router();
+router.add("/users/{identifier}", "actor");
+router.add("/users/{identifier}/followers", "followers");
+
+router.route("/users/alice");
+// → { name: "actor",
+// template: "/users/{identifier}",
+// values: { identifier: "alice" } }
+
+router.route("/users/alice/followers");
+// → { name: "followers", … }
+
+router.build("actor", { identifier: "alice" });
+// → "/users/alice"
+~~~~
+
+Register several routes at once with `register()`, and inspect a template
+without registering it through `Router.compile()` or `Router.variables()`:
+
+~~~~ typescript twoslash
+import { Router } from "@fedify/uri-template";
+// ---cut-before---
+const router = new Router();
+router.register([
+ ["/users/{identifier}", "actor"],
+ ["/users/{identifier}/inbox", "inbox"],
+] as const);
+
+Router.variables("/users/{identifier}/posts/{id}");
+// → Set { "identifier", "id" }
+~~~~
+
+### Per-route variable constraints
+
+Each route is a `[pathOrPattern, name, options?]` tuple. The optional
+third element constrains matching per template variable through its
+`variables` field:
+
+~~~~ typescript twoslash
+import { Router } from "@fedify/uri-template";
+// ---cut-before---
+const router = new Router();
+router.add("/search{?q}", "search", {
+ variables: { q: { nullable: true } },
+});
+
+// `q` is nullable, so the bare path still matches:
+router.route("/search");
+// → { name: "search", template: "/search{?q}", values: { q: null } }
+~~~~
+
+The constraint defaults are deliberately strict so routes fail loudly at
+registration time rather than mis-matching at runtime:
+
+ - `nullable` defaults to `false`: an unbound or empty variable is a
+ no-match (the router falls back to the next candidate). This is why a
+ `/search{?q}` route does *not* match `/search` until `q` is marked
+ `nullable: true`.
+ - `multiple` is derived from the specification (explode `{tags*}` ⇒
+ `true`, prefix `{id:3}` ⇒ `false`, plain ⇒ `false`). A contradicting
+ `multiple`, or the same name carrying conflicting explode/prefix
+ modifiers, throws `ConflictingVarSpecError`.
+ - `duplicable`, `prefixable`, and `explodable` all default to `false`:
+ a repeated variable, a `{var:N}` prefix, or a `{var*}` explode each
+ throws at registration time (`DuplicateRouteVariableError` and
+ `DisallowedVarSpecModifierError`) unless the matching flag is opted
+ in.
+ - `operatables` defaults to `[]` (every operator allowed); set it to a
+ non-empty operator list to reject other operators with
+ `DisallowedOperatorError`.
+
+The options object also takes `exact` (default `true`): when a
+`variables` object is supplied its keys must match the template's
+variables exactly, otherwise registration throws
+`RouteTemplateOptionsNotMatchedError`. Pass `{ exact: false }` to leave
+unlisted variables at their defaults and ignore unknown keys. Routes
+registered without a `variables` object keep every default and are
+unaffected.
+
+`Router.route()` is generic over the constraint map, so the recovered
+`values` narrow to `string` or `readonly string[]` per variable when you
+pass the constraints at the call site.
+
+> [!NOTE]
+> The standalone `Template` and `Router` accept every RFC 6570 operator.
+> When you use URI Templates for Fedify dispatchers, however, required
+> identifiers must be bound from the request path, so follow the
+> recommendations in [Expansion types](#expansion-types) and [Common use
+> cases in Fedify](#common-use-cases-in-fedify) above rather than every
+> operator the package can parse.
+
+
Further reading
---------------
[RFC 6570]: URI Template
: The official specification
+[`@fedify/uri-template`][@fedify/uri-template]
+: The standalone RFC 6570 package powering Fedify's router
+
[Actor dispatcher](./actor.md)
: Learn about actor routing in Fedify
diff --git a/docs/package.json b/docs/package.json
index 39381ce79..9d59d59a7 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -23,6 +23,7 @@
"@fedify/sqlite": "workspace:^",
"@fedify/sveltekit": "workspace:^",
"@fedify/testing": "workspace:^",
+ "@fedify/uri-template": "workspace:^",
"@fedify/vocab": "workspace:^",
"@fedify/vocab-runtime": "workspace:^",
"@hackmd/markdown-it-task-lists": "^2.1.4",
diff --git a/mise.toml b/mise.toml
index 095f58713..fb5968d9c 100644
--- a/mise.toml
+++ b/mise.toml
@@ -52,14 +52,17 @@ depends = [
[tasks."check:fmt"]
description = "Check code formatting"
+wait_for = ["install"]
run = "deno fmt --check"
[tasks."check:lint"]
description = "Check code linting"
+wait_for = ["install"]
run = "deno lint"
[tasks."check:types"]
description = "Check TypeScript types"
+wait_for = ["install"]
run = "deno check $(deno eval 'import m from \"./deno.json\" with { type: \"json\" }; for (let p of m.workspace) console.log(p)')"
[tasks."check:md"]
@@ -69,6 +72,7 @@ run = "hongdown --check"
[tasks.check-versions]
description = "Check that all package versions are consistent across the monorepo"
usage = 'flag "--fix" help="Automatically fix version mismatches"'
+wait_for = ["install"]
run = '''
if [ "${usage_fix}" = "true" ]; then
deno run --allow-read --allow-write scripts/check_versions.ts --fix
@@ -79,6 +83,7 @@ fi
[tasks."check:fixture-usage"]
description = "Ensure @fedify/fixture is only used in **/*.test.ts files"
+wait_for = ["install"]
run = "deno run --allow-read scripts/check_fixture_usage.ts"
[tasks."check:manifest:workspace-protocol"]
diff --git a/packages/fedify/README.md b/packages/fedify/README.md
index 9f6a9cdd9..8c17b11a4 100644
--- a/packages/fedify/README.md
+++ b/packages/fedify/README.md
@@ -123,6 +123,7 @@ Here is the list of packages:
| [@fedify/sqlite](/packages/sqlite/) | [JSR][jsr:@fedify/sqlite] | [npm][npm:@fedify/sqlite] | SQLite driver |
| [@fedify/sveltekit](/packages/sveltekit/) | [JSR][jsr:@fedify/sveltekit] | [npm][npm:@fedify/sveltekit] | SvelteKit integration |
| [@fedify/testing](/packages/testing/) | [JSR][jsr:@fedify/testing] | [npm][npm:@fedify/testing] | Testing utilities |
+| [@fedify/uri-template](/packages/uri-template/) | [JSR][jsr:@fedify/uri-template] | [npm][npm:@fedify/uri-template] | RFC 6570 URI Template library |
| [@fedify/vocab](/packages/vocab/) | [JSR][jsr:@fedify/vocab] | [npm][npm:@fedify/vocab] | Activity Vocabulary library |
| [@fedify/vocab-runtime](/packages/vocab-runtime/) | [JSR][jsr:@fedify/vocab-runtime] | [npm][npm:@fedify/vocab-runtime] | Runtime library for code-generated vocab |
| [@fedify/vocab-tools](/packages/vocab-tools/) | [JSR][jsr:@fedify/vocab-tools] | [npm][npm:@fedify/vocab-tools] | Code generation tools for Activity Vocab |
@@ -176,6 +177,8 @@ Here is the list of packages:
[npm:@fedify/sveltekit]: https://www.npmjs.com/package/@fedify/sveltekit
[jsr:@fedify/testing]: https://jsr.io/@fedify/testing
[npm:@fedify/testing]: https://www.npmjs.com/package/@fedify/testing
+[jsr:@fedify/uri-template]: https://jsr.io/@fedify/uri-template
+[npm:@fedify/uri-template]: https://www.npmjs.com/package/@fedify/uri-template
[jsr:@fedify/vocab]: https://jsr.io/@fedify/vocab
[npm:@fedify/vocab]: https://www.npmjs.com/package/@fedify/vocab
[jsr:@fedify/vocab-runtime]: https://jsr.io/@fedify/vocab-runtime
diff --git a/packages/fedify/deno.json b/packages/fedify/deno.json
index ac8e0ad18..f4c071af8 100644
--- a/packages/fedify/deno.json
+++ b/packages/fedify/deno.json
@@ -22,9 +22,7 @@
"json-canon": "npm:json-canon@^1.0.1",
"jsonld": "npm:jsonld@^9.0.0",
"pkijs": "npm:pkijs@^3.3.3",
- "structured-field-values": "npm:structured-field-values@^2.0.4",
- "uri-template-router": "npm:uri-template-router@^1.0.0",
- "url-template": "npm:url-template@^3.1.1"
+ "structured-field-values": "npm:structured-field-values@^2.0.4"
},
"exclude": [
".test-report.xml",
diff --git a/packages/fedify/package.json b/packages/fedify/package.json
index c2c40fcdc..d5e13579a 100644
--- a/packages/fedify/package.json
+++ b/packages/fedify/package.json
@@ -139,6 +139,7 @@
}
},
"dependencies": {
+ "@fedify/uri-template": "workspace:*",
"@fedify/vocab": "workspace:*",
"@fedify/vocab-runtime": "workspace:*",
"@fedify/webfinger": "workspace:*",
@@ -153,8 +154,6 @@
"json-canon": "^1.0.1",
"jsonld": "^9.0.0",
"structured-field-values": "^2.0.4",
- "uri-template-router": "^1.0.0",
- "url-template": "^3.1.1",
"urlpattern-polyfill": "catalog:"
},
"devDependencies": {
diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts
index 9d691c40d..038741f94 100644
--- a/packages/fedify/src/federation/builder.test.ts
+++ b/packages/fedify/src/federation/builder.test.ts
@@ -1,4 +1,11 @@
import { test } from "@fedify/fixture";
+import {
+ DisallowedOperatorError,
+ DisallowedVarSpecModifierError,
+ DuplicateRouteVariableError,
+ RouterError,
+ RouteTemplateOptionsNotMatchedError,
+} from "@fedify/uri-template";
import { Activity, Note, Person } from "@fedify/vocab";
import { assertEquals, assertExists, assertThrows } from "@std/assert";
import type { Protocol } from "../nodeinfo/types.ts";
@@ -13,7 +20,6 @@ import type {
} from "./callback.ts";
import { MemoryKvStore } from "./kv.ts";
import type { FederationImpl } from "./middleware.ts";
-import { RouterError } from "./router.ts";
test("FederationBuilder", async (t) => {
await t.step(
@@ -160,6 +166,31 @@ test("FederationBuilder", async (t) => {
},
);
+ await t.step("should snapshot router state on build", async () => {
+ const builder = createFederationBuilder();
+ const kv = new MemoryKvStore();
+ const noteRouteName = `object:${Note.typeId.href}`;
+
+ builder.setActorDispatcher("/users/{identifier}", () => null);
+ const federation1 = await builder.build({ kv });
+ const impl1 = federation1 as FederationImpl;
+
+ builder.setObjectDispatcher(Note, "/notes/{id}", () => null);
+ assertEquals(impl1.router.route("/notes/1"), null);
+
+ const federation2 = await builder.build({ kv });
+ const impl2 = federation2 as FederationImpl;
+ assertEquals(impl2.router.route("/notes/1")?.name, noteRouteName);
+
+ impl1.router.add("/leaked/{id}", "leaked");
+ assertEquals(impl1.router.route("/leaked/1")?.name, "leaked");
+ assertEquals(impl2.router.route("/leaked/1"), null);
+
+ const federation3 = await builder.build({ kv });
+ const impl3 = federation3 as FederationImpl;
+ assertEquals(impl3.router.route("/leaked/1"), null);
+ });
+
await t.step("should build with default options", async () => {
const builder = createFederationBuilder();
const kv = new MemoryKvStore();
@@ -209,12 +240,33 @@ test("FederationBuilder", async (t) => {
builderAfterInvalid.setOutboxListeners(
"/users/{identifier}/outbox/{extra}",
),
- RouterError,
+ RouteTemplateOptionsNotMatchedError,
+ );
+ assertThrows(
+ () =>
+ builderAfterInvalid.setOutboxListeners(
+ "/users/{identifier:3}/outbox" as `${string}{identifier}${string}`,
+ ),
+ DisallowedVarSpecModifierError,
+ );
+ assertThrows(
+ () =>
+ builderAfterInvalid.setOutboxListeners(
+ "/users/{identifier*}/outbox" as `${string}{identifier}${string}`,
+ ),
+ DisallowedVarSpecModifierError,
+ );
+ assertThrows(
+ () =>
+ builderAfterInvalid.setOutboxListeners(
+ "/users/{identifier,identifier}/outbox" as `${string}{identifier}${string}`,
+ ),
+ DuplicateRouteVariableError,
);
builderAfterInvalid.setOutboxListeners("/users/{identifier}/outbox");
const builder2 = createFederationBuilder();
- builder2.setOutboxListeners("/users{/identifier}/outbox");
+ builder2.setOutboxListeners("/users/{identifier}/outbox");
assertThrows(
() =>
@@ -228,7 +280,19 @@ test("FederationBuilder", async (t) => {
const builder3 = createFederationBuilder();
assertThrows(
() => builder3.setOutboxListeners("/users{?identifier}/outbox"),
- RouterError,
+ DisallowedOperatorError,
+ );
+
+ const builder3a = createFederationBuilder();
+ assertThrows(
+ () => builder3a.setOutboxListeners("/users{;identifier}/outbox"),
+ DisallowedOperatorError,
+ );
+
+ const builder3b = createFederationBuilder();
+ assertThrows(
+ () => builder3b.setOutboxListeners("/users{.identifier}/outbox"),
+ DisallowedOperatorError,
);
const builder4 = createFederationBuilder();
@@ -238,10 +302,368 @@ test("FederationBuilder", async (t) => {
"/users{?identifier}/outbox",
() => ({ items: [] }),
),
- RouterError,
+ DisallowedOperatorError,
+ );
+
+ const builder5 = createFederationBuilder();
+ assertThrows(
+ () =>
+ builder5.setOutboxDispatcher(
+ "/users/{identifier:3}/outbox" as `${string}{identifier}${string}`,
+ () => ({ items: [] }),
+ ),
+ DisallowedVarSpecModifierError,
);
});
+ await t.step(
+ "rejects non-segment-boundary identifier operators at registration " +
+ "for required-identifier routes",
+ () => {
+ // Fedify's actor/inbox/outbox/collection dispatchers expose a single
+ // required `identifier: string`. Path-style expansion
+ // (`{/identifier}`), reserved expansion (`{+identifier}`), and the
+ // optional operators (`{?identifier}`, `{;identifier}`,
+ // `{.identifier}`) can all match without binding a concrete
+ // segment-bounded identifier, so they are rejected at registration
+ // time rather than relying on a runtime no-match. See
+ // https://github.com/fedify-dev/fedify/pull/758#discussion_r3252548632
+ type IdPath = `${string}{identifier}${string}`;
+
+ // Actor dispatcher.
+ assertThrows(
+ () =>
+ createFederationBuilder().setActorDispatcher(
+ "/users{/identifier}" as IdPath,
+ () => null,
+ ),
+ DisallowedOperatorError,
+ );
+ assertThrows(
+ () =>
+ createFederationBuilder().setActorDispatcher(
+ "{/identifier}" as IdPath,
+ () => null,
+ ),
+ DisallowedOperatorError,
+ );
+ assertThrows(
+ () =>
+ createFederationBuilder().setActorDispatcher(
+ "/users/{/identifier}" as IdPath,
+ () => null,
+ ),
+ DisallowedOperatorError,
+ );
+ assertThrows(
+ () =>
+ createFederationBuilder().setActorDispatcher(
+ "/users{?identifier}" as IdPath,
+ () => null,
+ ),
+ DisallowedOperatorError,
+ );
+
+ // Inbox listeners.
+ assertThrows(
+ () =>
+ createFederationBuilder().setInboxListeners(
+ "/users{/identifier}/inbox" as IdPath,
+ ),
+ DisallowedOperatorError,
+ );
+
+ // Outbox listeners and dispatcher keep the same strict shape.
+ assertThrows(
+ () =>
+ createFederationBuilder().setOutboxListeners(
+ "/users{/identifier}/outbox" as IdPath,
+ ),
+ DisallowedOperatorError,
+ );
+
+ // Prefix and explode modifiers remain registration errors too.
+ assertThrows(
+ () =>
+ createFederationBuilder().setActorDispatcher(
+ "/users/{identifier:3}" as IdPath,
+ () => null,
+ ),
+ DisallowedVarSpecModifierError,
+ );
+ assertThrows(
+ () =>
+ createFederationBuilder().setActorDispatcher(
+ "/users/{identifier*}" as IdPath,
+ () => null,
+ ),
+ DisallowedVarSpecModifierError,
+ );
+
+ // Simple expansion `{identifier}` must keep working.
+ createFederationBuilder().setActorDispatcher(
+ "/users/{identifier}",
+ () => null,
+ );
+ createFederationBuilder().setInboxListeners(
+ "/users/{identifier}/inbox",
+ );
+ },
+ );
+
+ await t.step(
+ "every required-identifier setter rejects every omissible operator",
+ () => {
+ // The broader invariant from the review: a Fedify route whose callback
+ // contract exposes `identifier: string` must never match without a
+ // concrete identifier. Path-style (`{/identifier}`) and the optional
+ // forms (`{?identifier}`, `{;identifier}`, `{.identifier}`) can all
+ // match without binding a segment-bounded value, so every setter that
+ // registers such a route must reject them at registration time, not
+ // just the `actor`/`outbox` ones spot-checked above. See
+ // https://github.com/fedify-dev/fedify/pull/758#discussion_r3252548632
+ type IdPath = `${string}{identifier}${string}`;
+
+ // `op` is spliced where a plain `{identifier}` expression would go.
+ const omissibleExprs = [
+ "{/identifier}",
+ "{?identifier}",
+ "{;identifier}",
+ "{.identifier}",
+ ] as const;
+
+ // Each entry registers exactly one required-identifier route on a
+ // fresh builder so per-route "already set" guards never fire first.
+ const registrars: ReadonlyArray<
+ readonly [name: string, register: (expr: string) => void]
+ > = [
+ [
+ "setActorDispatcher",
+ (expr) =>
+ createFederationBuilder().setActorDispatcher(
+ `/users${expr}` as IdPath,
+ () => null,
+ ),
+ ],
+ [
+ "setInboxListeners",
+ (expr) =>
+ createFederationBuilder().setInboxListeners(
+ `/users${expr}/inbox` as IdPath,
+ ),
+ ],
+ [
+ "setOutboxListeners",
+ (expr) =>
+ createFederationBuilder().setOutboxListeners(
+ `/users${expr}/outbox` as IdPath,
+ ),
+ ],
+ [
+ "setOutboxDispatcher",
+ (expr) =>
+ createFederationBuilder().setOutboxDispatcher(
+ `/users${expr}/outbox` as IdPath,
+ () => ({ items: [] }),
+ ),
+ ],
+ [
+ "setFollowingDispatcher",
+ (expr) =>
+ createFederationBuilder().setFollowingDispatcher(
+ `/users${expr}/following` as IdPath,
+ () => ({ items: [] }),
+ ),
+ ],
+ [
+ "setFollowersDispatcher",
+ (expr) =>
+ createFederationBuilder().setFollowersDispatcher(
+ `/users${expr}/followers` as IdPath,
+ () => ({ items: [] }),
+ ),
+ ],
+ [
+ "setLikedDispatcher",
+ (expr) =>
+ createFederationBuilder().setLikedDispatcher(
+ `/users${expr}/liked` as IdPath,
+ () => ({ items: [] }),
+ ),
+ ],
+ [
+ "setFeaturedDispatcher",
+ (expr) =>
+ createFederationBuilder().setFeaturedDispatcher(
+ `/users${expr}/featured` as IdPath,
+ () => ({ items: [] }),
+ ),
+ ],
+ [
+ "setFeaturedTagsDispatcher",
+ (expr) =>
+ createFederationBuilder().setFeaturedTagsDispatcher(
+ `/users${expr}/tags` as IdPath,
+ () => ({ items: [] }),
+ ),
+ ],
+ ];
+
+ for (const [name, register] of registrars) {
+ for (const expr of omissibleExprs) {
+ assertThrows(
+ () => register(expr),
+ DisallowedOperatorError,
+ undefined,
+ `${name} must reject ${expr} at registration`,
+ );
+ }
+ // Positive control: the plain expansion still registers.
+ register("{identifier}");
+ }
+ },
+ );
+
+ await t.step(
+ "empty or missing identifier segments produce a runtime no-match",
+ async () => {
+ // The default `nullable: false` constraint makes every dispatcher
+ // route reject empty/unbound bindings at match time, so the former
+ // `assertIdentifierPath` / `variables.size < 1` registration guards
+ // are no longer needed.
+ const kv = new MemoryKvStore();
+ const builder = createFederationBuilder();
+ builder.setActorDispatcher("/users/{identifier}", () => null);
+ builder.setInboxListeners("/users/{identifier}/inbox");
+ builder.setOutboxDispatcher(
+ "/users/{identifier}/outbox",
+ () => ({ items: [] }),
+ );
+ builder.setObjectDispatcher(Note, "/notes/{id}", () => null);
+ const impl = (await builder.build({ kv })) as FederationImpl;
+
+ // Sanity: non-empty bindings still match.
+ assertEquals(impl.router.route("/users/alice")?.name, "actor");
+ assertEquals(
+ impl.router.route("/users/alice/inbox")?.name,
+ "inbox",
+ );
+ assertEquals(
+ impl.router.route("/users/alice/outbox")?.name,
+ "outbox",
+ );
+ assertEquals(
+ impl.router.route("/notes/1")?.name,
+ `object:${Note.typeId.href}`,
+ );
+
+ // Empty/blank identifier segments no longer match. The review
+ // explicitly calls out the actor/inbox/outbox callback contract
+ // (`identifier: string`), so all three are exercised here.
+ assertEquals(impl.router.route("/users/"), null);
+ assertEquals(impl.router.route("/users//inbox"), null);
+ assertEquals(impl.router.route("/users//outbox"), null);
+ // Object dispatcher with an empty variable.
+ assertEquals(impl.router.route("/notes/"), null);
+ },
+ );
+
+ await t.step(
+ "object dispatcher optional-operator routes no-match when unbound",
+ async () => {
+ // CuPEr: the review's own scenarios — `/notes{?id}`, `/notes{;id}`,
+ // `/notes{.id}` — must register but no-match the variable-less form
+ // instead of matching with an empty `values`.
+ const kv = new MemoryKvStore();
+ const objectName = `object:${Note.typeId.href}`;
+
+ const query = createFederationBuilder();
+ query.setObjectDispatcher(Note, "/notes{?id}", () => null);
+ const queryImpl = (await query.build({ kv })) as FederationImpl;
+ assertEquals(queryImpl.router.route("/notes"), null);
+ assertEquals(queryImpl.router.route("/notes?id=1")?.name, objectName);
+
+ const matrix = createFederationBuilder();
+ matrix.setObjectDispatcher(Note, "/notes{;id}", () => null);
+ const matrixImpl = (await matrix.build({ kv })) as FederationImpl;
+ assertEquals(matrixImpl.router.route("/notes"), null);
+ assertEquals(matrixImpl.router.route("/notes;id=1")?.name, objectName);
+
+ const label = createFederationBuilder();
+ label.setObjectDispatcher(Note, "/notes{.id}", () => null);
+ const labelImpl = (await label.build({ kv })) as FederationImpl;
+ assertEquals(labelImpl.router.route("/notes"), null);
+ assertEquals(labelImpl.router.route("/notes.1")?.name, objectName);
+ },
+ );
+
+ await t.step(
+ "custom collection routes no-match empty or unbound variables",
+ async () => {
+ // CuPEr plan item 3: the custom collection dispatcher must also
+ // reject empty-segment and unbound optional-operator bindings via
+ // the router's default nullable:false constraint.
+ const kv = new MemoryKvStore();
+ const builder = createFederationBuilder();
+ builder.setCollectionDispatcher(
+ "samples",
+ Note,
+ "/groups/{id}",
+ () => ({ items: [] }),
+ );
+ builder.setCollectionDispatcher(
+ "optionals",
+ Note,
+ "/optional-groups{?id}",
+ () => ({ items: [] }),
+ );
+ // The review also names matrix and label operators, which share the
+ // same optional shape: `/matrix-groups{;id}` and `/label-groups{.id}`
+ // both reduce to their literal prefix when `id` is unbound.
+ builder.setCollectionDispatcher(
+ "matrixOptionals",
+ Note,
+ "/matrix-groups{;id}",
+ () => ({ items: [] }),
+ );
+ builder.setCollectionDispatcher(
+ "labelOptionals",
+ Note,
+ "/label-groups{.id}",
+ () => ({ items: [] }),
+ );
+ const impl = (await builder.build({ kv })) as FederationImpl;
+
+ // Sanity: a bound segment still matches.
+ assertEquals(impl.router.route("/groups/1"), {
+ name: "collection:samples",
+ values: { id: "1" },
+ template: "/groups/{id}",
+ });
+ assertEquals(impl.router.route("/optional-groups?id=1"), {
+ name: "collection:optionals",
+ values: { id: "1" },
+ template: "/optional-groups{?id}",
+ });
+ assertEquals(impl.router.route("/matrix-groups;id=1"), {
+ name: "collection:matrixOptionals",
+ values: { id: "1" },
+ template: "/matrix-groups{;id}",
+ });
+ assertEquals(impl.router.route("/label-groups.1"), {
+ name: "collection:labelOptionals",
+ values: { id: "1" },
+ template: "/label-groups{.id}",
+ });
+
+ // Empty segment and unbound optional operators no-match.
+ assertEquals(impl.router.route("/groups/"), null);
+ assertEquals(impl.router.route("/optional-groups"), null);
+ assertEquals(impl.router.route("/matrix-groups"), null);
+ assertEquals(impl.router.route("/label-groups"), null);
+ },
+ );
+
await t.step("should pass build options correctly", async () => {
const builder = createFederationBuilder();
const kv = new MemoryKvStore();
diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts
index da5cf9845..95c989a00 100644
--- a/packages/fedify/src/federation/builder.ts
+++ b/packages/fedify/src/federation/builder.ts
@@ -1,3 +1,9 @@
+import {
+ assertPath,
+ type Path,
+ Router,
+ RouterError,
+} from "@fedify/uri-template";
import type {
Activity,
Actor,
@@ -8,9 +14,10 @@ import type {
} from "@fedify/vocab";
import { getTypeId, Tombstone } from "@fedify/vocab";
import { getLogger } from "@logtape/logtape";
-import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
import type { Tracer } from "@opentelemetry/api";
+import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
import metadata from "../../deno.json" with { type: "json" };
+import { ActivityListenerSet } from "./activity-listener.ts";
import type {
ActorAliasMapper,
ActorDispatcher,
@@ -41,7 +48,6 @@ import type {
OutboxContext,
RequestContext,
} from "./context.ts";
-import { ActivityListenerSet } from "./activity-listener.ts";
import type {
ActorCallbackSetters,
CollectionCallbackSetters,
@@ -61,35 +67,43 @@ import type {
CollectionCallbacks,
CustomCollectionCallbacks,
} from "./handler.ts";
-import { Router, RouterError } from "./router.ts";
export const ACTOR_ALIAS_PREFIX = "actorAlias:";
-function validateSingleIdentifierVariablePath(
- path: string,
- errorMessage: string,
-): void {
- const operatorMatches = globalThis.Array.from(
- path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g),
- );
- if (
- operatorMatches.length !== 1 ||
- operatorMatches[0]?.[2] !== "identifier"
- ) {
- throw new RouterError(errorMessage);
- }
- if (
- operatorMatches.some((match) =>
- ["?", "&", "#"].includes(match[1]) && match[2] === "identifier"
- )
- ) {
- throw new RouterError(errorMessage);
- }
- const variables = new Router().add(path, "outbox");
- if (variables.size !== 1 || !variables.has("identifier")) {
- throw new RouterError(errorMessage);
- }
-}
+/**
+ * Route options shared by every dispatcher whose path must expose exactly
+ * one `{identifier}` variable bound to a single, non-empty value
+ * for `setOutboxDispatcher/Listener()`.
+ */
+const identifierSingular = {
+ exact: true,
+ variables: {
+ identifier: {
+ operatables: [""],
+ },
+ },
+} as const;
+
+/**
+ * Route options shared by every dispatcher whose path must expose
+ * `{identifier}` and `{+identifier}` variables bound to the same single,
+ * non-empty value for following setters:
+ * - `setActorDispatcher()` (actor path)
+ * - `setInboxDispatcher/Listener()` (inbox path)
+ * - `setFollowingDispatcher()` (following path)
+ * - `setFollowersDispatcher()` (followers path)
+ * - `setLikedDispatcher()` (liked path)
+ * - `setFeaturedDispatcher()` (featured path)
+ * - `setFeaturedTagsDispatcher()` (featured tags path)
+ */
+const identifierSingularAllowPlus = {
+ exact: true,
+ variables: {
+ identifier: {
+ operatables: ["", "+"],
+ },
+ },
+} as const;
export class FederationBuilderImpl
implements FederationBuilder {
@@ -251,15 +265,8 @@ export class FederationBuilderImpl
if (this.router.has("actor")) {
throw new RouterError("Actor dispatcher already set.");
}
- const variables = this.router.add(path, "actor");
- if (
- variables.size !== 1 ||
- !variables.has("identifier")
- ) {
- throw new RouterError(
- "Path for actor dispatcher must have one variable: {identifier}",
- );
- }
+ assertPath(path);
+ this.router.add(path, "actor", identifierSingularAllowPlus);
const callbacks: ActorCallbacks = {
dispatcher: async (context, identifier) => {
const actor = await this._getTracer().startActiveSpan(
@@ -524,7 +531,7 @@ export class FederationBuilderImpl
callbacks.aliasMapper = mapper;
return setters;
},
- mapActorAlias: (path: `/${string}`, identifier: string) => {
+ mapActorAlias: (path: Path, identifier: string) => {
if (identifier === "") {
throw new RouterError("Identifier cannot be empty.");
}
@@ -533,7 +540,7 @@ export class FederationBuilderImpl
`Actor alias for "${identifier}" already set.`,
);
}
- const variables = new Router().add(path, "temp");
+ const variables = Router.variables(path);
if (variables.size > 0) {
throw new RouterError(
"Path for actor alias must have no variables.",
@@ -563,12 +570,13 @@ export class FederationBuilderImpl
if (this.router.has("nodeInfo")) {
throw new RouterError("NodeInfo dispatcher already set.");
}
- const variables = this.router.add(path, "nodeInfo");
+ const variables = Router.variables(path as Path);
if (variables.size !== 0) {
throw new RouterError(
"Path for NodeInfo dispatcher must have no variables.",
);
}
+ this.router.add(path as Path, "nodeInfo");
this.nodeInfoDispatcher = dispatcher;
}
@@ -624,12 +632,9 @@ export class FederationBuilderImpl
if (this.router.has(routeName)) {
throw new RouterError(`Object dispatcher for ${cls.name} already set.`);
}
- const variables = this.router.add(path, routeName);
- if (variables.size < 1) {
- throw new RouterError(
- "Path for object dispatcher must have at least one variable.",
- );
- }
+ assertPath(path);
+ const variables = Router.variables(path);
+ this.router.add(path, routeName);
const callbacks: ObjectCallbacks = {
dispatcher: (ctx, values) => {
const tracer = this._getTracer();
@@ -711,15 +716,8 @@ export class FederationBuilderImpl
);
}
} else {
- const variables = this.router.add(path, "inbox");
- if (
- variables.size !== 1 ||
- !variables.has("identifier")
- ) {
- throw new RouterError(
- "Path for inbox dispatcher must have one variable: {identifier}",
- );
- }
+ assertPath(path);
+ this.router.add(path, "inbox", identifierSingularAllowPlus);
this.inboxPath = path;
}
const callbacks: CollectionCallbacks<
@@ -789,11 +787,8 @@ export class FederationBuilderImpl
);
}
} else {
- validateSingleIdentifierVariablePath(
- path,
- "Path for outbox dispatcher must have one variable: {identifier}",
- );
- this.router.add(path, "outbox");
+ assertPath(path);
+ this.router.add(path, "outbox", identifierSingular);
this.outboxPath = path;
}
const callbacks: CollectionCallbacks<
@@ -853,13 +848,11 @@ export class FederationBuilderImpl
);
}
} else {
- validateSingleIdentifierVariablePath(
- outboxPath,
- "Path for outbox must have one variable: {identifier}",
- );
- this.router.add(outboxPath, "outbox");
+ assertPath(outboxPath);
+ this.router.add(outboxPath, "outbox", identifierSingular);
this.outboxPath = outboxPath;
}
+
const listeners = this.outboxListeners = new ActivityListenerSet<
OutboxContext
>();
@@ -904,16 +897,8 @@ export class FederationBuilderImpl
if (this.router.has("following")) {
throw new RouterError("Following collection dispatcher already set.");
}
- const variables = this.router.add(path, "following");
- if (
- variables.size !== 1 ||
- !variables.has("identifier")
- ) {
- throw new RouterError(
- "Path for following collection dispatcher must have one variable: " +
- "{identifier}",
- );
- }
+ assertPath(path);
+ this.router.add(path, "following", identifierSingularAllowPlus);
const callbacks: CollectionCallbacks<
Actor | URL,
RequestContext,
@@ -970,16 +955,8 @@ export class FederationBuilderImpl
if (this.router.has("followers")) {
throw new RouterError("Followers collection dispatcher already set.");
}
- const variables = this.router.add(path, "followers");
- if (
- variables.size !== 1 ||
- !variables.has("identifier")
- ) {
- throw new RouterError(
- "Path for followers collection dispatcher must have one variable: " +
- "{identifier}",
- );
- }
+ assertPath(path);
+ this.router.add(path, "followers", identifierSingularAllowPlus);
const callbacks: CollectionCallbacks<
Recipient,
Context,
@@ -1032,16 +1009,8 @@ export class FederationBuilderImpl
if (this.router.has("liked")) {
throw new RouterError("Liked collection dispatcher already set.");
}
- const variables = this.router.add(path, "liked");
- if (
- variables.size !== 1 ||
- !variables.has("identifier")
- ) {
- throw new RouterError(
- "Path for liked collection dispatcher must have one variable: " +
- "{identifier}",
- );
- }
+ assertPath(path);
+ this.router.add(path, "liked", identifierSingularAllowPlus);
const callbacks: CollectionCallbacks<
Like,
RequestContext,
@@ -1102,16 +1071,8 @@ export class FederationBuilderImpl
if (this.router.has("featured")) {
throw new RouterError("Featured collection dispatcher already set.");
}
- const variables = this.router.add(path, "featured");
- if (
- variables.size !== 1 ||
- !variables.has("identifier")
- ) {
- throw new RouterError(
- "Path for featured collection dispatcher must have one variable: " +
- "{identifier}",
- );
- }
+ assertPath(path);
+ this.router.add(path, "featured", identifierSingularAllowPlus);
const callbacks: CollectionCallbacks<
Object,
RequestContext,
@@ -1172,16 +1133,8 @@ export class FederationBuilderImpl
if (this.router.has("featuredTags")) {
throw new RouterError("Featured tags collection dispatcher already set.");
}
- const variables = this.router.add(path, "featuredTags");
- if (
- variables.size !== 1 ||
- !variables.has("identifier")
- ) {
- throw new RouterError(
- "Path for featured tags collection dispatcher must have one " +
- "variable: {identifier}",
- );
- }
+ assertPath(path);
+ this.router.add(path, "featuredTags", identifierSingularAllowPlus);
const callbacks: CollectionCallbacks<
Hashtag,
RequestContext,
@@ -1240,24 +1193,18 @@ export class FederationBuilderImpl
);
}
} else {
- const variables = this.router.add(inboxPath, "inbox");
- if (
- variables.size !== 1 ||
- !variables.has("identifier")
- ) {
- throw new RouterError(
- "Path for inbox must have one variable: {identifier}",
- );
- }
+ assertPath(inboxPath);
+ this.router.add(inboxPath, "inbox", identifierSingularAllowPlus);
this.inboxPath = inboxPath;
}
if (sharedInboxPath != null) {
- const siVars = this.router.add(sharedInboxPath, "sharedInbox");
+ const siVars = Router.variables(sharedInboxPath as Path);
if (siVars.size !== 0) {
throw new RouterError(
"Path for shared inbox must have no variables.",
);
}
+ this.router.add(sharedInboxPath as Path, "sharedInbox");
}
const listeners = this.inboxListeners = new ActivityListenerSet<
InboxContext
@@ -1533,13 +1480,15 @@ export class FederationBuilderImpl
);
}
- const variables = this.router.add(path, routeName);
- if (variables.size < 1) {
+ assertPath(path);
+ if (Router.variables(path).size < 1) {
throw new RouterError(
"Path for collection dispatcher must have at least one variable.",
);
}
+ this.router.add(path, routeName);
+
const callbacks: CustomCollectionCallbacks<
TObject,
TParam,
diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts
index f2663e94e..66bbae528 100644
--- a/packages/fedify/src/federation/middleware.test.ts
+++ b/packages/fedify/src/federation/middleware.test.ts
@@ -4,6 +4,7 @@ import {
mockDocumentLoader,
test,
} from "@fedify/fixture";
+import { RouterError } from "@fedify/uri-template";
import { configure, type LogRecord, reset } from "@logtape/logtape";
import * as vocab from "@fedify/vocab";
import { getTypeId, lookupObject } from "@fedify/vocab";
@@ -59,7 +60,6 @@ import {
} from "./middleware.ts";
import type { MessageQueue } from "./mq.ts";
import type { InboxMessage, Message, OutboxMessage } from "./queue.ts";
-import { RouterError } from "./router.ts";
type IsEqual = (() => T extends A ? 1 : 2) extends
(() => T extends B ? 1 : 2) ? true : false;
@@ -1430,6 +1430,42 @@ test("Federation.fetch()", async (t) => {
assertEquals(response.status, 404);
});
+ await t.step(
+ "empty identifier segment is Not Found, dispatcher not invoked",
+ async () => {
+ // Regression for the bug fixed by this change: a request whose
+ // identifier segment is empty or missing (`/users/`, `/users//inbox`)
+ // must be treated as Not Found instead of invoking the dispatcher
+ // with an empty string, which would violate the `identifier: string`
+ // callback contract. `Federation.fetch()` routes against
+ // `URL.pathname`, so this exercises the real HTTP path, not just
+ // `Router.route()`. See
+ // https://github.com/fedify-dev/fedify/pull/758#discussion_r3252548632
+ const { federation, dispatches } = createTestContext();
+
+ const actorResponse = await federation.fetch(
+ new Request("https://example.com/users/", {
+ method: "GET",
+ headers: { "Accept": "application/activity+json" },
+ }),
+ { contextData: undefined },
+ );
+ assertEquals(actorResponse.status, 404);
+
+ const inboxResponse = await federation.fetch(
+ new Request("https://example.com/users//inbox", {
+ method: "POST",
+ headers: { "accept": "application/ld+json" },
+ }),
+ { contextData: undefined },
+ );
+ assertEquals(inboxResponse.status, 404);
+
+ // The actor dispatcher must never have seen an empty identifier.
+ assertEquals(dispatches.includes(""), false);
+ },
+ );
+
await t.step("onNotAcceptable with GET", async () => {
const { federation } = createTestContext();
diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts
index ec5d2629d..2384fb8c6 100644
--- a/packages/fedify/src/federation/middleware.ts
+++ b/packages/fedify/src/federation/middleware.ts
@@ -1,3 +1,4 @@
+import { type Path, RouterError } from "@fedify/uri-template";
import type {
Actor,
Collection,
@@ -67,7 +68,7 @@ import { getKeyOwner, type GetKeyOwnerOptions } from "../sig/owner.ts";
import { hasProofLike, signObject, verifyObject } from "../sig/proof.ts";
import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts";
import { kvCache } from "../utils/kv-cache.ts";
-import { FederationBuilderImpl } from "./builder.ts";
+import { ACTOR_ALIAS_PREFIX, FederationBuilderImpl } from "./builder.ts";
import type { OutboxErrorHandler } from "./callback.ts";
import { buildCollectionSynchronizationHeader } from "./collection.ts";
import type {
@@ -125,9 +126,6 @@ import type {
SenderKeyJwkPair,
} from "./queue.ts";
import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts";
-import { RouterError } from "./router.ts";
-import { ACTOR_ALIAS_PREFIX } from "./builder.ts";
-
import {
extractInboxes,
sendActivity,
@@ -1583,7 +1581,7 @@ export class FederationImpl
onNotAcceptable ??= notAcceptable;
onUnauthorized ??= unauthorized;
const url = new URL(request.url);
- const route = this.router.route(url.pathname);
+ const route = this.router.route(url.pathname as Path);
if (route == null) {
metricState.endpoint = "not_found";
return await onNotFound(request);
@@ -2190,7 +2188,7 @@ export class ContextImpl implements Context {
if (uri.origin !== this.origin && uri.origin !== this.canonicalOrigin) {
return null;
}
- const route = this.federation.router.route(uri.pathname);
+ const route = this.federation.router.route(uri.pathname as Path);
if (route == null) return null;
else if (route.name === "sharedInbox") {
return {
diff --git a/packages/fedify/src/federation/router.test.ts b/packages/fedify/src/federation/router.test.ts
index b1cb00bdb..a25853926 100644
--- a/packages/fedify/src/federation/router.test.ts
+++ b/packages/fedify/src/federation/router.test.ts
@@ -1,4 +1,5 @@
import { test } from "@fedify/fixture";
+import { RouterError as UriTemplateRouterError } from "@fedify/uri-template";
import { assert, assertEquals, assertFalse, assertThrows } from "@std/assert";
import { Router, RouterError, type RouterOptions } from "./router.ts";
@@ -80,6 +81,24 @@ test("Router.route()", () => {
});
});
+test("Router.trailingSlashInsensitive (post-construction mutation)", () => {
+ const router = setUp();
+ assertFalse(router.trailingSlashInsensitive);
+ assertEquals(router.route("/users/bob/"), null);
+
+ router.trailingSlashInsensitive = true;
+ assert(router.trailingSlashInsensitive);
+ assertEquals(router.route("/users/bob/"), {
+ name: "user",
+ template: "/users/{name}",
+ values: { name: "bob" },
+ });
+
+ router.trailingSlashInsensitive = false;
+ assertFalse(router.trailingSlashInsensitive);
+ assertEquals(router.route("/users/bob/"), null);
+});
+
test("Router.build()", () => {
const router = setUp();
assertEquals(router.build("user", { name: "alice" }), "/users/alice");
@@ -88,3 +107,23 @@ test("Router.build()", () => {
"/users/alice/posts/123",
);
});
+
+test("Router.route() returns null for non-path inputs", () => {
+ const router = setUp();
+ // The old Fedify 2.x `Router` returned `null` (not threw) when probed
+ // with non-path inputs such as absolute URLs.
+ assertEquals(router.route("https://example.com/users/alice"), null);
+ assertEquals(router.route("users/alice"), null);
+ assertEquals(router.route("not a path"), null);
+ // Valid paths that simply do not match still return null.
+ assertEquals(router.route("/unknown"), null);
+});
+
+test("Compatibility between RouterErrors", () => {
+ const newError = new UriTemplateRouterError("boom");
+ assert(newError instanceof UriTemplateRouterError);
+ assert(newError instanceof RouterError);
+ const previousError = new RouterError("boom");
+ assert(previousError instanceof RouterError);
+ assert(previousError instanceof UriTemplateRouterError);
+});
diff --git a/packages/fedify/src/federation/router.ts b/packages/fedify/src/federation/router.ts
index b1d542ff9..0ae1317de 100644
--- a/packages/fedify/src/federation/router.ts
+++ b/packages/fedify/src/federation/router.ts
@@ -1,94 +1,99 @@
-// @ts-ignore TS7016
-import { cloneDeep } from "es-toolkit";
-import { Router as InnerRouter } from "uri-template-router";
-import { parseTemplate, type Template } from "url-template";
+import type {
+ RouterOptions as _RouterOptions,
+ RouterRouteResult as _RouterRouteResult,
+} from "@fedify/uri-template";
+import {
+ assertPath,
+ isPath,
+ Router as _Router,
+ RouterError as _RouterError,
+} from "@fedify/uri-template";
+import { getLogger } from "@logtape/logtape";
+
+const logger = getLogger(["fedify", "federation", "router", "deprecated"]);
+
+let deprecationWarned = false;
+
+function warnDeprecated(): void {
+ if (deprecationWarned) return;
+ deprecationWarned = true;
+ logger.warn(
+ "The `Router` and `RouterError` classes from `@fedify/fedify` are " +
+ "deprecated. Please use `Router` from `@fedify/uri-template` instead.",
+ );
+}
/**
* Options for the {@link Router}.
* @since 0.12.0
+ * @deprecated Import `RouterOptions` from `@fedify/uri-template` instead.
*/
-export interface RouterOptions {
- /**
- * Whether to ignore trailing slashes when matching paths.
- */
- trailingSlashInsensitive?: boolean;
-}
+export interface RouterOptions extends _RouterOptions {}
/**
* The result of {@link Router.route} method.
* @since 1.3.0
+ * @deprecated Import `RouterRouteResult` from `@fedify/uri-template` instead.
*/
-export interface RouterRouteResult {
- /**
- * The matched route name.
- */
- name: string;
-
- /**
- * The URL template of the matched route.
- */
- template: string;
-
- /**
- * The values extracted from the URL.
- */
- values: Record;
-}
-
-function cloneInnerRouter(router: InnerRouter): InnerRouter {
- const clone = new InnerRouter();
- clone.nid = router.nid;
- clone.fsm = cloneDeep(router.fsm);
- clone.routeSet = new Set(router.routeSet);
- clone.templateRouteMap = new Map(router.templateRouteMap);
- clone.valueRouteMap = new Map(router.valueRouteMap);
- clone.hierarchy = cloneDeep(router.hierarchy);
- return clone;
-}
+export interface RouterRouteResult
+ extends _RouterRouteResult> {}
/**
* URL router and constructor based on URI Template
* ([RFC 6570](https://tools.ietf.org/html/rfc6570)).
+ *
+ * @deprecated Import `Router` from `@fedify/uri-template` instead. This class
+ * remains only for compatibility with older Fedify code. The
+ * `@fedify/uri-template` router is the replacement implementation
+ * and should be used directly in new code.
*/
export class Router {
- #router: InnerRouter;
- #templates: Record;
- #templateStrings: Record;
-
+ #router: _Router;
/**
- * Whether to ignore trailing slashes when matching paths.
- * @since 1.6.0
+ * Create a new {@link Router}.
+ * @param options Options for the router.
+ * @deprecated Use `new Router(options)` from `@fedify/uri-template`
+ * instead.
*/
- trailingSlashInsensitive: boolean;
+ constructor(options?: _RouterOptions) {
+ this.#router = convertRouterError(() => new _Router(options));
+ }
/**
- * Create a new {@link Router}.
- * @param options Options for the router.
+ * Whether to ignore trailing slashes when matching paths.
+ * @deprecated Use `Router` from `@fedify/uri-template` instead. This
+ * accessor forwards to the underlying `@fedify/uri-template`
+ * router so that post-construction mutation keeps working as
+ * in older Fedify code.
*/
- constructor(options: RouterOptions = {}) {
- this.#router = new InnerRouter();
- this.#templates = {};
- this.#templateStrings = {};
- this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false;
+ get trailingSlashInsensitive(): boolean {
+ return this.#router.trailingSlashInsensitive;
}
+ set trailingSlashInsensitive(value: boolean) {
+ this.#router.trailingSlashInsensitive = value;
+ }
+
+ /**
+ * Clones this router.
+ * @deprecated Use `Router` from `@fedify/uri-template` instead.
+ */
clone(): Router {
- const clone = new Router({
- trailingSlashInsensitive: this.trailingSlashInsensitive,
+ return convertRouterError(() => {
+ const clone = new Router();
+ clone.#router = this.#router.clone();
+ return clone;
});
- clone.#router = cloneInnerRouter(this.#router);
- clone.#templates = { ...this.#templates };
- clone.#templateStrings = { ...this.#templateStrings };
- return clone;
}
/**
* Checks if a path name exists in the router.
* @param name The name of the path.
* @returns `true` if the path name exists, otherwise `false`.
+ * @deprecated Use `Router` from `@fedify/uri-template` instead.
*/
has(name: string): boolean {
- return name in this.#templates;
+ return convertRouterError(() => this.#router.has(name));
}
/**
@@ -96,15 +101,23 @@ export class Router {
* @param template The path pattern.
* @param name The name of the path.
* @returns The names of the variables in the path pattern.
+ * @deprecated Use `Router` from `@fedify/uri-template` instead. In this
+ * compatibility class, `add()` both registers the route and
+ * returns the variables in the path pattern. In
+ * `@fedify/uri-template`, these two responsibilities are split:
+ * `router.add(template, name)` registers the route and returns
+ * `void`, while the pure static method
+ * `Router.variables(template)` returns the variable names. To
+ * migrate, call `Router.variables(template)` when variables are
+ * needed, then call `router.add(template, name)` to register the
+ * route.
*/
add(template: string, name: string): Set {
- if (!template.startsWith("/")) {
- throw new RouterError("Path must start with a slash.");
- }
- const rule = this.#router.addTemplate(template, {}, name);
- this.#templates[name] = parseTemplate(template);
- this.#templateStrings[name] = template;
- return new Set(rule.variables.map((v: { varname: string }) => v.varname));
+ return convertRouterError(() => {
+ assertPath(template);
+ this.#router.add(template, name);
+ return _Router.variables(template);
+ });
}
/**
@@ -112,20 +125,16 @@ export class Router {
* @param url The URL to resolve.
* @returns The name of the path and its values, if any match. Otherwise,
* `null`.
+ * @deprecated Use `Router` from `@fedify/uri-template` instead. Unlike the
+ * stricter `@fedify/uri-template` router, this compatibility
+ * method keeps the old Fedify 2.x contract of returning `null`
+ * (rather than throwing) for inputs that are not router paths.
*/
route(url: string): RouterRouteResult | null {
- let match = this.#router.resolveURI(url);
- if (match == null) {
- if (!this.trailingSlashInsensitive) return null;
- url = url.endsWith("/") ? url.replace(/\/+$/, "") : `${url}/`;
- match = this.#router.resolveURI(url);
- if (match == null) return null;
- }
- return {
- name: match.matchValue,
- template: this.#templateStrings[match.matchValue],
- values: match.params,
- };
+ return convertRouterError(() => {
+ if (!isPath(url)) return null;
+ return this.#router.route(url);
+ });
}
/**
@@ -133,25 +142,47 @@ export class Router {
* @param name The name of the path.
* @param values The values to expand the path with.
* @returns The URL/path, if the name exists. Otherwise, `null`.
+ * @deprecated Use `Router` from `@fedify/uri-template` instead.
*/
build(name: string, values: Record): string | null {
- if (name in this.#templates) {
- return this.#templates[name].expand(values);
- }
- return null;
+ return convertRouterError(() => this.#router.build(name, values));
}
}
/**
* An error thrown by the {@link Router}.
+ * @deprecated Import `RouterError` from `@fedify/uri-template` instead.
*/
-export class RouterError extends Error {
+export class RouterError extends _RouterError {
+ /**
+ * Treats every `RouterError` from `@fedify/uri-template` as an instance of
+ * this deprecated class.
+ *
+ * @deprecated Import `RouterError` from `@fedify/uri-template` instead.
+ */
+ static override [Symbol.hasInstance](instance: unknown): boolean {
+ return instance instanceof _RouterError;
+ }
+
/**
* Create a new {@link RouterError}.
* @param message The error message.
+ * @deprecated Import `RouterError` from `@fedify/uri-template` instead.
*/
constructor(message: string) {
super(message);
- this.name = "RouterError";
+ warnDeprecated();
+ }
+}
+
+function convertRouterError(func: () => T): T {
+ try {
+ warnDeprecated();
+ return func();
+ } catch (error) {
+ if (error instanceof _RouterError) {
+ throw new RouterError(error.message);
+ }
+ throw error;
}
}
diff --git a/packages/fedify/src/nodeinfo/handler.ts b/packages/fedify/src/nodeinfo/handler.ts
index 89d4e068d..3bd001a09 100644
--- a/packages/fedify/src/nodeinfo/handler.ts
+++ b/packages/fedify/src/nodeinfo/handler.ts
@@ -1,7 +1,7 @@
import type { Link, ResourceDescriptor } from "@fedify/webfinger";
+import { RouterError } from "@fedify/uri-template";
import type { NodeInfoDispatcher } from "../federation/callback.ts";
import type { RequestContext } from "../federation/context.ts";
-import { RouterError } from "../federation/router.ts";
import { nodeInfoToJson } from "./types.ts";
/**
diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts
index 8787d11ce..0f9781d14 100644
--- a/packages/fedify/src/testing/context.ts
+++ b/packages/fedify/src/testing/context.ts
@@ -1,4 +1,5 @@
import { mockDocumentLoader } from "@fedify/fixture";
+import { RouterError } from "@fedify/uri-template";
import {
lookupObject as globalLookupObject,
traverseCollection as globalTraverseCollection,
@@ -12,7 +13,6 @@ import type {
RequestContext,
} from "../federation/context.ts";
import type { Federation } from "../federation/federation.ts";
-import { RouterError } from "../federation/router.ts";
export function createContext(
values: Partial> & {
diff --git a/packages/uri-template/README.md b/packages/uri-template/README.md
new file mode 100644
index 000000000..3b6d677fe
--- /dev/null
+++ b/packages/uri-template/README.md
@@ -0,0 +1,284 @@
+
+
+@fedify/uri-template: Round-trip RFC 6570 URI Template library
+==============================================================
+
+[![JSR][JSR badge]][JSR]
+[![npm][npm badge]][npm]
+
+This package provides an [RFC 6570] URI Template implementation that performs
+both expansion and pattern matching with round-trip verification. It is part of
+the [Fedify] framework but can be used independently.
+
+[JSR badge]: https://jsr.io/badges/@fedify/uri-template
+[JSR]: https://jsr.io/@fedify/uri-template
+[npm badge]: https://img.shields.io/npm/v/@fedify/uri-template?logo=npm
+[npm]: https://www.npmjs.com/package/@fedify/uri-template
+[RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570
+[Fedify]: https://fedify.dev/
+
+
+Why `@fedify/uri-template`?
+---------------------------
+
+Fedify previously relied on two independent third-party implementations:
+[url-template] for URI Template expansion and [uri-template-router] for
+route matching. `@fedify/uri-template` replaces both with one strict RFC 6570
+parser and one expansion/matching model.
+
+[url-template]: https://www.npmjs.com/package/url-template
+[uri-template-router]: https://www.npmjs.com/package/uri-template-router
+
+### Why replacing [url-template] with `Template`?
+
+[url-template] describes itself as an RFC 6570 implementation, but its behavior
+is not strict enough for Fedify's URI routing and round-trip matching needs.
+The test in *old/url-template.test.ts* records the differences against
+`npm:url-template@^3.1.1`.
+
+The important failures are:
+
+ - It double-encodes pct-encoded triplets in variable names when named
+ operators emit the variable name. For example, `{?abc%20def}` expands to
+ `?abc%2520def=spaced` instead of `?abc%20def=spaced`. [RFC 6570 §2.3]
+ allows `pct-encoded` inside `varname` and treats it as part of the
+ variable name. [RFC 6570 §3.2.8] emits the variable name as a literal
+ string, and [RFC 6570 §2.1] permits `pct-encoded` literals. Therefore
+ `%20` and `%41` must be preserved, not encoded again as `%2520` and
+ `%2541`.
+ - It accepts malformed templates instead of reporting syntax errors.
+ [RFC 6570 §2] requires expressions to be delimited by matching braces,
+ [RFC 6570 §2.1] excludes raw braces, control characters, spaces, raw `%`
+ outside a pct-encoded triplet, and other forbidden literal characters, and
+ [RFC 6570 §3] says grammar errors should indicate their location and type
+ to the invoking application. `@fedify/uri-template` reports these cases as
+ typed errors.
+ - It applies prefix modifiers to composite values such as lists and
+ associative arrays. [RFC 6570 §2.4.1] states that prefix modifiers are not
+ applicable to variables with composite values, so `{list:3}`, `{keys:3}`,
+ and `{count:2}` must fail.
+
+`Template` was written as a new implementation instead of wrapping
+[url-template] because Fedify needs strict RFC 6570 expansion, typed syntax
+errors, and round-trip-checked matching behavior. Applications that need a
+looser parser can opt in explicitly: `strict: false` passes parse and expansion
+errors to `report` without throwing, and a custom `report` function can allow
+all errors or throw only for selected error classes.
+
+[RFC 6570 §2.3]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.3
+[RFC 6570 §3.2.8]: https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8
+[RFC 6570 §2.1]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.1
+[RFC 6570 §2]: https://datatracker.ietf.org/doc/html/rfc6570#section-2
+[RFC 6570 §3]: https://datatracker.ietf.org/doc/html/rfc6570#section-3
+[RFC 6570 §2.4.1]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.4.1
+
+### Why replacing [uri-template-router] with `Router`?
+
+The previous router shape combined two independent third-party
+implementations: [url-template] for building URLs and [uri-template-router] for
+matching URLs. *old/uri-template-router.test.ts* defines that old shape as
+closely as possible so the differences are visible under the same route API.
+
+The important differences are:
+
+ - Build, match, and variable extraction all use the same strict RFC 6570
+ parser. The previous router expanded with [url-template] but matched with
+ [uri-template-router], so a value could be encoded by one implementation
+ and decoded by another with different rules.
+ - Route matches are round-trip checked. A candidate route is accepted only
+ when the recovered values expand back to the exact input URI. This rejects
+ matches that look plausible after decoding but cannot reproduce the
+ original URI. By default, matching is exact against the input URI.
+ `trailingSlashInsensitive` can be enabled to use a looser path lookup for
+ trailing slash differences before applying the same round-trip check.
+ - Pct-encoded triplets are preserved where RFC 6570 treats them as syntax.
+ Literal triplets, pct-encoded variable names, and named query parameters
+ such as `{?abc%20def}` remain `%20` instead of becoming `%2520`.
+ - Reserved expansion values keep their encoded form when that is what the
+ URI contained. Under the previous matching path, `/files/a%2Fb` could be
+ reported as `a/b`, `/files/%30%23` as `0#`, and pct-encoded UTF-8 octets as
+ Unicode text. Those values do not round-trip to the original URI under the
+ same template.
+ - Path templates are validated by `Router.compile()` before registration.
+ The standalone router accepts ordinary slash-prefixed paths and the
+ leading path-expansion form—a template that begins with a `{/var}`
+ expression, such as `{/identifier}/inbox`. Accepting that shape is a
+ standalone-router capability and is independent of Fedify: Fedify's own
+ dispatcher routes apply a non-empty constraint to required identifiers
+ (`nullable` defaults to `false`), so a leading path-expansion route
+ registers but only matches when the variable is actually bound, and the
+ Fedify builder may reject such a shape for routes whose callback
+ contract requires a concrete `identifier`.
+ - `Router.variables()` and `Router.compile()` expose variable extraction
+ without mutating a router. The legacy `Router.add()` returned variables as
+ a side effect of registering the route.
+ - Candidate lookup combines a token-level state trie with a fallback prefix
+ trie. Indexable path templates—those whose expressions each hold a single
+ variable with the `""`, `/`, or `+` operator and never sit directly
+ adjacent to another expression—are walked token by token in the state
+ trie. Shapes that cannot be safely indexed fall back to a prefix trie
+ keyed by the initial literal prefix of each route. Candidates from both
+ tries are merged, deduplicated, and ordered deterministically by literal
+ length, initial literal prefix length, variable count, and insertion order
+ before the round-trip matcher runs.
+ - Cloning and route replacement do not depend on copying private mutable
+ state from [uri-template-router]. The router stores compiled templates and
+ active route entries directly, which keeps the implementation independent
+ and dependency-free at runtime.
+
+The concrete differences from the previous [url-template] and
+[uri-template-router] libraries are encoded as repository-only compatibility
+tests under *packages/uri-template/old/* in the package's source repository.
+Those tests intentionally fail when run with `deno task test:old` because they
+execute the older libraries against Fedify's expected behavior and document the
+known legacy gaps.
+
+
+Features
+--------
+
+ - Full RFC 6570 expansion for all expression types
+ (`{var}`, `{+var}`, `{#var}`, `{.var}`, `{/var}`, `{;var}`, `{?var}`,
+ `{&var}`)
+ - Round-trip pattern matching that mirrors expansion: when `match(uri)`
+ returns values, `expand(values) === uri`
+ - Per-variable matching constraints (`nullable`, `multiple`) with safe
+ defaults
+ - Strict TypeScript types with no `any` in the public surface
+ - Zero runtime dependencies
+
+
+Route variable constraints
+--------------------------
+
+`Router` registers every RFC 6570 operator, but matching is constrained
+per template variable. `Router.add()`, `Router.register()`, the
+constructor, and `Router.from()` accept the route as a
+`[pathOrPattern, name, options?]` tuple, where the optional third element
+is the per-route options object:
+
+~~~~ typescript
+import { Router } from "@fedify/uri-template";
+
+const router = new Router();
+router.add("/users/{identifier}", "actor");
+router.add("/search{?q}", "search", {
+ variables: { q: { nullable: true } },
+});
+~~~~
+
+`options.variables` maps a variable name to a partial constraint; any
+field you omit falls back to its default, and any template variable you
+do not list is still constrained with the all-default constraint. The
+constraint fields are:
+
+ - **`nullable`** defaults to `false`: a variable that is unbound or binds
+ to an empty value makes the route a no-match (the router falls back to
+ the next candidate). Pass `{ nullable: true }` to opt out, so an
+ optional operator such as `{?q}` may match with `q` absent. Because
+ of this default, optional-operator and leading-path-expansion routes
+ register successfully but only match when the variable is actually
+ present and non-empty.
+ - **`multiple`** is derived from the variable specification: explode
+ (`{tags*}`) implies `true` and binds `readonly string[]`; a prefix
+ modifier (`{id:3}`) implies `false`; a plain variable defaults to
+ `false` (binding `string`) but may be set either way. Specifying a
+ `multiple` that contradicts the derived value, or using the same
+ variable name with conflicting explode/prefix modifiers (`{x}` and
+ `{x*}`), throws `ConflictingVarSpecError` at registration time.
+ - **`duplicable`** defaults to `false`: a variable that appears in more
+ than one variable specification within the same template throws
+ `DuplicateRouteVariableError` at registration time. Set it to `true`
+ to allow repeated occurrences; their bindings must still agree when a
+ URI is matched.
+ - **`prefixable`** defaults to `false`: a `{var:N}` prefix-modifier
+ specification throws `DisallowedVarSpecModifierError` unless the
+ variable is marked `{ prefixable: true }`.
+ - **`explodable`** defaults to `false`: it is a *registration
+ permission*, not an output-shape declaration. A `{var*}`
+ explode-modifier specification throws `DisallowedVarSpecModifierError`
+ unless the variable is marked `{ explodable: true }`; the option by
+ itself does not turn a value into a list. A value becomes a
+ `readonly string[]` only when the template actually uses the explode
+ modifier (`{var*}`), because that varspec is what resolves `multiple`
+ to `true`. The same `{ explodable: true }` set on a non-exploded spec
+ such as `/users/{id}` still binds a scalar `string` at runtime.
+ - **`operatables`** defaults to `[]`, which permits every operator.
+ When set to a non-empty list of operators (`""`, `"+"`, `"#"`, `"."`,
+ `"/"`, `";"`, `"?"`, `"&"`), using the variable under any operator
+ outside the list throws `DisallowedOperatorError` at registration
+ time.
+
+The options object also accepts **`exact`**, which defaults to `true`:
+when you supply a `variables` object, its keys must match the template's
+variables exactly—every template variable must be listed and no unknown
+key may appear, otherwise registration throws
+`RouteTemplateOptionsNotMatchedError`. Set `{ exact: false }` to relax
+this so unlisted variables keep their defaults and unknown keys are
+ignored. Routes registered without a `variables` object are unaffected
+and keep every default.
+
+~~~~ typescript
+const router = new Router();
+
+// Throws RouteTemplateOptionsNotMatchedError: `id` is not listed.
+router.add("/posts/{slug}/{id}", "post", {
+ variables: { slug: { nullable: true } },
+});
+
+// OK: opt out of the exact-keys check.
+router.add("/posts/{slug}/{id}", "post", {
+ exact: false,
+ variables: { slug: { nullable: true } },
+});
+
+// OK: explode requires opting in.
+router.add("/tags{/tags*}", "tags", {
+ variables: { tags: { explodable: true } },
+});
+~~~~
+
+`Router.route()` is generic over the constraint map, so `values` narrows
+accordingly:
+
+~~~~ typescript
+const constraints = {
+ identifier: { nullable: false, multiple: false },
+} as const;
+router.add("/users/{identifier}", "actor", { variables: constraints });
+
+const matched = router.route("/users/alice");
+if (matched != null) {
+ const id: string = matched.values.identifier;
+}
+~~~~
+
+The narrowed type is derived from `multiple` and `nullable` only, never
+from `explodable`. Since `explodable` governs registration rather than
+the resolved value shape, an exploded route must carry `multiple: true`
+in the type argument—not merely `explodable: true`—for `values` to narrow
+to `readonly string[]`:
+
+~~~~ typescript
+const tagConstraints = {
+ tags: { explodable: true, multiple: true },
+} as const;
+router.add("/tags{?tags*}", "tags", { variables: tagConstraints });
+
+const matched = router.route("/tags?tags=a&tags=b");
+if (matched != null) {
+ const tags: readonly string[] = matched.values.tags;
+}
+~~~~
+
+
+Installation
+------------
+
+~~~~ bash
+deno add jsr:@fedify/uri-template # Deno
+npm add @fedify/uri-template # npm
+pnpm add @fedify/uri-template # pnpm
+yarn add @fedify/uri-template # Yarn
+bun add @fedify/uri-template # Bun
+~~~~
diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json
new file mode 100644
index 000000000..e4763ac1c
--- /dev/null
+++ b/packages/uri-template/deno.json
@@ -0,0 +1,37 @@
+{
+ "name": "@fedify/uri-template",
+ "version": "2.3.0",
+ "license": "MIT",
+ "exports": {
+ ".": "./src/mod.ts"
+ },
+ "description": "RFC 6570 URI Template expansion and round-trip pattern matching for Fedify",
+ "author": {
+ "name": "Chanhaeng Lee",
+ "email": "2chanhaeng@gmail.com",
+ "url": "https://chomu.dev"
+ },
+ "imports": {},
+ "exclude": [
+ "dist",
+ "node_modules"
+ ],
+ "publish": {
+ "exclude": [
+ "**/*.bench.ts",
+ "**/*.test.ts",
+ "old/",
+ "src/tests/",
+ "summary.txt",
+ "tsdown.config.ts"
+ ]
+ },
+ "tasks": {
+ "check": "deno fmt --check && deno lint && deno check",
+ "test": "deno test --allow-env",
+ "test:old": {
+ "command": "OLD=true deno test --allow-env old/",
+ "description": "Repository-only compatibility comparison against older libraries; expected to fail for known legacy gaps."
+ }
+ }
+}
diff --git a/packages/uri-template/old/uri-template-router.test.ts b/packages/uri-template/old/uri-template-router.test.ts
new file mode 100644
index 000000000..2c82dfb98
--- /dev/null
+++ b/packages/uri-template/old/uri-template-router.test.ts
@@ -0,0 +1,233 @@
+// deno-lint-ignore-file no-import-prefix
+import { test } from "@fedify/fixture";
+import { cloneDeep } from "es-toolkit";
+import { Router as InnerRouter } from "npm:uri-template-router@^1.0.0";
+import {
+ parseTemplate,
+ type Template as UrlTemplate,
+} from "npm:url-template@^3.1.1";
+import {
+ createRouterAddTest,
+ createRouterBuildTest,
+ createRouterCloneTest,
+ createRouterRouteTest,
+ createRouterVariablesTest,
+ routerBuildTestSuites,
+ routerCloneTestSuites,
+ routerRouteDefinitions,
+ routerRouteTestSuites,
+ routerVariablesCases,
+} from "../src/tests/mod.ts";
+import type { Path } from "../src/types.ts";
+
+/**
+ * Known failures for npm:uri-template-router@^1.0.0, checked with
+ * `deno task test:old`. These pct-encoding gaps are the main routing
+ * correctness issues that motivated the @fedify/uri-template Router
+ * implementation.
+ *
+ * RFC 6570 treats pct-encoded triplets as valid literal and varname syntax.
+ * It also distinguishes reserved characters from their pct-encoded forms
+ * under reserved expansion (`+` and `#` allow sets). The previous Router
+ * loses that distinction when matching reserved expansions: `/files/a%2Fb`
+ * becomes `a/b`, `/files/%30%23` becomes `0#`, and UTF-8 pct-encoded octets
+ * are decoded to Unicode characters. That makes route results fail to
+ * round-trip the actual URI template value.
+ *
+ * The companion `url-template` expander has the opposite problem for named
+ * variables: pct-encoded triplets in varnames such as `{?abc%20def}`,
+ * `{;%41}`, and `{&abc%20def}` are double-encoded as `%2520` or `%2541`
+ * when building URIs. The new Router uses the same strict RFC 6570 parser
+ * for building, matching, and variable extraction, so pct-encoded variable
+ * names and reserved expansion values are preserved instead of decoded or
+ * encoded a second time.
+ *
+ * The same compatibility run also records route-shape differences that matter
+ * for Fedify routes. The previous router rejects leading path expansion
+ * templates such as `{/identifier}/inbox` when they partially overlap with
+ * slash-prefixed routes, and it misses optional form-style query matches such
+ * as `/search{?q,page}` with only one query variable present.
+ */
+export interface RouterOptions {
+ trailingSlashInsensitive?: boolean;
+}
+
+export interface RouterRouteResult {
+ name: string;
+ template: Path;
+ values: Record;
+}
+
+export interface RouterPathPattern {
+ readonly path: Path;
+ readonly template: UrlTemplate;
+ readonly variables: ReadonlySet;
+}
+
+interface InnerRouteMatch {
+ readonly matchValue: string;
+ readonly params: Record;
+}
+
+function cloneInnerRouter(router: InnerRouter): InnerRouter {
+ const clone = new InnerRouter();
+ clone.nid = router.nid;
+ clone.fsm = cloneDeep(router.fsm);
+ clone.routeSet = new Set(router.routeSet);
+ clone.templateRouteMap = new Map(router.templateRouteMap);
+ clone.valueRouteMap = new Map(router.valueRouteMap);
+ clone.hierarchy = cloneDeep(router.hierarchy);
+ return clone;
+}
+
+export class Router {
+ #router: InnerRouter;
+ #templates: Record;
+ #templateStrings: Record;
+
+ trailingSlashInsensitive: boolean;
+
+ constructor(options: RouterOptions = {}) {
+ this.#router = new InnerRouter();
+ this.#templates = {};
+ this.#templateStrings = {};
+ this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false;
+ }
+
+ clone(): Router {
+ const clone = new Router({
+ trailingSlashInsensitive: this.trailingSlashInsensitive,
+ });
+ clone.#router = cloneInnerRouter(this.#router);
+ clone.#templates = { ...this.#templates };
+ clone.#templateStrings = { ...this.#templateStrings };
+ return clone;
+ }
+
+ static compile(path: Path): RouterPathPattern {
+ const router = new InnerRouter();
+ const rule = router.addTemplate(path, {}, "temp");
+ return {
+ path,
+ template: parseTemplate(path),
+ variables: new Set(
+ rule.variables.map((v: { varname: string }) => v.varname),
+ ),
+ };
+ }
+
+ static variables(path: Path): Set {
+ return new Set(Router.compile(path).variables);
+ }
+
+ has(name: string): boolean {
+ return name in this.#templates;
+ }
+
+ add(template: Path, name: string): void {
+ this.#router.addTemplate(template, {}, name);
+ this.#templates[name] = parseTemplate(template);
+ this.#templateStrings[name] = template as Path;
+ }
+
+ route(url: Path): RouterRouteResult | null {
+ let match = this.#router.resolveURI(url) as InnerRouteMatch | null;
+ if (match == null) {
+ if (!this.trailingSlashInsensitive) return null;
+ const retryUrl = toggleTrailingSlash(url);
+ if (retryUrl == null) return null;
+ match = this.#router.resolveURI(retryUrl) as InnerRouteMatch | null;
+ if (match == null) return null;
+ }
+ const values = toRouteValues(match.params);
+ if (values == null) return null;
+
+ return {
+ name: match.matchValue,
+ template: this.#templateStrings[match.matchValue],
+ values,
+ };
+ }
+
+ build(name: string, values: Record): Path | null {
+ if (name in this.#templates) {
+ return this.#templates[name].expand(values) as Path;
+ }
+ return null;
+ }
+}
+
+const isPath = (path: string): path is Path =>
+ path.startsWith("/") || /^\{\/[^}]+\}\//.test(path);
+
+const toggleTrailingSlash = (path: Path): Path | null => {
+ if (!path.endsWith("/")) return `${path}/`;
+
+ const trimmed = path.replace(/\/+$/, "");
+ return isPath(trimmed) ? trimmed : null;
+};
+
+const toRouteValues = (
+ params: Record,
+): Record | null => {
+ const values: Record = {};
+
+ for (const [key, value] of Object.entries(params)) {
+ if (typeof value !== "string") return null;
+ values[key] = value;
+ }
+
+ return values;
+};
+
+export class RouterError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "RouterError";
+ }
+}
+
+const isOldTest = Deno.env.get("OLD") === "true";
+
+const runAddCases = createRouterAddTest(Router);
+test(
+ "Router.add()",
+ { ignore: !isOldTest },
+ runAddCases(routerRouteDefinitions),
+);
+
+const runVariablesCases = createRouterVariablesTest(Router);
+test(
+ "Router.variables()",
+ { ignore: !isOldTest },
+ runVariablesCases(routerVariablesCases),
+);
+
+const runCloneCases = createRouterCloneTest(Router);
+test(
+ "Router.clone()",
+ { ignore: !isOldTest },
+ runCloneCases(routerCloneTestSuites),
+);
+
+const runRouteCases = createRouterRouteTest(Router);
+for (
+ const { name, options, routeDefinitions, cases } of routerRouteTestSuites
+) {
+ test(
+ `Router.route(): ${name}`,
+ { ignore: !isOldTest },
+ runRouteCases(routeDefinitions, options)(cases),
+ );
+}
+
+const runBuildCases = createRouterBuildTest(Router);
+for (
+ const { name, options, routeDefinitions, cases } of routerBuildTestSuites
+) {
+ test(
+ `Router.build(): ${name}`,
+ { ignore: !isOldTest },
+ runBuildCases(routeDefinitions, options)(cases),
+ );
+}
diff --git a/packages/uri-template/old/url-template.test.ts b/packages/uri-template/old/url-template.test.ts
new file mode 100644
index 000000000..0e5851a17
--- /dev/null
+++ b/packages/uri-template/old/url-template.test.ts
@@ -0,0 +1,102 @@
+import { test } from "@fedify/fixture";
+// deno-lint-ignore no-import-prefix
+import { parseTemplate } from "npm:url-template@^3.1.1";
+import {
+ createFixedTemplateTest,
+ createTemplateHardTest,
+ createTemplatePairTest,
+ createWrongTemplateTest,
+ fixedTestSuites,
+ hardTestSuites,
+ pairTestSuites,
+ wrongTestSuites,
+} from "../src/tests/mod.ts";
+
+/**
+ * Known failures for npm:url-template@^3.1.1, checked with
+ * `deno task test:old`.
+ * These are the compatibility gaps that motivated the strict
+ * @fedify/uri-template implementation.
+ *
+ * Expected-error cases that throw a different npm error are intentionally
+ * excluded. In the current run, none of the failing expected-error cases fell
+ * into that category; npm:url-template accepted and expanded them instead.
+ *
+ * RFC 6570 grounds for the expected behavior:
+ *
+ * - Section 2 defines a URI Template as zero or more literals or
+ * expressions, and each expression is delimited by a matching pair of
+ * braces. Section 3.2 also states that expressions cannot be nested.
+ * - Section 2.1 excludes CTL, SP, DQUOTE, "'", raw "%" outside a
+ * pct-encoded triplet, "<", ">", "\", "^", "`", "{", "|", and "}" from
+ * literals.
+ * - Section 2.3 defines `varname` as `varchar *( ["."] varchar )`, where
+ * `varchar` includes `pct-encoded`, and says pct-encoded triplets in a
+ * varname are essential parts of the variable name and are not decoded.
+ * - Section 3.2.8 says query expansion appends the variable name encoded as
+ * if it were a literal string. Since Section 2.1 permits `pct-encoded` in
+ * literals, a pct-encoded triplet in a variable name must be preserved and
+ * must not be encoded again.
+ * - Section 2.4.1 says prefix modifiers are not applicable to variables
+ * that have composite values.
+ * - Section 3 says grammar errors SHOULD indicate the location and type of
+ * error to the invoking application. @fedify/uri-template reports these
+ * cases as typed errors; npm:url-template silently returns a best-effort
+ * expansion for the cases below.
+ *
+ * Successful cases with different output:
+ *
+ * - `{?abc%20def}` expands to `?abc%2520def=spaced`; expected
+ * `?abc%20def=spaced`.
+ * - `{?%41}` expands to `?%2541=encoded-A`; expected `?%41=encoded-A`.
+ *
+ * Invalid templates accepted by npm:url-template:
+ *
+ * - `wrongTestSuites`: all 75 negative parser cases are accepted:
+ * Brackets not matched (9/9), Duplicated brackets (8/8), Wrong position
+ * of level 4 modifier (10/10), Wrong prefix modifier (10/10), Invalid
+ * characters in literals (21/21), and Invalid characters in expression
+ * (17/17).
+ * - `hardTestSuites` with `success: false`: all 10 negative cases are
+ * accepted: `%7Bvar}`, `{var%7D`, `}%7B%7D`, `%7B}`,
+ * `{var%7B%7D`, `{list:3}`, `{keys:3}`, `{?list:6}`, `{/keys:4}`,
+ * and `{count:2}`.
+ */
+class Template {
+ expand;
+ constructor(template: string) {
+ const { expand } = parseTemplate(template);
+ this.expand = expand;
+ }
+ match = (_: string) => null;
+}
+
+const isOldTest = Deno.env.get("OLD") === "true";
+
+const runPairCases = createTemplatePairTest(Template);
+test(
+ "old expand: examples",
+ { ignore: !isOldTest },
+ runPairCases(pairTestSuites),
+);
+
+const runFixedCases = createFixedTemplateTest(Template);
+test(
+ "old expand: fixed templates",
+ { ignore: !isOldTest },
+ runFixedCases(fixedTestSuites),
+);
+
+const runWrongCases = createWrongTemplateTest(Template);
+test(
+ "old parse: invalid templates",
+ { ignore: !isOldTest },
+ runWrongCases(wrongTestSuites),
+);
+
+const runHardCases = createTemplateHardTest(Template);
+test(
+ "old expand: hard cases",
+ { ignore: !isOldTest },
+ runHardCases(hardTestSuites),
+);
diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json
new file mode 100644
index 000000000..6aea6d40c
--- /dev/null
+++ b/packages/uri-template/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@fedify/uri-template",
+ "version": "2.3.0",
+ "homepage": "https://fedify.dev/",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/fedify-dev/fedify.git",
+ "directory": "packages/uri-template"
+ },
+ "bugs": {
+ "url": "https://github.com/fedify-dev/fedify/issues"
+ },
+ "funding": [
+ "https://opencollective.com/fedify",
+ "https://github.com/sponsors/dahlia"
+ ],
+ "engines": {
+ "deno": ">=2.0.0",
+ "node": ">=22.0.0",
+ "bun": ">=1.1.0"
+ },
+ "description": "RFC 6570 URI Template expansion and round-trip pattern matching",
+ "type": "module",
+ "main": "./dist/mod.cjs",
+ "module": "./dist/mod.js",
+ "types": "./dist/mod.d.ts",
+ "exports": {
+ ".": {
+ "require": {
+ "types": "./dist/mod.d.cts",
+ "default": "./dist/mod.cjs"
+ },
+ "import": {
+ "types": "./dist/mod.d.ts",
+ "default": "./dist/mod.js"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "build:self": "tsdown",
+ "build": "pnpm --filter @fedify/uri-template... run build:self",
+ "prepack": "pnpm build",
+ "prepublish": "pnpm build",
+ "test:bun": "bun test src/",
+ "test": "cd src && node --experimental-transform-types --test"
+ },
+ "files": [
+ "dist",
+ "package.json",
+ "README.md"
+ ],
+ "keywords": [
+ "Fedify",
+ "URI Template",
+ "RFC 6570",
+ "ActivityPub",
+ "Fediverse"
+ ],
+ "author": {
+ "name": "Chanhaeng Lee",
+ "email": "2chanhaeng@gmail.com",
+ "url": "https://chomu.dev/"
+ },
+ "license": "MIT",
+ "devDependencies": {
+ "@fedify/fixture": "workspace:^",
+ "@types/node": "catalog:",
+ "tsdown": "catalog:",
+ "typescript": "catalog:"
+ },
+ "dependencies": {},
+ "sideEffects": false
+}
diff --git a/packages/uri-template/src/const.ts b/packages/uri-template/src/const.ts
new file mode 100644
index 000000000..959597fcc
--- /dev/null
+++ b/packages/uri-template/src/const.ts
@@ -0,0 +1,78 @@
+/**
+ * Expansion behavior for a URI Template operator.
+ *
+ * Used by the expansion module to apply RFC 6570's `first`, `sep`, `named`,
+ * `ifemp`, and allowed-character rules uniformly.
+ */
+export interface OperatorSpec {
+ /** Prefix emitted before the first defined value in the expression. */
+ first: string;
+ /** Separator emitted between defined values or exploded members. */
+ sep: string;
+ /** Whether the expansion emits variable names or associative keys. */
+ named: boolean;
+ /** Suffix emitted after a name when the corresponding value is empty. */
+ ifEmpty: string;
+ /** Whether reserved characters and pct-encoded triplets pass through. */
+ allowReserved: boolean;
+}
+
+/**
+ * Operators implemented by this package, including `""` for simple string
+ * expansion with no explicit operator.
+ */
+export const OPERATORS = ["", "+", ".", "/", ";", "?", "&", "#"] as const;
+
+/**
+ * Union of supported URI Template operators.
+ */
+export type Operator = typeof OPERATORS[number];
+
+/**
+ * RFC 6570 operator behavior table used during expansion.
+ * This table is from RFC 6570 Appendix A.
+ *
+ * | | `NUL` | `+` | `.` | `/` | `;` | `?` | `&` | `#` |
+ * | ----- | ------- | ------- | ------- | ------- | ------ | ------ | ------ | ------- |
+ * | first | `""` | `""` | `"."` | `"/"` | `";"` | `"?"` | `"&"` | `"#"` |
+ * | sep | `","` | `","` | `"."` | `"/"` | `";"` | `"&"` | `"&"` | `","` |
+ * | named | `false` | `false` | `false` | `false` | `true` | `true` | `true` | `false` |
+ * | ifemp | `""` | `""` | `""` | `""` | `""` | `"="` | `"="` | `""` |
+ * | allow | `U` | `U+R` | `U` | `U` | `U` | `U` | `U` | `U+R` |
+ */
+export const operatorSpecs: Record = {
+ "": { first: "", sep: ",", named: false, ifEmpty: "", allowReserved: false },
+ "+": { first: "", sep: ",", named: false, ifEmpty: "", allowReserved: true },
+ ".": {
+ first: ".",
+ sep: ".",
+ named: false,
+ ifEmpty: "",
+ allowReserved: false,
+ },
+ "/": {
+ first: "/",
+ sep: "/",
+ named: false,
+ ifEmpty: "",
+ allowReserved: false,
+ },
+ ";": { first: ";", sep: ";", named: true, ifEmpty: "", allowReserved: false },
+ "?": {
+ first: "?",
+ sep: "&",
+ named: true,
+ ifEmpty: "=",
+ allowReserved: false,
+ },
+ "&": {
+ first: "&",
+ sep: "&",
+ named: true,
+ ifEmpty: "=",
+ allowReserved: false,
+ },
+ "#": { first: "#", sep: ",", named: false, ifEmpty: "", allowReserved: true },
+};
+
+// cspell: ignore ifemp
diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts
new file mode 100644
index 000000000..e6b913d07
--- /dev/null
+++ b/packages/uri-template/src/mod.ts
@@ -0,0 +1,60 @@
+/**
+ * [RFC 6570] URI Template expansion and round-trip pattern matching.
+ *
+ * [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570
+ *
+ * @module
+ */
+
+export {
+ ConflictingVarSpecError,
+ DisallowedOperatorError,
+ DisallowedVarSpecModifierError,
+ DuplicateRouteVariableError,
+ Router,
+ RouterError,
+ RouteTemplateOptionsNotMatchedError,
+ RouteTemplatePathError,
+} from "./router/mod.ts";
+export type {
+ ConstraintValue,
+ RouteOptions,
+ RouterOptions,
+ RouterPathPattern,
+ RouterRoute,
+ RouterRouteResult,
+ RouteValues,
+ VariableConstraint,
+} from "./router/mod.ts";
+export {
+ EmptyExpressionError,
+ EmptyVarNameError,
+ InvalidLiteralError,
+ InvalidPrefixError,
+ InvalidVarNameError,
+ InvalidVarSpecError,
+ NestedOpeningBraceError,
+ PrefixModifierNotApplicableError,
+ ReservedOperatorError,
+ StrayClosingBraceError,
+ Template,
+ TemplateExpansionError,
+ TemplateParseError,
+ TrailingCommaError,
+ UnclosedExpressionError,
+ UnexpectedCharacterError,
+ UnknownOperatorError,
+} from "./template/mod.ts";
+export type {
+ AssociativeValue,
+ ExpandContext,
+ ExpandValue,
+ Operator,
+ Path,
+ PrimitiveValue,
+ Reporter,
+ TemplateOptions,
+ Token,
+ VarSpec,
+} from "./types.ts";
+export { assertPath, isExpression, isPath } from "./utils.ts";
diff --git a/packages/uri-template/src/router/errors.ts b/packages/uri-template/src/router/errors.ts
new file mode 100644
index 000000000..be718373e
--- /dev/null
+++ b/packages/uri-template/src/router/errors.ts
@@ -0,0 +1,160 @@
+/**
+ * Common base class for router-level errors.
+ */
+export class RouterError extends Error {
+ /**
+ * @param message Human-readable summary.
+ */
+ constructor(message: string) {
+ super(message);
+ this.name = "RouterError";
+ }
+}
+
+/**
+ * Raised when a route template is not a path template.
+ */
+export class RouteTemplatePathError extends RouterError {
+ constructor(
+ /**
+ * The route template that failed validation.
+ */
+ public readonly template: string,
+ ) {
+ super("Path must start with a slash or a path expansion.");
+ this.name = "RouteTemplatePathError";
+ }
+}
+
+/**
+ * Raised when the same variable name appears in multiple variable
+ * specifications whose modifiers imply contradictory `multiple` semantics
+ * within a single route template (for example, `{x}` together with `{x*}`).
+ */
+export class ConflictingVarSpecError extends RouterError {
+ constructor(
+ /**
+ * The route template containing the conflicting variable specifications.
+ */
+ public readonly template: string,
+ /**
+ * The variable name with conflicting variable specifications.
+ */
+ public readonly variable: string,
+ ) {
+ super(
+ `Variable "${variable}" has conflicting explode/prefix modifiers ` +
+ `across the template "${template}".`,
+ );
+ this.name = "ConflictingVarSpecError";
+ }
+}
+
+/**
+ * Raised under the default `exact` route option when the `variables` keys
+ * do not exactly match the route template's variables: the set of supplied
+ * keys must equal the set of template variables (no unknown keys, none
+ * missing). All mismatched names — both unknown and missing — are reported.
+ */
+export class RouteTemplateOptionsNotMatchedError extends RouterError {
+ constructor(
+ /**
+ * The route template whose variables were not matched exactly.
+ */
+ public readonly template: string,
+ /**
+ * The mismatched variable names: keys not declared by the template
+ * together with template variables absent from the options.
+ */
+ public readonly variable: readonly string[],
+ ) {
+ super(
+ `Route options variables do not exactly match the template ` +
+ `"${template}"; mismatched: ${
+ variable.map((v) => `"${v}"`).join(", ")
+ }.`,
+ );
+ this.name = "RouteTemplateOptionsNotMatchedError";
+ }
+}
+
+/**
+ * Raised when a variable appears more than once in a route template while
+ * its `duplicable` constraint is `false` (the default).
+ */
+export class DuplicateRouteVariableError extends RouterError {
+ constructor(
+ /**
+ * The route template containing the duplicated variable.
+ */
+ public readonly template: string,
+ /**
+ * The variable name that appears more than once.
+ */
+ public readonly variable: string,
+ ) {
+ super(
+ `Variable "${variable}" appears more than once in the template ` +
+ `"${template}" but is not marked "duplicable: true".`,
+ );
+ this.name = "DuplicateRouteVariableError";
+ }
+}
+
+/**
+ * Raised when a variable specification uses the explode (`*`) or prefix
+ * (`:N`) modifier while the corresponding `explodable`/`prefixable`
+ * constraint is `false` (the default).
+ */
+export class DisallowedVarSpecModifierError extends RouterError {
+ constructor(
+ /**
+ * The route template containing the disallowed modifier.
+ */
+ public readonly template: string,
+ /**
+ * The variable name whose specification uses the modifier.
+ */
+ public readonly variable: string,
+ /**
+ * The disallowed modifier.
+ */
+ public readonly modifier: "explode" | "prefix",
+ ) {
+ super(
+ `Variable "${variable}" uses the ${modifier} modifier in the ` +
+ `template "${template}" but is not marked ` +
+ `"${modifier === "explode" ? "explodable" : "prefixable"}: true".`,
+ );
+ this.name = "DisallowedVarSpecModifierError";
+ }
+}
+
+/**
+ * Raised when a variable is used with an expression operator that is not in
+ * its `operatables` allow-list.
+ */
+export class DisallowedOperatorError extends RouterError {
+ constructor(
+ /**
+ * The route template containing the disallowed operator.
+ */
+ public readonly template: string,
+ /**
+ * The variable name used with the disallowed operator.
+ */
+ public readonly variable: string,
+ /**
+ * The disallowed expression operator (`""`, `"+"`, `"#"`, `"."`, `"/"`,
+ * `";"`, `"?"`, or `"&"`).
+ */
+ public readonly operator: string,
+ ) {
+ super(
+ `Variable "${variable}" is used with the operator ` +
+ `"${operator}" in the template "${template}", which is not in its ` +
+ `"operatables" allow-list.`,
+ );
+ this.name = "DisallowedOperatorError";
+ }
+}
diff --git a/packages/uri-template/src/router/fill.ts b/packages/uri-template/src/router/fill.ts
new file mode 100644
index 000000000..16622adeb
--- /dev/null
+++ b/packages/uri-template/src/router/fill.ts
@@ -0,0 +1,208 @@
+import type { Operator, Token, VarSpec } from "../types.ts";
+import { isExpression } from "../utils.ts";
+import {
+ ConflictingVarSpecError,
+ DisallowedOperatorError,
+ DisallowedVarSpecModifierError,
+ DuplicateRouteVariableError,
+ RouteTemplateOptionsNotMatchedError,
+} from "./errors.ts";
+import type {
+ RouteOptions,
+ RouterPathPattern,
+ VariableConstraint,
+} from "./types.ts";
+
+/**
+ * Resolves a partial options input against a path pattern into fully-resolved
+ * {@link RouteOptions}. Mirrors `fillOptions` in *template.ts*: missing
+ * fields are filled with their defaults, with `multiple` derived from the
+ * variable specification when not given. Throws when the supplied
+ * `variables` keys do not match the template (under `exact`), when the same
+ * variable name carries contradictory explode/prefix modifiers, or when a
+ * per-field constraint is violated.
+ */
+export function fillRouteOptions(
+ options: {
+ readonly variables?: Readonly>>;
+ readonly exact?: boolean;
+ } = {},
+ pattern: RouterPathPattern,
+): RouteOptions {
+ const overrides = options?.variables ?? {};
+ const exact = options?.exact ?? true;
+
+ // Under `exact` (the default), the supplied `variables` keys must equal
+ // the template's variables exactly: `Set(keys) === pattern.variables`.
+ // Skipped when no `variables` object was supplied at all, so routes
+ // registered without per-variable options keep working.
+ if (exact && options?.variables != null) {
+ const keys = Object.keys(overrides);
+ const unknown = keys.filter((key) => !pattern.variables.has(key));
+ const missing = [...pattern.variables].filter(
+ (name) => !(name in overrides),
+ );
+ const mismatched = [...unknown, ...missing];
+ if (mismatched.length > 0) {
+ throw new RouteTemplateOptionsNotMatchedError(
+ pattern.path,
+ mismatched,
+ );
+ }
+ }
+
+ const operatorsByName = groupVarOperators(pattern.template.tokens);
+ const variables: Record = {};
+ for (const [name, specs] of groupVarSpecs(pattern.template.tokens)) {
+ const override = overrides[name];
+
+ const hasExplode = specs.some((spec) => spec.explode);
+ const hasPrefix = specs.some((spec) => spec.prefix != null);
+ const hasPlain = specs.some(
+ (spec) => !spec.explode && spec.prefix == null,
+ );
+
+ const multiple = override?.multiple;
+ if (
+ (hasExplode && (hasPrefix || hasPlain)) ||
+ (hasExplode && multiple === false) ||
+ (hasPrefix && multiple === true)
+ ) {
+ throw new ConflictingVarSpecError(pattern.path, name);
+ }
+
+ variables[name] = {
+ nullable: fillNullable(override?.nullable),
+ multiple: fillMultiple(
+ multiple,
+ hasExplode,
+ hasPrefix,
+ ),
+ duplicable: fillDuplicable(
+ override?.duplicable,
+ pattern.path,
+ name,
+ specs.length,
+ ),
+ prefixable: fillPrefixable(
+ override?.prefixable,
+ pattern.path,
+ name,
+ hasPrefix,
+ ),
+ explodable: fillExplodable(
+ override?.explodable,
+ pattern.path,
+ name,
+ hasExplode,
+ ),
+ operatables: fillOperatables(
+ override?.operatables,
+ pattern.path,
+ name,
+ operatorsByName.get(name),
+ ),
+ };
+ }
+
+ return { variables, exact };
+}
+
+export const fillNullable = (override: boolean | undefined): boolean =>
+ override ?? false;
+
+export const fillMultiple = (
+ requested: boolean | undefined,
+ hasExplode: boolean,
+ hasPrefix: boolean,
+): boolean => {
+ if (hasExplode) return true;
+ if (hasPrefix) return false;
+ return requested ?? false;
+};
+
+export const fillDuplicable = (
+ override: boolean | undefined,
+ template: string,
+ name: string,
+ occurrences: number,
+): boolean => {
+ const duplicable = override ?? false;
+ if (occurrences > 1 && !duplicable) {
+ throw new DuplicateRouteVariableError(template, name);
+ }
+ return duplicable;
+};
+
+export const fillPrefixable = (
+ override: boolean | undefined,
+ template: string,
+ name: string,
+ hasPrefix: boolean,
+): boolean => {
+ const prefixable = override ?? false;
+ if (hasPrefix && !prefixable) {
+ throw new DisallowedVarSpecModifierError(template, name, "prefix");
+ }
+ return prefixable;
+};
+
+export const fillExplodable = (
+ override: boolean | undefined,
+ template: string,
+ name: string,
+ hasExplode: boolean,
+): boolean => {
+ const explodable = override ?? false;
+ if (hasExplode && !explodable) {
+ throw new DisallowedVarSpecModifierError(template, name, "explode");
+ }
+ return explodable;
+};
+
+export const fillOperatables = (
+ override: readonly Operator[] | undefined,
+ template: string,
+ name: string,
+ operators: ReadonlySet | undefined,
+): readonly Operator[] => {
+ const operatables = override ?? [];
+ if (operatables.length > 0 && operators != null) {
+ for (const operator of operators) {
+ if (!operatables.includes(operator)) {
+ throw new DisallowedOperatorError(template, name, operator);
+ }
+ }
+ }
+ return operatables;
+};
+
+const groupVarSpecs = (
+ tokens: readonly Token[],
+): ReadonlyMap => {
+ const grouped = new Map();
+ for (const token of tokens) {
+ if (!isExpression(token)) continue;
+ for (const varSpec of token.vars) {
+ const list = grouped.get(varSpec.name);
+ if (list == null) grouped.set(varSpec.name, [varSpec]);
+ else list.push(varSpec);
+ }
+ }
+ return grouped;
+};
+
+const groupVarOperators = (
+ tokens: readonly Token[],
+): ReadonlyMap> => {
+ const grouped = new Map>();
+ for (const token of tokens) {
+ if (!isExpression(token)) continue;
+ for (const varSpec of token.vars) {
+ const set = grouped.get(varSpec.name);
+ if (set == null) grouped.set(varSpec.name, new Set([token.operator]));
+ else set.add(token.operator);
+ }
+ }
+ return grouped;
+};
diff --git a/packages/uri-template/src/router/mod.ts b/packages/uri-template/src/router/mod.ts
new file mode 100644
index 000000000..cac880cce
--- /dev/null
+++ b/packages/uri-template/src/router/mod.ts
@@ -0,0 +1,13 @@
+export * from "./errors.ts";
+export { default as Router } from "./router.ts";
+export type {
+ ConstraintValue,
+ PartialRouterRoute,
+ RouteOptions,
+ RouterOptions,
+ RouterPathPattern,
+ RouterRoute,
+ RouterRouteResult,
+ RouteValues,
+ VariableConstraint,
+} from "./types.ts";
diff --git a/packages/uri-template/src/router/router.bench.ts b/packages/uri-template/src/router/router.bench.ts
new file mode 100644
index 000000000..5d94c9db6
--- /dev/null
+++ b/packages/uri-template/src/router/router.bench.ts
@@ -0,0 +1,67 @@
+import {
+ createDeepPrefixRouterTest,
+ createDynamicRoutesTest,
+ createInactiveEntriesTest,
+ createRouterBuildPathsBench,
+ createRouterCompileAndAddBench,
+ createRouterFirstRouteAfterBuildBench,
+ createRouterRouteHitsBench,
+ createRouterRouteMissesBench,
+ createRoutesPressureTest,
+ routerBuildCases,
+ routerHitPaths,
+ routerMissPaths,
+ routerRouteDefinitions,
+} from "../tests/mod.ts";
+import Router from "./router.ts";
+
+const runCompileAndAddRoutes = createRouterCompileAndAddBench(Router);
+Deno.bench(
+ "Router: compile and add routes",
+ runCompileAndAddRoutes(routerRouteDefinitions, "actor"),
+);
+
+const runRouteHits = createRouterRouteHitsBench(Router);
+Deno.bench(
+ "Router: route mixed hits",
+ runRouteHits(routerRouteDefinitions, routerHitPaths),
+);
+
+const runRouteMisses = createRouterRouteMissesBench(Router);
+Deno.bench(
+ "Router: route misses",
+ runRouteMisses(routerRouteDefinitions, routerMissPaths),
+);
+
+const runBuildPaths = createRouterBuildPathsBench(Router);
+Deno.bench(
+ "Router: build paths",
+ runBuildPaths(routerRouteDefinitions, routerBuildCases),
+);
+
+const runFirstRouteAfterBuild = createRouterFirstRouteAfterBuildBench(Router);
+for (
+ const scenario of [
+ createRoutesPressureTest(),
+ createDeepPrefixRouterTest(),
+ createDynamicRoutesTest(),
+ createInactiveEntriesTest(),
+ ]
+) {
+ Deno.bench(
+ `Router: ${scenario.name}: compile and add routes`,
+ runCompileAndAddRoutes(scenario.routeDefinitions, scenario.routeName),
+ );
+ Deno.bench(
+ `Router: ${scenario.name}: first route after build`,
+ runFirstRouteAfterBuild(scenario.routeDefinitions, scenario.hitPaths[0]),
+ );
+ Deno.bench(
+ `Router: ${scenario.name}: route hits`,
+ runRouteHits(scenario.routeDefinitions, scenario.hitPaths),
+ );
+ Deno.bench(
+ `Router: ${scenario.name}: route misses`,
+ runRouteMisses(scenario.routeDefinitions, scenario.missPaths),
+ );
+}
diff --git a/packages/uri-template/src/router/router.test.ts b/packages/uri-template/src/router/router.test.ts
new file mode 100644
index 000000000..55e158fcc
--- /dev/null
+++ b/packages/uri-template/src/router/router.test.ts
@@ -0,0 +1,1110 @@
+import { test } from "@fedify/fixture";
+import { ok } from "node:assert";
+import { deepEqual, equal, throws } from "node:assert/strict";
+import type Template from "../template/template.ts";
+import {
+ createRouterAddTest,
+ createRouterBuildTest,
+ createRouterCloneTest,
+ createRouterCompileErrorTest,
+ createRouterRouteTest,
+ createRouterVariablesTest,
+ routerBuildTestSuites,
+ routerCloneTestSuites,
+ routerCompileErrorCases,
+ routerRouteDefinitions,
+ routerRouteTestSuites,
+ routerVariablesCases,
+} from "../tests/mod.ts";
+import type { ExpandContext, Path } from "../types.ts";
+import {
+ ConflictingVarSpecError,
+ DisallowedOperatorError,
+ DisallowedVarSpecModifierError,
+ DuplicateRouteVariableError,
+ RouterError,
+ RouteTemplateOptionsNotMatchedError,
+} from "./errors.ts";
+import Router from "./router.ts";
+import type { PartialRouterRoute, RouterPathPattern } from "./types.ts";
+
+const runAddCases = createRouterAddTest(Router);
+test("Router.add()", runAddCases(routerRouteDefinitions));
+
+const runCompileErrorCases = createRouterCompileErrorTest(Router);
+test(
+ "Router.compile() rejects invalid templates",
+ runCompileErrorCases(routerCompileErrorCases),
+);
+
+const runVariablesCases = createRouterVariablesTest(Router);
+test("Router.variables()", runVariablesCases(routerVariablesCases));
+
+const runCloneCases = createRouterCloneTest(Router);
+test("Router.clone()", runCloneCases(routerCloneTestSuites));
+
+const runRouteCases = createRouterRouteTest(Router);
+for (
+ const { name, options, routeDefinitions, cases } of routerRouteTestSuites
+) {
+ test(
+ `Router.route(): ${name}`,
+ runRouteCases(routeDefinitions, options)(cases),
+ );
+}
+
+const runBuildCases = createRouterBuildTest(Router);
+for (
+ const { name, options, routeDefinitions, cases } of routerBuildTestSuites
+) {
+ test(
+ `Router.build(): ${name}`,
+ runBuildCases(routeDefinitions, options)(cases),
+ );
+}
+
+const sampleRoutes: readonly PartialRouterRoute[] = [
+ ["/users/{id}", "user"] as const,
+ ["/posts/{id}", "post"] as const,
+ ["/users/{id}/posts/{postId}", "userPost"] as const,
+];
+
+const createCountingPattern = (
+ path: Path,
+ calls: Map,
+): RouterPathPattern => {
+ const pattern = Router.compile(path);
+ const match = pattern.template.match;
+ const template = {
+ get tokens(): typeof pattern.template.tokens {
+ return pattern.template.tokens;
+ },
+ expand: pattern.template.expand,
+ match: (uri: string): ExpandContext | null => {
+ calls.set(path, (calls.get(path) ?? 0) + 1);
+ return match(uri);
+ },
+ toString: pattern.template.toString,
+ } as unknown as Template;
+ return {
+ path: pattern.path,
+ template,
+ variables: pattern.variables,
+ };
+};
+
+test("Router indexes shared dynamic prefixes before template matching", () => {
+ const calls = new Map();
+ const routeDefinitions = [
+ ["/ap/{identifier}", "actor"],
+ ["/ap/{identifier}/inbox", "inbox"],
+ ["/ap/{identifier}/outbox", "outbox"],
+ ["/ap/{identifier}/followers", "followers"],
+ ["/ap/{identifier}/following", "following"],
+ ["/ap/{identifier}/featured", "featured"],
+ ] as const satisfies readonly PartialRouterRoute[];
+ const routes = routeDefinitions.map(
+ ([path, name]): PartialRouterRoute => [
+ createCountingPattern(path, calls),
+ name,
+ ],
+ );
+ const router = new Router(routes);
+
+ deepEqual(router.route("/ap/alice/inbox"), {
+ name: "inbox",
+ template: "/ap/{identifier}/inbox",
+ values: { identifier: "alice" },
+ });
+ equal(calls.get("/ap/{identifier}/inbox"), 1);
+ for (const [path] of routeDefinitions) {
+ if (path !== "/ap/{identifier}/inbox") {
+ equal(calls.get(path) ?? 0, 0);
+ }
+ }
+});
+
+test(
+ "Router indexes root-adjacent dynamic prefixes before template matching",
+ () => {
+ const calls = new Map();
+ const routeDefinitions = [
+ ["/{identifier}/inbox", "inbox"],
+ ["/{identifier}/outbox", "outbox"],
+ ["/{identifier}/followers", "followers"],
+ ["/{tenant}/users/{identifier}/inbox", "tenantInbox"],
+ ["/{tenant}/users/{identifier}/outbox", "tenantOutbox"],
+ ] as const satisfies readonly PartialRouterRoute[];
+ const routes = routeDefinitions.map(([path, name]): PartialRouterRoute => [
+ createCountingPattern(path, calls),
+ name,
+ ]);
+ const router = new Router(routes);
+
+ deepEqual(router.route("/alice/outbox"), {
+ name: "outbox",
+ template: "/{identifier}/outbox",
+ values: { identifier: "alice" },
+ });
+ equal(calls.get("/{identifier}/outbox"), 1);
+ for (const [path] of routeDefinitions) {
+ if (path !== "/{identifier}/outbox") {
+ equal(calls.get(path) ?? 0, 0);
+ }
+ }
+ },
+);
+
+test("Router preserves priority across state and fallback tries", () => {
+ const router = new Router([
+ ["/{id}", "state"],
+ ["/@{identifier}", "state"],
+ ["/x{first,second}", "fallback", {
+ exact: false,
+ variables: { second: { nullable: true } },
+ }],
+ ]);
+
+ deepEqual(router.route("/xalice"), {
+ name: "fallback",
+ template: "/x{first,second}",
+ values: { first: "alice", second: null },
+ });
+});
+
+test("Router#register() registers all routes in one call", async (t) => {
+ const router = new Router();
+ router.register(sampleRoutes);
+
+ await t.step("registers every name", () => {
+ equal(router.has("user"), true);
+ equal(router.has("post"), true);
+ equal(router.has("userPost"), true);
+ });
+
+ await t.step("matches routes equivalently to repeated add()", () => {
+ deepEqual(router.route("/users/42"), {
+ name: "user",
+ template: "/users/{id}",
+ values: { id: "42" },
+ });
+ deepEqual(router.route("/users/42/posts/7"), {
+ name: "userPost",
+ template: "/users/{id}/posts/{postId}",
+ values: { id: "42", postId: "7" },
+ });
+ });
+
+ await t.step("preserves insertion order against later add()", () => {
+ const reference = new Router();
+ for (const [path, name] of sampleRoutes) {
+ reference.add(path, name);
+ }
+ deepEqual(
+ router.route("/users/42"),
+ reference.route("/users/42"),
+ );
+ });
+
+ await t.step("accepts non-array iterables", () => {
+ function* iter(): Generator {
+ for (const route of sampleRoutes) yield route;
+ }
+ const fromGenerator = new Router();
+ fromGenerator.register(iter());
+ equal(fromGenerator.has("userPost"), true);
+ });
+});
+
+test("Router accepts pre-parsed RouterPathPattern", async (t) => {
+ const pattern = Router.compile("/items/{id}");
+
+ await t.step("via add()", () => {
+ const router = new Router();
+ router.add(pattern, "item");
+ equal(router.has("item"), true);
+ deepEqual(router.route("/items/9"), {
+ name: "item",
+ template: "/items/{id}",
+ values: { id: "9" },
+ });
+ });
+
+ await t.step("via register()", () => {
+ const router = new Router();
+ router.register([[pattern, "item"]]);
+ equal(router.has("item"), true);
+ });
+
+ await t.step("via constructor", () => {
+ const router = new Router([[pattern, "item"]]);
+ equal(router.has("item"), true);
+ });
+
+ await t.step("via Router.from()", () => {
+ const router = Router.from([[pattern, "item"]]);
+ equal(router.has("item"), true);
+ });
+});
+
+test("Router.compile() returns immutable path patterns", async (t) => {
+ const pattern = Router.compile("/items/{id}");
+ const router = new Router([[pattern, "item"]]);
+
+ await t.step("blocks Template entry point reassignment", () => {
+ throws(() => {
+ (pattern.template as {
+ match: (uri: string) => ExpandContext | null;
+ }).match = () => null;
+ }, TypeError);
+ throws(() => {
+ (pattern.template as {
+ expand: (context: ExpandContext) => string;
+ }).expand = () => "/changed";
+ }, TypeError);
+ });
+
+ await t.step("blocks RouterPathPattern wrapper reassignment", () => {
+ const otherTemplate = Router.compile("/other/{id}").template;
+ throws(() => {
+ (pattern as { path: Path }).path = "/other/{id}";
+ }, TypeError);
+ throws(() => {
+ (pattern as { template: typeof otherTemplate }).template = otherTemplate;
+ }, TypeError);
+ });
+
+ await t.step("blocks variables mutation", () => {
+ throws(() => {
+ (pattern.variables as Set).add("unexpected");
+ }, TypeError);
+ equal(pattern.variables.has("unexpected"), false);
+ equal(pattern.variables.size, 1);
+ });
+
+ await t.step("keeps registered routing behavior unchanged", () => {
+ deepEqual(router.route("/items/9"), {
+ name: "item",
+ template: "/items/{id}",
+ values: { id: "9" },
+ });
+ equal(router.build("item", { id: "9" }), "/items/9");
+ });
+});
+
+test("Router.clone() isolates route sets with shared pre-parsed patterns", () => {
+ const pattern = Router.compile("/items/{id}");
+ const original = new Router([[pattern, "item"]]);
+ const clone = original.clone();
+ const itemRoute = {
+ name: "item",
+ template: "/items/{id}",
+ values: { id: "9" },
+ };
+
+ deepEqual(original.route("/items/9"), itemRoute);
+ deepEqual(clone.route("/items/9"), itemRoute);
+
+ original.add("/posts/{postId}", "post");
+ equal(clone.route("/posts/3"), null);
+ deepEqual(original.route("/posts/3"), {
+ name: "post",
+ template: "/posts/{postId}",
+ values: { postId: "3" },
+ });
+
+ clone.add("/users/{userId}", "user");
+ equal(original.route("/users/5"), null);
+ deepEqual(clone.route("/users/5"), {
+ name: "user",
+ template: "/users/{userId}",
+ values: { userId: "5" },
+ });
+
+ original.add("/people/{id}", "item");
+ equal(original.route("/items/9"), null);
+ deepEqual(original.route("/people/9"), {
+ name: "item",
+ template: "/people/{id}",
+ values: { id: "9" },
+ });
+ equal(original.build("item", { id: "9" }), "/people/9");
+ deepEqual(clone.route("/items/9"), itemRoute);
+ equal(clone.route("/people/9"), null);
+ equal(clone.build("item", { id: "9" }), "/items/9");
+});
+
+test("Router trailing slash retry accepts empty root path", () => {
+ const router = new Router([["", "root"]], {
+ trailingSlashInsensitive: true,
+ });
+
+ deepEqual(router.route("/"), {
+ name: "root",
+ template: "",
+ values: {},
+ });
+});
+
+test("Router constructor argument variants", async (t) => {
+ await t.step("no arguments builds an empty router", () => {
+ const router = new Router();
+ equal(router.has("user"), false);
+ equal(router.trailingSlashInsensitive, false);
+ });
+
+ await t.step("options only", () => {
+ const router = new Router({ trailingSlashInsensitive: true });
+ equal(router.trailingSlashInsensitive, true);
+ equal(router.has("user"), false);
+ });
+
+ await t.step("routes only", () => {
+ const router = new Router(sampleRoutes);
+ equal(router.has("user"), true);
+ equal(router.trailingSlashInsensitive, false);
+ });
+
+ await t.step("routes and options together", () => {
+ const router = new Router(sampleRoutes, {
+ trailingSlashInsensitive: true,
+ });
+ equal(router.has("user"), true);
+ equal(router.trailingSlashInsensitive, true);
+ });
+});
+
+test("Router treats re-registration as replacement", async (t) => {
+ await t.step("add() replaces a previous add() with the same name", () => {
+ const router = new Router();
+ router.add("/old/{id}", "user");
+ router.add("/new/{id}", "user");
+
+ equal(router.route("/old/1"), null);
+ deepEqual(router.route("/new/1"), {
+ name: "user",
+ template: "/new/{id}",
+ values: { id: "1" },
+ });
+ equal(router.build("user", { id: "1" }), "/new/1");
+ });
+
+ await t.step("register() replaces previously registered names", () => {
+ const router = new Router();
+ router.register([
+ ["/a/{id}", "user"],
+ ["/b/{id}", "post"],
+ ]);
+ router.register([
+ ["/c/{id}", "user"],
+ ["/d/{id}", "post"],
+ ]);
+
+ equal(router.route("/a/1"), null);
+ equal(router.route("/b/1"), null);
+ equal(router.route("/c/1")?.name, "user");
+ equal(router.route("/d/1")?.name, "post");
+ });
+
+ await t.step("register() de-duplicates names within a single call", () => {
+ const router = new Router();
+ router.register([
+ ["/a/{id}", "user"],
+ ["/b/{id}", "user"],
+ ]);
+
+ equal(router.route("/a/1"), null);
+ deepEqual(router.route("/b/1"), {
+ name: "user",
+ template: "/b/{id}",
+ values: { id: "1" },
+ });
+ });
+
+ await t.step("constructor de-duplicates names in the input iterable", () => {
+ const router = new Router([
+ ["/v1/{id}", "user"],
+ ["/v2/{id}", "user"],
+ ]);
+
+ equal(router.route("/v1/1"), null);
+ equal(router.route("/v2/1")?.name, "user");
+ });
+
+ await t.step("only the latest survives repeated re-registration", () => {
+ const router = new Router();
+ for (let i = 0; i < 50; i++) {
+ router.add(`/v${i}/{id}`, "user");
+ }
+
+ equal(router.route("/v0/1"), null);
+ equal(router.route("/v25/1"), null);
+ deepEqual(router.route("/v49/1"), {
+ name: "user",
+ template: "/v49/{id}",
+ values: { id: "1" },
+ });
+ });
+
+ await t.step(
+ "mixed add() / register() preserves replacement semantics",
+ () => {
+ const router = new Router();
+ router.add("/old-a/{id}", "a");
+ router.add("/old-b/{id}", "b");
+ router.register([
+ ["/new-a/{id}", "a"],
+ ["/new-b/{id}", "b"],
+ ]);
+
+ equal(router.route("/old-a/1"), null);
+ equal(router.route("/old-b/1"), null);
+ equal(router.route("/new-a/1")?.name, "a");
+ equal(router.route("/new-b/1")?.name, "b");
+ },
+ );
+
+ await t.step("sibling routes survive re-registration of another name", () => {
+ const router = new Router();
+ router.register([
+ ["/users/{id}", "user"],
+ ["/posts/{id}", "post"],
+ ]);
+ router.add("/people/{id}", "user");
+
+ equal(router.route("/users/1"), null);
+ equal(router.route("/people/1")?.name, "user");
+ equal(router.route("/posts/9")?.name, "post");
+ });
+
+ await t.step(
+ "clone() after re-registration reflects only active routes",
+ () => {
+ const router = new Router();
+ router.add("/old/{id}", "user");
+ router.add("/new/{id}", "user");
+
+ const cloned = router.clone();
+ equal(cloned.has("user"), true);
+ equal(cloned.route("/old/1"), null);
+ deepEqual(cloned.route("/new/1"), {
+ name: "user",
+ template: "/new/{id}",
+ values: { id: "1" },
+ });
+ },
+ );
+});
+
+test("Router#register() is failure-atomic", async (t) => {
+ await t.step(
+ "a throwing entry leaves the previous router state intact",
+ () => {
+ const router = new Router();
+ router.add("/old/{id}", "user");
+
+ // Batch with a valid replacement for "user" followed by an invalid
+ // template. The invalid template makes resolvePathPattern() throw
+ // mid-batch.
+ throws(() =>
+ router.register([
+ ["/new/{id}", "user"],
+ ["/bad path", "broken"],
+ ])
+ );
+
+ // The previous "user" route must still resolve and build exactly as
+ // before the failed batch: no partial mutation may survive.
+ deepEqual(router.route("/old/1"), {
+ name: "user",
+ template: "/old/{id}",
+ values: { id: "1" },
+ });
+ equal(router.build("user", { id: "1" }), "/old/1");
+
+ // The aborted replacement and the unrelated invalid name must not leak.
+ equal(router.route("/new/1"), null);
+ equal(router.has("broken"), false);
+ },
+ );
+
+ await t.step(
+ "a throwing entry does not register any routes on an empty router",
+ () => {
+ const router = new Router();
+
+ throws(() =>
+ router.register([
+ ["/users/{id}", "user"],
+ ["foo" as Path, "relative"],
+ ])
+ );
+
+ equal(router.has("user"), false);
+ equal(router.has("relative"), false);
+ equal(router.route("/users/1"), null);
+ },
+ );
+});
+
+test("Router.from() mirrors the constructor", async (t) => {
+ await t.step("no arguments", () => {
+ const router = Router.from();
+ equal(router.has("user"), false);
+ equal(router.trailingSlashInsensitive, false);
+ });
+
+ await t.step("options only", () => {
+ const router = Router.from({ trailingSlashInsensitive: true });
+ equal(router.trailingSlashInsensitive, true);
+ });
+
+ await t.step("routes only", () => {
+ const router = Router.from(sampleRoutes);
+ equal(router.has("post"), true);
+ });
+
+ await t.step("routes and options together", () => {
+ const router = Router.from(sampleRoutes, {
+ trailingSlashInsensitive: true,
+ });
+ equal(router.has("post"), true);
+ equal(router.trailingSlashInsensitive, true);
+ });
+});
+
+test("Router applies the default nullable:false constraint", async (t) => {
+ const router = new Router([["/users/{id}", "user"]]);
+
+ await t.step("non-empty single binding matches", () => {
+ deepEqual(router.route("/users/alice"), {
+ name: "user",
+ template: "/users/{id}",
+ values: { id: "alice" },
+ });
+ });
+
+ await t.step("empty segment does not match", () => {
+ equal(router.route("/users/"), null);
+ });
+
+ await t.step("optional operator registers but no-matches unbound", () => {
+ const optional = new Router([["/users{?id}", "user"]]);
+ equal(optional.route("/users"), null);
+ deepEqual(optional.route("/users?id=alice"), {
+ name: "user",
+ template: "/users{?id}",
+ values: { id: "alice" },
+ });
+ });
+});
+
+test(
+ "Router registers every optional operator but no-matches when unbound",
+ async (t) => {
+ // CuHf6 plan item 5: each optional RFC 6570 operator must register
+ // successfully yet, under the default nullable:false constraint,
+ // produce a runtime no-match when the variable is left unbound.
+ const operators: ReadonlyArray<{
+ readonly operator: string;
+ readonly template: Path;
+ readonly unbound: readonly Path[];
+ readonly bound: Path;
+ }> = [
+ {
+ operator: "{?identifier}",
+ template: "/users{?identifier}",
+ unbound: ["/users"],
+ bound: "/users?identifier=alice",
+ },
+ {
+ operator: "{;identifier}",
+ template: "/users{;identifier}",
+ unbound: ["/users"],
+ bound: "/users;identifier=alice",
+ },
+ {
+ operator: "{.identifier}",
+ template: "/users{.identifier}",
+ unbound: ["/users"],
+ bound: "/users.alice",
+ },
+ {
+ operator: "{/identifier}",
+ template: "/users{/identifier}",
+ unbound: ["/users", "/users/"],
+ bound: "/users/alice",
+ },
+ {
+ operator: "{&identifier}",
+ template: "/users?fixed=true{&identifier}",
+ unbound: ["/users?fixed=true"],
+ bound: "/users?fixed=true&identifier=alice",
+ },
+ {
+ operator: "{#identifier}",
+ template: "/users{#identifier}",
+ unbound: ["/users"],
+ bound: "/users#alice",
+ },
+ ];
+
+ for (const { operator, template, unbound, bound } of operators) {
+ await t.step(operator, () => {
+ // Registration succeeds for every RFC 6570 operator.
+ const router = new Router([[template, "user"]]);
+ // The default nullable:false constraint rejects the unbound form.
+ for (const url of unbound) equal(router.route(url), null);
+ // A bound value still matches.
+ deepEqual(router.route(bound), {
+ name: "user",
+ template,
+ values: { identifier: "alice" },
+ });
+ });
+ }
+ },
+);
+
+test("Router honors nullable:true override", () => {
+ const router = new Router([
+ ["/users{?id}", "user", { variables: { id: { nullable: true } } }],
+ ]);
+
+ deepEqual(router.route("/users"), {
+ name: "user",
+ template: "/users{?id}",
+ values: { id: null },
+ });
+ deepEqual(router.route("/users?id=alice"), {
+ name: "user",
+ template: "/users{?id}",
+ values: { id: "alice" },
+ });
+});
+
+test("Router falls back past constraint-rejected candidates", () => {
+ const router = new Router([
+ ["/users/{id}", "strict"],
+ [
+ "/users/{rest}",
+ "loose",
+ { variables: { rest: { nullable: true } } },
+ ],
+ ]);
+
+ // "/users/" fails the strict route (empty id) and falls through to the
+ // nullable route registered for the same shape.
+ deepEqual(router.route("/users/"), {
+ name: "loose",
+ template: "/users/{rest}",
+ values: { rest: "" },
+ });
+});
+
+test("Router derives multiple from the varspec", async (t) => {
+ await t.step("explode requires explodable opt-in", () => {
+ throws(() => new Router([["/tags/{tags*}", "tags"]]), RouterError);
+ });
+
+ await t.step("explode binds a readonly string list", () => {
+ const router = new Router([
+ ["/tags/{tags*}", "tags", { variables: { tags: { explodable: true } } }],
+ ]);
+ deepEqual(router.route("/tags/a,b,c"), {
+ name: "tags",
+ template: "/tags/{tags*}",
+ values: { tags: ["a", "b", "c"] },
+ });
+ });
+
+ await t.step("explode rejects multiple:false", () => {
+ throws(
+ () =>
+ new Router([
+ ["/tags/{tags*}", "tags", {
+ variables: { tags: { explodable: true, multiple: false } },
+ }],
+ ]),
+ RouterError,
+ );
+ });
+
+ await t.step("prefix requires prefixable opt-in", () => {
+ throws(() => new Router([["/u/{id:3}", "u"]]), RouterError);
+ });
+
+ await t.step("prefix rejects multiple:true", () => {
+ throws(
+ () =>
+ new Router([
+ [
+ "/u/{id:3}",
+ "u",
+ { variables: { id: { prefixable: true, multiple: true } } },
+ ],
+ ]),
+ RouterError,
+ );
+ });
+
+ await t.step("empty list does not match", () => {
+ const router = new Router([
+ ["/tags{?tags*}", "tags", { variables: { tags: { explodable: true } } }],
+ ]);
+ equal(router.route("/tags"), null);
+ });
+});
+
+test("Router rejects unknown option variables", () => {
+ throws(
+ () =>
+ new Router([
+ ["/users/{id}", "user", { variables: { nope: { nullable: true } } }],
+ ]),
+ RouterError,
+ );
+});
+
+test("Router.add() rejects an option variable absent from the path", () => {
+ const router = new Router();
+ throws(
+ () =>
+ router.add("/users/{id}", "user", {
+ variables: { identifier: { nullable: false } },
+ }),
+ RouterError,
+ );
+ // The failed add() must not register the route.
+ equal(router.has("user"), false);
+});
+
+test("Router reports every mismatched option variable under exact", () => {
+ let caught: unknown;
+ try {
+ // `who` is unknown; the template variable `id` is missing.
+ new Router([
+ ["/users/{id}/{kind}", "user", {
+ variables: { kind: { nullable: true }, who: { nullable: true } },
+ }],
+ ]);
+ } catch (error) {
+ caught = error;
+ }
+ if (!(caught instanceof RouteTemplateOptionsNotMatchedError)) {
+ throw new Error("expected RouteTemplateOptionsNotMatchedError");
+ }
+ equal(caught.template, "/users/{id}/{kind}");
+ deepEqual([...caught.variable].sort(), ["id", "who"]);
+});
+
+test("Router.add() with exact:false ignores absent option variables", () => {
+ const router = new Router();
+ router.add("/users/{id}", "user", {
+ exact: false,
+ variables: { nope: { nullable: true }, id: { nullable: true } },
+ });
+ equal(router.has("user"), true);
+ // The bogus `nope` key is ignored; the real `id` override still applies.
+ deepEqual(router.route("/users/"), {
+ name: "user",
+ template: "/users/{id}",
+ values: { id: "" },
+ });
+});
+
+test("Router rejects contradictory varspecs for one name", () => {
+ let caught: unknown;
+ try {
+ new Router([["/x/{x}/{x*}", "x"]]);
+ } catch (error) {
+ caught = error;
+ }
+ if (!(caught instanceof ConflictingVarSpecError)) {
+ throw new Error("expected ConflictingVarSpecError");
+ }
+ equal(caught.template, "/x/{x}/{x*}");
+ equal(caught.variable, "x");
+});
+
+test("Router rejects a duplicated variable unless duplicable:true", () => {
+ throws(
+ () => new Router([["/x/{x}/y/{x}", "x"]]),
+ DuplicateRouteVariableError,
+ );
+
+ // Opting in allows the repeated occurrence; bindings must still agree.
+ const router = new Router([
+ ["/x/{x}/y/{x}", "x", { variables: { x: { duplicable: true } } }],
+ ]);
+ deepEqual(router.route("/x/a/y/a"), {
+ name: "x",
+ template: "/x/{x}/y/{x}",
+ values: { x: "a" },
+ });
+ equal(router.route("/x/a/y/b"), null);
+});
+
+test("Router gates explode/prefix behind explodable/prefixable", () => {
+ throws(
+ () => new Router([["/u/{id:3}", "u"]]),
+ DisallowedVarSpecModifierError,
+ );
+ throws(
+ () => new Router([["/t/{tags*}", "t"]]),
+ DisallowedVarSpecModifierError,
+ );
+
+ let caught: unknown;
+ try {
+ new Router([["/u/{id:3}", "u"]]);
+ } catch (error) {
+ caught = error;
+ }
+ if (!(caught instanceof DisallowedVarSpecModifierError)) {
+ throw new Error("expected DisallowedVarSpecModifierError");
+ }
+ equal(caught.modifier, "prefix");
+ equal(caught.variable, "id");
+
+ // Opting in registers; prefix yields a single truncated scalar.
+ const prefixed = new Router([
+ ["/u/{id:3}", "u", { variables: { id: { prefixable: true } } }],
+ ]);
+ equal(prefixed.has("u"), true);
+
+ const exploded = new Router([
+ ["/t/{tags*}", "t", { variables: { tags: { explodable: true } } }],
+ ]);
+ deepEqual(exploded.route("/t/a,b"), {
+ name: "t",
+ template: "/t/{tags*}",
+ values: { tags: ["a", "b"] },
+ });
+});
+
+test("Router restricts operators via the operatables allow-list", () => {
+ // Empty allow-list (the default) permits any operator.
+ const any = new Router([["/users{/id}", "any"]]);
+ deepEqual(any.route("/users/alice"), {
+ name: "any",
+ template: "/users{/id}",
+ values: { id: "alice" },
+ });
+
+ // A non-empty allow-list rejects operators outside it at registration.
+ let caught: unknown;
+ try {
+ new Router([
+ ["/users{/id}", "x", { variables: { id: { operatables: [""] } } }],
+ ]);
+ } catch (error) {
+ caught = error;
+ }
+ if (!(caught instanceof DisallowedOperatorError)) {
+ throw new Error("expected DisallowedOperatorError");
+ }
+ equal(caught.template, "/users{/id}");
+ equal(caught.variable, "id");
+ equal(caught.operator, "/");
+
+ // The operator is accepted when it is in the allow-list.
+ const allowed = new Router([
+ ["/users{/id}", "x", { variables: { id: { operatables: ["/"] } } }],
+ ]);
+ deepEqual(allowed.route("/users/alice"), {
+ name: "x",
+ template: "/users{/id}",
+ values: { id: "alice" },
+ });
+
+ // Plain `{id}` uses the "" operator; allow-listing only "/" rejects it.
+ throws(
+ () =>
+ new Router([
+ ["/users/{id}", "y", { variables: { id: { operatables: ["/"] } } }],
+ ]),
+ DisallowedOperatorError,
+ );
+});
+
+test("Router treats a comma segment as no-match, %2C as the value", () => {
+ const router = new Router([["/notes/{id}", "note"]]);
+ equal(router.route("/notes/a,b"), null);
+ deepEqual(router.route("/notes/a%2Cb"), {
+ name: "note",
+ template: "/notes/{id}",
+ values: { id: "a,b" },
+ });
+});
+
+test("Router.clone() preserves resolved constraints", () => {
+ const router = new Router([
+ ["/users{?id}", "user", { variables: { id: { nullable: true } } }],
+ ]);
+ const clone = router.clone();
+
+ deepEqual(clone.route("/users"), {
+ name: "user",
+ template: "/users{?id}",
+ values: { id: null },
+ });
+});
+
+test("Router.route() narrows values via the type argument", () => {
+ const router = new Router([
+ ["/tags/{tags*}", "tags", { variables: { tags: { explodable: true } } }],
+ ]);
+ const result = router.route<
+ {
+ tags: {
+ nullable: false;
+ multiple: true;
+ duplicable: false;
+ prefixable: false;
+ explodable: true;
+ operatables: [];
+ };
+ }
+ >("/tags/a,b");
+ ok(result);
+ const tags: readonly string[] = result.values.tags;
+ deepEqual(tags, ["a", "b"]);
+});
+
+test(
+ "Router.route() typing follows multiple/nullable, not explodable",
+ () => {
+ // `explodable` is a registration permission, not an output-shape
+ // declaration. On a non-exploded spec the runtime value is a scalar
+ // string, and the narrowed type must follow `multiple` (here `false`),
+ // not `explodable`. The `const id: string` annotation pins this: if
+ // `explodable` ever drove the value type again this would stop
+ // compiling.
+ const scalar = { id: { explodable: true } } as const;
+ const scalarRouter = new Router([
+ ["/users/{id}", "user", { variables: scalar }],
+ ]);
+ const scalarResult = scalarRouter.route("/users/alice");
+ ok(scalarResult);
+ const id: string = scalarResult.values.id;
+ equal(id, "alice");
+
+ // To narrow an exploded route to `readonly string[]`, the constraint
+ // passed to route() must carry `multiple: true` explicitly;
+ // `explodable: true` alone does not drive the value type.
+ const list = { tags: { explodable: true, multiple: true } } as const;
+ const listRouter = new Router([
+ ["/tags{?tags*}", "tags", { variables: list }],
+ ]);
+ const listResult = listRouter.route("/tags?tags=a&tags=b");
+ ok(listResult);
+ const tags: readonly string[] = listResult.values.tags;
+ deepEqual(tags, ["a", "b"]);
+ },
+);
+
+test(
+ "Router.route() binds an unbound nullable scalar as `null`, matching its " +
+ "declared type",
+ () => {
+ const variables = { q: { nullable: true } };
+ const router = new Router([
+ ["/search{?q}", "search", { variables }],
+ ]);
+ const result = router.route("/search");
+ ok(result != null);
+
+ // The declared type is `string | null`; the runtime value must be one
+ // of those, i.e. `null` here — not an absent key / `undefined`.
+ const typed = result.values.q;
+ equal("q" in result.values, true);
+ equal(typed, null);
+ deepEqual(result.values, { q: null });
+
+ // A bound value still round-trips as the string.
+ const bound = router.route("/search?q=hello");
+ ok(bound != null);
+ equal(bound.values.q, "hello");
+
+ if (bound.values.q != null) {
+ const narrowed: string = bound.values.q;
+ equal(narrowed, "hello");
+ }
+
+ // For contrast, a nullable *multiple* variable already behaves
+ // consistently: the key is present as an empty array.
+ const tagsVars = {
+ tags: { nullable: true, multiple: true, explodable: true },
+ } as const;
+ const arrRouter = new Router([
+ ["/tags{?tags*}", "tags", { variables: tagsVars }],
+ ]);
+ const abcdef = arrRouter.route("/tags?tags=abc&tags=def");
+ ok(abcdef?.values != null);
+ const tags = abcdef.values.tags;
+ ok(tags != null);
+ deepEqual(tags, ["abc", "def"]);
+ },
+);
+
+test("DisallowedVarSpecModifierError", () => {
+ const router = new Router();
+ throws(
+ () => router.add("/users/{identifier*}/outbox", "outbox"),
+ DisallowedVarSpecModifierError,
+ );
+ throws(
+ () => router.add("/users/{identifier:3}/outbox", "outbox"),
+ DisallowedVarSpecModifierError,
+ );
+});
+
+test(
+ "Router supports leading path expansion that Fedify's builder rejects",
+ async (t) => {
+ // The standalone `@fedify/uri-template` Router supports leading path
+ // expansion such as `{/identifier}/inbox`, even though Fedify's
+ // required-identifier builder routes reject it (the callback contract
+ // needs a non-empty `identifier`).
+ await t.step("registers and matches a bound leading segment", () => {
+ const router = new Router([["{/identifier}/inbox", "inbox"]]);
+ deepEqual(router.route("/alice/inbox"), {
+ name: "inbox",
+ template: "{/identifier}/inbox",
+ values: { identifier: "alice" },
+ });
+ // Round-trip: a successful match expands back to the same URI.
+ equal(router.build("inbox", { identifier: "alice" }), "/alice/inbox");
+ });
+
+ await t.step("default nullable:false no-matches the unbound form", () => {
+ const router = new Router([["{/identifier}/inbox", "inbox"]]);
+ // `{/identifier}` with no binding expands to nothing, so the URI
+ // collapses to `/inbox`; the default constraint rejects it instead
+ // of invoking a handler with a missing identifier.
+ equal(router.route("/inbox"), null);
+ equal(router.route("//inbox"), null);
+ });
+
+ await t.step("nullable:true opts the unbound form back in", () => {
+ const router = new Router([
+ [
+ "{/identifier}/inbox",
+ "inbox",
+ { variables: { identifier: { nullable: true } } },
+ ],
+ ]);
+ deepEqual(router.route("/inbox"), {
+ name: "inbox",
+ template: "{/identifier}/inbox",
+ values: { identifier: null },
+ });
+ deepEqual(router.route("/alice/inbox"), {
+ name: "inbox",
+ template: "{/identifier}/inbox",
+ values: { identifier: "alice" },
+ });
+ });
+ },
+);
diff --git a/packages/uri-template/src/router/router.ts b/packages/uri-template/src/router/router.ts
new file mode 100644
index 000000000..aef2993dc
--- /dev/null
+++ b/packages/uri-template/src/router/router.ts
@@ -0,0 +1,372 @@
+import { Template } from "../template/mod.ts";
+import type { ExpandContext, ExpandValue, Path, Token } from "../types.ts";
+import { isExpression, isLiteral, isPath } from "../utils.ts";
+import { RouteTemplatePathError } from "./errors.ts";
+import { fillRouteOptions } from "./fill.ts";
+import Trie from "./trie/mod.ts";
+import type {
+ MinimalConstraint,
+ PartialRouterRoute,
+ RouteEntry,
+ RouteOptions,
+ RouterOptions,
+ RouterPathPattern,
+ RouterRoute,
+ RouterRouteResult,
+ VariableConstraint,
+} from "./types.ts";
+
+/**
+ * Router that resolves URIs against registered RFC 6570 templates.
+ */
+export default class Router {
+ readonly #trie: Trie;
+ readonly #routesByName: Map;
+ #prevIndex: number = -1;
+
+ /**
+ * Whether to ignore trailing slashes when matching paths.
+ */
+ trailingSlashInsensitive: boolean;
+
+ /**
+ * Create a new {@link Router}.
+ *
+ * The first argument may be an iterable of routes, an options object, or
+ * omitted. When two arguments are passed, they are interpreted as
+ * `(routes, options)`.
+ *
+ * @param routes Routes to register on the new router.
+ * @param options Options for the router.
+ */
+ constructor(routes: Iterable, options?: RouterOptions);
+ constructor(options?: RouterOptions);
+ constructor(
+ routesOrOptions?: Iterable | RouterOptions,
+ maybeOptions?: RouterOptions,
+ ) {
+ const routes = isRoutesArgument(routesOrOptions)
+ ? routesOrOptions
+ : undefined;
+ const options = isRoutesArgument(routesOrOptions)
+ ? maybeOptions
+ : routesOrOptions;
+
+ this.#trie = new Trie();
+ this.#routesByName = new Map();
+ this.trailingSlashInsensitive = options?.trailingSlashInsensitive ?? false;
+
+ if (routes != null) this.register(routes);
+ }
+
+ /**
+ * Creates a new {@link Router}. Mirrors the constructor argument
+ * interface and is provided for ergonomic call sites that prefer a
+ * static factory over `new`.
+ */
+ static from(
+ routes: Iterable,
+ options?: RouterOptions,
+ ): Router;
+ static from(options?: RouterOptions): Router;
+ static from(
+ routesOrOptions?: Iterable | RouterOptions,
+ options?: RouterOptions,
+ ): Router {
+ return new Router(routesOrOptions as Iterable, options);
+ }
+
+ /**
+ * Compiles a path template without registering it in a router.
+ * @param path The path pattern.
+ * @returns A parsed path pattern.
+ */
+ static compile(path: Path): RouterPathPattern {
+ if (!isPath(path)) {
+ throw new RouteTemplatePathError(path);
+ }
+
+ const template = Template.parse(path);
+ return Object.freeze({
+ path,
+ template,
+ variables: collectVariables(template.tokens),
+ });
+ }
+
+ /**
+ * Returns the variable names in a path template without registering it.
+ * @param path The path pattern.
+ * @returns The names of the variables in the path pattern.
+ */
+ static variables = (path: Path): Set =>
+ new Set(Router.compile(path).variables);
+
+ /**
+ * Checks if a path name exists in the router.
+ * @param name The name of the path.
+ * @returns `true` if the path name exists, otherwise `false`.
+ */
+ has = (name: string): boolean => this.#routesByName.has(name);
+
+ /**
+ * Adds a new path rule to the router.
+ * @param pathOrPattern The path template, or a pre-parsed
+ * {@link RouterPathPattern} produced by
+ * {@link Router.compile}.
+ * @param name The name of the path.
+ * @param options Per-route options, including per-variable constraints.
+ */
+ add: (...args: PartialRouterRoute) => void = (
+ pathOrPattern,
+ name,
+ options?,
+ ): void => {
+ const pattern = resolvePathPattern(pathOrPattern);
+ const previous = this.#routesByName.get(name);
+ if (previous != null) this.#trie.remove(previous);
+
+ const entry = createRouteEntry({
+ index: this.#index,
+ name,
+ pattern,
+ options: fillRouteOptions(options, pattern),
+ });
+
+ this.#routesByName.set(name, entry);
+ this.#trie.insert(entry);
+ };
+
+ /**
+ * Registers multiple path rules at once.
+ * @param routes Iterable of `[pathOrPattern, name]` pairs to register.
+ */
+ register = (routes: Iterable): void => {
+ const resolved = Iterator.from(routes)
+ .map(([pathOrPattern, name, options]) =>
+ [resolvePathPattern(pathOrPattern), name, options] as const
+ ).map(([pattern, name, options]) =>
+ [
+ name,
+ createRouteEntry({
+ index: this.#index,
+ name,
+ pattern,
+ options: fillRouteOptions(options, pattern),
+ }),
+ ] as const
+ );
+
+ const pending = new Map(resolved);
+
+ for (const name of pending.keys()) {
+ const committed = this.#routesByName.get(name);
+ if (committed != null) this.#trie.remove(committed);
+ }
+
+ for (const [name, entry] of pending) this.#routesByName.set(name, entry);
+
+ this.#trie.insertAll(pending.values());
+ };
+
+ get #index(): number {
+ return this.#prevIndex++;
+ }
+
+ /**
+ * Resolves a path name and values from a URI, if any match.
+ * @param url The URI to resolve.
+ * @returns The name of the path and its values, if any match. Otherwise,
+ * `null`.
+ */
+ route = <
+ TConstraints extends Record = Record<
+ never,
+ never
+ >,
+ >(url: Path): RouterRouteResult | null =>
+ this.#route(url) ??
+ (this.trailingSlashInsensitive
+ ? this.#route(toggleTrailingSlash(url))
+ : null);
+
+ #route<
+ TConstraints extends Record,
+ >(url: Path): RouterRouteResult | null {
+ for (const entry of this.#trie.candidates(url)) {
+ const context = entry.pattern.template.match(url);
+ if (context == null) continue;
+
+ const values = resolveValues(context, entry);
+ if (values == null) continue;
+
+ return {
+ name: entry.name,
+ template: entry.pattern.path,
+ values: values as RouterRouteResult["values"],
+ };
+ }
+
+ return null;
+ }
+
+ /**
+ * Constructs a URL/path from a path name and values.
+ * @param name The name of the path.
+ * @param values The values to expand the path with.
+ * @returns The URL/path, if the name exists. Otherwise, `null`.
+ */
+ build = <
+ TConstraints extends Record = Record<
+ never,
+ never
+ >,
+ >(
+ name: string,
+ values: RouterRouteResult["values"],
+ ): Path | null => (this.#routesByName.get(name)
+ ?.pattern.template.expand(values as ExpandContext) as Path ?? null);
+
+ /**
+ * Creates a shallow clone of the router. The clone shares immutable
+ * registered path patterns with the original, but changes to the route set
+ * (adding, removing, or re-registering routes) do not affect the other
+ * router.
+ * @returns A new router with the same routes and options as this one.
+ */
+ clone = (): Router =>
+ new Router(
+ this.#activeEntries(),
+ { trailingSlashInsensitive: this.trailingSlashInsensitive },
+ );
+
+ #activeEntries = (): RouterRoute[] =>
+ Array.from(this.#routesByName.values())
+ .sort((left, right) => left.index - right.index)
+ .map((entry) => [entry.pattern, entry.name, entry.options]);
+}
+
+const createRouteEntry = ({
+ index,
+ name,
+ pattern,
+ options,
+}: {
+ readonly index: number;
+ readonly name: string;
+ readonly pattern: RouterPathPattern;
+ readonly options: RouteOptions;
+}): RouteEntry => ({
+ index,
+ name,
+ pattern,
+ tokens: pattern.template.tokens,
+ initialLiteralPrefix: getInitialLiteralPrefix(pattern.template.tokens),
+ literalLength: getLiteralLength(pattern.template.tokens),
+ variableCount: pattern.variables.size,
+ options,
+ constraints: new Map(Object.entries(options.variables)),
+});
+
+const resolvePathPattern = (
+ value: Path | RouterPathPattern,
+): RouterPathPattern =>
+ typeof value === "string" ? Router.compile(value) : value;
+
+const isRoutesArgument = (
+ value: Iterable | RouterOptions | undefined,
+): value is Iterable =>
+ value != null &&
+ typeof value === "object" &&
+ Symbol.iterator in (value as object);
+
+const toggleTrailingSlash = (path: Path): Path =>
+ path.endsWith("/") ? (path.replace(/\/+$/, "") as Path) : `${path}/`;
+
+const collectVariables = (tokens: readonly Token[]): ImmutableSet =>
+ new ImmutableSet(
+ tokens
+ .filter(isExpression)
+ .flatMap((token) => token.vars.map((varSpec) => varSpec.name)),
+ );
+
+class ImmutableSet extends Set implements ReadonlySet {
+ constructor(values?: Iterable) {
+ super();
+ if (values != null) { for (const value of values) super.add(value); }
+ }
+
+ override add(_value: T): this {
+ throw new TypeError("ImmutableSet cannot be mutated.");
+ }
+
+ override delete(_value: T): boolean {
+ throw new TypeError("ImmutableSet cannot be mutated.");
+ }
+
+ override clear(): void {
+ throw new TypeError("ImmutableSet cannot be mutated.");
+ }
+}
+
+const getInitialLiteralPrefix = (tokens: readonly Token[]): string =>
+ tokens[0] != null && isLiteral(tokens[0]) ? tokens[0].text : "";
+
+const getLiteralLength = (tokens: readonly Token[]): number =>
+ tokens.reduce(
+ (sum, token) => isLiteral(token) ? sum + token.text.length : sum,
+ 0,
+ );
+
+/**
+ * Applies the resolved per-variable constraints to a matched context,
+ * producing the route values or `null` when a constraint rejects the match
+ * (so the caller falls back to the next candidate).
+ */
+const resolveValues = (
+ context: ExpandContext,
+ entry: RouteEntry,
+): Record | null => {
+ const values: Record = {};
+
+ for (const [name, constraint] of entry.constraints) {
+ const raw: ExpandValue | undefined = context[name];
+
+ if (constraint.multiple) {
+ const list = toStringList(raw);
+ if (list == null) return null;
+ const empty = list.length < 1 || list.every((item) => item === "");
+ if (empty && !constraint.nullable) return null;
+ values[name] = list;
+ continue;
+ }
+
+ if (typeof raw === "string") {
+ if (raw === "" && !constraint.nullable) return null;
+ values[name] = raw;
+ } else if (!constraint.nullable) {
+ // Unbound, or bound to a list/associative value: not a scalar match.
+ return null;
+ } else {
+ // Nullable scalar with no scalar binding: present the key as `null`
+ // so the result matches its declared `string | null` type instead of
+ // omitting the key entirely.
+ values[name] = null;
+ }
+ }
+
+ return values;
+};
+
+const toStringList = (
+ value: ExpandValue | undefined,
+): readonly string[] | null => {
+ if (value === undefined) return [];
+ if (typeof value === "string") return [value];
+ if (Array.isArray(value)) {
+ return value.every((item) => typeof item === "string")
+ ? (value as readonly string[])
+ : null;
+ }
+ return null;
+};
diff --git a/packages/uri-template/src/router/trie/fallback/node.ts b/packages/uri-template/src/router/trie/fallback/node.ts
new file mode 100644
index 000000000..0df102784
--- /dev/null
+++ b/packages/uri-template/src/router/trie/fallback/node.ts
@@ -0,0 +1,37 @@
+import Node from "../node.ts";
+import { mergeRouteEntries, type RouteEntry } from "../priority.ts";
+
+/**
+ * Prefix-trie node for routes the main router index cannot handle.
+ * Precomputes merged candidates per node so path lookups only read the
+ * deepest matching node's {@link candidates}.
+ */
+export default class FallbackNode
+ extends Node {
+ readonly #children = new Map>();
+ #candidates: readonly TEntry[] = [];
+
+ get candidates(): readonly TEntry[] {
+ return this.#candidates;
+ }
+
+ child = (key: string): FallbackNode | undefined =>
+ this.#children.get(key);
+
+ childOrInsert = (key: string): FallbackNode => {
+ const existing = this.#children.get(key);
+ if (existing != null) return existing;
+
+ const inserted = new FallbackNode();
+ this.#children.set(key, inserted);
+ return inserted;
+ };
+
+ rebuildCandidates = (parentCandidates: readonly TEntry[]): void => {
+ this.#candidates = mergeRouteEntries(parentCandidates, this.entries);
+
+ for (const child of this.#children.values()) {
+ child.rebuildCandidates(this.#candidates);
+ }
+ };
+}
diff --git a/packages/uri-template/src/router/trie/fallback/trie.test.ts b/packages/uri-template/src/router/trie/fallback/trie.test.ts
new file mode 100644
index 000000000..2e20d907e
--- /dev/null
+++ b/packages/uri-template/src/router/trie/fallback/trie.test.ts
@@ -0,0 +1,121 @@
+import { test } from "@fedify/fixture";
+import { deepEqual } from "node:assert/strict";
+import { Template } from "../../../template/mod.ts";
+import type { Path, Token } from "../../../types.ts";
+import { isLiteral } from "../../../utils.ts";
+import type { RouteEntry } from "../priority.ts";
+import FallbackTrie from "./trie.ts";
+
+const makeEntry = (path: string, index = 0): RouteEntry => {
+ const tokens = new Template(path).tokens;
+ return {
+ index,
+ tokens,
+ initialLiteralPrefix: tokens[0] != null && isLiteral(tokens[0])
+ ? tokens[0].text
+ : "",
+ literalLength: literalLengthOf(tokens),
+ variableCount: variableCountOf(tokens),
+ };
+};
+
+const literalLengthOf = (tokens: readonly Token[]): number =>
+ tokens.reduce(
+ (sum, token) => isLiteral(token) ? sum + token.text.length : sum,
+ 0,
+ );
+
+const variableCountOf = (tokens: readonly Token[]): number => {
+ const names = new Set();
+ for (const token of tokens) {
+ if (isLiteral(token)) continue;
+ for (const varSpec of token.vars) names.add(varSpec.name);
+ }
+ return names.size;
+};
+
+const candidatesOf = (
+ trie: FallbackTrie,
+ path: Path,
+): RouteEntry[] => Array.from(trie.candidates(path));
+
+test(
+ "FallbackTrie.candidates() yields entries " +
+ "whose literal prefix is a prefix of the path",
+ () => {
+ const trie = new FallbackTrie();
+ const a = makeEntry("/a/{x,y}", 0);
+ const ab = makeEntry("/ab/{x,y}", 1);
+ trie.insert(a);
+ trie.insert(ab);
+
+ deepEqual(candidatesOf(trie, "/a/foo"), [a]);
+ deepEqual(candidatesOf(trie, "/ab/foo"), [ab]);
+ },
+);
+
+test("FallbackTrie inherits ancestor entries at deeper descent points", () => {
+ const trie = new FallbackTrie();
+ const root = makeEntry("/{x,y}", 0);
+ const sub = makeEntry("/x/{a,b}", 1);
+ trie.insert(root);
+ trie.insert(sub);
+
+ deepEqual(candidatesOf(trie, "/x/anything"), [sub, root]);
+ deepEqual(candidatesOf(trie, "/other"), [root]);
+});
+
+test("FallbackTrie.remove() removes an inserted entry from candidates", () => {
+ const trie = new FallbackTrie();
+ const a = makeEntry("/a/{x,y}", 0);
+ const b = makeEntry("/b/{x,y}", 1);
+ trie.insert(a);
+ trie.insert(b);
+ trie.remove(a);
+
+ deepEqual(candidatesOf(trie, "/a/foo"), []);
+ deepEqual(candidatesOf(trie, "/b/foo"), [b]);
+});
+
+test(
+ "FallbackTrie.remove() is a no-op for an entry that was never inserted",
+ () => {
+ const trie = new FallbackTrie();
+ const inserted = makeEntry("/a/{x,y}", 0);
+ const stranger = makeEntry("/b/{x,y}", 1);
+ trie.insert(inserted);
+ trie.remove(stranger);
+
+ deepEqual(candidatesOf(trie, "/a/foo"), [inserted]);
+ },
+);
+
+test(
+ "FallbackTrie.remove() is a no-op when the entry's prefix is not in the trie",
+ () => {
+ const trie = new FallbackTrie();
+ const inserted = makeEntry("/a/{x,y}", 0);
+ const stranger = makeEntry("/zzz/{x,y}", 1);
+ trie.insert(inserted);
+ trie.remove(stranger);
+
+ deepEqual(candidatesOf(trie, "/a/foo"), [inserted]);
+ },
+);
+
+test(
+ "FallbackTrie.candidates() yields nothing " +
+ "when no entry prefix matches the path",
+ () => {
+ const trie = new FallbackTrie();
+ const entry = makeEntry("/a/{x,y}", 0);
+ trie.insert(entry);
+
+ deepEqual(candidatesOf(trie, "/z"), []);
+ },
+);
+
+test("FallbackTrie.candidates() is empty for an empty trie", () => {
+ const trie = new FallbackTrie();
+ deepEqual(candidatesOf(trie, "/anything"), []);
+});
diff --git a/packages/uri-template/src/router/trie/fallback/trie.ts b/packages/uri-template/src/router/trie/fallback/trie.ts
new file mode 100644
index 000000000..742681089
--- /dev/null
+++ b/packages/uri-template/src/router/trie/fallback/trie.ts
@@ -0,0 +1,48 @@
+import type { Path } from "../../../types.ts";
+import { fold, foldWhileDefined } from "../../../utils.ts";
+import type { RouteEntry } from "../priority.ts";
+import FallbackNode from "./node.ts";
+
+/**
+ * Prefix trie for route templates that cannot be safely compiled into the
+ * token-level state trie.
+ */
+export default class FallbackTrie {
+ readonly #root = new FallbackNode();
+ #dirty = true;
+
+ insert = (entry: TEntry): void => {
+ fold(
+ (n: FallbackNode, char: string) => n.childOrInsert(char),
+ this.#root,
+ entry.initialLiteralPrefix,
+ ).insert(entry);
+ this.#dirty = true;
+ };
+
+ remove = (entry: TEntry): void => {
+ foldWhileDefined(
+ (node: FallbackNode, char: string) => node.child(char),
+ this.#root,
+ entry.initialLiteralPrefix,
+ ).remove(entry);
+ this.#dirty = true;
+ };
+
+ *candidates(path: Path): Generator {
+ if (this.#dirty) this.#rebuildCandidates();
+ yield* this.#deepestNode(path).candidates;
+ }
+
+ #deepestNode = (path: Path): FallbackNode =>
+ foldWhileDefined(
+ (n: FallbackNode, char: string) => n.child(char),
+ this.#root,
+ path,
+ );
+
+ #rebuildCandidates = (): void => {
+ this.#root.rebuildCandidates([]);
+ this.#dirty = false;
+ };
+}
diff --git a/packages/uri-template/src/router/trie/mod.ts b/packages/uri-template/src/router/trie/mod.ts
new file mode 100644
index 000000000..f859d7821
--- /dev/null
+++ b/packages/uri-template/src/router/trie/mod.ts
@@ -0,0 +1,79 @@
+import type { Operator, Path, Token } from "../../types.ts";
+import { isLiteral } from "../../utils.ts";
+import FallbackTrie from "./fallback/trie.ts";
+import { compareRouteEntries, type RouteEntry } from "./priority.ts";
+import StateTrie from "./state/trie.ts";
+
+/**
+ * Route candidate trie that delegates to a state trie for indexable templates
+ * and a fallback prefix trie for the remaining RFC 6570 shapes.
+ */
+export default class Trie {
+ readonly #state = new StateTrie();
+ readonly #fallback = new FallbackTrie();
+
+ insert = (entry: TEntry): void => {
+ if (isStateTrieEntry(entry)) {
+ this.#state.insert(entry);
+ } else {
+ this.#fallback.insert(entry);
+ }
+ };
+
+ remove = (entry: TEntry): void => {
+ if (isStateTrieEntry(entry)) {
+ this.#state.remove(entry);
+ } else {
+ this.#fallback.remove(entry);
+ }
+ };
+
+ insertAll = (entries: Iterable): void => {
+ for (const entry of entries) this.insert(entry);
+ };
+
+ *candidates(path: Path): Generator {
+ yield* Array.from(uniqueMergedEntries(
+ this.#state.candidates(path),
+ this.#fallback.candidates(path),
+ )).sort(compareRouteEntries);
+ }
+}
+
+const isStateTrieEntry = (entry: RouteEntry): boolean =>
+ isIndexableRoute(entry.tokens);
+
+const isIndexableRoute = (tokens: readonly Token[]): boolean => {
+ let previousWasExpression = false;
+
+ for (const token of tokens) {
+ if (isLiteral(token)) {
+ previousWasExpression = false;
+ } else {
+ if (previousWasExpression) return false;
+ if (!isIndexableExpression(token)) return false;
+ previousWasExpression = true;
+ }
+ }
+
+ return true;
+};
+
+type ExpressionToken = Extract;
+
+const isIndexableExpression = ({ vars, operator }: ExpressionToken): boolean =>
+ vars.length === 1 && INDEXABLE_OPERATORS.has(operator);
+
+const INDEXABLE_OPERATORS = new Set(["", "/", "+"]);
+
+function uniqueMergedEntries(
+ ...sources: Iterable[]
+): Set {
+ const seen = new Set();
+ for (const source of sources) {
+ for (const entry of source) {
+ seen.add(entry);
+ }
+ }
+ return seen;
+}
diff --git a/packages/uri-template/src/router/trie/node.ts b/packages/uri-template/src/router/trie/node.ts
new file mode 100644
index 000000000..4607c6879
--- /dev/null
+++ b/packages/uri-template/src/router/trie/node.ts
@@ -0,0 +1,38 @@
+import { compareRouteEntries, type RouteEntry } from "./priority.ts";
+
+/**
+ * Base trie node storing entries sorted by route priority.
+ */
+export default class Node {
+ readonly #entries: TEntry[] = [];
+
+ get entries(): readonly TEntry[] {
+ return this.#entries;
+ }
+
+ insert = (entry: TEntry): void => {
+ this.#entries.splice(this.#insertionIndex(entry), 0, entry);
+ };
+
+ remove = (entry: TEntry): void => {
+ const index = this.#entries.indexOf(entry);
+ if (index < 0) return;
+ this.#entries.splice(index, 1);
+ };
+
+ #insertionIndex(entry: TEntry): number {
+ let low = 0;
+ let high = this.#entries.length;
+
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2);
+ if (compareRouteEntries(this.#entries[middle], entry) <= 0) {
+ low = middle + 1;
+ } else {
+ high = middle;
+ }
+ }
+
+ return low;
+ }
+}
diff --git a/packages/uri-template/src/router/trie/priority.ts b/packages/uri-template/src/router/trie/priority.ts
new file mode 100644
index 000000000..5750a7c0e
--- /dev/null
+++ b/packages/uri-template/src/router/trie/priority.ts
@@ -0,0 +1,43 @@
+import type { Token } from "../../types.ts";
+
+export interface RouteEntry {
+ readonly index: number;
+ readonly initialLiteralPrefix: string;
+ readonly literalLength: number;
+ readonly tokens: readonly Token[];
+ readonly variableCount: number;
+}
+
+export const compareRouteEntries = (
+ left: RouteEntry,
+ right: RouteEntry,
+): number =>
+ right.literalLength - left.literalLength ||
+ right.initialLiteralPrefix.length - left.initialLiteralPrefix.length ||
+ left.variableCount - right.variableCount ||
+ left.index - right.index;
+
+export const mergeRouteEntries = (
+ left: readonly TEntry[],
+ right: readonly TEntry[],
+): readonly TEntry[] => {
+ if (left.length < 1) return right;
+ if (right.length < 1) return left;
+
+ const merged: TEntry[] = [];
+ let leftIndex = 0;
+ let rightIndex = 0;
+
+ while (leftIndex < left.length && rightIndex < right.length) {
+ if (compareRouteEntries(left[leftIndex], right[rightIndex]) <= 0) {
+ merged.push(left[leftIndex++]);
+ } else {
+ merged.push(right[rightIndex++]);
+ }
+ }
+
+ while (leftIndex < left.length) merged.push(left[leftIndex++]);
+ while (rightIndex < right.length) merged.push(right[rightIndex++]);
+
+ return merged;
+};
diff --git a/packages/uri-template/src/router/trie/state/node.ts b/packages/uri-template/src/router/trie/state/node.ts
new file mode 100644
index 000000000..0a6bb8446
--- /dev/null
+++ b/packages/uri-template/src/router/trie/state/node.ts
@@ -0,0 +1,90 @@
+import type { Operator } from "../../../types.ts";
+import Node from "../node.ts";
+import type { RouteEntry } from "../priority.ts";
+
+export interface LiteralEdge {
+ text: string;
+ node: StateNode;
+}
+
+export interface ExpressionEdge {
+ readonly key: string;
+ readonly operator: Operator;
+ readonly node: StateNode;
+}
+
+export default class StateNode extends Node {
+ readonly literalEdges: LiteralEdge[] = [];
+ readonly expressionEdges = new Map>();
+
+ insertLiteral = (text: string): StateNode => {
+ if (text === "") return this;
+
+ const edge = this.#findCommonLiteralEdge(text);
+ if (edge == null) return this.#appendLiteralEdge(text);
+
+ const length = commonPrefixLength(edge.text, text);
+ if (length === edge.text.length) {
+ return edge.node.insertLiteral(text.slice(length));
+ }
+
+ const intermediate = splitLiteralEdge(edge, length);
+ if (length === text.length) return intermediate;
+ return intermediate.#appendLiteralEdge(text.slice(length));
+ };
+
+ findLiteral = (text: string): StateNode | undefined => {
+ if (text === "") return this;
+
+ const edge: LiteralEdge | undefined = this.literalEdges
+ .find(({ text: edgeText }) => text.startsWith(edgeText));
+ if (edge == null) return undefined;
+ return edge.node.findLiteral(text.slice(edge.text.length));
+ };
+
+ childOrInsertExpression = ({
+ key,
+ operator,
+ }: Omit, "node">): StateNode => {
+ const existing = this.expressionEdges.get(key);
+ if (existing != null) return existing.node;
+
+ const node = new StateNode();
+ this.expressionEdges.set(key, { key, operator, node });
+ return node;
+ };
+
+ childExpression = (key: string): StateNode | undefined =>
+ this.expressionEdges.get(key)?.node;
+
+ #appendLiteralEdge(text: string): StateNode {
+ const child = new StateNode();
+ this.literalEdges.push({ text, node: child });
+ return child;
+ }
+
+ #findCommonLiteralEdge = (text: string): LiteralEdge | undefined =>
+ this.literalEdges.find(
+ (edge) => commonPrefixLength(edge.text, text) > 0,
+ );
+}
+
+const splitLiteralEdge = (
+ edge: LiteralEdge,
+ length: number,
+): StateNode => {
+ const intermediate = new StateNode();
+ const suffix = edge.text.slice(length);
+ intermediate.literalEdges.push({ text: suffix, node: edge.node });
+ edge.text = edge.text.slice(0, length);
+ edge.node = intermediate;
+ return intermediate;
+};
+
+const commonPrefixLength = (left: string, right: string): number => {
+ const max = Math.min(left.length, right.length);
+ for (let index = 0; index < max; index++) {
+ if (left[index] !== right[index]) return index;
+ }
+ return max;
+};
diff --git a/packages/uri-template/src/router/trie/state/trie.test.ts b/packages/uri-template/src/router/trie/state/trie.test.ts
new file mode 100644
index 000000000..87c03beef
--- /dev/null
+++ b/packages/uri-template/src/router/trie/state/trie.test.ts
@@ -0,0 +1,173 @@
+import { test } from "@fedify/fixture";
+import { deepEqual } from "node:assert/strict";
+import { Template } from "../../../template/mod.ts";
+import type { Path, Token } from "../../../types.ts";
+import { isLiteral } from "../../../utils.ts";
+import type { RouteEntry } from "../priority.ts";
+import StateTrie from "./trie.ts";
+
+const makeEntry = (path: string, index = 0): RouteEntry => {
+ const tokens = new Template(path).tokens;
+ return {
+ index,
+ tokens,
+ initialLiteralPrefix: tokens[0] != null && isLiteral(tokens[0])
+ ? tokens[0].text
+ : "",
+ literalLength: literalLengthOf(tokens),
+ variableCount: variableCountOf(tokens),
+ };
+};
+
+const literalLengthOf = (tokens: readonly Token[]): number =>
+ tokens.reduce(
+ (sum, token) => isLiteral(token) ? sum + token.text.length : sum,
+ 0,
+ );
+
+const variableCountOf = (tokens: readonly Token[]): number => {
+ const names = new Set();
+ for (const token of tokens) {
+ if (isLiteral(token)) continue;
+ for (const varSpec of token.vars) names.add(varSpec.name);
+ }
+ return names.size;
+};
+
+const candidatesOf = (
+ trie: StateTrie,
+ path: Path,
+): RouteEntry[] => Array.from(trie.candidates(path));
+
+test(
+ "StateTrie.candidates() yields entries with matching literal segments",
+ () => {
+ const trie = new StateTrie();
+ const users = makeEntry("/users/{id}", 0);
+ const posts = makeEntry("/posts/{id}", 1);
+ trie.insert(users);
+ trie.insert(posts);
+
+ deepEqual(candidatesOf(trie, "/users/42"), [users]);
+ deepEqual(candidatesOf(trie, "/posts/42"), [posts]);
+ deepEqual(candidatesOf(trie, "/unknown/42"), []);
+ },
+);
+
+test(
+ "StateTrie shares dynamic prefixes across entries that diverge by suffix",
+ () => {
+ const trie = new StateTrie();
+ const inbox = makeEntry("/ap/{id}/inbox", 0);
+ const outbox = makeEntry("/ap/{id}/outbox", 1);
+ trie.insert(inbox);
+ trie.insert(outbox);
+
+ deepEqual(candidatesOf(trie, "/ap/alice/inbox"), [inbox]);
+ deepEqual(candidatesOf(trie, "/ap/alice/outbox"), [outbox]);
+ deepEqual(candidatesOf(trie, "/ap/alice/unknown"), []);
+ },
+);
+
+test("StateTrie supports path-expansion {/var} templates", () => {
+ const trie = new StateTrie();
+ const entry = makeEntry("{/id}", 0);
+ trie.insert(entry);
+
+ deepEqual(candidatesOf(trie, "/anything"), [entry]);
+});
+
+test("StateTrie.remove() removes an inserted entry from candidates", () => {
+ const trie = new StateTrie();
+ const entry = makeEntry("/users/{id}", 0);
+ trie.insert(entry);
+ trie.remove(entry);
+
+ deepEqual(candidatesOf(trie, "/users/42"), []);
+});
+
+test(
+ "StateTrie.remove() is a no-op for an entry that was never inserted",
+ () => {
+ const trie = new StateTrie();
+ const inserted = makeEntry("/users/{id}", 0);
+ const stranger = makeEntry("/posts/{id}", 1);
+ trie.insert(inserted);
+ trie.remove(stranger);
+
+ deepEqual(candidatesOf(trie, "/users/42"), [inserted]);
+ },
+);
+
+test(
+ "StateTrie.remove() is a no-op " +
+ "when descent partially overlaps an inserted path",
+ () => {
+ const trie = new StateTrie();
+ const inserted = makeEntry("/users/{id}", 0);
+ const stranger = makeEntry("/users/{id}/posts/{post}", 1);
+ trie.insert(inserted);
+ trie.remove(stranger);
+
+ deepEqual(candidatesOf(trie, "/users/42"), [inserted]);
+ },
+);
+
+test("StateTrie.candidates() is empty for an empty trie", () => {
+ const trie = new StateTrie();
+ deepEqual(candidatesOf(trie, "/users/42"), []);
+});
+
+test(
+ "StateTrie shares /ap/{identifier} dynamic prefix across sibling endpoints",
+ () => {
+ const trie = new StateTrie();
+ const actor = makeEntry("/ap/{identifier}", 0);
+ const inbox = makeEntry("/ap/{identifier}/inbox", 1);
+ const outbox = makeEntry("/ap/{identifier}/outbox", 2);
+ const followers = makeEntry("/ap/{identifier}/followers", 3);
+ const following = makeEntry("/ap/{identifier}/following", 4);
+ const featured = makeEntry("/ap/{identifier}/featured", 5);
+ for (
+ const entry of [actor, inbox, outbox, followers, following, featured]
+ ) {
+ trie.insert(entry);
+ }
+
+ deepEqual(candidatesOf(trie, "/ap/alice"), [actor]);
+ deepEqual(candidatesOf(trie, "/ap/alice/inbox"), [inbox]);
+ deepEqual(candidatesOf(trie, "/ap/alice/outbox"), [outbox]);
+ deepEqual(candidatesOf(trie, "/ap/alice/followers"), [followers]);
+ deepEqual(candidatesOf(trie, "/ap/alice/following"), [following]);
+ deepEqual(candidatesOf(trie, "/ap/alice/featured"), [featured]);
+ deepEqual(candidatesOf(trie, "/ap/alice/unknown"), []);
+ },
+);
+
+test(
+ "StateTrie shares root-adjacent {identifier} prefix " +
+ "across multi-tenant routes",
+ () => {
+ const trie = new StateTrie();
+ const inbox = makeEntry("/{identifier}/inbox", 0);
+ const outbox = makeEntry("/{identifier}/outbox", 1);
+ const followers = makeEntry("/{identifier}/followers", 2);
+ const tenantInbox = makeEntry("/{tenant}/users/{identifier}/inbox", 3);
+ const tenantOutbox = makeEntry("/{tenant}/users/{identifier}/outbox", 4);
+ for (const entry of [inbox, outbox, followers, tenantInbox, tenantOutbox]) {
+ trie.insert(entry);
+ }
+
+ deepEqual(candidatesOf(trie, "/alice/inbox"), [inbox]);
+ deepEqual(candidatesOf(trie, "/alice/outbox"), [outbox]);
+ deepEqual(candidatesOf(trie, "/alice/followers"), [followers]);
+ deepEqual(
+ candidatesOf(trie, "/example/users/alice/inbox"),
+ [tenantInbox],
+ );
+ deepEqual(
+ candidatesOf(trie, "/example/users/alice/outbox"),
+ [tenantOutbox],
+ );
+ },
+);
diff --git a/packages/uri-template/src/router/trie/state/trie.ts b/packages/uri-template/src/router/trie/state/trie.ts
new file mode 100644
index 000000000..790102916
--- /dev/null
+++ b/packages/uri-template/src/router/trie/state/trie.ts
@@ -0,0 +1,129 @@
+import type { Operator, Path, Token } from "../../../types.ts";
+import { fold, foldWhileDefined, isLiteral } from "../../../utils.ts";
+import type { RouteEntry } from "../priority.ts";
+import StateNode, { type ExpressionEdge } from "./node.ts";
+
+type RouteSegment =
+ | { readonly kind: "literal"; readonly text: string }
+ | {
+ readonly kind: "expression";
+ readonly key: string;
+ readonly operator: Operator;
+ };
+
+type ExpressionToken = Extract;
+
+/**
+ * Token-level state trie for common path-shaped URI templates.
+ */
+export default class StateTrie {
+ readonly #root = new StateNode();
+
+ insert = (entry: TEntry): void =>
+ fold(
+ (node: StateNode, segment: RouteSegment) =>
+ isLiteral(segment)
+ ? node.insertLiteral(segment.text)
+ : node.childOrInsertExpression(segment),
+ this.#root,
+ compileSegments(entry.tokens),
+ ).insert(entry);
+
+ remove = (entry: TEntry): void =>
+ foldWhileDefined(
+ (node: StateNode, segment: RouteSegment) =>
+ isLiteral(segment)
+ ? node.findLiteral(segment.text)
+ : node.childExpression(segment.key),
+ this.#root,
+ compileSegments(entry.tokens),
+ ).remove(entry);
+
+ *candidates(path: Path): Generator {
+ yield* stateEntries(this.#root, path, 0);
+ }
+}
+
+function* compileSegments(
+ tokens: readonly Token[],
+): Generator {
+ for (const token of tokens) {
+ if (isLiteral(token)) {
+ if (token.text !== "") yield token;
+ } else {
+ yield {
+ kind: "expression",
+ key: expressionKey(token),
+ operator: token.operator,
+ };
+ }
+ }
+}
+
+const expressionKey = (
+ { operator, vars: [{ explode, prefix }] }: ExpressionToken,
+): string => [operator, explode ? "*" : "", prefix ?? ""].join("\0");
+
+function* stateEntries(
+ node: StateNode,
+ path: Path,
+ index: number,
+): Generator {
+ if (index === path.length) {
+ yield* node.entries;
+ }
+
+ for (const edge of node.literalEdges) {
+ if (path.startsWith(edge.text, index)) {
+ yield* stateEntries(
+ edge.node,
+ path,
+ index + edge.text.length,
+ );
+ }
+ }
+
+ for (const edge of node.expressionEdges.values()) {
+ yield* expressionEntries(edge, path, index);
+ }
+}
+
+function* expressionEntries(
+ edge: ExpressionEdge,
+ path: Path,
+ index: number,
+): Generator {
+ for (const end of expressionEndIndexes(edge.node, path, index)) {
+ const expression = path.slice(index, end);
+ if (!matchesExpressionShape(edge.operator, expression)) continue;
+ yield* stateEntries(edge.node, path, end);
+ }
+}
+
+function* expressionEndIndexes(
+ node: StateNode,
+ path: Path,
+ index: number,
+): Generator {
+ if (node.entries.length > 0) yield path.length;
+
+ for (const edge of node.literalEdges) {
+ for (
+ let found = path.indexOf(edge.text, index);
+ found >= 0;
+ found = path.indexOf(edge.text, found + 1)
+ ) {
+ yield found;
+ }
+ }
+}
+
+const matchesExpressionShape = (
+ operator: Operator,
+ expression: string,
+): boolean => {
+ if (expression === "") return true;
+ if (operator === "") return !expression.includes("/");
+ if (operator === "/") return expression.startsWith("/");
+ return true;
+};
diff --git a/packages/uri-template/src/router/types.ts b/packages/uri-template/src/router/types.ts
new file mode 100644
index 000000000..4d4a979a6
--- /dev/null
+++ b/packages/uri-template/src/router/types.ts
@@ -0,0 +1,230 @@
+import type { Template } from "../template/mod.ts";
+import type { Operator, Path, Token } from "../types.ts";
+
+/**
+ * Options for the {@link Router}.
+ */
+export interface RouterOptions {
+ /**
+ * Whether to ignore trailing slashes when matching paths.
+ */
+ trailingSlashInsensitive?: boolean;
+}
+
+/**
+ * Fully-resolved per-variable matching constraint. Every template variable
+ * is constrained even when it is not listed in {@link RouteOptions.variables};
+ * the listed entries only override the defaults. All fields are required;
+ * call sites pass a {@link Partial} and {@link fillRouteOptions} fills the
+ * missing fields with their defaults.
+ */
+export interface VariableConstraint {
+ /**
+ * When `true`, an unbound or empty binding still matches (opt-out of the
+ * non-empty requirement). Defaults to `false`.
+ */
+ readonly nullable: boolean;
+
+ /**
+ * Whether the variable binds to a list of values rather than a single
+ * scalar. When omitted it is derived from the variable specification:
+ * explode (`*`) implies `true`, a prefix modifier (`:N`) implies `false`,
+ * and a plain variable defaults to `false` but may be set either way.
+ * Specifying a value that contradicts the derived one is a registration
+ * error.
+ */
+ readonly multiple: boolean;
+
+ /**
+ * Whether the variable may appear more than once in the route template.
+ * Defaults to `false`: a variable that occurs in multiple variable
+ * specifications is a registration error (`DuplicateRouteVariableError`).
+ * Set to `true` to allow repeated occurrences (their bindings must still
+ * agree at match time).
+ */
+ readonly duplicable: boolean;
+
+ /**
+ * Whether a variable specification may use the prefix modifier (`:N`).
+ * Defaults to `false`: a `{var:N}` specification is a registration error
+ * (`DisallowedVarSpecModifierError`). Incompatible with `multiple: true`
+ * (a prefix yields a single truncated scalar); that combination is
+ * already rejected by the `multiple` derivation.
+ */
+ readonly prefixable: boolean;
+
+ /**
+ * Whether a variable specification may use the explode modifier (`*`).
+ * Defaults to `false`: a `{var*}` specification is a registration error
+ * (`DisallowedVarSpecModifierError`). Only meaningful with
+ * `multiple: true` (explode yields a list); the `multiple` derivation
+ * already forces and checks that coupling.
+ */
+ readonly explodable: boolean;
+
+ /**
+ * Allow-list of expression operators the variable may be used with.
+ * Defaults to `[]`, which permits every operator. When non-empty, using
+ * the variable under an operator outside this list is a registration
+ * error (`DisallowedOperatorError`).
+ */
+ readonly operatables: readonly Operator[];
+}
+
+/**
+ * Fully-resolved options attached to a registered route. All fields are
+ * required; {@link fillRouteOptions} resolves a {@link Partial} input against
+ * a {@link RouterPathPattern} into this shape.
+ */
+export interface RouteOptions {
+ /**
+ * Per-variable constraint, keyed by variable name. After resolution this
+ * contains an entry for every template variable, not just the overridden
+ * ones.
+ */
+ readonly variables: Readonly>;
+
+ /**
+ * When `true` (the default), the `variables` keys must exactly match the
+ * template's variables: a key that is not an actual template variable is
+ * a registration error (typo guard). When `false`, such keys are
+ * silently ignored.
+ */
+ readonly exact: boolean;
+}
+
+/**
+ * The subset of a per-variable constraint that {@link ConstraintValue}
+ * inspects to compute a matched value's static type. Only `multiple` and
+ * `nullable` shape the value: `multiple: true` yields `readonly string[]`,
+ * `nullable: true` additionally admits `null`.
+ *
+ * `explodable` is a *registration permission* (it lets a varspec use the
+ * explode modifier `{var*}` without throwing) and deliberately does **not**
+ * affect the computed value type. An exploded route still resolves
+ * `multiple` to `true` via {@link fillMultiple}, so callers that want the
+ * matched value narrowed to `readonly string[]` must pass `multiple: true`
+ * in the {@link Router.route} type argument, not merely `explodable: true`.
+ */
+export interface MinimalConstraint {
+ readonly multiple?: boolean;
+ readonly nullable?: boolean;
+ readonly explodable?: boolean;
+}
+
+/**
+ * Computes the value type of a single matched variable from its constraint:
+ * `multiple: true` yields `readonly string[]`, otherwise `string`;
+ * `nullable: true` additionally admits `null`.
+ *
+ * The trailing `extends infer R ? R : never` is an identity that forces
+ * TypeScript to evaluate the conditional union eagerly.
+ */
+export type ConstraintValue = (
+ | (C extends { multiple: true } ? readonly string[] : string)
+ | (C extends { nullable: true } ? null : never)
+) extends infer R ? R : never;
+
+/**
+ * Computes the `values` record type from a map of variable constraints. An
+ * empty map (the default) widens to `Record` because the
+ * matched route is not known at the type level.
+ */
+export type RouteValues<
+ TConstraints extends Record,
+> = [keyof TConstraints] extends [never]
+ ? Record>
+ : { [K in keyof TConstraints]: ConstraintValue };
+
+/**
+ * The result of {@link Router.route}. The type argument is the per-variable
+ * constraint map; pass it at the call site to narrow `values` (for example,
+ * `router.route<{ tags: { nullable: false; multiple: true } }>(...)`).
+ */
+export interface RouterRouteResult<
+ TConstraints extends Record,
+> {
+ /**
+ * The matched route name.
+ */
+ name: string;
+
+ /**
+ * The URI template of the matched route.
+ */
+ template: Path;
+
+ /**
+ * The values extracted from the URI.
+ */
+ values: RouteValues;
+}
+
+/**
+ * Parsed path template ready to be registered in a {@link Router}.
+ *
+ * Instances returned by {@link Router.compile} are immutable and may be shared
+ * safely between routers and router clones.
+ */
+export interface RouterPathPattern {
+ /**
+ * The original path template string.
+ */
+ readonly path: Path;
+
+ /**
+ * Parsed URI Template.
+ */
+ readonly template: Template;
+
+ /**
+ * Variable names found in the template.
+ */
+ readonly variables: ReadonlySet;
+}
+
+/**
+ * Resolved route definition produced internally and returned by
+ * {@link Router#clone} round-trips. Unlike {@link PartialRouterRoute}, the
+ * path is already compiled and the options are fully resolved.
+ */
+export type RouterRoute = readonly [
+ pathOrPattern: RouterPathPattern,
+ name: string,
+ options: RouteOptions,
+];
+
+/**
+ * Route definition accepted by {@link Router#register}, the {@link Router}
+ * constructor, and {@link Router.from}. The first element is either a path
+ * template string or a pre-parsed {@link RouterPathPattern} from
+ * {@link Router.compile}; the second element is the route name; the optional
+ * third element is the per-route options (missing fields are filled with
+ * their defaults).
+ */
+export type PartialRouterRoute = readonly [
+ pathOrPattern: Path | RouterPathPattern,
+ name: string,
+ options?: {
+ readonly variables?: Readonly>>;
+ readonly exact?: boolean;
+ },
+];
+
+/**
+ * Internal trie entry: a registered route plus the indexing metadata and
+ * resolved constraints the matcher needs.
+ */
+export interface RouteEntry {
+ readonly index: number;
+ readonly name: string;
+ readonly pattern: RouterPathPattern;
+ readonly tokens: readonly Token[];
+ readonly initialLiteralPrefix: string;
+ readonly literalLength: number;
+ readonly variableCount: number;
+ /** Resolved per-route options, retained so {@link Router#clone} round-trips. */
+ readonly options: RouteOptions;
+ /** Resolved per-variable constraints for every template variable. */
+ readonly constraints: ReadonlyMap;
+}
diff --git a/packages/uri-template/src/template/encoding.ts b/packages/uri-template/src/template/encoding.ts
new file mode 100644
index 000000000..96c2f4372
--- /dev/null
+++ b/packages/uri-template/src/template/encoding.ts
@@ -0,0 +1,214 @@
+const textEncoder = new TextEncoder();
+
+const hexDigits = "0123456789ABCDEF";
+
+/**
+ * Returns whether a character is an RFC 3986 hexadecimal digit.
+ *
+ * Used by parsers and encoders when recognizing pct-encoded triplets.
+ */
+export const isHexDigit: (char: string) => boolean = (
+ char: string,
+): boolean =>
+ some(
+ between(0x30, 0x39),
+ between(0x41, 0x46),
+ between(0x61, 0x66),
+ )(char.charCodeAt(0));
+
+/**
+ * Returns whether `value[index]` starts a complete pct-encoded triplet.
+ */
+export const isPctEncodedAt: (
+ value: string,
+ index: number,
+) => boolean = (value: string, index: number): boolean =>
+ value[index] === "%" &&
+ index + 2 < value.length &&
+ isHexDigit(value[index + 1]) &&
+ isHexDigit(value[index + 2]);
+
+/**
+ * Returns the UTF-16 length of an RFC 6570 `varchar` at `index`, or `0` when
+ * no varchar starts there.
+ */
+export function isVarcharAt(value: string, index: number): number {
+ const char = value[index];
+ if (char == null) return 0;
+ if (some(isAlpha, isDigit, eq("_"))(char)) return 1;
+ return isPctEncodedAt(value, index) ? 3 : 0;
+}
+
+/**
+ * Returns the UTF-16 length of an RFC 6570 literal token at `index`, or `0`
+ * when the character is not valid literal syntax.
+ */
+export function isLiteralAt(value: string, index: number): number {
+ if (isPctEncodedAt(value, index)) return 3;
+ const { char, size } = readCodePoint(value, index);
+ return isLiteralChar(char) ? size : 0;
+}
+
+/**
+ * Reads one Unicode code point from a JavaScript string.
+ */
+export function readCodePoint(
+ value: string,
+ index: number,
+): { char: string; size: number } {
+ const codePoint = value.codePointAt(index);
+ if (codePoint == null) return { char: "", size: 0 };
+ const size = codePoint > 0xffff ? 2 : 1;
+ return { char: value.slice(index, index + size), size };
+}
+
+/**
+ * Percent-encodes an expanded variable value according to the operator's
+ * allowed-character rule.
+ */
+export const encodeValue: (
+ allowReserved: boolean,
+) => (value: string) => string = (
+ allowReserved: boolean,
+): (value: string) => string =>
+(value: string): string => {
+ let encoded = "";
+ for (let index = 0; index < value.length;) {
+ if (allowReserved && isPctEncodedAt(value, index)) {
+ encoded += value.slice(index, index + 3);
+ index += 3;
+ continue;
+ }
+
+ const { char, size } = readCodePoint(value, index);
+ encoded += some(
+ isUnreserved,
+ (char: string): boolean => allowReserved && isReserved(char),
+ )(char)
+ ? char
+ : percentEncode(char);
+ index += size;
+ }
+ return encoded;
+};
+
+/**
+ * Percent-encodes a variable name or associative key for named expansions.
+ */
+export function encodeName(value: string): string {
+ let encoded = "";
+ for (let index = 0; index < value.length;) {
+ if (isPctEncodedAt(value, index)) {
+ encoded += value.slice(index, index + 3);
+ index += 3;
+ continue;
+ }
+
+ const { char, size } = readCodePoint(value, index);
+ encoded += isUnreserved(char) ? char : percentEncode(char);
+ index += size;
+ }
+ return encoded;
+}
+
+/**
+ * Returns the first `length` RFC 6570 prefix characters without splitting a
+ * Unicode code point or a pct-encoded triplet.
+ */
+export function truncateValue(value: string, length: number): string {
+ let truncated = "";
+ let count = 0;
+ for (let index = 0; index < value.length && count < length; count++) {
+ if (isPctEncodedAt(value, index)) {
+ truncated += value.slice(index, index + 3);
+ index += 3;
+ continue;
+ }
+
+ const { char, size } = readCodePoint(value, index);
+ truncated += char;
+ index += size;
+ }
+ return truncated;
+}
+
+const isAlpha: (char: string) => boolean = (char: string): boolean =>
+ some(between(0x41, 0x5a), between(0x61, 0x7a))(char.charCodeAt(0));
+
+const isDigit: (char: string) => boolean = (char: string): boolean =>
+ between(0x30, 0x39)(char.charCodeAt(0));
+
+const isUnreserved: (char: string) => boolean = (char: string): boolean =>
+ some(
+ isAlpha,
+ isDigit,
+ (char: string) => "-._~".includes(char),
+ )(char);
+
+const isReserved: (char: string) => boolean = (char: string): boolean =>
+ ":/?#[]@!$&'()*+,;=".includes(char);
+
+const isLiteralChar: (char: string) => boolean = (char: string): boolean =>
+ isLiteralCodePoint(char.codePointAt(0));
+
+const isLiteralCodePoint: (
+ code: number | undefined,
+) => boolean = (code: number | undefined): boolean =>
+ code != null &&
+ some(
+ eq(0x21),
+ between(0x23, 0x24),
+ eq(0x26),
+ between(0x28, 0x3b),
+ eq(0x3d),
+ between(0x3f, 0x5b),
+ eq(0x5d),
+ eq(0x5f),
+ between(0x61, 0x7a),
+ eq(0x7e),
+ isUcsChar,
+ isIPrivate,
+ )(code);
+
+function isUcsChar(code: number): boolean {
+ if (code < 0x10000) {
+ return some(
+ between(0xa0, 0xd7ff),
+ between(0xf900, 0xfdcf),
+ between(0xfdf0, 0xffef),
+ )(code);
+ }
+
+ if (code > 0xefffd) return false;
+ const offset = code % 0x10000;
+ return offset <= 0xfffd && (code < 0xe0000 || offset >= 0x1000);
+}
+
+const isIPrivate: (code: number) => boolean = (code: number): boolean =>
+ some(
+ between(0xe000, 0xf8ff),
+ between(0xf0000, 0xffffd),
+ between(0x100000, 0x10fffd),
+ )(code);
+
+const percentEncode: (char: string) => string = (char: string): string =>
+ Array.from(textEncoder.encode(char))
+ .map((byte) => `%${hexDigits[byte >> 4]}${hexDigits[byte & 0x0f]}`)
+ .join("");
+
+const between: (
+ min: number,
+ max: number,
+) => (num: number) => boolean =
+ (min: number, max: number): (num: number) => boolean =>
+ (num: number): boolean => min <= num && num <= max;
+
+const eq: (a: T) => (b: T) => boolean =
+ (a: T): (b: T) => boolean => (b: T): boolean => a === b;
+
+const some: (
+ ...preds: ((arg: T) => boolean)[]
+) => (arg: T) => boolean =
+ (...preds: ((arg: T) => boolean)[]): (arg: T) => boolean =>
+ (arg: T): boolean => preds.some((pred) => pred(arg));
+// cspell: ignore preds
diff --git a/packages/uri-template/src/template/errors.ts b/packages/uri-template/src/template/errors.ts
new file mode 100644
index 000000000..13ee05fd4
--- /dev/null
+++ b/packages/uri-template/src/template/errors.ts
@@ -0,0 +1,361 @@
+/**
+ * Errors raised when an RFC 6570 URI template fails to parse or expand.
+ *
+ * Parse-time hierarchy:
+ *
+ * ~~~~
+ * TemplateParseError
+ * ├── UnclosedExpressionError
+ * ├── StrayClosingBraceError
+ * ├── NestedOpeningBraceError
+ * ├── EmptyExpressionError
+ * ├── ReservedOperatorError
+ * ├── UnknownOperatorError
+ * ├── InvalidLiteralError
+ * ├── InvalidVarSpecError
+ * │ ├── EmptyVarNameError
+ * │ ├── InvalidVarNameError
+ * │ ├── InvalidPrefixError
+ * │ └── TrailingCommaError
+ * └── UnexpectedCharacterError
+ * ~~~~
+ *
+ * Expansion-time hierarchy:
+ *
+ * ~~~~
+ * TemplateExpansionError
+ * └── PrefixModifierNotApplicableError
+ * ~~~~
+ *
+ * Parse errors carry the original `template` and the 0-based `position` where
+ * the offending input was located. Expansion errors carry the runtime variable
+ * name whose value cannot be expanded.
+ *
+ * @module
+ */
+
+/**
+ * Common base class for every parse-time error produced by the RFC 6570 parser.
+ */
+export class TemplateParseError extends Error {
+ /**
+ * @param template The full URI template string that was being parsed.
+ * @param position 0-based index into `template` where the problem was
+ * detected. When the offending input spans a range,
+ * this is the start of that range.
+ * @param hint Short, actionable instruction for the user.
+ * @param message Human-readable summary.
+ */
+ constructor(
+ public readonly template: string,
+ public readonly position: number,
+ public readonly hint: string,
+ message: string,
+ ) {
+ super(`${message} (at position ${position}): ${hint}`);
+ this.name = "TemplateParseError";
+ }
+}
+
+/**
+ * Raised when an opening `{` has no matching `}` before the template ends.
+ *
+ * Fix: close the expression with `}` or pct-encode the literal `{` as `%7B`.
+ * RFC 6570 does not define an escape syntax.
+ */
+export class UnclosedExpressionError extends TemplateParseError {
+ constructor(template: string, position: number) {
+ super(
+ template,
+ position,
+ "Add the missing '}' to close the expression, or remove the stray '{'.",
+ "Unclosed expression: '{' has no matching '}'",
+ );
+ this.name = "UnclosedExpressionError";
+ }
+}
+
+/**
+ * Raised when a `}` appears outside of any expression.
+ *
+ * Fix: remove the stray `}` or precede it with a matching `{`.
+ */
+export class StrayClosingBraceError extends TemplateParseError {
+ constructor(template: string, position: number) {
+ super(
+ template,
+ position,
+ "Remove this stray '}' or add a matching '{' before it.",
+ "Stray '}' outside of any expression",
+ );
+ this.name = "StrayClosingBraceError";
+ }
+}
+
+/**
+ * Raised when a `{` appears inside another expression before that expression
+ * is closed. RFC 6570 expressions cannot nest.
+ *
+ * Fix: close the outer expression with `}` before opening a new one.
+ */
+export class NestedOpeningBraceError extends TemplateParseError {
+ constructor(template: string, position: number) {
+ super(
+ template,
+ position,
+ "RFC 6570 expressions cannot nest. Close the outer expression with " +
+ "'}' before starting a new one.",
+ "Nested '{' inside an unclosed expression",
+ );
+ this.name = "NestedOpeningBraceError";
+ }
+}
+
+/**
+ * Raised when a literal section of the template contains a character that is
+ * outside the RFC 6570 `literals` set: CTL, SP, `"`, `'`, lone `%`, `<`, `>`,
+ * `\\`, `^`, `` ` ``, `|`.
+ *
+ * Fix: pct-encode the offending character or remove it.
+ */
+export class InvalidLiteralError extends TemplateParseError {
+ constructor(
+ template: string,
+ position: number,
+ public readonly char: string,
+ ) {
+ super(
+ template,
+ position,
+ `Pct-encode '${char}' (e.g. '%${
+ char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")
+ }') or remove it. Literals may not contain CTL, SP, '\"', '\\'', lone ` +
+ "'%', '<', '>', '\\\\', '^', '`', or '|'.",
+ `Invalid literal character '${char}'`,
+ );
+ this.name = "InvalidLiteralError";
+ }
+}
+
+/**
+ * Raised for `{}` — an expression that contains neither operator nor varspec.
+ *
+ * Fix: insert at least one varname between the braces, e.g. `{var}`.
+ */
+export class EmptyExpressionError extends TemplateParseError {
+ constructor(template: string, position: number) {
+ super(
+ template,
+ position,
+ "Provide at least one varname inside the braces, e.g. '{var}'.",
+ "Empty expression '{}'",
+ );
+ this.name = "EmptyExpressionError";
+ }
+}
+
+/**
+ * Raised when the operator slot holds one of the characters reserved by
+ * RFC 6570 §2.2 for future extensions: `=`, `,`, `!`, `@`, `|`.
+ *
+ * Fix: drop the reserved operator or replace it with one of the implemented
+ * operators (`+`, `#`, `.`, `/`, `;`, `?`, `&`).
+ */
+export class ReservedOperatorError extends TemplateParseError {
+ constructor(
+ template: string,
+ position: number,
+ public readonly operator: string,
+ ) {
+ super(
+ template,
+ position,
+ `Operator '${operator}' is reserved by RFC 6570 §2.2 for future ` +
+ "extensions. Use one of '+', '#', '.', '/', ';', '?', '&' instead, " +
+ "or remove the operator.",
+ `Reserved operator '${operator}'`,
+ );
+ this.name = "ReservedOperatorError";
+ }
+}
+
+/**
+ * Raised when the operator slot holds a character that is neither a defined
+ * RFC 6570 operator nor part of the varname grammar.
+ *
+ * Fix: use one of the implemented operators (`+`, `#`, `.`, `/`, `;`, `?`,
+ * `&`) or remove the character.
+ */
+export class UnknownOperatorError extends TemplateParseError {
+ constructor(
+ template: string,
+ position: number,
+ public readonly operator: string,
+ ) {
+ super(
+ template,
+ position,
+ `Replace '${operator}' with one of '+', '#', '.', '/', ';', '?', '&' ` +
+ "or remove it.",
+ `Unknown operator '${operator}'`,
+ );
+ this.name = "UnknownOperatorError";
+ }
+}
+
+/**
+ * Common base for malformed varspec errors so users can `instanceof`-filter.
+ */
+export class InvalidVarSpecError extends TemplateParseError {
+ constructor(
+ template: string,
+ position: number,
+ hint: string,
+ message: string,
+ public readonly varSpec: string,
+ ) {
+ super(template, position, hint, message);
+ this.name = "InvalidVarSpecError";
+ }
+}
+
+/**
+ * Raised when a varspec contains no varname (e.g. `{,foo}` or `{foo,}`).
+ */
+export class EmptyVarNameError extends InvalidVarSpecError {
+ constructor(template: string, position: number) {
+ super(
+ template,
+ position,
+ "Remove the stray comma or insert a varname before/after it.",
+ "Empty varname in variable-list",
+ "",
+ );
+ this.name = "EmptyVarNameError";
+ }
+}
+
+/**
+ * Raised when a varname contains characters outside the RFC 6570 varchar set
+ * (`ALPHA / DIGIT / "_" / pct-encoded`, optionally separated by `.`).
+ */
+export class InvalidVarNameError extends InvalidVarSpecError {
+ constructor(
+ template: string,
+ position: number,
+ varSpec: string,
+ public readonly offendingChar: string,
+ ) {
+ super(
+ template,
+ position,
+ "Varnames may only contain ALPHA, DIGIT, '_', '.', or pct-encoded " +
+ `triplets. Replace '${offendingChar}' or pct-encode it.`,
+ `Invalid character '${offendingChar}' in varname`,
+ varSpec,
+ );
+ this.name = "InvalidVarNameError";
+ }
+}
+
+/**
+ * Raised when a prefix modifier (`:N`) is malformed: missing digits, leading
+ * zero, or `N` outside the range `1..9999`.
+ */
+export class InvalidPrefixError extends InvalidVarSpecError {
+ constructor(
+ template: string,
+ position: number,
+ varSpec: string,
+ public readonly prefix: string,
+ ) {
+ super(
+ template,
+ position,
+ "Prefix modifiers must be ':N' where N is a positive integer in " +
+ "1..9999 with no leading zero (e.g. ':3').",
+ `Invalid prefix modifier ':${prefix}'`,
+ varSpec,
+ );
+ this.name = "InvalidPrefixError";
+ }
+}
+
+/**
+ * Raised when a varspec ends with a trailing comma followed by `}` or end of
+ * variable-list (e.g. `{a,b,}`).
+ */
+export class TrailingCommaError extends InvalidVarSpecError {
+ constructor(template: string, position: number) {
+ super(
+ template,
+ position,
+ "Remove the trailing comma, or add a varspec after it.",
+ "Trailing ',' in variable-list",
+ "",
+ );
+ this.name = "TrailingCommaError";
+ }
+}
+
+/**
+ * Raised when an unexpected character appears between a varspec and the next
+ * separator (`,` or `}`), e.g. `{a b}` or `{a:3x}`.
+ */
+export class UnexpectedCharacterError extends TemplateParseError {
+ constructor(
+ template: string,
+ position: number,
+ public readonly char: string,
+ ) {
+ super(
+ template,
+ position,
+ "Expected ',' or '}' here. Remove the unexpected character or " +
+ "pct-encode it if it belongs in the varname.",
+ `Unexpected character '${char}' in expression`,
+ );
+ this.name = "UnexpectedCharacterError";
+ }
+}
+
+/**
+ * Common base class for runtime expansion errors.
+ */
+export class TemplateExpansionError extends Error {
+ /**
+ * @param variableName The variable whose resolved value cannot be expanded.
+ * @param hint Short, actionable instruction for the user.
+ * @param message Human-readable summary.
+ */
+ constructor(
+ public readonly variableName: string,
+ public readonly hint: string,
+ message: string,
+ ) {
+ super(`${message} for '${variableName}': ${hint}`);
+ this.name = "TemplateExpansionError";
+ }
+}
+
+/**
+ * Raised when a prefix modifier is applied to a composite value.
+ *
+ * RFC 6570 §2.4.1 defines prefix modifiers for string values only; lists and
+ * associative arrays must use normal or explode expansion instead.
+ */
+export class PrefixModifierNotApplicableError extends TemplateExpansionError {
+ constructor(
+ variableName: string,
+ public readonly prefix: number,
+ public readonly valueType: "list" | "associative",
+ ) {
+ super(
+ variableName,
+ "Remove the prefix modifier from this varspec, or provide a string " +
+ "value instead of a composite value.",
+ `Prefix modifier ':${prefix}' is not applicable to ${valueType} values`,
+ );
+ this.name = "PrefixModifierNotApplicableError";
+ }
+}
diff --git a/packages/uri-template/src/template/expand.ts b/packages/uri-template/src/template/expand.ts
new file mode 100644
index 000000000..51a27cfbe
--- /dev/null
+++ b/packages/uri-template/src/template/expand.ts
@@ -0,0 +1,170 @@
+import { operatorSpecs } from "../const.ts";
+import type {
+ AssociativeValue,
+ ExpandContext,
+ OperatorSpec,
+ PrimitiveValue,
+ TemplateOptions,
+ Token,
+ VarSpec,
+} from "../types.ts";
+import { encodeName, encodeValue, truncateValue } from "./encoding.ts";
+import { PrefixModifierNotApplicableError } from "./errors.ts";
+
+/**
+ * Expands one parsed URI Template expression against the supplied variable
+ * context using the operator behavior table from RFC 6570.
+ */
+export default function expand(
+ tokens: readonly Token[],
+ context: ExpandContext,
+ options: TemplateOptions,
+): string {
+ return tokens.map((token) =>
+ token.kind === "literal"
+ ? token.text
+ : expandExpressions(token.vars, token.operator, context, options)
+ ).join("");
+}
+
+function expandExpressions(
+ vars: readonly VarSpec[],
+ operator: keyof typeof operatorSpecs,
+ context: ExpandContext,
+ options: TemplateOptions,
+): string {
+ const spec = operatorSpecs[operator];
+ const parts = vars.flatMap((varSpec) =>
+ expandValue(varSpec, context[varSpec.name], spec, options)
+ );
+ return parts.length < 1 ? "" : `${spec.first}${parts.join(spec.sep)}`;
+}
+
+function expandValue(
+ varSpec: VarSpec,
+ value: ExpandContext[string],
+ spec: OperatorSpec,
+ options: TemplateOptions,
+): string[] {
+ if (value == null) return [];
+ if (isPrimitiveList(value)) {
+ const encoded = encodeListMembers(value, spec.allowReserved);
+ if (encoded.length < 1) return [];
+ if (!reportPrefixModifierError(varSpec, "list", options)) return [];
+ return expandList(varSpec, encoded, spec);
+ }
+ if (isAssociative(value)) {
+ const pairs = encodeAssociativePairs(value, spec.allowReserved);
+ if (pairs.length < 1) return [];
+ if (!reportPrefixModifierError(varSpec, "associative", options)) return [];
+ return expandAssociative(varSpec, pairs, spec);
+ }
+ return expandPrimitive(varSpec, value, spec);
+}
+
+function expandPrimitive(
+ varSpec: VarSpec,
+ value: Exclude,
+ spec: OperatorSpec,
+): string[] {
+ const text = String(value);
+ const prefixed = varSpec.prefix == null
+ ? text
+ : truncateValue(text, varSpec.prefix);
+ const encoded = encodeValue(spec.allowReserved)(prefixed);
+ if (!spec.named) return [encoded];
+
+ const name = encodeName(varSpec.name);
+ return [expandNamedPair(name, encoded, spec)];
+}
+
+function expandList(
+ varSpec: VarSpec,
+ encoded: readonly string[],
+ spec: OperatorSpec,
+): string[] {
+ const name = encodeName(varSpec.name);
+ if (varSpec.explode) {
+ return spec.named
+ ? encoded.map((item) => expandNamedPair(name, item, spec))
+ : [...encoded];
+ }
+
+ const joined = encoded.join(",");
+ return spec.named ? [expandNamedPair(name, joined, spec)] : [joined];
+}
+
+function expandAssociative(
+ varSpec: VarSpec,
+ pairs: readonly (readonly [key: string, value: string])[],
+ spec: OperatorSpec,
+): string[] {
+ if (varSpec.explode) {
+ return pairs.map(([key, item]) => expandNamedPair(key, item, spec));
+ }
+
+ const item = pairs.flat(1).join(",");
+ if (!spec.named) return [item];
+
+ const key = encodeName(varSpec.name);
+ return [expandNamedPair(key, item, spec)];
+}
+
+const expandNamedPair = (
+ key: string,
+ item: string,
+ spec: OperatorSpec,
+): string => item === "" ? `${key}${spec.ifEmpty}` : `${key}=${item}`;
+
+const encodeListMembers = (
+ value: readonly PrimitiveValue[],
+ allowReserved: boolean,
+): string[] =>
+ value
+ .filter((item) => item != null)
+ .map(String)
+ .map(encodeValue(allowReserved));
+
+const encodeAssociativePairs = (
+ value: AssociativeValue,
+ allowReserved: boolean,
+): (readonly [key: string, value: string])[] =>
+ Object.entries(value)
+ .map(([key, item]) => [key, normalizePairValue(item) as string] as const)
+ .filter(([, normalized]) => normalized != null)
+ .map((kv) => kv.map(encodeValue(allowReserved)) as [string, string]);
+
+function normalizePairValue(
+ value: PrimitiveValue | readonly PrimitiveValue[],
+): string | null {
+ if (value == null) return null;
+ if (!Array.isArray(value)) return String(value);
+
+ const items = value.filter((item) => item != null).map(String);
+ return items.length < 1 ? null : items.join(",");
+}
+
+const isAssociative = (
+ value: ExpandContext[string],
+): value is AssociativeValue =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const isPrimitiveList = (
+ value: ExpandContext[string],
+): value is readonly PrimitiveValue[] => Array.isArray(value);
+
+function reportPrefixModifierError(
+ varSpec: VarSpec,
+ valueType: "list" | "associative",
+ { report, strict }: TemplateOptions,
+): boolean {
+ if (varSpec.prefix == null) return true;
+ const error = new PrefixModifierNotApplicableError(
+ varSpec.name,
+ varSpec.prefix,
+ valueType,
+ );
+ report(error);
+ if (strict) throw error;
+ return false;
+}
diff --git a/packages/uri-template/src/template/expression.ts b/packages/uri-template/src/template/expression.ts
new file mode 100644
index 000000000..d09f0a48d
--- /dev/null
+++ b/packages/uri-template/src/template/expression.ts
@@ -0,0 +1,182 @@
+import { OPERATORS } from "../const.ts";
+import type { Operator, Token, VarSpec } from "../types.ts";
+import { isVarcharAt } from "./encoding.ts";
+import {
+ EmptyExpressionError,
+ EmptyVarNameError,
+ InvalidPrefixError,
+ InvalidVarNameError,
+ ReservedOperatorError,
+ TrailingCommaError,
+ UnexpectedCharacterError,
+ UnknownOperatorError,
+} from "./errors.ts";
+
+const reservedOperators = ["=", ",", "!", "@", "|"] as const;
+
+/**
+ * Parses the content between `{` and `}` into one expression token.
+ *
+ * The tokenizer supplies the original template and offset so errors can point
+ * at the original source string.
+ */
+export default function parseExpression(
+ source: string,
+ template: string,
+ position: number,
+): Token {
+ const raiseExpressionError = (error: Error): never => {
+ throw error;
+ };
+
+ if (source.length < 1) {
+ return raiseExpressionError(new EmptyExpressionError(template, position));
+ }
+
+ const first = source[0];
+ if (isReservedOperator(first)) {
+ return raiseExpressionError(
+ new ReservedOperatorError(template, position + 1, first),
+ );
+ }
+ if (!isOperator(first) && isVarcharAt(source, 0) < 1) {
+ return raiseExpressionError(
+ new UnknownOperatorError(template, position + 1, first),
+ );
+ }
+
+ const operator: Operator = isOperator(first) ? first : "";
+ const varListStart = operator === "" ? 0 : 1;
+ const vars = parseVarList(source, template, position + 1, varListStart);
+ return {
+ kind: "expression",
+ operator,
+ vars,
+ };
+}
+
+function parseVarList(
+ source: string,
+ template: string,
+ offset: number,
+ start: number,
+): VarSpec[] {
+ if (start >= source.length) {
+ throw new EmptyVarNameError(template, offset + start);
+ }
+
+ const vars: VarSpec[] = [];
+ for (let index = start; index < source.length;) {
+ if (source[index] === ",") {
+ throw index === source.length - 1
+ ? new TrailingCommaError(template, offset + index)
+ : new EmptyVarNameError(template, offset + index);
+ }
+
+ const varStart = index;
+ const nameEnd = readVarNameEnd(source, index);
+ if (nameEnd === varStart) {
+ throw new EmptyVarNameError(template, offset + index);
+ }
+
+ const name = source.slice(varStart, nameEnd);
+ index = nameEnd;
+
+ const modifier = readModifier(source, template, offset, index, name);
+ index = modifier.index;
+
+ if (index < source.length && source[index] !== ",") {
+ throw modifier.used
+ ? new UnexpectedCharacterError(template, offset + index, source[index])
+ : new InvalidVarNameError(
+ template,
+ offset + index,
+ name,
+ source[index],
+ );
+ }
+
+ vars.push({
+ name,
+ explode: modifier.explode,
+ ...(modifier.prefix == null ? {} : { prefix: modifier.prefix }),
+ });
+
+ if (index < source.length) {
+ index++;
+ if (index >= source.length) {
+ throw new TrailingCommaError(template, offset + index - 1);
+ }
+ }
+ }
+
+ return vars;
+}
+
+function readVarNameEnd(source: string, start: number): number {
+ let index = start;
+ let expectVarchar = true;
+ while (index < source.length) {
+ const varcharLength = isVarcharAt(source, index);
+ if (varcharLength > 0) {
+ index += varcharLength;
+ expectVarchar = false;
+ continue;
+ }
+ if (source[index] !== ".") break;
+ if (expectVarchar || isVarcharAt(source, index + 1) < 1) break;
+ index++;
+ expectVarchar = true;
+ }
+ return index;
+}
+
+function readModifier(
+ source: string,
+ template: string,
+ offset: number,
+ index: number,
+ varSpec: string,
+): {
+ readonly explode: boolean;
+ readonly index: number;
+ readonly prefix?: number;
+ readonly used: boolean;
+} {
+ if (source[index] === "*") {
+ return { explode: true, index: index + 1, used: true };
+ }
+
+ if (source[index] !== ":") {
+ return { explode: false, index, used: false };
+ }
+
+ const digitsStart = index + 1;
+ let digitsEnd = digitsStart;
+ while (digitsEnd < source.length && isDigit(source[digitsEnd])) digitsEnd++;
+
+ const prefix = source.slice(digitsStart, digitsEnd);
+ if (!/^[1-9][0-9]{0,3}$/.test(prefix)) {
+ throw new InvalidPrefixError(template, offset + index, varSpec, prefix);
+ }
+
+ return {
+ explode: false,
+ index: digitsEnd,
+ prefix: Number(prefix),
+ used: true,
+ };
+}
+
+function isOperator(char: string): char is Operator {
+ return (OPERATORS as readonly string[]).includes(char) && char !== "";
+}
+
+function isReservedOperator(char: string): boolean {
+ return (reservedOperators as readonly string[]).includes(char);
+}
+
+function isDigit(char: string): boolean {
+ const code = char.charCodeAt(0);
+ return code >= 0x30 && code <= 0x39;
+}
diff --git a/packages/uri-template/src/template/match.ts b/packages/uri-template/src/template/match.ts
new file mode 100644
index 000000000..835cc05f8
--- /dev/null
+++ b/packages/uri-template/src/template/match.ts
@@ -0,0 +1,735 @@
+import { operatorSpecs } from "../const.ts";
+import type {
+ AssociativeValue,
+ ExpandContext,
+ ExpandValue,
+ OperatorSpec,
+ TemplateOptions,
+ Token,
+ VarSpec,
+} from "../types.ts";
+import { encodeName, truncateValue } from "./encoding.ts";
+import expand from "./expand.ts";
+
+/**
+ * Matches a URI against a parsed URI template and extracts variable bindings.
+ *
+ * The inverse of {@link expand}: given the token stream produced by parsing a
+ * template and a concrete URI, recovers an {@link ExpandContext} such that
+ * re-expanding the template with that context reproduces the original URI.
+ *
+ * @param tokens The parsed template tokens to match against.
+ * @param uri The concrete URI to decompose.
+ * @param options Template options shared with the expansion side.
+ * @returns The recovered variable context, or `null` if the URI does not match
+ * the template under any interpretation.
+ */
+export default function match(
+ tokens: readonly Token[],
+ uri: string,
+ options: TemplateOptions,
+): ExpandContext | null {
+ return matchTokens(tokens, uri, options, 0, 0, {});
+}
+
+interface Binding {
+ readonly prefix?: number;
+ readonly value: ExpandValue;
+}
+
+type Bindings = Record;
+
+interface NamedPart {
+ readonly name: string;
+ readonly value: string;
+}
+
+interface ConsumedParts {
+ readonly bindings: Bindings;
+ readonly index: number;
+}
+
+/**
+ * Walks the token stream and the URI in lockstep, backtracking over every
+ * candidate decomposition until one survives roundtrip verification.
+ *
+ * Literal tokens advance deterministically; expression tokens fan out across
+ * all viable end positions and value interpretations. When the token stream is
+ * exhausted, the accumulated bindings are accepted only if re-expanding the
+ * template with them yields the original URI exactly — this is what filters
+ * out the spurious interpretations that the permissive parsing stage admits.
+ *
+ * @returns The first surviving context, or `null` if every branch fails.
+ */
+function matchTokens(
+ tokens: readonly Token[],
+ uri: string,
+ options: TemplateOptions,
+ tokenIndex: number,
+ uriIndex: number,
+ bindings: Bindings,
+): ExpandContext | null {
+ if (tokenIndex >= tokens.length) {
+ if (uriIndex !== uri.length) return null;
+ const context = toExpandContext(bindings);
+ return expand(tokens, context, options) === uri ? context : null;
+ }
+
+ const token = tokens[tokenIndex];
+ if (token.kind === "literal") {
+ return uri.startsWith(token.text, uriIndex)
+ ? matchTokens(
+ tokens,
+ uri,
+ options,
+ tokenIndex + 1,
+ uriIndex + token.text.length,
+ bindings,
+ )
+ : null;
+ }
+
+ for (const end of expressionEnds(tokens, uri, tokenIndex, uriIndex)) {
+ const expression = uri.slice(uriIndex, end);
+ for (
+ const expressionBindings of matchExpression(
+ token.vars,
+ token.operator,
+ expression,
+ )
+ ) {
+ const merged = mergeBindings(bindings, expressionBindings);
+ if (merged == null) continue;
+ const result = matchTokens(
+ tokens,
+ uri,
+ options,
+ tokenIndex + 1,
+ end,
+ merged,
+ );
+ if (result != null) return result;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Generates candidate end positions in the URI for the expression token at
+ * `tokenIndex`, using the next non-empty literal token as a search anchor.
+ *
+ * If no following literal exists the expression may run to the end of the URI,
+ * so every position from the URI end back to `uriIndex` is yielded (longest
+ * first, biasing the search toward greedy matches). Otherwise only the offsets
+ * where the next literal appears are returned, which prunes the search space
+ * dramatically in templates with structural separators.
+ */
+function* expressionEnds(
+ tokens: readonly Token[],
+ uri: string,
+ tokenIndex: number,
+ uriIndex: number,
+): Generator {
+ const nextLiteral = tokens
+ .slice(tokenIndex + 1)
+ .find((token) => token.kind === "literal" && token.text !== "");
+
+ if (nextLiteral == null || nextLiteral.kind !== "literal") {
+ yield* range(uri.length, uriIndex);
+ return;
+ }
+
+ for (
+ let index = uri.indexOf(nextLiteral.text, uriIndex);
+ index >= 0;
+ index = uri.indexOf(nextLiteral.text, index + 1)
+ ) {
+ yield index;
+ }
+}
+
+function* range(from: number, to: number): Generator {
+ for (let value = from; value >= to; value--) yield value;
+}
+
+/**
+ * Decomposes a single expression substring into every plausible binding set.
+ *
+ * Validates the operator's leading sigil (`?`, `#`, `/`, etc.), strips it, and
+ * dispatches to the named or unnamed parser based on the operator spec. The
+ * empty-expression case is delegated to {@link matchEmptyExpression}, which
+ * decides when an empty expression substring may be read back as an
+ * empty-string binding rather than as no binding at all.
+ */
+function* matchExpression(
+ vars: readonly VarSpec[],
+ operator: keyof typeof operatorSpecs,
+ expression: string,
+): Generator {
+ if (expression === "") {
+ yield* matchEmptyExpression(vars, operator);
+ return;
+ }
+
+ const spec = operatorSpecs[operator];
+ if (!expression.startsWith(spec.first)) return;
+
+ const body = expression.slice(spec.first.length);
+ yield* (spec.named
+ ? matchNamedExpression(vars, spec, body)
+ : matchUnnamedExpression(vars, spec, body));
+}
+
+const matchEmptyExpression = (
+ vars: readonly VarSpec[],
+ operator: keyof typeof operatorSpecs,
+): Bindings[] => {
+ if ((operator === "" || operator === "+") && vars.length === 1) {
+ return [bindValue(vars[0], "")];
+ }
+ return [{}];
+};
+
+const matchUnnamedExpression = (
+ vars: readonly VarSpec[],
+ spec: OperatorSpec,
+ body: string,
+): Generator =>
+ matchUnnamedFrom(vars, spec, split(body, spec.sep), 0, 0);
+
+/**
+ * Distributes the separator-split parts of an unnamed expression across the
+ * remaining variables via backtracking.
+ *
+ * For each variable, every contiguous slice of parts that respects the
+ * `minLength`/`maxLength` budget is tried as that variable's value, and the
+ * variable may also be skipped entirely (consuming zero parts) to handle
+ * undefined variables in the template. Surviving combinations are yielded for
+ * the caller to filter.
+ */
+function* matchUnnamedFrom(
+ vars: readonly VarSpec[],
+ spec: OperatorSpec,
+ parts: readonly string[],
+ varIndex: number,
+ partIndex: number,
+): Generator {
+ if (varIndex >= vars.length) {
+ if (partIndex >= parts.length) yield {};
+ return;
+ }
+
+ const varSpec = vars[varIndex];
+ for (
+ const consumed of consumeUnnamed(varSpec, spec, parts, partIndex)
+ ) {
+ for (
+ const rest of matchUnnamedFrom(
+ vars,
+ spec,
+ parts,
+ varIndex + 1,
+ consumed.index,
+ )
+ ) {
+ const merged = mergeBindings(consumed.bindings, rest);
+ if (merged != null) yield merged;
+ }
+ }
+
+ yield* matchUnnamedFrom(vars, spec, parts, varIndex + 1, partIndex);
+}
+
+/**
+ * Enumerates every (binding, next-part-index) pair produced by letting one
+ * unnamed variable consume any number of remaining parts.
+ */
+function* consumeUnnamed(
+ varSpec: VarSpec,
+ spec: OperatorSpec,
+ parts: readonly string[],
+ partIndex: number,
+): Generator {
+ if (partIndex >= parts.length) return;
+
+ const maxLength = parts.length - partIndex;
+ for (let length = 1; length <= maxLength; length++) {
+ const slice = parts.slice(partIndex, partIndex + length);
+ for (const bindings of parseUnnamedValue(varSpec, spec, slice)) {
+ yield { bindings, index: partIndex + length };
+ }
+ }
+}
+
+/**
+ * Yields every binding interpretation of a slice assigned to one unnamed
+ * variable: scalar, comma-list, associative, and (for explode) an
+ * exploded list or associative reading of the same parts.
+ *
+ * Prefix-bound variables collapse to the scalar reading only.
+ */
+function* parseUnnamedValue(
+ varSpec: VarSpec,
+ spec: OperatorSpec,
+ parts: readonly string[],
+): Generator {
+ const joined = parts.join(spec.sep);
+ const nonExploded = parseNonExplodedValue(varSpec, spec, joined);
+ if (varSpec.prefix != null) {
+ yield* nonExploded;
+ return;
+ }
+
+ if (varSpec.explode && parts.length > 0) {
+ yield* parseExplodedUnnamed(varSpec, spec, parts);
+ }
+ yield* nonExploded;
+}
+
+function* parseExplodedUnnamed(
+ varSpec: VarSpec,
+ spec: OperatorSpec,
+ parts: readonly string[],
+): Generator {
+ const decodedList = decodeValues(parts, spec.allowReserved);
+ if (decodedList == null) return;
+
+ const object = parseExplodedAssociative(parts, spec);
+ if (object != null) yield bindValue(varSpec, object);
+ yield bindValue(varSpec, decodedList);
+}
+
+const parseExplodedAssociative = (
+ parts: readonly string[],
+ spec: OperatorSpec,
+): AssociativeValue | null =>
+ parseExplodedAssociativeBody(parts.join(spec.sep), spec);
+
+function parseExplodedAssociativeBody(
+ body: string,
+ spec: OperatorSpec,
+): AssociativeValue | null {
+ const entries: [string, string][] = [];
+
+ for (let index = 0; index < body.length;) {
+ const equals = body.indexOf("=", index);
+ if (equals < 0) return null;
+
+ const valueStart = equals + 1;
+ const valueEnd = findExplodedPairBoundary(body, valueStart, spec.sep);
+ const key = decodeValue(body.slice(index, equals), spec.allowReserved);
+ const value = decodeValue(
+ body.slice(valueStart, valueEnd),
+ spec.allowReserved,
+ );
+ if (key == null || value == null) return null;
+
+ entries.push([key, value]);
+ index = valueEnd + spec.sep.length;
+ }
+
+ return entries.length < 1 ? null : Object.fromEntries(entries);
+}
+
+function findExplodedPairBoundary(
+ body: string,
+ start: number,
+ separator: string,
+): number {
+ for (let index = start; index < body.length; index++) {
+ if (isExplodedPairBoundary(body, index, separator)) return index;
+ }
+ return body.length;
+}
+
+const isExplodedPairBoundary = (
+ body: string,
+ index: number,
+ separator: string,
+): boolean => {
+ if (!body.startsWith(separator, index)) return false;
+
+ const keyStart = index + separator.length;
+ for (let i = keyStart; i < body.length; i++) {
+ if (body[i] === "=") return i > keyStart;
+ if (body.startsWith(separator, i)) return false;
+ }
+ return false;
+};
+
+/**
+ * Yields candidate readings of a single value string under non-exploded
+ * encoding: scalar, comma-separated list, and (when an even element count
+ * permits) comma-separated associative array. Some candidates will not
+ * round-trip — for instance a comma-bearing scalar gets re-encoded with
+ * `%2C` on expansion — and are filtered out by the roundtrip check in
+ * {@link matchTokens}.
+ *
+ * The yielded bindings are ordered from most structured to least, so the
+ * surrounding backtracking tries the richer interpretations before the scalar
+ * one.
+ */
+function* parseNonExplodedValue(
+ varSpec: VarSpec,
+ spec: OperatorSpec,
+ value: string,
+): Generator {
+ const primitive = decodeValue(value, spec.allowReserved);
+ if (primitive == null) return;
+
+ const primitiveBinding = bindValue(varSpec, primitive);
+ if (varSpec.prefix != null) {
+ yield primitiveBinding;
+ return;
+ }
+
+ const commaParts = split(value, ",");
+ if (commaParts.length < 2) {
+ yield primitiveBinding;
+ return;
+ }
+
+ const decodedList = decodeValues(commaParts, spec.allowReserved);
+ if (decodedList == null) {
+ yield primitiveBinding;
+ return;
+ }
+
+ const associative = commaParts.length % 2 === 0
+ ? parseAssociative(commaParts, spec.allowReserved)
+ : null;
+
+ if (associative != null) yield bindValue(varSpec, associative);
+ yield bindValue(varSpec, decodedList);
+ yield primitiveBinding;
+}
+
+const matchNamedExpression = (
+ vars: readonly VarSpec[],
+ spec: OperatorSpec,
+ body: string,
+): Generator =>
+ matchNamedFrom(vars, spec, split(body, spec.sep).map(splitNamedPart), 0, 0);
+
+/**
+ * Named-expression counterpart of {@link matchUnnamedFrom}: backtracks over
+ * `name=value` parts assigning them to declared variables.
+ *
+ * Like the unnamed variant, each variable may either consume a contiguous run
+ * of parts (via {@link consumeNamed}) or be skipped entirely so that variables
+ * absent from the URI remain unbound.
+ */
+function* matchNamedFrom(
+ vars: readonly VarSpec[],
+ spec: OperatorSpec,
+ parts: readonly NamedPart[],
+ varIndex: number,
+ partIndex: number,
+): Generator {
+ if (varIndex >= vars.length) {
+ if (partIndex >= parts.length) yield {};
+ return;
+ }
+
+ const varSpec = vars[varIndex];
+ for (
+ const consumed of consumeNamed(varSpec, spec, parts, partIndex, vars)
+ ) {
+ for (
+ const rest of matchNamedFrom(
+ vars,
+ spec,
+ parts,
+ varIndex + 1,
+ consumed.index,
+ )
+ ) {
+ const merged = mergeBindings(consumed.bindings, rest);
+ if (merged != null) yield merged;
+ }
+ }
+
+ yield* matchNamedFrom(vars, spec, parts, varIndex + 1, partIndex);
+}
+
+function* consumeNamed(
+ varSpec: VarSpec,
+ spec: OperatorSpec,
+ parts: readonly NamedPart[],
+ partIndex: number,
+ vars: readonly VarSpec[],
+): Generator {
+ if (partIndex >= parts.length) return;
+
+ if (varSpec.explode && varSpec.prefix == null) {
+ yield* consumeExplodedNamed(varSpec, spec, parts, partIndex, vars);
+ return;
+ }
+
+ yield* consumeNamedValue(varSpec, spec, parts, partIndex);
+}
+
+function* consumeNamedValue(
+ varSpec: VarSpec,
+ spec: OperatorSpec,
+ parts: readonly NamedPart[],
+ partIndex: number,
+): Generator {
+ const part = parts[partIndex];
+ if (part.name !== encodeName(varSpec.name)) return;
+
+ for (const bindings of parseNonExplodedValue(varSpec, spec, part.value)) {
+ yield { bindings, index: partIndex + 1 };
+ }
+}
+
+function* consumeExplodedNamed(
+ varSpec: VarSpec,
+ spec: OperatorSpec,
+ parts: readonly NamedPart[],
+ partIndex: number,
+ vars: readonly VarSpec[],
+): Generator {
+ yield* consumeNamedList(varSpec, spec, parts, partIndex);
+ yield* consumeNamedAssociative(varSpec, spec, parts, partIndex, vars);
+}
+
+/**
+ * Reads consecutive parts that share the variable's name, decoding each as a
+ * list element. Used for the explode-as-list interpretation of a named
+ * variable.
+ */
+function* consumeNamedList(
+ varSpec: VarSpec,
+ spec: OperatorSpec,
+ parts: readonly NamedPart[],
+ partIndex: number,
+): Generator