From 5cd88629202cabc5bf9e80834154fd2b9fe09120 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 27 Apr 2026 06:30:49 +0000 Subject: [PATCH 01/49] Initialize @fedify/uri-template package Add a new workspace package that will host Fedify's own symmetric RFC 6570 URI Template implementation, replacing the third-party url-template (expansion) and uri-template-router (parsing) libraries whose asymmetric behavior has been a recurring source of encoding/decoding bugs. This commit only lays down the package skeleton and a public API mockup so that downstream packages can wire imports against the final module path before the algorithms land: - Package metadata (deno.json, package.json, tsdown.config.ts, README.md) modeled after the existing @fedify/webfinger package. - src/types.ts: shared types (PrimitiveValue, ExpandContext, Template, VariableSpec, Route, HierarchyNode, Result). - src/template.ts: parseTemplate stub returning a Template whose expand() throws. - src/router.ts: Router class with the public mutable fields (nid, fsm, routeSet, templateRouteMap, valueRouteMap, hierarchy) that the current consumer relies on for structural cloning; addTemplate/resolveURI throw. - src/mod.ts: explicit named re-exports of the public surface only. - Register the package in the root deno.json workspace and in pnpm-workspace.yaml. The mocked surface mirrors the names and shapes used by packages/fedify/src/federation/router.ts so that the consumer can later switch its imports without further interface changes. https://github.com/fedify-dev/fedify/issues/418 Assisted-by: Claude Code:claude-opus-4-7 --- deno.json | 1 + packages/uri-template/README.md | 42 ++++++++++++++++ packages/uri-template/deno.json | 27 +++++++++++ packages/uri-template/package.json | 66 ++++++++++++++++++++++++++ packages/uri-template/src/constants.ts | 1 + packages/uri-template/src/mod.ts | 17 +++++++ packages/uri-template/src/router.ts | 43 +++++++++++++++++ packages/uri-template/src/template.ts | 21 ++++++++ packages/uri-template/src/types.ts | 63 ++++++++++++++++++++++++ packages/uri-template/tsdown.config.ts | 14 ++++++ pnpm-workspace.yaml | 1 + 11 files changed, 296 insertions(+) create mode 100644 packages/uri-template/README.md create mode 100644 packages/uri-template/deno.json create mode 100644 packages/uri-template/package.json create mode 100644 packages/uri-template/src/constants.ts create mode 100644 packages/uri-template/src/mod.ts create mode 100644 packages/uri-template/src/router.ts create mode 100644 packages/uri-template/src/template.ts create mode 100644 packages/uri-template/src/types.ts create mode 100644 packages/uri-template/tsdown.config.ts diff --git a/deno.json b/deno.json index cc061b9fd..639eecf75 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/packages/uri-template/README.md b/packages/uri-template/README.md new file mode 100644 index 000000000..68c0aa8ae --- /dev/null +++ b/packages/uri-template/README.md @@ -0,0 +1,42 @@ + + +@fedify/uri-template: Symmetric 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 guaranteed symmetric (round-trip) +behavior. 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/ + + +Features +-------- + + - Full RFC 6570 expansion for all expression types + (`{var}`, `{+var}`, `{#var}`, `{.var}`, `{/var}`, `{;var}`, `{?var}`, + `{&var}`) + - Symmetric pattern matching that mirrors expansion to guarantee + `expand(parse(url)) === url` and `parse(expand(value)) === value` + - Strict TypeScript types with no `any` in the public surface + - Zero runtime dependencies + + +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..1ffaddaae --- /dev/null +++ b/packages/uri-template/deno.json @@ -0,0 +1,27 @@ +{ + "name": "@fedify/uri-template", + "version": "2.2.0", + "license": "MIT", + "exports": { + ".": "./src/mod.ts" + }, + "description": "Symmetric RFC 6570 URI Template expansion and pattern matching for Fedify", + "author": { + "name": "Chanhaeng Lee" + }, + "imports": {}, + "exclude": [ + "dist", + "node_modules" + ], + "publish": { + "exclude": [ + "**/*.test.ts", + "tsdown.config.ts" + ] + }, + "tasks": { + "check": "deno fmt --check && deno lint && deno check src/*.ts", + "test": "deno test" + } +} diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json new file mode 100644 index 000000000..f313ceb8f --- /dev/null +++ b/packages/uri-template/package.json @@ -0,0 +1,66 @@ +{ + "name": "@fedify/uri-template", + "version": "2.2.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": "Symmetric RFC 6570 URI Template expansion and 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": "node --experimental-transform-types --test" + }, + "keywords": [ + "Fedify", + "URI Template", + "RFC 6570", + "ActivityPub", + "Fediverse" + ], + "author": { + "name": "Chanhaeng Lee", + "email": "2chanhaeng@gmail.com", + "url": "https://chomu.dev/" + }, + "license": "MIT", + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "dependencies": {} +} diff --git a/packages/uri-template/src/constants.ts b/packages/uri-template/src/constants.ts new file mode 100644 index 000000000..8525f7941 --- /dev/null +++ b/packages/uri-template/src/constants.ts @@ -0,0 +1 @@ +export const NOT_IMPLEMENTED = "@fedify/uri-template is not implemented yet"; diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts new file mode 100644 index 000000000..cf4a25b95 --- /dev/null +++ b/packages/uri-template/src/mod.ts @@ -0,0 +1,17 @@ +/** + * Symmetric RFC 6570 URI Template expansion and pattern matching. + * + * @module + */ + +export { Router } from "./router.ts"; +export { parseTemplate } from "./template.ts"; +export type { + ExpandContext, + HierarchyNode, + PrimitiveValue, + Result, + Route, + Template, + VariableSpec, +} from "./types.ts"; diff --git a/packages/uri-template/src/router.ts b/packages/uri-template/src/router.ts new file mode 100644 index 000000000..1f3e1f3b9 --- /dev/null +++ b/packages/uri-template/src/router.ts @@ -0,0 +1,43 @@ +import { NOT_IMPLEMENTED } from "./constants.ts"; +import type { HierarchyNode, Result, Route } from "./types.ts"; + +/** + * Router that resolves URIs against a set of registered RFC 6570 templates. + */ +export class Router { + nid: number; + fsm: unknown[]; + routeSet: Set; + templateRouteMap: Map; + valueRouteMap: Map; + hierarchy: HierarchyNode; + + constructor() { + this.nid = 0; + this.fsm = []; + this.routeSet = new Set(); + this.templateRouteMap = new Map(); + this.valueRouteMap = new Map(); + this.hierarchy = { children: [] }; + } + + /** + * Registers a URI template under the given match value and returns the + * resulting {@link Route}. + */ + addTemplate( + _uriTemplate: string, + _options: Record, + _matchValue: unknown, + ): Route { + throw new Error(NOT_IMPLEMENTED); + } + + /** + * Resolves a URI against the registered templates, returning a + * {@link Result} when a match is found, or `undefined` otherwise. + */ + resolveURI(_uri: string): Result | undefined { + throw new Error(NOT_IMPLEMENTED); + } +} diff --git a/packages/uri-template/src/template.ts b/packages/uri-template/src/template.ts new file mode 100644 index 000000000..60b01103a --- /dev/null +++ b/packages/uri-template/src/template.ts @@ -0,0 +1,21 @@ +import { NOT_IMPLEMENTED } from "./constants.ts"; +import type { ExpandContext, Template } from "./types.ts"; + +/** + * Parses an RFC 6570 URI template string into a {@link Template}. + */ +export function parseTemplate(template: string): Template { + return new TemplateImpl(template); +} + +class TemplateImpl implements Template { + readonly source: string; + + constructor(source: string) { + this.source = source; + } + + expand(_context: ExpandContext): string { + throw new Error(NOT_IMPLEMENTED); + } +} diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts new file mode 100644 index 000000000..fe28e9552 --- /dev/null +++ b/packages/uri-template/src/types.ts @@ -0,0 +1,63 @@ +/** + * Primitive value accepted by {@link Template.expand}. + */ +export type PrimitiveValue = string | number | boolean | null; + +/** + * Context object accepted by {@link Template.expand}. Each variable resolves + * to a primitive, an ordered list of primitives, or an associative map. + */ +export type ExpandContext = Record< + string, + | PrimitiveValue + | PrimitiveValue[] + | Record +>; + +/** + * Compiled URI template that can be expanded against an {@link ExpandContext}. + */ +export interface Template { + /** + * Expands the template against the supplied context, returning the resolved + * URI string. + */ + expand(context: ExpandContext): string; +} + +/** + * Variable specification produced when a template is added to a {@link Router}. + */ +export interface VariableSpec { + varname: string; +} + +/** + * Route entry returned by {@link Router.addTemplate}. + */ +export interface Route { + uriTemplate: string; + matchValue: unknown; + variables: VariableSpec[]; +} + +/** + * Hierarchy node tracked internally by a {@link Router}, exposed as a mutable + * field so that callers may clone routers via structural copy. + */ +export interface HierarchyNode { + children: HierarchyNode[]; + node?: Route; + uriTemplate?: string; +} + +/** + * Result returned by {@link Router.resolveURI} when a URI matches a registered + * template. + */ +export interface Result { + matchValue: string; + params: Record; + uri: string; + uriTemplate: string; +} diff --git a/packages/uri-template/tsdown.config.ts b/packages/uri-template/tsdown.config.ts new file mode 100644 index 000000000..bf33f512d --- /dev/null +++ b/packages/uri-template/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/mod.ts"], + dts: true, + format: ["esm", "cjs"], + platform: "node", + outExtensions({ format }) { + return { + js: format === "cjs" ? ".cjs" : ".js", + dts: format === "cjs" ? ".d.cts" : ".d.ts", + }; + }, +}); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b7f5dcda4..fb87a9434 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,6 +26,7 @@ packages: - packages/sqlite - packages/sveltekit - packages/testing +- packages/uri-template - packages/vocab - packages/vocab-runtime - packages/vocab-tools From 25644de7c6d1813847c55d7d64338179f617762a Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 28 Apr 2026 10:42:38 +0000 Subject: [PATCH 02/49] Update version of @fedify/uri-template --- packages/uri-template/deno.json | 2 +- packages/uri-template/package.json | 2 +- pnpm-lock.yaml | 18 +++++++++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json index 1ffaddaae..4ac488fd7 100644 --- a/packages/uri-template/deno.json +++ b/packages/uri-template/deno.json @@ -1,6 +1,6 @@ { "name": "@fedify/uri-template", - "version": "2.2.0", + "version": "2.3.0", "license": "MIT", "exports": { ".": "./src/mod.ts" diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json index f313ceb8f..04c6ecc74 100644 --- a/packages/uri-template/package.json +++ b/packages/uri-template/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/uri-template", - "version": "2.2.0", + "version": "2.3.0", "homepage": "https://fedify.dev/", "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e959474ee..6fa32a60c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1675,6 +1675,18 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/uri-template: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.19.1 + tsdown: + specifier: 'catalog:' + version: 0.21.6(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/vocab: dependencies: '@fedify/vocab-runtime': @@ -20099,7 +20111,7 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 eslint: 9.32.0(jiti@2.6.1) - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.7 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.16 @@ -20114,7 +20126,7 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 eslint: 8.57.1 - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.7 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.16 @@ -20129,7 +20141,7 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 eslint: 9.32.0(jiti@2.6.1) - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.7 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.16 From 3b29ce3f5b4f2ace9b21ee5859ae658dc1204d13 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 28 Apr 2026 20:32:15 +0000 Subject: [PATCH 03/49] Add test for template Co-authored-by: Copilot Add test for template Add test for template --- packages/uri-template/deno.json | 2 +- packages/uri-template/package.json | 1 + packages/uri-template/src/mod.ts | 5 +- packages/uri-template/src/template.test.ts | 337 +++++++++++++++++++++ pnpm-lock.yaml | 3 + 5 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 packages/uri-template/src/template.test.ts diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json index 4ac488fd7..4ed62efae 100644 --- a/packages/uri-template/deno.json +++ b/packages/uri-template/deno.json @@ -22,6 +22,6 @@ }, "tasks": { "check": "deno fmt --check && deno lint && deno check src/*.ts", - "test": "deno test" + "test": "deno test --allow-env" } } diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json index 04c6ecc74..5107b6988 100644 --- a/packages/uri-template/package.json +++ b/packages/uri-template/package.json @@ -58,6 +58,7 @@ }, "license": "MIT", "devDependencies": { + "@fedify/fixture": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts index cf4a25b95..ea931b81b 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -1,5 +1,8 @@ /** - * Symmetric RFC 6570 URI Template expansion and pattern matching. + * Symmetric [RFC 6570] URI + * Template expansion and pattern matching. + * + * [RFC 6570]: https://www.rfc-editor.org/rfc/rfc6570.html * * @module */ diff --git a/packages/uri-template/src/template.test.ts b/packages/uri-template/src/template.test.ts new file mode 100644 index 000000000..755ced3da --- /dev/null +++ b/packages/uri-template/src/template.test.ts @@ -0,0 +1,337 @@ +import { test } from "@fedify/fixture"; +import { equal } from "node:assert/strict"; +import { parseTemplate } from "./template.ts"; +import type { ExpandContext } from "./types.ts"; + +const vars: ExpandContext = { + count: ["one", "two", "three"], + dom: ["example", "com"], + dub: "me/too", + hello: "Hello World!", + half: "50%", + var: "value", + who: "fred", + base: "http://example.com/home/", + path: "/foo/bar", + list: ["red", "green", "blue"], + keys: { semi: ";", dot: ".", comma: "," }, + v: "6", + x: "1024", + y: "768", + empty: "", + empty_keys: {}, + undef: null, + semi: ";", + year: ["1965", "2000", "2012"], +}; + +type Case = readonly [template: string, expanded: string]; + +async function runCases( + t: Deno.TestContext, + cases: readonly Case[], + context: ExpandContext = vars, +): Promise { + for (const [template, expected] of cases) { + await t.step(`${template} => ${expected}`, () => { + equal(parseTemplate(template).expand(context), expected); + }); + } +} + +test("Section 1.2 — Level 1 (Simple String Expansion)", async (t) => { + await runCases(t, [ + ["{var}", "value"], + ["{hello}", "Hello%20World%21"], + ]); +}); + +test("Section 1.2 — Level 2 (Reserved + Fragment Expansion)", async (t) => { + await runCases(t, [ + ["{+var}", "value"], + ["{+hello}", "Hello%20World!"], + ["{+path}/here", "/foo/bar/here"], + ["here?ref={+path}", "here?ref=/foo/bar"], + ["X{#var}", "X#value"], + ["X{#hello}", "X#Hello%20World!"], + ]); +}); + +test("Section 1.2 — Level 3 (Multiple Variables, More Operators)", async (t) => { + await runCases(t, [ + ["map?{x,y}", "map?1024,768"], + ["{x,hello,y}", "1024,Hello%20World%21,768"], + ["{+x,hello,y}", "1024,Hello%20World!,768"], + ["{+path,x}/here", "/foo/bar,1024/here"], + ["{#x,hello,y}", "#1024,Hello%20World!,768"], + ["{#path,x}/here", "#/foo/bar,1024/here"], + ["X{.var}", "X.value"], + ["X{.x,y}", "X.1024.768"], + ["{/var}", "/value"], + ["{/var,x}/here", "/value/1024/here"], + ["{;x,y}", ";x=1024;y=768"], + ["{;x,y,empty}", ";x=1024;y=768;empty"], + ["{?x,y}", "?x=1024&y=768"], + ["{?x,y,empty}", "?x=1024&y=768&empty="], + ["?fixed=yes{&x}", "?fixed=yes&x=1024"], + ["{&x,y,empty}", "&x=1024&y=768&empty="], + ]); +}); + +test("Section 1.2 — Level 4 (Value Modifiers)", async (t) => { + await runCases(t, [ + ["{var:3}", "val"], + ["{var:30}", "value"], + ["{list}", "red,green,blue"], + ["{list*}", "red,green,blue"], + ["{keys}", "semi,%3B,dot,.,comma,%2C"], + ["{keys*}", "semi=%3B,dot=.,comma=%2C"], + ["{+path:6}/here", "/foo/b/here"], + ["{+list}", "red,green,blue"], + ["{+list*}", "red,green,blue"], + ["{+keys}", "semi,;,dot,.,comma,,"], + ["{+keys*}", "semi=;,dot=.,comma=,"], + ["{#path:6}/here", "#/foo/b/here"], + ["{#list}", "#red,green,blue"], + ["{#list*}", "#red,green,blue"], + ["{#keys}", "#semi,;,dot,.,comma,,"], + ["{#keys*}", "#semi=;,dot=.,comma=,"], + ["X{.var:3}", "X.val"], + ["X{.list}", "X.red,green,blue"], + ["X{.list*}", "X.red.green.blue"], + ["X{.keys}", "X.semi,%3B,dot,.,comma,%2C"], + ["X{.keys*}", "X.semi=%3B.dot=..comma=%2C"], + ["{/var:1,var}", "/v/value"], + ["{/list}", "/red,green,blue"], + ["{/list*}", "/red/green/blue"], + ["{/list*,path:4}", "/red/green/blue/%2Ffoo"], + ["{/keys}", "/semi,%3B,dot,.,comma,%2C"], + ["{/keys*}", "/semi=%3B/dot=./comma=%2C"], + ["{;hello:5}", ";hello=Hello"], + ["{;list}", ";list=red,green,blue"], + ["{;list*}", ";list=red;list=green;list=blue"], + ["{;keys}", ";keys=semi,%3B,dot,.,comma,%2C"], + ["{;keys*}", ";semi=%3B;dot=.;comma=%2C"], + ["{?var:3}", "?var=val"], + ["{?list}", "?list=red,green,blue"], + ["{?list*}", "?list=red&list=green&list=blue"], + ["{?keys}", "?keys=semi,%3B,dot,.,comma,%2C"], + ["{?keys*}", "?semi=%3B&dot=.&comma=%2C"], + ["{&var:3}", "&var=val"], + ["{&list}", "&list=red,green,blue"], + ["{&list*}", "&list=red&list=green&list=blue"], + ["{&keys}", "&keys=semi,%3B,dot,.,comma,%2C"], + ["{&keys*}", "&semi=%3B&dot=.&comma=%2C"], + ]); +}); + +test("Section 2.4.1 — Prefix Modifier", async (t) => { + await runCases(t, [ + ["{var}", "value"], + ["{var:20}", "value"], + ["{var:3}", "val"], + ["{semi}", "%3B"], + ["{semi:2}", "%3B"], + ]); +}); + +test("Section 2.4.2 — Composite (Explode) Values", async (t) => { + await runCases(t, [ + ["find{?year*}", "find?year=1965&year=2000&year=2012"], + ["www{.dom*}", "www.example.com"], + ]); +}); + +test("Section 3.2.1 — Variable Expansion (List with Various Operators)", async (t) => { + await runCases(t, [ + ["{count}", "one,two,three"], + ["{count*}", "one,two,three"], + ["{/count}", "/one,two,three"], + ["{/count*}", "/one/two/three"], + ["{;count}", ";count=one,two,three"], + ["{;count*}", ";count=one;count=two;count=three"], + ["{?count}", "?count=one,two,three"], + ["{?count*}", "?count=one&count=two&count=three"], + ["{&count*}", "&count=one&count=two&count=three"], + ]); +}); + +test("Section 3.2.2 — Simple String Expansion: {var}", async (t) => { + await runCases(t, [ + ["{var}", "value"], + ["{hello}", "Hello%20World%21"], + ["{half}", "50%25"], + ["O{empty}X", "OX"], + ["O{undef}X", "OX"], + ["{x,y}", "1024,768"], + ["{x,hello,y}", "1024,Hello%20World%21,768"], + ["?{x,empty}", "?1024,"], + ["?{x,undef}", "?1024"], + ["?{undef,y}", "?768"], + ["{var:3}", "val"], + ["{var:30}", "value"], + ["{list}", "red,green,blue"], + ["{list*}", "red,green,blue"], + ["{keys}", "semi,%3B,dot,.,comma,%2C"], + ["{keys*}", "semi=%3B,dot=.,comma=%2C"], + ]); +}); + +test("Section 3.2.3 — Reserved Expansion: {+var}", async (t) => { + await runCases(t, [ + ["{+var}", "value"], + ["{+hello}", "Hello%20World!"], + ["{+half}", "50%25"], + ["{base}index", "http%3A%2F%2Fexample.com%2Fhome%2Findex"], + ["{+base}index", "http://example.com/home/index"], + ["O{+empty}X", "OX"], + ["O{+undef}X", "OX"], + ["{+path}/here", "/foo/bar/here"], + ["here?ref={+path}", "here?ref=/foo/bar"], + ["up{+path}{var}/here", "up/foo/barvalue/here"], + ["{+x,hello,y}", "1024,Hello%20World!,768"], + ["{+path,x}/here", "/foo/bar,1024/here"], + ["{+path:6}/here", "/foo/b/here"], + ["{+list}", "red,green,blue"], + ["{+list*}", "red,green,blue"], + ["{+keys}", "semi,;,dot,.,comma,,"], + ["{+keys*}", "semi=;,dot=.,comma=,"], + ]); +}); + +test("Section 3.2.4 — Fragment Expansion: {#var}", async (t) => { + await runCases(t, [ + ["{#var}", "#value"], + ["{#hello}", "#Hello%20World!"], + ["{#half}", "#50%25"], + ["foo{#empty}", "foo#"], + ["foo{#undef}", "foo"], + ["{#x,hello,y}", "#1024,Hello%20World!,768"], + ["{#path,x}/here", "#/foo/bar,1024/here"], + ["{#path:6}/here", "#/foo/b/here"], + ["{#list}", "#red,green,blue"], + ["{#list*}", "#red,green,blue"], + ["{#keys}", "#semi,;,dot,.,comma,,"], + ["{#keys*}", "#semi=;,dot=.,comma=,"], + ]); +}); + +test("Section 3.2.5 — Label Expansion with Dot-Prefix: {.var}", async (t) => { + await runCases(t, [ + ["{.who}", ".fred"], + ["{.who,who}", ".fred.fred"], + ["{.half,who}", ".50%25.fred"], + ["www{.dom*}", "www.example.com"], + ["X{.var}", "X.value"], + ["X{.empty}", "X."], + ["X{.undef}", "X"], + ["X{.var:3}", "X.val"], + ["X{.list}", "X.red,green,blue"], + ["X{.list*}", "X.red.green.blue"], + ["X{.keys}", "X.semi,%3B,dot,.,comma,%2C"], + ["X{.keys*}", "X.semi=%3B.dot=..comma=%2C"], + ["X{.empty_keys}", "X"], + ["X{.empty_keys*}", "X"], + ]); +}); + +test("Section 3.2.6 — Path Segment Expansion: {/var}", async (t) => { + await runCases(t, [ + ["{/who}", "/fred"], + ["{/who,who}", "/fred/fred"], + ["{/half,who}", "/50%25/fred"], + ["{/who,dub}", "/fred/me%2Ftoo"], + ["{/var}", "/value"], + ["{/var,empty}", "/value/"], + ["{/var,undef}", "/value"], + ["{/var,x}/here", "/value/1024/here"], + ["{/var:1,var}", "/v/value"], + ["{/list}", "/red,green,blue"], + ["{/list*}", "/red/green/blue"], + ["{/list*,path:4}", "/red/green/blue/%2Ffoo"], + ["{/keys}", "/semi,%3B,dot,.,comma,%2C"], + ["{/keys*}", "/semi=%3B/dot=./comma=%2C"], + ]); +}); + +test("Section 3.2.7 — Path-Style Parameter Expansion: {;var}", async (t) => { + await runCases(t, [ + ["{;who}", ";who=fred"], + ["{;half}", ";half=50%25"], + ["{;empty}", ";empty"], + ["{;v,empty,who}", ";v=6;empty;who=fred"], + ["{;v,bar,who}", ";v=6;who=fred"], + ["{;x,y}", ";x=1024;y=768"], + ["{;x,y,empty}", ";x=1024;y=768;empty"], + ["{;x,y,undef}", ";x=1024;y=768"], + ["{;hello:5}", ";hello=Hello"], + ["{;list}", ";list=red,green,blue"], + ["{;list*}", ";list=red;list=green;list=blue"], + ["{;keys}", ";keys=semi,%3B,dot,.,comma,%2C"], + ["{;keys*}", ";semi=%3B;dot=.;comma=%2C"], + ]); +}); + +test("Section 3.2.8 — Form-Style Query Expansion: {?var}", async (t) => { + await runCases(t, [ + ["{?who}", "?who=fred"], + ["{?half}", "?half=50%25"], + ["{?x,y}", "?x=1024&y=768"], + ["{?x,y,empty}", "?x=1024&y=768&empty="], + ["{?x,y,undef}", "?x=1024&y=768"], + ["{?var:3}", "?var=val"], + ["{?list}", "?list=red,green,blue"], + ["{?list*}", "?list=red&list=green&list=blue"], + ["{?keys}", "?keys=semi,%3B,dot,.,comma,%2C"], + ["{?keys*}", "?semi=%3B&dot=.&comma=%2C"], + ]); +}); + +test("Section 3.2.9 — Form-Style Query Continuation: {&var}", async (t) => { + await runCases(t, [ + ["{&who}", "&who=fred"], + ["{&half}", "&half=50%25"], + ["?fixed=yes{&x}", "?fixed=yes&x=1024"], + ["{&x,y,empty}", "&x=1024&y=768&empty="], + ["{&x,y,undef}", "&x=1024&y=768"], + ["{&var:3}", "&var=val"], + ["{&list}", "&list=red,green,blue"], + ["{&list*}", "&list=red&list=green&list=blue"], + ["{&keys}", "&keys=semi,%3B,dot,.,comma,%2C"], + ["{&keys*}", "&semi=%3B&dot=.&comma=%2C"], + ]); +}); + +test( + "Section 1.1 — Introductory Examples (Form-style with Undef Cases)", + async (t) => { + const template = "http://www.example.com/foo{?query,number}"; + await t.step("query=mycelium, number=100", () => { + equal( + parseTemplate(template).expand({ query: "mycelium", number: 100 }), + "http://www.example.com/foo?query=mycelium&number=100", + ); + }); + await t.step("query undefined, number=100", () => { + equal( + parseTemplate(template).expand({ query: null, number: 100 }), + "http://www.example.com/foo?number=100", + ); + }); + await t.step("query and number both undefined", () => { + equal( + parseTemplate(template).expand({ query: null, number: null }), + "http://www.example.com/foo", + ); + }); + }, +); + +test("Section 2.4.2 — Composite Address Example", () => { + equal( + parseTemplate("/mapper{?address*}").expand({ + address: { city: "Newport Beach", state: "CA" }, + }), + "/mapper?city=Newport%20Beach&state=CA", + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fa32a60c..e3d2c679c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1677,6 +1677,9 @@ importers: packages/uri-template: devDependencies: + '@fedify/fixture': + specifier: workspace:^ + version: link:../fixture '@types/node': specifier: 'catalog:' version: 22.19.1 From 76e7c7562baab7f98e012d62443953174d07ab41 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 1 May 2026 23:19:03 +0000 Subject: [PATCH 04/49] Allow **/*.bench.ts files use `@fedfiy/fixture` --- scripts/check_fixture_usage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_fixture_usage.ts b/scripts/check_fixture_usage.ts index 75496e5eb..7ca5e28ff 100644 --- a/scripts/check_fixture_usage.ts +++ b/scripts/check_fixture_usage.ts @@ -85,7 +85,7 @@ for await ( }) ) { const rel = relative(projectRoot, entry.path); - if (rel.endsWith(".test.ts")) continue; + if (rel.endsWith(".test.ts") || rel.endsWith(".bench.ts")) continue; if (allowed.has(rel)) continue; const content = await Deno.readTextFile(entry.path); From c4c909039ea52faa5fc80eab16a5ac9a7dd0261b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 1 May 2026 23:20:29 +0000 Subject: [PATCH 05/49] Implement RFC 6570 URI template expansion Replace the stub `parseTemplate` exposed by `@fedify/uri-template` with a full RFC 6570 Level 4 expansion implementation, structured as a `Template` class that owns tokenization and delegates expression expansion to a dedicated module. The single `template.ts` is split into a `template/` directory with focused modules: `token` (tokenizer), `expression` (varspec parser), `expand` (expansion algorithm), `encoding` (pct-encoding helpers), and `mod` (the `Template` class). Operator metadata moves into `const.ts` so tokenizer and expander share a single source of truth for the RFC 6570 first/sep/named/ifemp/allow table. Introduce a typed error hierarchy in `errors.ts` covering both parse and expansion diagnostics, plus a `report`/`strict` option pair on `TemplateOptions` so callers can collect every diagnostic in one pass instead of failing on the first issue. Broaden `ExpandContext` to allow `undefined` and `readonly` arrays, and split it into `PrimitiveValue`, `AssociativeValue`, and `ExpandValue` so the expansion contract matches RFC 6570's primitive /list/associative trichotomy. Re-export the new error classes, `Token`, `VarSpec`, `Operator`, `Reporter`, and `TemplateOptions` from `mod.ts`. Add a `test:bun` script and a package-local `cspell.json` for the new identifiers (`varspec`, `varname`, etc.). `summary.txt` keeps an authoritative excerpt of the RFC 6570 grammar and expansion table next to the implementation for reference. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/cspell.json | 9 + packages/uri-template/package.json | 1 + packages/uri-template/src/const.ts | 72 ++++ packages/uri-template/src/constants.ts | 1 - packages/uri-template/src/errors.ts | 364 ++++++++++++++++++ packages/uri-template/src/mod.ts | 30 +- packages/uri-template/src/template.test.ts | 337 ---------------- packages/uri-template/src/template.ts | 21 - .../uri-template/src/template/encoding.ts | 191 +++++++++ packages/uri-template/src/template/expand.ts | 172 +++++++++ .../uri-template/src/template/expression.ts | 187 +++++++++ packages/uri-template/src/template/mod.ts | 81 ++++ packages/uri-template/src/template/token.ts | 83 ++++ packages/uri-template/src/types.ts | 86 ++++- packages/uri-template/summary.txt | 188 +++++++++ 15 files changed, 1446 insertions(+), 377 deletions(-) create mode 100644 packages/uri-template/cspell.json create mode 100644 packages/uri-template/src/const.ts delete mode 100644 packages/uri-template/src/constants.ts create mode 100644 packages/uri-template/src/errors.ts delete mode 100644 packages/uri-template/src/template.test.ts delete mode 100644 packages/uri-template/src/template.ts create mode 100644 packages/uri-template/src/template/encoding.ts create mode 100644 packages/uri-template/src/template/expand.ts create mode 100644 packages/uri-template/src/template/expression.ts create mode 100644 packages/uri-template/src/template/mod.ts create mode 100644 packages/uri-template/src/template/token.ts create mode 100644 packages/uri-template/summary.txt diff --git a/packages/uri-template/cspell.json b/packages/uri-template/cspell.json new file mode 100644 index 000000000..4cd64f2ed --- /dev/null +++ b/packages/uri-template/cspell.json @@ -0,0 +1,9 @@ +{ + "words": [ + "varspec", + "varname", + "varnames", + "varchar", + "varchars" + ] +} diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json index 5107b6988..11653ff3e 100644 --- a/packages/uri-template/package.json +++ b/packages/uri-template/package.json @@ -42,6 +42,7 @@ "build": "pnpm --filter @fedify/uri-template... run build:self", "prepack": "pnpm build", "prepublish": "pnpm build", + "test:bun": "bun test", "test": "node --experimental-transform-types --test" }, "keywords": [ diff --git a/packages/uri-template/src/const.ts b/packages/uri-template/src/const.ts new file mode 100644 index 000000000..2452261d5 --- /dev/null +++ b/packages/uri-template/src/const.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ +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 }, +}; + +/** + * Placeholder message used by modules whose behavior is still unimplemented. + */ +export const NOT_IMPLEMENTED = "@fedify/uri-template is not implemented yet"; diff --git a/packages/uri-template/src/constants.ts b/packages/uri-template/src/constants.ts deleted file mode 100644 index 8525f7941..000000000 --- a/packages/uri-template/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const NOT_IMPLEMENTED = "@fedify/uri-template is not implemented yet"; diff --git a/packages/uri-template/src/errors.ts b/packages/uri-template/src/errors.ts new file mode 100644 index 000000000..0a8de702c --- /dev/null +++ b/packages/uri-template/src/errors.ts @@ -0,0 +1,364 @@ +/** + * 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"; + } + throw(): never { + throw this; + } +} + +/** + * Raised when an opening `{` has no matching `}` before the template ends. + * + * Fix: close the expression with `}` or escape the literal `{` (RFC 6570 + * does not define an escape; remove the stray brace). + */ +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/mod.ts b/packages/uri-template/src/mod.ts index ea931b81b..9b36c9ebd 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -2,19 +2,43 @@ * Symmetric [RFC 6570] URI * Template expansion and pattern matching. * - * [RFC 6570]: https://www.rfc-editor.org/rfc/rfc6570.html + * [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 * * @module */ +export { + EmptyExpressionError, + EmptyVarNameError, + InvalidLiteralError, + InvalidPrefixError, + InvalidVarNameError, + InvalidVarSpecError, + NestedOpeningBraceError, + PrefixModifierNotApplicableError, + ReservedOperatorError, + StrayClosingBraceError, + TemplateExpansionError, + TemplateParseError, + TrailingCommaError, + UnclosedExpressionError, + UnexpectedCharacterError, + UnknownOperatorError, +} from "./errors.ts"; export { Router } from "./router.ts"; -export { parseTemplate } from "./template.ts"; +export { default as Template } from "./template/mod.ts"; export type { + AssociativeValue, ExpandContext, + ExpandValue, HierarchyNode, + Operator, PrimitiveValue, + Reporter, Result, Route, - Template, + TemplateOptions, + Token, VariableSpec, + VarSpec, } from "./types.ts"; diff --git a/packages/uri-template/src/template.test.ts b/packages/uri-template/src/template.test.ts deleted file mode 100644 index 755ced3da..000000000 --- a/packages/uri-template/src/template.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { test } from "@fedify/fixture"; -import { equal } from "node:assert/strict"; -import { parseTemplate } from "./template.ts"; -import type { ExpandContext } from "./types.ts"; - -const vars: ExpandContext = { - count: ["one", "two", "three"], - dom: ["example", "com"], - dub: "me/too", - hello: "Hello World!", - half: "50%", - var: "value", - who: "fred", - base: "http://example.com/home/", - path: "/foo/bar", - list: ["red", "green", "blue"], - keys: { semi: ";", dot: ".", comma: "," }, - v: "6", - x: "1024", - y: "768", - empty: "", - empty_keys: {}, - undef: null, - semi: ";", - year: ["1965", "2000", "2012"], -}; - -type Case = readonly [template: string, expanded: string]; - -async function runCases( - t: Deno.TestContext, - cases: readonly Case[], - context: ExpandContext = vars, -): Promise { - for (const [template, expected] of cases) { - await t.step(`${template} => ${expected}`, () => { - equal(parseTemplate(template).expand(context), expected); - }); - } -} - -test("Section 1.2 — Level 1 (Simple String Expansion)", async (t) => { - await runCases(t, [ - ["{var}", "value"], - ["{hello}", "Hello%20World%21"], - ]); -}); - -test("Section 1.2 — Level 2 (Reserved + Fragment Expansion)", async (t) => { - await runCases(t, [ - ["{+var}", "value"], - ["{+hello}", "Hello%20World!"], - ["{+path}/here", "/foo/bar/here"], - ["here?ref={+path}", "here?ref=/foo/bar"], - ["X{#var}", "X#value"], - ["X{#hello}", "X#Hello%20World!"], - ]); -}); - -test("Section 1.2 — Level 3 (Multiple Variables, More Operators)", async (t) => { - await runCases(t, [ - ["map?{x,y}", "map?1024,768"], - ["{x,hello,y}", "1024,Hello%20World%21,768"], - ["{+x,hello,y}", "1024,Hello%20World!,768"], - ["{+path,x}/here", "/foo/bar,1024/here"], - ["{#x,hello,y}", "#1024,Hello%20World!,768"], - ["{#path,x}/here", "#/foo/bar,1024/here"], - ["X{.var}", "X.value"], - ["X{.x,y}", "X.1024.768"], - ["{/var}", "/value"], - ["{/var,x}/here", "/value/1024/here"], - ["{;x,y}", ";x=1024;y=768"], - ["{;x,y,empty}", ";x=1024;y=768;empty"], - ["{?x,y}", "?x=1024&y=768"], - ["{?x,y,empty}", "?x=1024&y=768&empty="], - ["?fixed=yes{&x}", "?fixed=yes&x=1024"], - ["{&x,y,empty}", "&x=1024&y=768&empty="], - ]); -}); - -test("Section 1.2 — Level 4 (Value Modifiers)", async (t) => { - await runCases(t, [ - ["{var:3}", "val"], - ["{var:30}", "value"], - ["{list}", "red,green,blue"], - ["{list*}", "red,green,blue"], - ["{keys}", "semi,%3B,dot,.,comma,%2C"], - ["{keys*}", "semi=%3B,dot=.,comma=%2C"], - ["{+path:6}/here", "/foo/b/here"], - ["{+list}", "red,green,blue"], - ["{+list*}", "red,green,blue"], - ["{+keys}", "semi,;,dot,.,comma,,"], - ["{+keys*}", "semi=;,dot=.,comma=,"], - ["{#path:6}/here", "#/foo/b/here"], - ["{#list}", "#red,green,blue"], - ["{#list*}", "#red,green,blue"], - ["{#keys}", "#semi,;,dot,.,comma,,"], - ["{#keys*}", "#semi=;,dot=.,comma=,"], - ["X{.var:3}", "X.val"], - ["X{.list}", "X.red,green,blue"], - ["X{.list*}", "X.red.green.blue"], - ["X{.keys}", "X.semi,%3B,dot,.,comma,%2C"], - ["X{.keys*}", "X.semi=%3B.dot=..comma=%2C"], - ["{/var:1,var}", "/v/value"], - ["{/list}", "/red,green,blue"], - ["{/list*}", "/red/green/blue"], - ["{/list*,path:4}", "/red/green/blue/%2Ffoo"], - ["{/keys}", "/semi,%3B,dot,.,comma,%2C"], - ["{/keys*}", "/semi=%3B/dot=./comma=%2C"], - ["{;hello:5}", ";hello=Hello"], - ["{;list}", ";list=red,green,blue"], - ["{;list*}", ";list=red;list=green;list=blue"], - ["{;keys}", ";keys=semi,%3B,dot,.,comma,%2C"], - ["{;keys*}", ";semi=%3B;dot=.;comma=%2C"], - ["{?var:3}", "?var=val"], - ["{?list}", "?list=red,green,blue"], - ["{?list*}", "?list=red&list=green&list=blue"], - ["{?keys}", "?keys=semi,%3B,dot,.,comma,%2C"], - ["{?keys*}", "?semi=%3B&dot=.&comma=%2C"], - ["{&var:3}", "&var=val"], - ["{&list}", "&list=red,green,blue"], - ["{&list*}", "&list=red&list=green&list=blue"], - ["{&keys}", "&keys=semi,%3B,dot,.,comma,%2C"], - ["{&keys*}", "&semi=%3B&dot=.&comma=%2C"], - ]); -}); - -test("Section 2.4.1 — Prefix Modifier", async (t) => { - await runCases(t, [ - ["{var}", "value"], - ["{var:20}", "value"], - ["{var:3}", "val"], - ["{semi}", "%3B"], - ["{semi:2}", "%3B"], - ]); -}); - -test("Section 2.4.2 — Composite (Explode) Values", async (t) => { - await runCases(t, [ - ["find{?year*}", "find?year=1965&year=2000&year=2012"], - ["www{.dom*}", "www.example.com"], - ]); -}); - -test("Section 3.2.1 — Variable Expansion (List with Various Operators)", async (t) => { - await runCases(t, [ - ["{count}", "one,two,three"], - ["{count*}", "one,two,three"], - ["{/count}", "/one,two,three"], - ["{/count*}", "/one/two/three"], - ["{;count}", ";count=one,two,three"], - ["{;count*}", ";count=one;count=two;count=three"], - ["{?count}", "?count=one,two,three"], - ["{?count*}", "?count=one&count=two&count=three"], - ["{&count*}", "&count=one&count=two&count=three"], - ]); -}); - -test("Section 3.2.2 — Simple String Expansion: {var}", async (t) => { - await runCases(t, [ - ["{var}", "value"], - ["{hello}", "Hello%20World%21"], - ["{half}", "50%25"], - ["O{empty}X", "OX"], - ["O{undef}X", "OX"], - ["{x,y}", "1024,768"], - ["{x,hello,y}", "1024,Hello%20World%21,768"], - ["?{x,empty}", "?1024,"], - ["?{x,undef}", "?1024"], - ["?{undef,y}", "?768"], - ["{var:3}", "val"], - ["{var:30}", "value"], - ["{list}", "red,green,blue"], - ["{list*}", "red,green,blue"], - ["{keys}", "semi,%3B,dot,.,comma,%2C"], - ["{keys*}", "semi=%3B,dot=.,comma=%2C"], - ]); -}); - -test("Section 3.2.3 — Reserved Expansion: {+var}", async (t) => { - await runCases(t, [ - ["{+var}", "value"], - ["{+hello}", "Hello%20World!"], - ["{+half}", "50%25"], - ["{base}index", "http%3A%2F%2Fexample.com%2Fhome%2Findex"], - ["{+base}index", "http://example.com/home/index"], - ["O{+empty}X", "OX"], - ["O{+undef}X", "OX"], - ["{+path}/here", "/foo/bar/here"], - ["here?ref={+path}", "here?ref=/foo/bar"], - ["up{+path}{var}/here", "up/foo/barvalue/here"], - ["{+x,hello,y}", "1024,Hello%20World!,768"], - ["{+path,x}/here", "/foo/bar,1024/here"], - ["{+path:6}/here", "/foo/b/here"], - ["{+list}", "red,green,blue"], - ["{+list*}", "red,green,blue"], - ["{+keys}", "semi,;,dot,.,comma,,"], - ["{+keys*}", "semi=;,dot=.,comma=,"], - ]); -}); - -test("Section 3.2.4 — Fragment Expansion: {#var}", async (t) => { - await runCases(t, [ - ["{#var}", "#value"], - ["{#hello}", "#Hello%20World!"], - ["{#half}", "#50%25"], - ["foo{#empty}", "foo#"], - ["foo{#undef}", "foo"], - ["{#x,hello,y}", "#1024,Hello%20World!,768"], - ["{#path,x}/here", "#/foo/bar,1024/here"], - ["{#path:6}/here", "#/foo/b/here"], - ["{#list}", "#red,green,blue"], - ["{#list*}", "#red,green,blue"], - ["{#keys}", "#semi,;,dot,.,comma,,"], - ["{#keys*}", "#semi=;,dot=.,comma=,"], - ]); -}); - -test("Section 3.2.5 — Label Expansion with Dot-Prefix: {.var}", async (t) => { - await runCases(t, [ - ["{.who}", ".fred"], - ["{.who,who}", ".fred.fred"], - ["{.half,who}", ".50%25.fred"], - ["www{.dom*}", "www.example.com"], - ["X{.var}", "X.value"], - ["X{.empty}", "X."], - ["X{.undef}", "X"], - ["X{.var:3}", "X.val"], - ["X{.list}", "X.red,green,blue"], - ["X{.list*}", "X.red.green.blue"], - ["X{.keys}", "X.semi,%3B,dot,.,comma,%2C"], - ["X{.keys*}", "X.semi=%3B.dot=..comma=%2C"], - ["X{.empty_keys}", "X"], - ["X{.empty_keys*}", "X"], - ]); -}); - -test("Section 3.2.6 — Path Segment Expansion: {/var}", async (t) => { - await runCases(t, [ - ["{/who}", "/fred"], - ["{/who,who}", "/fred/fred"], - ["{/half,who}", "/50%25/fred"], - ["{/who,dub}", "/fred/me%2Ftoo"], - ["{/var}", "/value"], - ["{/var,empty}", "/value/"], - ["{/var,undef}", "/value"], - ["{/var,x}/here", "/value/1024/here"], - ["{/var:1,var}", "/v/value"], - ["{/list}", "/red,green,blue"], - ["{/list*}", "/red/green/blue"], - ["{/list*,path:4}", "/red/green/blue/%2Ffoo"], - ["{/keys}", "/semi,%3B,dot,.,comma,%2C"], - ["{/keys*}", "/semi=%3B/dot=./comma=%2C"], - ]); -}); - -test("Section 3.2.7 — Path-Style Parameter Expansion: {;var}", async (t) => { - await runCases(t, [ - ["{;who}", ";who=fred"], - ["{;half}", ";half=50%25"], - ["{;empty}", ";empty"], - ["{;v,empty,who}", ";v=6;empty;who=fred"], - ["{;v,bar,who}", ";v=6;who=fred"], - ["{;x,y}", ";x=1024;y=768"], - ["{;x,y,empty}", ";x=1024;y=768;empty"], - ["{;x,y,undef}", ";x=1024;y=768"], - ["{;hello:5}", ";hello=Hello"], - ["{;list}", ";list=red,green,blue"], - ["{;list*}", ";list=red;list=green;list=blue"], - ["{;keys}", ";keys=semi,%3B,dot,.,comma,%2C"], - ["{;keys*}", ";semi=%3B;dot=.;comma=%2C"], - ]); -}); - -test("Section 3.2.8 — Form-Style Query Expansion: {?var}", async (t) => { - await runCases(t, [ - ["{?who}", "?who=fred"], - ["{?half}", "?half=50%25"], - ["{?x,y}", "?x=1024&y=768"], - ["{?x,y,empty}", "?x=1024&y=768&empty="], - ["{?x,y,undef}", "?x=1024&y=768"], - ["{?var:3}", "?var=val"], - ["{?list}", "?list=red,green,blue"], - ["{?list*}", "?list=red&list=green&list=blue"], - ["{?keys}", "?keys=semi,%3B,dot,.,comma,%2C"], - ["{?keys*}", "?semi=%3B&dot=.&comma=%2C"], - ]); -}); - -test("Section 3.2.9 — Form-Style Query Continuation: {&var}", async (t) => { - await runCases(t, [ - ["{&who}", "&who=fred"], - ["{&half}", "&half=50%25"], - ["?fixed=yes{&x}", "?fixed=yes&x=1024"], - ["{&x,y,empty}", "&x=1024&y=768&empty="], - ["{&x,y,undef}", "&x=1024&y=768"], - ["{&var:3}", "&var=val"], - ["{&list}", "&list=red,green,blue"], - ["{&list*}", "&list=red&list=green&list=blue"], - ["{&keys}", "&keys=semi,%3B,dot,.,comma,%2C"], - ["{&keys*}", "&semi=%3B&dot=.&comma=%2C"], - ]); -}); - -test( - "Section 1.1 — Introductory Examples (Form-style with Undef Cases)", - async (t) => { - const template = "http://www.example.com/foo{?query,number}"; - await t.step("query=mycelium, number=100", () => { - equal( - parseTemplate(template).expand({ query: "mycelium", number: 100 }), - "http://www.example.com/foo?query=mycelium&number=100", - ); - }); - await t.step("query undefined, number=100", () => { - equal( - parseTemplate(template).expand({ query: null, number: 100 }), - "http://www.example.com/foo?number=100", - ); - }); - await t.step("query and number both undefined", () => { - equal( - parseTemplate(template).expand({ query: null, number: null }), - "http://www.example.com/foo", - ); - }); - }, -); - -test("Section 2.4.2 — Composite Address Example", () => { - equal( - parseTemplate("/mapper{?address*}").expand({ - address: { city: "Newport Beach", state: "CA" }, - }), - "/mapper?city=Newport%20Beach&state=CA", - ); -}); diff --git a/packages/uri-template/src/template.ts b/packages/uri-template/src/template.ts deleted file mode 100644 index 60b01103a..000000000 --- a/packages/uri-template/src/template.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NOT_IMPLEMENTED } from "./constants.ts"; -import type { ExpandContext, Template } from "./types.ts"; - -/** - * Parses an RFC 6570 URI template string into a {@link Template}. - */ -export function parseTemplate(template: string): Template { - return new TemplateImpl(template); -} - -class TemplateImpl implements Template { - readonly source: string; - - constructor(source: string) { - this.source = source; - } - - expand(_context: ExpandContext): string { - throw new Error(NOT_IMPLEMENTED); - } -} diff --git a/packages/uri-template/src/template/encoding.ts b/packages/uri-template/src/template/encoding.ts new file mode 100644 index 000000000..b4ca49077 --- /dev/null +++ b/packages/uri-template/src/template/encoding.ts @@ -0,0 +1,191 @@ +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 function isHexDigit(char: string): boolean { + const code = char.charCodeAt(0); + return ( + code >= 0x30 && code <= 0x39 || + code >= 0x41 && code <= 0x46 || + code >= 0x61 && code <= 0x66 + ); +} + +/** + * Returns whether `value[index]` starts a complete pct-encoded triplet. + */ +export function isPctEncodedAt(value: string, index: number): boolean { + return 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 (isAlpha(char) || isDigit(char) || 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 function encodeValue(value: string, allowReserved: boolean): 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 += isUnreserved(char) || allowReserved && isReserved(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; +} + +function isAlpha(char: string): boolean { + const code = char.charCodeAt(0); + return code >= 0x41 && code <= 0x5a || code >= 0x61 && code <= 0x7a; +} + +function isDigit(char: string): boolean { + const code = char.charCodeAt(0); + return code >= 0x30 && code <= 0x39; +} + +function isUnreserved(char: string): boolean { + return isAlpha(char) || isDigit(char) || + char === "-" || char === "." || char === "_" || char === "~"; +} + +function isReserved(char: string): boolean { + return ":/?#[]@!$&'()*+,;=".includes(char); +} + +function isLiteralChar(char: string): boolean { + const code = char.codePointAt(0); + if (code == null) return false; + return code === 0x21 || + code >= 0x23 && code <= 0x24 || + code === 0x26 || + code >= 0x28 && code <= 0x3b || + code === 0x3d || + code >= 0x3f && code <= 0x5b || + code === 0x5d || + code === 0x5f || + code >= 0x61 && code <= 0x7a || + code === 0x7e || + isUcsChar(code) || + isIPrivate(code); +} + +function isUcsChar(code: number): boolean { + return code >= 0xa0 && code <= 0xd7ff || + code >= 0xf900 && code <= 0xfdcf || + code >= 0xfdf0 && code <= 0xffef || + code >= 0x10000 && code <= 0x1fffd || + code >= 0x20000 && code <= 0x2fffd || + code >= 0x30000 && code <= 0x3fffd || + code >= 0x40000 && code <= 0x4fffd || + code >= 0x50000 && code <= 0x5fffd || + code >= 0x60000 && code <= 0x6fffd || + code >= 0x70000 && code <= 0x7fffd || + code >= 0x80000 && code <= 0x8fffd || + code >= 0x90000 && code <= 0x9fffd || + code >= 0xa0000 && code <= 0xafffd || + code >= 0xb0000 && code <= 0xbfffd || + code >= 0xc0000 && code <= 0xcfffd || + code >= 0xd0000 && code <= 0xdfffd || + code >= 0xe1000 && code <= 0xefffd; +} + +function isIPrivate(code: number): boolean { + return code >= 0xe000 && code <= 0xf8ff || + code >= 0xf0000 && code <= 0xffffd || + code >= 0x100000 && code <= 0x10fffd; +} + +function percentEncode(char: string): string { + return Array.from(textEncoder.encode(char)) + .map((byte) => `%${hexDigits[byte >> 4]}${hexDigits[byte & 0x0f]}`) + .join(""); +} diff --git a/packages/uri-template/src/template/expand.ts b/packages/uri-template/src/template/expand.ts new file mode 100644 index 000000000..4bc73e1aa --- /dev/null +++ b/packages/uri-template/src/template/expand.ts @@ -0,0 +1,172 @@ +import { operatorSpecs } from "../const.ts"; +import { PrefixModifierNotApplicableError } from "../errors.ts"; +import type { + AssociativeValue, + ExpandContext, + PrimitiveValue, + VarSpec, +} from "../types.ts"; +import { encodeName, encodeValue, truncateValue } from "./encoding.ts"; + +/** + * Expands one parsed URI Template expression against the supplied variable + * context using the operator behavior table from RFC 6570. + */ +export default function expand( + vars: VarSpec[], + operator: keyof typeof operatorSpecs, + context: ExpandContext, +): string { + const spec = operatorSpecs[operator]; + const parts = vars.flatMap((varSpec) => + expandValue(varSpec, context[varSpec.name], spec) + ); + return parts.length < 1 ? "" : `${spec.first}${parts.join(spec.sep)}`; +} + +function expandValue( + varSpec: VarSpec, + value: ExpandContext[string], + spec: typeof operatorSpecs[keyof typeof operatorSpecs], +): string[] { + if (isUndefined(value)) return []; + if (isList(value)) { + const encoded = encodeListMembers(value, spec.allowReserved); + if (encoded.length < 1) return []; + assertNoPrefixModifier(varSpec, "list"); + return expandList(varSpec, encoded, spec); + } + if (isAssociative(value)) { + const pairs = encodeAssociativePairs(value, spec.allowReserved); + if (pairs.length < 1) return []; + assertNoPrefixModifier(varSpec, "associative"); + return expandAssociative(varSpec, pairs, spec); + } + return expandPrimitive(varSpec, value, spec); +} + +function expandPrimitive( + varSpec: VarSpec, + value: Exclude, + spec: typeof operatorSpecs[keyof typeof operatorSpecs], +): string[] { + const text = String(value); + const prefixed = varSpec.prefix == null + ? text + : truncateValue(text, varSpec.prefix); + const encoded = encodeValue(prefixed, spec.allowReserved); + if (!spec.named) return [encoded]; + + const name = encodeName(varSpec.name); + return [encoded === "" ? `${name}${spec.ifEmpty}` : `${name}=${encoded}`]; +} + +function expandList( + varSpec: VarSpec, + encoded: readonly string[], + spec: typeof operatorSpecs[keyof typeof operatorSpecs], +): string[] { + const name = encodeName(varSpec.name); + if (varSpec.explode) { + return spec.named + ? encoded.map((item) => + item === "" ? `${name}${spec.ifEmpty}` : `${name}=${item}` + ) + : [...encoded]; + } + + const joined = encoded.join(","); + return spec.named + ? [joined === "" ? `${name}${spec.ifEmpty}` : `${name}=${joined}`] + : [joined]; +} + +function expandAssociative( + varSpec: VarSpec, + pairs: readonly (readonly [key: string, value: string])[], + spec: typeof operatorSpecs[keyof typeof operatorSpecs], +): string[] { + if (varSpec.explode) { + return pairs.map(([key, item]) => { + return item === "" ? `${key}${spec.ifEmpty}` : `${key}=${item}`; + }); + } + + const joined = pairs.flatMap(([key, item]) => [ + key, + item, + ]).join(","); + + if (!spec.named) return [joined]; + + const name = encodeName(varSpec.name); + return [joined === "" ? `${name}${spec.ifEmpty}` : `${name}=${joined}`]; +} + +function encodeListMembers( + value: readonly PrimitiveValue[], + allowReserved: boolean, +): string[] { + return value + .filter((item): item is Exclude => + !isUndefined(item) + ) + .map((item) => encodeValue(String(item), allowReserved)); +} + +function encodeAssociativePairs( + value: AssociativeValue, + allowReserved: boolean, +): (readonly [key: string, value: string])[] { + return Object.entries(value).flatMap(([key, item]) => { + const normalized = normalizePairValue(item); + return normalized == null ? [] : [ + [ + encodeValue(key, allowReserved), + encodeValue(normalized, allowReserved), + ] as const, + ]; + }); +} + +function normalizePairValue( + value: PrimitiveValue | readonly PrimitiveValue[], +): string | undefined { + if (isUndefined(value)) return undefined; + if (!Array.isArray(value)) return String(value); + + const items = value + .filter((item): item is Exclude => + !isUndefined(item) + ) + .map(String); + return items.length < 1 ? undefined : items.join(","); +} + +function isAssociative( + value: ExpandContext[string], +): value is AssociativeValue { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isList( + value: ExpandContext[string], +): value is readonly PrimitiveValue[] { + return Array.isArray(value); +} + +function isUndefined(value: unknown): value is null | undefined { + return value == null; +} + +function assertNoPrefixModifier( + varSpec: VarSpec, + valueType: "list" | "associative", +): void { + if (varSpec.prefix == null) return; + throw new PrefixModifierNotApplicableError( + varSpec.name, + varSpec.prefix, + valueType, + ); +} diff --git a/packages/uri-template/src/template/expression.ts b/packages/uri-template/src/template/expression.ts new file mode 100644 index 000000000..84d1807b3 --- /dev/null +++ b/packages/uri-template/src/template/expression.ts @@ -0,0 +1,187 @@ +import { OPERATORS } from "../const.ts"; +import { + EmptyExpressionError, + EmptyVarNameError, + InvalidPrefixError, + InvalidVarNameError, + ReservedOperatorError, + TrailingCommaError, + UnexpectedCharacterError, + UnknownOperatorError, +} from "../errors.ts"; +import type { Operator, TemplateOptions, Token, VarSpec } from "../types.ts"; +import { isVarcharAt } from "./encoding.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, + { report }: TemplateOptions, +): Token { + const reportExpressionError = (error: Error): Token => { + report(error); + return { + kind: "literal", + text: template.slice(position, position + source.length + 2), + }; + }; + + if (source.length < 1) { + return reportExpressionError(new EmptyExpressionError(template, position)); + } + + const first = source[0]; + if (isReservedOperator(first)) { + return reportExpressionError( + new ReservedOperatorError(template, position + 1, first), + ); + } + if (!isOperator(first) && isVarcharAt(source, 0) < 1) { + return reportExpressionError( + 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/mod.ts b/packages/uri-template/src/template/mod.ts new file mode 100644 index 000000000..10011445e --- /dev/null +++ b/packages/uri-template/src/template/mod.ts @@ -0,0 +1,81 @@ +import type { + ExpandContext, + Reporter, + TemplateOptions, + Token, +} from "../types.ts"; +import expand from "./expand.ts"; +import tokenize from "./token.ts"; + +/** + * Parsed RFC 6570 URI Template that can be expanded repeatedly. + * + * This class owns tokenization and delegates expression expansion to the + * expansion module. + */ +export default class Template { + readonly #tokens: Token[]; + readonly fullOptions: TemplateOptions; + + constructor( + /** + * URI template string to parse. See [RFC 6570] for syntax details. + * + * [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 + */ + public readonly uriTemplate: string, + /** + * Options for parsing the template. By default, `strict` is `true` and + * `report` ignores parse errors. If `strict` is `true`, the first error + * encountered while parsing will be automatically thrown after being + * reported. If `strict` is `false`, errors will be reported but none will + * be thrown unless the `report` function itself throws. + */ + readonly options: Partial = {}, + ) { + this.fullOptions = fillOptions(options); + this.#tokens = tokenize(uriTemplate, this.fullOptions); + } + + /** + * Parses a URI Template using default strict parsing options. + */ + static parse(uriTemplate: string): Template { + return new Template(uriTemplate); + } + + /** + * Parsed token stream for diagnostics and router integration. + */ + get tokens(): readonly Token[] { + return this.#tokens; + } + + #expand(context: ExpandContext): string { + return this.#tokens.map((token) => + token.kind === "literal" + ? token.text + : expand(token.vars, token.operator, context) + ).join(""); + } + /** + * Expands this template against a variable context. + */ + expand: (context: ExpandContext) => string = this.#expand.bind(this); +} + +const defaultReporter = (_error: Error): void => {}; + +const fillOptions = ( + { strict, report }: Partial, +): TemplateOptions => { + report ??= defaultReporter; + strict ??= true; + report = strict ? strictWrapper(report) : report; + return { strict, report }; +}; + +const strictWrapper = (reporter: Reporter) => (error: Error): never => { + reporter(error); + throw error; +}; diff --git a/packages/uri-template/src/template/token.ts b/packages/uri-template/src/template/token.ts new file mode 100644 index 000000000..12b8c25f4 --- /dev/null +++ b/packages/uri-template/src/template/token.ts @@ -0,0 +1,83 @@ +import { + InvalidLiteralError, + NestedOpeningBraceError, + StrayClosingBraceError, + UnclosedExpressionError, +} from "../errors.ts"; +import type { TemplateOptions, Token } from "../types.ts"; +import { isLiteralAt, readCodePoint } from "./encoding.ts"; +import parseExpression from "./expression.ts"; + +/** + * Splits a URI Template source string into literal and expression tokens. + * + * This module validates RFC 6570 literal syntax and delegates expression + * parsing to the expression parser. + */ +export default function tokenize( + template: string, + options: TemplateOptions, +): Token[] { + const { report } = options; + const tokens: Token[] = []; + const appendLiteral = (text: string): void => { + const previous = tokens.at(-1); + if (previous?.kind === "literal") { + previous.text += text; + } else { + tokens.push({ kind: "literal", text }); + } + }; + + for (let index = 0; index < template.length;) { + const char = template[index]; + + if (char === "{") { + const closeIndex = template.indexOf("}", index + 1); + if (closeIndex < 0) { + report(new UnclosedExpressionError(template, index)); + appendLiteral(template.slice(index)); + break; + } + + const nestedIndex = template.indexOf("{", index + 1); + if (nestedIndex >= 0 && nestedIndex < closeIndex) { + report(new NestedOpeningBraceError(template, nestedIndex)); + appendLiteral(template.slice(index, closeIndex + 1)); + index = closeIndex + 1; + continue; + } + + const expression = template.slice(index + 1, closeIndex); + try { + tokens.push(parseExpression(expression, template, index, options)); + } catch (error) { + report(error instanceof Error ? error : new Error(String(error))); + appendLiteral(template.slice(index, closeIndex + 1)); + } + index = closeIndex + 1; + continue; + } + + if (char === "}") { + report(new StrayClosingBraceError(template, index)); + appendLiteral(char); + index++; + continue; + } + + const literalLength = isLiteralAt(template, index); + if (literalLength > 0) { + appendLiteral(template.slice(index, index + literalLength)); + index += literalLength; + continue; + } + + const { char: invalidChar, size } = readCodePoint(template, index); + report(new InvalidLiteralError(template, index, invalidChar)); + appendLiteral(invalidChar); + index += size; + } + + return tokens; +} diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index fe28e9552..f31942d90 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -1,29 +1,35 @@ +export type { Operator, OperatorSpec } from "./const.ts"; +import type { Operator } from "./const.ts"; + /** * Primitive value accepted by {@link Template.expand}. */ -export type PrimitiveValue = string | number | boolean | null; +export type PrimitiveValue = string | number | boolean | null | undefined; /** - * Context object accepted by {@link Template.expand}. Each variable resolves - * to a primitive, an ordered list of primitives, or an associative map. + * Associative composite value accepted by {@link Template.expand}. + * + * Keys are expanded as URI Template associative names. Values may be primitive + * values or primitive lists. */ -export type ExpandContext = Record< +export type AssociativeValue = Record< string, - | PrimitiveValue - | PrimitiveValue[] - | Record + PrimitiveValue | readonly PrimitiveValue[] >; /** - * Compiled URI template that can be expanded against an {@link ExpandContext}. + * Any value shape accepted for one template variable during expansion. */ -export interface Template { - /** - * Expands the template against the supplied context, returning the resolved - * URI string. - */ - expand(context: ExpandContext): string; -} +export type ExpandValue = + | PrimitiveValue + | readonly PrimitiveValue[] + | AssociativeValue; + +/** + * Context object accepted by {@link Template.expand}. Each variable resolves + * to a primitive, an ordered list of primitives, or an associative map. + */ +export type ExpandContext = Record; /** * Variable specification produced when a template is added to a {@link Router}. @@ -61,3 +67,53 @@ export interface Result { uri: string; uriTemplate: string; } + +/** + * Parsed RFC 6570 variable specification inside an expression. + * + * Produced by the expression parser and consumed by the expansion module. + */ +export interface VarSpec { + /** Variable name to look up in the expansion context. */ + name: string; + /** Whether the varspec uses the Level 4 explode modifier (`*`). */ + explode: boolean; + /** Prefix length from a Level 4 prefix modifier (`:N`), if present. */ + prefix?: number; +} + +/** + * Token produced by parsing a URI Template. + * + * Literal tokens are copied directly. Expression tokens are expanded with a + * context object. + */ +export type Token = + | { kind: "literal"; text: string } + | { kind: "expression"; operator: Operator; vars: VarSpec[] }; + +/** + * Options controlling URI Template parsing diagnostics. + */ +export interface TemplateOptions { + /** + * If `true`, the first error in the template will be automatically thrown + * while parsing after being reported. `true` is the default value. + * If `false`, all errors will be reported to by the `report` function, + * but none will be thrown unless the `report` function itself throws. + */ + strict: boolean; + /** + * A function that will be called with any errors encountered while parsing. + * By default, errors are ignored. In strict mode, they are still thrown + * after this reporter runs. + * @param error The error that was encountered while parsing the template. + * @returns The result of the report function. + */ + report: Reporter; +} + +/** + * Callback used by the parser to report recoverable parse diagnostics. + */ +export type Reporter = (error: Error) => void; diff --git a/packages/uri-template/summary.txt b/packages/uri-template/summary.txt new file mode 100644 index 000000000..b8ce0ed8e --- /dev/null +++ b/packages/uri-template/summary.txt @@ -0,0 +1,188 @@ +This is a summary of the URI Template specification. +The full specification is https://datatracker.ietf.org/doc/html/rfc6570. +If there are any discrepancies between this summary and the full specification, +the full specification is authoritative. +Fix this summary if you find any such discrepancies. + +ALPHA = %x41-5A / %x61-7A ; A-Z / a-z +DIGIT = %x30-39 ; 0-9 +HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" + ; case-insensitive + +pct-encoded = "%" HEXDIG HEXDIG +unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" +reserved = gen-delims / sub-delims +gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" +sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + / "*" / "+" / "," / ";" / "=" + +ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF + / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD + / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD + / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD + / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD + / %xD0000-DFFFD / %xE1000-EFFFD + +iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD + +URI-Template = *( literals / expression ) + +literals = %x21 / %x23-24 / %x26 / %x28-3B / %x3D / %x3F-5B + / %x5D / %x5F / %x61-7A / %x7E / ucschar / iprivate + / pct-encoded + ; any Unicode character except: CTL, SP, + ; DQUOTE, "'", "%" (aside from pct-encoded), + ; "<", ">", "\", "^", "`", "{", "|", "}" + +expression = "{" [ operator ] variable-list "}" +operator = op-level2 / op-level3 / op-reserve +op-level2 = "+" / "#" +op-level3 = "." / "/" / ";" / "?" / "&" +op-reserve = "=" / "," / "!" / "@" / "|" + +modifier-level4 = prefix / explode +explode = "*" +prefix = ":" max-length +max-length = %x31-39 0*3DIGIT ; positive integer < 10000 + +The operator characters +- `+`: Reserved character strings; +- `#`: Fragment identifiers prefixed by "#"; +- `.`: Name labels or extensions prefixed by "."; +- `/`: Path segments prefixed by "/"; +- `;`: Path parameter name or name=value pairs prefixed by ";"; +- `?`: Query component beginning with "?" and consisting of name=value pairs separated by "&"; and, + & Continuation of query-style &name=value pairs within a literal query component. + +The operator characters equals ("="), comma (","), exclamation ("!"), at sign ("@"), and pipe ("|") are reserved for future extensions. + +variable-list = varspec *( "," varspec ) +varspec = varname [ modifier-level4 ] +varname = varchar *( ["."] varchar ) +varchar = ALPHA / DIGIT / "_" / pct-encoded + +The normative sections on expansion describe each operator with a +separate expansion process for the sake of descriptive clarity. In +actual implementations, we expect the expressions to be processed +left-to-right using a common algorithm that has only minor variations +in process per operator. This non-normative appendix describes one +such algorithm. + +Initialize an empty result string and its non-error state. + +Scan the template and copy literals to the result string (as in +Section 3.1) until an expression is indicated by a "{", an error is +indicated by the presence of a non-literals character other than "{", +or the template ends. When it ends, return the result string and its +current error or non-error state. + +o If an expression is found, scan the template to the next "}" and + extract the characters in between the braces. + +o If the template ends before a "}", then append the "{" and + extracted characters to the result string and return with an error + status indicating the expression is malformed. + +Examine the first character of the extracted expression for an +operator. + +o If the expression ended (i.e., is "{}"), an operator is found that + is unknown or unimplemented, or the character is not in the + varchar set (Section 2.3), then append "{", the extracted + expression, and "}" to the result string, remember that the result + is in an error state, and then go back to scan the remainder of + the template. + +o If a known and implemented operator is found, store the operator + and skip to the next character to begin the varspec-list. + +o Otherwise, store the operator as NUL (simple string expansion). + +Use the following value table to determine the processing behavior by +expression type operator. The entry for "first" is the string to +append to the result first if any of the expression's variables are +defined. The entry for "sep" is the separator to append to the +result before any second (or subsequent) defined variable expansion. +The entry for "named" is a boolean for whether or not the expansion +includes the variable or key name when no explode modifier is given. +The entry for "ifemp" is a string to append to the name if its +corresponding value is empty. The entry for "allow" indicates what +characters to allow unencoded within the value expansion: (U) means +any character not in the unreserved set will be encoded; (U+R) means +any character not in the union of (unreserved / reserved / pct- +encoding) will be encoded; and, for both cases, each disallowed +character is first encoded as its sequence of octets in UTF-8 and +then each such octet is encoded as a pct-encoded triplet. + +.------------------------------------------------------------------. +| NUL + . / ; ? & # | +|------------------------------------------------------------------| +| first | "" "" "." "/" ";" "?" "&" "#" | +| sep | "," "," "." "/" ";" "&" "&" "," | +| named | false false false false true true true false | +| ifemp | "" "" "" "" "" "=" "=" "" | +| allow | U U+R U U U U U U+R | +`------------------------------------------------------------------' + +With the above table in mind, process the variable-list as follows: + +For each varspec, extract a variable name and optional modifier from +the expression by scanning the variable-list until a character not in +the varname set is found or the end of the expression is reached. + +o If it is the end of the expression and the varname is empty, go back to scan the remainder of the template. + +o If it is not the end of the expression and the last character found indicates a modifier ("*" or ":"), remember that modifier. If it is an explode ("*"), scan the next character. If it is a prefix (":"), continue scanning the next one to four characters for the max-length represented as a decimal integer and then, if it is still not the end of the expression, scan the next character. + +o If it is not the end of the expression and the last character found is not a comma (","), append "{", the stored operator (if any), the scanned varname and modifier, the remaining expression, and "}" to the result string, remember that the result is in an error state, and then go back to scan the remainder of the template. + +Lookup the value for the scanned variable name, and then + +o If the varname is unknown or corresponds to a variable with an undefined value (Section 2.3), then skip to the next varspec. + +o If this is the first defined variable for this expression, append the first string for this expression type to the result string and remember that it has been done. Otherwise, append the sep string to the result string. + +o If this variable's value is a string, then + + * if named is true, append the varname to the result string using the same encoding process as for literals, and + + + if the value is empty, append the ifemp string to the result string and skip to the next varspec; + + + otherwise, append "=" to the result string. + + * if a prefix modifier is present and the prefix length is less than the value string length in number of Unicode characters, append that number of characters from the beginning of the value string to the result string, after pct-encoding any characters that are not in the allow set, while taking care not to split multi-octet or pct-encoded triplet characters that represent a single Unicode code point; + + * otherwise, append the value to the result string after pct- encoding any characters that are not in the allow set. + +o else if no explode modifier is given, then + + * if named is true, append the varname to the result string using the same encoding process as for literals, and + + + if the value is empty, append the ifemp string to the result string and skip to the next varspec; + + + otherwise, append "=" to the result string; and + + * if this variable's value is a list, append each defined list member to the result string, after pct-encoding any characters that are not in the allow set, with a comma (",") appended to the result between each defined list member; + + * if this variable's value is an associative array or any other form of paired (name, value) structure, append each pair with a defined value to the result string as "name,value", after pct- encoding any characters that are not in the allow set, with a comma (",") appended to the result between each defined pair. + +o else if an explode modifier is given, then + + * if named is true, then for each defined list member or array (name, value) pair with a defined value, do: + + + if this is not the first defined member/value, append the sep string to the result string; + + + if this is a list, append the varname to the result string using the same encoding process as for literals; + + + if this is a pair, append the name to the result string using the same encoding process as for literals; + + + if the member/value is empty, append the ifemp string to the result string; otherwise, append "=" and the member/value to the result string, after pct-encoding any member/value characters that are not in the allow set. + + * else if named is false, then + + + if this is a list, append each defined list member to the result string, after pct-encoding any characters that are not in the allow set, with the sep string appended to the result between each defined list member. + + + if this is an array of (name, value) pairs, append each pair with a defined value to the result string as "name=value", after pct-encoding any characters that are not in the allow set, with the sep string appended to the result between each defined pair. + +When the variable-list for this expression is exhausted, go back to +scan the remainder of the template. From 01a2b4a0f53ac6f30da2a6fa8d7a0d6ec7a8fc3c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 1 May 2026 23:21:17 +0000 Subject: [PATCH 06/49] Add test suite for @fedify/uri-template Template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a runtime-agnostic test harness for the new RFC 6570 `Template` implementation, organized as JSON fixtures plus typed runner factories so the same cases can drive `template.test.ts` and the companion `template.bench.ts`. The fixtures cover four scenarios: - *tests/json/references/{pairs,fixed,vars}.json*: the canonical `uritemplate-test` reference cases (template/expansion pairs, fixed-template variations, and the standard variable context). - *tests/json/hard.json*: hard-mode cases targeting subtle RFC 6570 rules — pct-encoded literals, mixed encoding/normalization, and edge cases for explode/prefix modifiers — each with a `reason` explaining the expected behavior. - *tests/json/wrong.json*: negative cases pinned to the exact error class from `errors.ts` so regressions in diagnostic precision are caught, not just "something throws". `tests/assert.ts` validates the JSON shapes at load time, and `tests/lib.ts` exposes `createTemplatePairTest`, `createFixedTemplateTest`, `createWrongTemplateTest`, and `createTemplateHardTest` factories that accept any `TemplateConstructor`, allowing the same suites to exercise alternative implementations or the benchmark harness. Assisted-by: Claude Code:claude-opus-4-7 --- .../src/template/template.bench.ts | 17 + .../src/template/template.test.ts | 75 ++ packages/uri-template/src/tests/assert.ts | 113 +++ .../uri-template/src/tests/json/hard.json | 324 +++++++ .../src/tests/json/references/fixed.json | 48 + .../src/tests/json/references/pairs.json | 829 ++++++++++++++++++ .../src/tests/json/references/vars.json | 58 ++ .../uri-template/src/tests/json/wrong.json | 407 +++++++++ packages/uri-template/src/tests/lib.ts | 213 +++++ packages/uri-template/src/tests/mod.ts | 36 + 10 files changed, 2120 insertions(+) create mode 100644 packages/uri-template/src/template/template.bench.ts create mode 100644 packages/uri-template/src/template/template.test.ts create mode 100644 packages/uri-template/src/tests/assert.ts create mode 100644 packages/uri-template/src/tests/json/hard.json create mode 100644 packages/uri-template/src/tests/json/references/fixed.json create mode 100644 packages/uri-template/src/tests/json/references/pairs.json create mode 100644 packages/uri-template/src/tests/json/references/vars.json create mode 100644 packages/uri-template/src/tests/json/wrong.json create mode 100644 packages/uri-template/src/tests/lib.ts create mode 100644 packages/uri-template/src/tests/mod.ts diff --git a/packages/uri-template/src/template/template.bench.ts b/packages/uri-template/src/template/template.bench.ts new file mode 100644 index 000000000..5722c57bc --- /dev/null +++ b/packages/uri-template/src/template/template.bench.ts @@ -0,0 +1,17 @@ +import { test } from "@fedify/fixture"; +import { createTemplatePairTest, pairTestSuites } from "../tests/mod.ts"; +import Template from "./mod.ts"; + +Deno.bench("Template using RegExp", (b) => { + const runPairCases = createTemplatePairTest(Template); + b.start(); + for (const _ of Array(10000)) { + for (const { name, cases } of pairTestSuites) { + test( + name, + runPairCases(cases as unknown as readonly [string, string][]), + ); + } + } + b.end(); +}); diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts new file mode 100644 index 000000000..92f8510c1 --- /dev/null +++ b/packages/uri-template/src/template/template.test.ts @@ -0,0 +1,75 @@ +import { test } from "@fedify/fixture"; +import { deepEqual, equal } from "node:assert"; +import { throws } from "node:assert/strict"; +import { + InvalidLiteralError, + InvalidPrefixError, + ReservedOperatorError, + UnclosedExpressionError, +} from "../errors.ts"; +import { + createFixedTemplateTest, + createTemplateHardTest, + createTemplatePairTest, + createWrongTemplateTest, + fixedTestSuites, + hardTestSuites, + pairTestSuites, + wrongTestSuites, +} from "../tests/mod.ts"; +import Template from "./mod.ts"; + +const runPairCases = createTemplatePairTest(Template); +for (const { name, cases } of pairTestSuites) { + test(name, runPairCases(cases as unknown as readonly [string, string][])); +} + +const runFixedCases = createFixedTemplateTest(Template); +for (const { template, name, cases } of fixedTestSuites) { + test(name, runFixedCases(template)(cases)); +} + +const runWrongCases = createWrongTemplateTest(Template); +for (const { name, cases } of wrongTestSuites) { + test(name, runWrongCases(cases)); +} + +const runHardCases = createTemplateHardTest(Template); +for (const { name, cases } of hardTestSuites) { + test(name, runHardCases(cases)); +} + +test("throws parse errors in strict mode", () => { + throws(() => new Template("{var"), UnclosedExpressionError); + throws(() => new Template("bad literal"), InvalidLiteralError); + throws(() => new Template("{=var}"), ReservedOperatorError); + throws(() => new Template("{var:0}"), InvalidPrefixError); +}); + +test("reports parse errors without throwing in non-strict mode", () => { + const errors: Error[] = []; + const template = new Template("{=bad}/{ok}", { + strict: false, + report: (error: Error) => errors.push(error), + }); + + equal(template.expand({ ok: "value" }), "{=bad}/value"); + equal(errors.length, 1); + equal(errors[0] instanceof ReservedOperatorError, true); +}); + +test("parses reusable template instances", () => { + const template = Template.parse("/mapper{?address*}"); + equal( + template.expand({ address: { city: "Newport Beach", state: "CA" } }), + "/mapper?city=Newport%20Beach&state=CA", + ); + deepEqual(template.tokens, [ + { kind: "literal", text: "/mapper" }, + { + kind: "expression", + operator: "?", + vars: [{ name: "address", explode: true }], + }, + ]); +}); diff --git a/packages/uri-template/src/tests/assert.ts b/packages/uri-template/src/tests/assert.ts new file mode 100644 index 000000000..9eb2dd3c1 --- /dev/null +++ b/packages/uri-template/src/tests/assert.ts @@ -0,0 +1,113 @@ +import * as ERROR_CLASSES from "../errors.ts"; +import type { HardTestSuite, PairTestSuite, WrongTestSuite } from "./lib.ts"; + +const ERROR_NAMES: ReadonlySet = new Set(Object.keys(ERROR_CLASSES)); + +export function assertPairTestSuite( + suites: unknown, +): asserts suites is readonly PairTestSuite[] { + validateSuites(suites, validatePairCase); +} + +export function assertWrongTestSuite( + suites: unknown, +): asserts suites is readonly WrongTestSuite[] { + validateSuites(suites, validateWrongCase); +} + +export function assertHardTestSuite( + suites: unknown, +): asserts suites is readonly HardTestSuite[] { + validateSuites(suites, validateHardCase); +} + +function validateSuites( + suites: unknown, + validateCase: (c: unknown) => void, +): void { + assertArray(suites, "suites"); + for (const suite of suites) { + assertObject(suite, "suite"); + assertString(suite.name, "suite.name"); + assertArray(suite.cases, "suite.cases"); + for (const c of suite.cases) validateCase(c); + } +} + +function validatePairCase(c: unknown): void { + if ( + !Array.isArray(c) || c.length !== 2 || + typeof c[0] !== "string" || typeof c[1] !== "string" + ) { + throw new TypeError( + "each case must be a [template: string, expanded: string] tuple", + ); + } +} + +function validateWrongCase(c: unknown): void { + assertObject(c, "case"); + assertString(c.name, "case.name"); + assertString(c.template, "case.template"); + assertErrorName(c.expected, "case.expected"); +} + +function validateHardCase(c: unknown): void { + assertObject(c, "case"); + assertString(c.name, "case.name"); + assertString(c.template, "case.template"); + assertString(c.expected, "case.expected"); + assertBoolean(c.success, "case.success"); + if (c.reason !== undefined) assertString(c.reason, "case.reason"); + if (!c.success) assertErrorName(c.expected, "case.expected"); +} + +function assertString( + value: unknown, + label: string, +): asserts value is string { + if (typeof value !== "string") { + throw new TypeError(`${label} must be a string`); + } +} + +function assertBoolean( + value: unknown, + label: string, +): asserts value is boolean { + if (typeof value !== "boolean") { + throw new TypeError(`${label} must be a boolean`); + } +} + +function assertObject( + value: unknown, + label: string, +): asserts value is Record { + if (typeof value !== "object" || value === null) { + throw new TypeError(`${label} must be an object`); + } +} + +function assertArray( + value: unknown, + label: string, +): asserts value is unknown[] { + if (!Array.isArray(value)) { + throw new TypeError(`${label} must be an array`); + } +} + +function assertErrorName( + value: unknown, + label: string, +): asserts value is string { + assertString(value, label); + if (!ERROR_NAMES.has(value)) { + throw new TypeError( + `${label} must be one of [${[...ERROR_NAMES].join(", ")}], got ${ + JSON.stringify(value) + }`, + ); + } +} diff --git a/packages/uri-template/src/tests/json/hard.json b/packages/uri-template/src/tests/json/hard.json new file mode 100644 index 000000000..22aa139b5 --- /dev/null +++ b/packages/uri-template/src/tests/json/hard.json @@ -0,0 +1,324 @@ +[ + { + "name": "Pct-encoded triplets in literal text are emitted verbatim", + "cases": [ + { + "name": "Pct-encoded path separators in surrounding literal are preserved as-is", + "template": "%2Fapi%2Fv1%2F{var}", + "expected": "%2Fapi%2Fv1%2Fvalue", + "reason": "Literal text including pct-encoded triplets is emitted verbatim — the parser MUST NOT re-encode `%2F` to `%252F` nor decode it to `/`.", + "success": true + }, + { + "name": "Lowercase hex in a literal pct-encoded triplet is preserved without normalization", + "template": "%2f{var}%2F", + "expected": "%2fvalue%2F", + "reason": "RFC 6570 says hex digits are case-insensitive, but literals are emitted verbatim. The lowercase `%2f` MUST stay lowercase in the output even though the trailing `%2F` is uppercase.", + "success": true + }, + { + "name": "Pct-encoded percent sign in a literal does not become double-encoded", + "template": "%25{var}", + "expected": "%25value", + "reason": "`%25` is the literal pct-encoded form of `%`. Because it appears in literal text (not in an expanded value), it is NOT re-encoded into `%2525`.", + "success": true + }, + { + "name": "Multi-octet UTF-8 sequence written as pct-encoded literals stays untouched", + "template": "%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4{?var}", + "expected": "%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4?var=value", + "reason": "The literal `%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4` (the UTF-8 encoding of `페디파이`) consists of twelve pct-encoded triplets. The parser must accept consecutive pct-encoded literals and emit them unchanged.", + "success": true + }, + { + "name": "Pct-encoded operator-like characters in a literal do not introduce another expression", + "template": "%23section{?var}", + "expected": "%23section?var=value", + "reason": "`%23` is the encoded `#`, which is forbidden as a bare literal but ALLOWED as a pct-encoded triplet. It must NOT trigger fragment-style expansion behavior.", + "success": true + }, + { + "name": "Pct-encoded reserved characters appear in literal even though their decoded forms are forbidden", + "template": "%3C%3E%5C%5E%60%7C{var}", + "expected": "%3C%3E%5C%5E%60%7Cvalue", + "reason": "The bare characters `<`, `>`, `\\`, `^`, `` ` ``, `|` are forbidden in literals, but their pct-encoded forms `%3C`, `%3E`, `%5C`, `%5E`, `%60`, `%7C` are allowed verbatim.", + "success": true + } + ] + }, + { + "name": "Pct-encoded brackets in literals are not delimiters", + "cases": [ + { + "name": "Encoded `{` and `}` surrounding a varname are literal text, no expansion happens", + "template": "%7Bvar%7D", + "expected": "%7Bvar%7D", + "reason": "`%7B`/`%7D` are pct-encoded literals — they look like brackets to a human but are NOT expression delimiters. The whole template is one literal; `var` is never looked up.", + "success": true + }, + { + "name": "Encoded brackets surrounding a real expression form decorative literals", + "template": "%7B{var}%7D", + "expected": "%7Bvalue%7D", + "reason": "Only the unencoded `{`/`}` denote the expression. `%7B` before and `%7D` after are literal text — useful when you actually want braces around an expanded value.", + "success": true + }, + { + "name": "Encoded close-brace inside literal does not terminate a previous open expression", + "template": "{var}%7Dtail", + "expected": "value%7Dtail", + "reason": "After the expression `{var}` closes, the literal `%7Dtail` follows. The encoded `%7D` is ordinary literal text and does not interact with bracket parsing.", + "success": true + }, + { + "name": "Encoded brackets adjacent to real brackets must not be confused", + "template": "%7B%7B{var}%7D%7D", + "expected": "%7B%7Bvalue%7D%7D", + "reason": "Doubled encoded brackets are still literal. The single real expression `{var}` expands; the two encoded `%7B%7B` and `%7D%7D` flank it as decoration.", + "success": true + }, + { + "name": "Encoded brackets between two expressions do not nest or escape anything", + "template": "{var}%7B%7D{hello}", + "expected": "value%7B%7DHello%20World%21", + "reason": "The two expressions are independent and concatenate; the encoded `%7B%7D` literal sits between them like any other literal text.", + "success": true + } + ] + }, + { + "name": "Pct-encoded characters within varnames", + "cases": [ + { + "name": "Varname containing a pct-encoded space is looked up verbatim", + "template": "{abc%20def}", + "expected": "spaced", + "reason": "Per RFC 6570 ABNF, `varchar = ALPHA / DIGIT / \"_\" / pct-encoded`. So `abc%20def` is one valid varname. The lookup uses the pct-encoded form as the key — it is NOT decoded to `abc def`.", + "success": true + }, + { + "name": "Varname starting with a pct-encoded character is not interpreted as an operator", + "template": "{%2Bvar}", + "expected": "plus", + "reason": "The first character of the expression is `%`, which is not an operator. The varname is the entire pct-encoded string `%2Bvar` — it must NOT be confused with the `+` operator (whose pct-encoded form is `%2B`).", + "success": true + }, + { + "name": "Pct-encoded varname is preserved when emitted by a named operator", + "template": "{?abc%20def}", + "expected": "?abc%20def=spaced", + "reason": "Named operators echo the varname using \"the same encoding process as for literals\". A pct-encoded triplet inside the varname is therefore preserved verbatim — neither decoded nor double-encoded.", + "success": true + }, + { + "name": "Varname with an internal dot is one varname, not two joined by `.`", + "template": "{foo.bar}", + "expected": "dotted", + "reason": "ABNF: `varname = varchar *( [\".\"] varchar )`. Dots are part of the varname, so `foo.bar` is the literal lookup key — NOT a path into nested objects.", + "success": true + }, + { + "name": "Varname consisting only of digits is valid", + "template": "{123}", + "expected": "numeric", + "reason": "`varchar` includes DIGIT, so a varname may start with — and consist entirely of — digits.", + "success": true + }, + { + "name": "Pct-encoded varname combined with a normal varname in one expression", + "template": "{abc%20def,var}", + "expected": "spaced,value", + "reason": "Two independent varspecs joined by `,`. The first uses a pct-encoded varname; the lookup is independent of how varnames are written.", + "success": true + }, + { + "name": "Varname that looks like an encoded close-brace is a valid (undefined) varname", + "template": "O{%7D}X", + "expected": "OX", + "reason": "RFC 6570 §2.3: \"A varname MAY contain one or more pct-encoded triplets.\" So `%7D` is a single pct-encoded varchar — and a complete, valid varname. With no `%7D` key defined, the varname is undefined per §3.2.1 and the expression collapses to an empty string.", + "success": true + }, + { + "name": "Varname that looks like an encoded open-brace resolves to its defined value", + "template": "{%7B}", + "expected": "open-brace", + "reason": "Symmetric to the undefined `%7D` case: `%7B` is a single pct-encoded varchar and a complete varname. With `%7B` mapped to `open-brace` in the variable set, the expression expands to that defined value — the encoded-bracket appearance does NOT prevent normal lookup.", + "success": true + }, + { + "name": "Varname spelled in pct-encoded form is NOT the same as its decoded counterpart", + "template": "{%41}", + "expected": "encoded-A", + "reason": "RFC 6570 §2.3: \"A varname containing pct-encoded characters is not the same variable as a varname with those same characters decoded.\" So `%41` and `A` are two distinct variable names. The lookup uses `%41` literally and yields `encoded-A`, not whatever `A` might (or might not) resolve to.", + "success": true + }, + { + "name": "Pct-encoded varname under a named operator emits the encoded form as the parameter name", + "template": "{?%41}", + "expected": "?%41=encoded-A", + "reason": "Named-operator output echoes the varname \"using the same encoding process as for literals\". Since `%41` is already a valid pct-encoded literal triplet, it is preserved verbatim — the parameter name in the URI is literally `%41`, not `A`.", + "success": true + } + ] + }, + { + "name": "Composite values with null members", + "cases": [ + { + "name": "Null entries in a list are skipped during expansion", + "template": "{sparse}", + "expected": "one,three", + "reason": "RFC 6570 §2.3 says \"a list... a null... is considered undefined\". The null at index 1 is skipped; only `one` and `three` are joined by `,`.", + "success": true + }, + { + "name": "Null entries in a list are skipped under named explode too", + "template": "{?sparse*}", + "expected": "?sparse=one&sparse=three", + "reason": "Null members are excluded from the iteration entirely. Only the two defined members emit `sparse=…` pairs joined by `&`.", + "success": true + }, + { + "name": "Null entries do not produce an empty path segment", + "template": "{/sparse*}", + "expected": "/one/three", + "reason": "Each defined member produces one path segment. The null member is skipped, so the output has TWO segments — NOT three with an empty middle one.", + "success": true + }, + { + "name": "Null values in an associative array drop their key", + "template": "{nullable_keys}", + "expected": "a,1,c,3", + "reason": "The pair `(b, null)` is excluded because its value is undefined. Only `a=1` and `c=3` survive, flattened as `name,value` joined by `,`.", + "success": true + }, + { + "name": "Null values are skipped under named explode of an associative array", + "template": "{?nullable_keys*}", + "expected": "?a=1&c=3", + "reason": "Pairs with undefined values are skipped before the `?`/`&` separator logic runs. The output looks identical to an object that simply did not contain key `b`.", + "success": true + } + ] + }, + { + "name": "Explode modifier on string values is silently ignored", + "cases": [ + { + "name": "Explode on a string under NUL is a no-op", + "template": "{var*}", + "expected": "value", + "reason": "The expansion algorithm dispatches on value type. For string values, the explode flag is irrelevant — the result is identical to `{var}`.", + "success": true + }, + { + "name": "Explode on a string under named operator does not multiply assignments", + "template": "{?var*}", + "expected": "?var=value", + "reason": "Strings have no \"members\" to explode over. The output is the same as `{?var}`. People often expect `?var=v&var=a&var=l&var=u&var=e` — that is wrong.", + "success": true + }, + { + "name": "Explode on an empty string is also a no-op", + "template": "{?empty*}", + "expected": "?empty=", + "reason": "Empty string is a defined string value. The string-branch logic runs and emits `name=` because of `ifemp = =`. The `*` has no effect.", + "success": true + }, + { + "name": "Explode on a path-like string still encodes reserved characters under NUL", + "template": "{path*}", + "expected": "%2Ffoo%2Fbar", + "reason": "Same string-branch — `*` is ignored. Encoding rules of NUL still apply: each `/` becomes `%2F`.", + "success": true + }, + { + "name": "Explode on a string with prefix-length-exceeding value is still a single value", + "template": "{hello*}", + "expected": "Hello%20World%21", + "reason": "`*` is silently ignored on the string `hello`. The result is the same as `{hello}`.", + "success": true + } + ] + }, + { + "name": "Mismatched pct-encoded brackets and stray brackets", + "cases": [ + { + "name": "Bare closing brace in literals is forbidden even when an encoded open brace precedes it", + "template": "%7Bvar}", + "expected": "StrayClosingBraceError", + "reason": "`%7B` is a pct-encoded literal — it does NOT open an expression. The trailing bare `}` is forbidden in literals (allowed character set excludes `}`), so the template is malformed.", + "success": false + }, + { + "name": "Real opening brace closed only by an encoded brace is unclosed", + "template": "{var%7D", + "expected": "UnclosedExpressionError", + "reason": "Inside the expression, `%7D` is a valid pct-encoded varchar — it becomes part of the varname `var%7D`. There is no actual `}` to terminate the expression, so parsing fails.", + "success": false + }, + { + "name": "Stray bare close-brace before any literal text or expression fails", + "template": "}%7B%7D", + "expected": "StrayClosingBraceError", + "reason": "The leading `}` is a bare close-brace in the literals position. RFC 6570 §2.1 forbids `}` from the literals character set regardless of what follows — the trailing encoded `%7B%7D` does not rescue the template.", + "success": false + }, + { + "name": "Encoded open brace followed by stray real close brace fails on the close brace", + "template": "%7B}", + "expected": "StrayClosingBraceError", + "reason": "The literal `%7B` is fine. The following bare `}` is forbidden in literals.", + "success": false + }, + { + "name": "Real opening brace nested inside a varname-shaped sequence is invalid", + "template": "{var%7B%7D", + "expected": "UnclosedExpressionError", + "reason": "The expression body becomes `var%7B%7D` — three pct-encoded characters all valid as varchars — but the expression never sees a literal `}`. Unclosed.", + "success": false + } + ] + }, + { + "name": "Prefix modifier on composite values (RFC 6570 §2.4.1)", + "cases": [ + { + "name": "Prefix on a list is not applicable per spec", + "template": "{list:3}", + "expected": "PrefixModifierNotApplicableError", + "reason": "RFC 6570 §2.4.1 states: \"Prefix modifiers are not applicable to variables that have composite values.\" `list` is composite, so this must fail at expansion time.", + "success": false + }, + { + "name": "Prefix on an associative array is not applicable per spec", + "template": "{keys:3}", + "expected": "PrefixModifierNotApplicableError", + "reason": "Same rule as for lists: prefix length is undefined for object values.", + "success": false + }, + { + "name": "Prefix on a list under named operator also fails", + "template": "{?list:6}", + "expected": "PrefixModifierNotApplicableError", + "reason": "The named operator does not bypass the §2.4.1 restriction. The prefix modifier remains inapplicable regardless of the surrounding operator.", + "success": false + }, + { + "name": "Prefix on a path-segment expansion of a composite still fails", + "template": "{/keys:4}", + "expected": "PrefixModifierNotApplicableError", + "reason": "Path-segment operator does not change the type of the variable. Composite means composite.", + "success": false + }, + { + "name": "Prefix on the canonical `count` list also fails", + "template": "{count:2}", + "expected": "PrefixModifierNotApplicableError", + "reason": "`count` is `[\"one\", \"two\", \"three\"]`. Prefix modifier is invalid for it. The error is at expansion time, not parsing time — the syntax `count:2` is grammatically well-formed.", + "success": false + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/references/fixed.json b/packages/uri-template/src/tests/json/references/fixed.json new file mode 100644 index 000000000..9a95a231d --- /dev/null +++ b/packages/uri-template/src/tests/json/references/fixed.json @@ -0,0 +1,48 @@ +[ + { + "name": "Section 1.1 — Introductory Examples (Form-style with Undef Cases)", + "template": "http://www.example.com/foo{?query,number}", + "cases": [ + { + "name": "query=mycelium, number=100", + "context": { + "query": "mycelium", + "number": 100 + }, + "expected": "http://www.example.com/foo?query=mycelium&number=100" + }, + { + "name": "query undefined, number=100", + "context": { + "query": null, + "number": 100 + }, + "expected": "http://www.example.com/foo?number=100" + }, + { + "name": "query and number both undefined", + "context": { + "query": null, + "number": null + }, + "expected": "http://www.example.com/foo" + } + ] + }, + { + "name": "Section 2.4.2 — Composite Address Example", + "template": "/mapper{?address*}", + "cases": [ + { + "name": "address with city and state", + "context": { + "address": { + "city": "Newport Beach", + "state": "CA" + } + }, + "expected": "/mapper?city=Newport%20Beach&state=CA" + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/references/pairs.json b/packages/uri-template/src/tests/json/references/pairs.json new file mode 100644 index 000000000..6e0e18ab8 --- /dev/null +++ b/packages/uri-template/src/tests/json/references/pairs.json @@ -0,0 +1,829 @@ +[ + { + "name": "Section 1.2 — Level 1 (Simple String Expansion)", + "cases": [ + [ + "{var}", + "value" + ], + [ + "{hello}", + "Hello%20World%21" + ] + ] + }, + { + "name": "Section 1.2 — Level 2 (Reserved + Fragment Expansion)", + "cases": [ + [ + "{+var}", + "value" + ], + [ + "{+hello}", + "Hello%20World!" + ], + [ + "{+path}/here", + "/foo/bar/here" + ], + [ + "here?ref={+path}", + "here?ref=/foo/bar" + ], + [ + "X{#var}", + "X#value" + ], + [ + "X{#hello}", + "X#Hello%20World!" + ] + ] + }, + { + "name": "Section 1.2 — Level 3 (Multiple Variables, More Operators)", + "cases": [ + [ + "map?{x,y}", + "map?1024,768" + ], + [ + "{x,hello,y}", + "1024,Hello%20World%21,768" + ], + [ + "{+x,hello,y}", + "1024,Hello%20World!,768" + ], + [ + "{+path,x}/here", + "/foo/bar,1024/here" + ], + [ + "{#x,hello,y}", + "#1024,Hello%20World!,768" + ], + [ + "{#path,x}/here", + "#/foo/bar,1024/here" + ], + [ + "X{.var}", + "X.value" + ], + [ + "X{.x,y}", + "X.1024.768" + ], + [ + "{/var}", + "/value" + ], + [ + "{/var,x}/here", + "/value/1024/here" + ], + [ + "{;x,y}", + ";x=1024;y=768" + ], + [ + "{;x,y,empty}", + ";x=1024;y=768;empty" + ], + [ + "{?x,y}", + "?x=1024&y=768" + ], + [ + "{?x,y,empty}", + "?x=1024&y=768&empty=" + ], + [ + "?fixed=yes{&x}", + "?fixed=yes&x=1024" + ], + [ + "{&x,y,empty}", + "&x=1024&y=768&empty=" + ] + ] + }, + { + "name": "Section 1.2 — Level 4 (Value Modifiers)", + "cases": [ + [ + "{var:3}", + "val" + ], + [ + "{var:30}", + "value" + ], + [ + "{list}", + "red,green,blue" + ], + [ + "{list*}", + "red,green,blue" + ], + [ + "{keys}", + "semi,%3B,dot,.,comma,%2C" + ], + [ + "{keys*}", + "semi=%3B,dot=.,comma=%2C" + ], + [ + "{+path:6}/here", + "/foo/b/here" + ], + [ + "{+list}", + "red,green,blue" + ], + [ + "{+list*}", + "red,green,blue" + ], + [ + "{+keys}", + "semi,;,dot,.,comma,," + ], + [ + "{+keys*}", + "semi=;,dot=.,comma=," + ], + [ + "{#path:6}/here", + "#/foo/b/here" + ], + [ + "{#list}", + "#red,green,blue" + ], + [ + "{#list*}", + "#red,green,blue" + ], + [ + "{#keys}", + "#semi,;,dot,.,comma,," + ], + [ + "{#keys*}", + "#semi=;,dot=.,comma=," + ], + [ + "X{.var:3}", + "X.val" + ], + [ + "X{.list}", + "X.red,green,blue" + ], + [ + "X{.list*}", + "X.red.green.blue" + ], + [ + "X{.keys}", + "X.semi,%3B,dot,.,comma,%2C" + ], + [ + "X{.keys*}", + "X.semi=%3B.dot=..comma=%2C" + ], + [ + "{/var:1,var}", + "/v/value" + ], + [ + "{/list}", + "/red,green,blue" + ], + [ + "{/list*}", + "/red/green/blue" + ], + [ + "{/list*,path:4}", + "/red/green/blue/%2Ffoo" + ], + [ + "{/keys}", + "/semi,%3B,dot,.,comma,%2C" + ], + [ + "{/keys*}", + "/semi=%3B/dot=./comma=%2C" + ], + [ + "{;hello:5}", + ";hello=Hello" + ], + [ + "{;list}", + ";list=red,green,blue" + ], + [ + "{;list*}", + ";list=red;list=green;list=blue" + ], + [ + "{;keys}", + ";keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{;keys*}", + ";semi=%3B;dot=.;comma=%2C" + ], + [ + "{?var:3}", + "?var=val" + ], + [ + "{?list}", + "?list=red,green,blue" + ], + [ + "{?list*}", + "?list=red&list=green&list=blue" + ], + [ + "{?keys}", + "?keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{?keys*}", + "?semi=%3B&dot=.&comma=%2C" + ], + [ + "{&var:3}", + "&var=val" + ], + [ + "{&list}", + "&list=red,green,blue" + ], + [ + "{&list*}", + "&list=red&list=green&list=blue" + ], + [ + "{&keys}", + "&keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{&keys*}", + "&semi=%3B&dot=.&comma=%2C" + ] + ] + }, + { + "name": "Section 2.4.1 — Prefix Modifier", + "cases": [ + [ + "{var}", + "value" + ], + [ + "{var:20}", + "value" + ], + [ + "{var:3}", + "val" + ], + [ + "{semi}", + "%3B" + ], + [ + "{semi:2}", + "%3B" + ] + ] + }, + { + "name": "Section 2.4.2 — Composite (Explode) Values", + "cases": [ + [ + "find{?year*}", + "find?year=1965&year=2000&year=2012" + ], + [ + "www{.dom*}", + "www.example.com" + ] + ] + }, + { + "name": "Section 3.2.1 — Variable Expansion (List with Various Operators)", + "cases": [ + [ + "{count}", + "one,two,three" + ], + [ + "{count*}", + "one,two,three" + ], + [ + "{/count}", + "/one,two,three" + ], + [ + "{/count*}", + "/one/two/three" + ], + [ + "{;count}", + ";count=one,two,three" + ], + [ + "{;count*}", + ";count=one;count=two;count=three" + ], + [ + "{?count}", + "?count=one,two,three" + ], + [ + "{?count*}", + "?count=one&count=two&count=three" + ], + [ + "{&count*}", + "&count=one&count=two&count=three" + ] + ] + }, + { + "name": "Section 3.2.2 — Simple String Expansion: {var}", + "cases": [ + [ + "{var}", + "value" + ], + [ + "{hello}", + "Hello%20World%21" + ], + [ + "{half}", + "50%25" + ], + [ + "O{empty}X", + "OX" + ], + [ + "O{undef}X", + "OX" + ], + [ + "{x,y}", + "1024,768" + ], + [ + "{x,hello,y}", + "1024,Hello%20World%21,768" + ], + [ + "?{x,empty}", + "?1024," + ], + [ + "?{x,undef}", + "?1024" + ], + [ + "?{undef,y}", + "?768" + ], + [ + "{var:3}", + "val" + ], + [ + "{var:30}", + "value" + ], + [ + "{list}", + "red,green,blue" + ], + [ + "{list*}", + "red,green,blue" + ], + [ + "{keys}", + "semi,%3B,dot,.,comma,%2C" + ], + [ + "{keys*}", + "semi=%3B,dot=.,comma=%2C" + ] + ] + }, + { + "name": "Section 3.2.3 — Reserved Expansion: {+var}", + "cases": [ + [ + "{+var}", + "value" + ], + [ + "{+hello}", + "Hello%20World!" + ], + [ + "{+half}", + "50%25" + ], + [ + "{base}index", + "http%3A%2F%2Fexample.com%2Fhome%2Findex" + ], + [ + "{+base}index", + "http://example.com/home/index" + ], + [ + "O{+empty}X", + "OX" + ], + [ + "O{+undef}X", + "OX" + ], + [ + "{+path}/here", + "/foo/bar/here" + ], + [ + "here?ref={+path}", + "here?ref=/foo/bar" + ], + [ + "up{+path}{var}/here", + "up/foo/barvalue/here" + ], + [ + "{+x,hello,y}", + "1024,Hello%20World!,768" + ], + [ + "{+path,x}/here", + "/foo/bar,1024/here" + ], + [ + "{+path:6}/here", + "/foo/b/here" + ], + [ + "{+list}", + "red,green,blue" + ], + [ + "{+list*}", + "red,green,blue" + ], + [ + "{+keys}", + "semi,;,dot,.,comma,," + ], + [ + "{+keys*}", + "semi=;,dot=.,comma=," + ] + ] + }, + { + "name": "Section 3.2.4 — Fragment Expansion: {#var}", + "cases": [ + [ + "{#var}", + "#value" + ], + [ + "{#hello}", + "#Hello%20World!" + ], + [ + "{#half}", + "#50%25" + ], + [ + "foo{#empty}", + "foo#" + ], + [ + "foo{#undef}", + "foo" + ], + [ + "{#x,hello,y}", + "#1024,Hello%20World!,768" + ], + [ + "{#path,x}/here", + "#/foo/bar,1024/here" + ], + [ + "{#path:6}/here", + "#/foo/b/here" + ], + [ + "{#list}", + "#red,green,blue" + ], + [ + "{#list*}", + "#red,green,blue" + ], + [ + "{#keys}", + "#semi,;,dot,.,comma,," + ], + [ + "{#keys*}", + "#semi=;,dot=.,comma=," + ] + ] + }, + { + "name": "Section 3.2.5 — Label Expansion with Dot-Prefix: {.var}", + "cases": [ + [ + "{.who}", + ".fred" + ], + [ + "{.who,who}", + ".fred.fred" + ], + [ + "{.half,who}", + ".50%25.fred" + ], + [ + "www{.dom*}", + "www.example.com" + ], + [ + "X{.var}", + "X.value" + ], + [ + "X{.empty}", + "X." + ], + [ + "X{.undef}", + "X" + ], + [ + "X{.var:3}", + "X.val" + ], + [ + "X{.list}", + "X.red,green,blue" + ], + [ + "X{.list*}", + "X.red.green.blue" + ], + [ + "X{.keys}", + "X.semi,%3B,dot,.,comma,%2C" + ], + [ + "X{.keys*}", + "X.semi=%3B.dot=..comma=%2C" + ], + [ + "X{.empty_keys}", + "X" + ], + [ + "X{.empty_keys*}", + "X" + ] + ] + }, + { + "name": "Section 3.2.6 — Path Segment Expansion: {/var}", + "cases": [ + [ + "{/who}", + "/fred" + ], + [ + "{/who,who}", + "/fred/fred" + ], + [ + "{/half,who}", + "/50%25/fred" + ], + [ + "{/who,dub}", + "/fred/me%2Ftoo" + ], + [ + "{/var}", + "/value" + ], + [ + "{/var,empty}", + "/value/" + ], + [ + "{/var,undef}", + "/value" + ], + [ + "{/var,x}/here", + "/value/1024/here" + ], + [ + "{/var:1,var}", + "/v/value" + ], + [ + "{/list}", + "/red,green,blue" + ], + [ + "{/list*}", + "/red/green/blue" + ], + [ + "{/list*,path:4}", + "/red/green/blue/%2Ffoo" + ], + [ + "{/keys}", + "/semi,%3B,dot,.,comma,%2C" + ], + [ + "{/keys*}", + "/semi=%3B/dot=./comma=%2C" + ] + ] + }, + { + "name": "Section 3.2.7 — Path-Style Parameter Expansion: {;var}", + "cases": [ + [ + "{;who}", + ";who=fred" + ], + [ + "{;half}", + ";half=50%25" + ], + [ + "{;empty}", + ";empty" + ], + [ + "{;v,empty,who}", + ";v=6;empty;who=fred" + ], + [ + "{;v,bar,who}", + ";v=6;who=fred" + ], + [ + "{;x,y}", + ";x=1024;y=768" + ], + [ + "{;x,y,empty}", + ";x=1024;y=768;empty" + ], + [ + "{;x,y,undef}", + ";x=1024;y=768" + ], + [ + "{;hello:5}", + ";hello=Hello" + ], + [ + "{;list}", + ";list=red,green,blue" + ], + [ + "{;list*}", + ";list=red;list=green;list=blue" + ], + [ + "{;keys}", + ";keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{;keys*}", + ";semi=%3B;dot=.;comma=%2C" + ] + ] + }, + { + "name": "Section 3.2.8 — Form-Style Query Expansion: {?var}", + "cases": [ + [ + "{?who}", + "?who=fred" + ], + [ + "{?half}", + "?half=50%25" + ], + [ + "{?x,y}", + "?x=1024&y=768" + ], + [ + "{?x,y,empty}", + "?x=1024&y=768&empty=" + ], + [ + "{?x,y,undef}", + "?x=1024&y=768" + ], + [ + "{?var:3}", + "?var=val" + ], + [ + "{?list}", + "?list=red,green,blue" + ], + [ + "{?list*}", + "?list=red&list=green&list=blue" + ], + [ + "{?keys}", + "?keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{?keys*}", + "?semi=%3B&dot=.&comma=%2C" + ] + ] + }, + { + "name": "Section 3.2.9 — Form-Style Query Continuation: {&var}", + "cases": [ + [ + "{&who}", + "&who=fred" + ], + [ + "{&half}", + "&half=50%25" + ], + [ + "?fixed=yes{&x}", + "?fixed=yes&x=1024" + ], + [ + "{&x,y,empty}", + "&x=1024&y=768&empty=" + ], + [ + "{&x,y,undef}", + "&x=1024&y=768" + ], + [ + "{&var:3}", + "&var=val" + ], + [ + "{&list}", + "&list=red,green,blue" + ], + [ + "{&list*}", + "&list=red&list=green&list=blue" + ], + [ + "{&keys}", + "&keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{&keys*}", + "&semi=%3B&dot=.&comma=%2C" + ] + ] + } +] diff --git a/packages/uri-template/src/tests/json/references/vars.json b/packages/uri-template/src/tests/json/references/vars.json new file mode 100644 index 000000000..8535b6808 --- /dev/null +++ b/packages/uri-template/src/tests/json/references/vars.json @@ -0,0 +1,58 @@ +{ + "count": [ + "one", + "two", + "three" + ], + "dom": [ + "example", + "com" + ], + "dub": "me/too", + "hello": "Hello World!", + "half": "50%", + "var": "value", + "who": "fred", + "base": "http://example.com/home/", + "path": "/foo/bar", + "list": [ + "red", + "green", + "blue" + ], + "keys": { + "semi": ";", + "dot": ".", + "comma": "," + }, + "v": "6", + "x": "1024", + "y": "768", + "empty": "", + "empty_keys": {}, + "undef": null, + "semi": ";", + "year": [ + "1965", + "2000", + "2012" + ], + "empty_list": [], + "unicode": "페디파이", + "abc%20def": "spaced", + "%2Bvar": "plus", + "%7B": "open-brace", + "%41": "encoded-A", + "foo.bar": "dotted", + "123": "numeric", + "sparse": [ + "one", + null, + "three" + ], + "nullable_keys": { + "a": "1", + "b": null, + "c": "3" + } +} diff --git a/packages/uri-template/src/tests/json/wrong.json b/packages/uri-template/src/tests/json/wrong.json new file mode 100644 index 000000000..b7b9e551e --- /dev/null +++ b/packages/uri-template/src/tests/json/wrong.json @@ -0,0 +1,407 @@ +[ + { + "name": "Brackets not matched", + "cases": [ + { + "template": "{var", + "name": "unclosed opening brace", + "expected": "UnclosedExpressionError" + }, + { + "template": "var}", + "name": "unmatched closing brace after literal", + "expected": "StrayClosingBraceError" + }, + { + "template": "{", + "name": "lone opening brace", + "expected": "UnclosedExpressionError" + }, + { + "template": "}", + "name": "lone closing brace", + "expected": "StrayClosingBraceError" + }, + { + "template": "prefix{var", + "name": "unclosed expression after literal prefix", + "expected": "UnclosedExpressionError" + }, + { + "template": "{a}{b", + "name": "second expression unclosed", + "expected": "UnclosedExpressionError" + }, + { + "template": "{+var:3", + "name": "unclosed expression with operator and prefix modifier", + "expected": "UnclosedExpressionError" + }, + { + "template": "{var,name", + "name": "unclosed expression with multiple varspecs", + "expected": "UnclosedExpressionError" + }, + { + "template": "text{?q,r", + "name": "unclosed query-style expression", + "expected": "UnclosedExpressionError" + } + ] + }, + { + "name": "Duplicated brackets", + "cases": [ + { + "template": "{{var}}", + "name": "nested opening braces", + "expected": "NestedOpeningBraceError" + }, + { + "template": "{var}}", + "name": "extra closing brace after expression", + "expected": "StrayClosingBraceError" + }, + { + "template": "{{var}", + "name": "extra opening brace before expression", + "expected": "NestedOpeningBraceError" + }, + { + "template": "{}}", + "name": "extra closing brace after empty expression", + "expected": "EmptyExpressionError" + }, + { + "template": "{{}", + "name": "nested empty expression", + "expected": "NestedOpeningBraceError" + }, + { + "template": "{a{b}", + "name": "opening brace inside expression", + "expected": "NestedOpeningBraceError" + }, + { + "template": "{a}b}", + "name": "extra closing brace after literal", + "expected": "StrayClosingBraceError" + }, + { + "template": "{{{var}}}", + "name": "triple-nested braces", + "expected": "NestedOpeningBraceError" + } + ] + }, + { + "name": "Wrong position of level4 modifier", + "cases": [ + { + "template": "{*var}", + "name": "explode placed before varname", + "expected": "UnknownOperatorError" + }, + { + "template": "{:3var}", + "name": "prefix placed before varname", + "expected": "UnknownOperatorError" + }, + { + "template": "{var*name}", + "name": "explode placed in the middle of varname", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var:3:5}", + "name": "two prefix modifiers on a single varspec", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var**}", + "name": "two explode modifiers on a single varspec", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var*:3}", + "name": "explode followed by prefix on a single varspec", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var:3*}", + "name": "prefix followed by explode on a single varspec", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{*}", + "name": "lone explode modifier without varname", + "expected": "UnknownOperatorError" + }, + { + "template": "{:3}", + "name": "lone prefix modifier without varname", + "expected": "UnknownOperatorError" + }, + { + "template": "{a*,b*c}", + "name": "explode placed in the middle of the second varname", + "expected": "UnexpectedCharacterError" + } + ] + }, + { + "name": "Wrong prefix modifier", + "cases": [ + { + "template": "{var:}", + "name": "empty prefix length (max-length requires at least one digit)", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:0}", + "name": "zero prefix length (max-length first digit is %x31-39)", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:01}", + "name": "leading zero in prefix length", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:10000}", + "name": "prefix length must be less than 10000", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:99999}", + "name": "prefix length exceeds max-length digit count", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:abc}", + "name": "non-numeric prefix length", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:-1}", + "name": "negative prefix length", + "expected": "InvalidPrefixError" + }, + { + "template": "{var: 3}", + "name": "space before prefix length", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:3a}", + "name": "non-digit character after digits in prefix length", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var:1.5}", + "name": "decimal point in prefix length", + "expected": "UnexpectedCharacterError" + } + ] + }, + { + "name": "Invalid characters in literals", + "cases": [ + { + "template": "\\u0000", + "name": "NUL control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u0001", + "name": "SOH control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u0009", + "name": "TAB control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u000A", + "name": "LF control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u000D", + "name": "CR control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u001F", + "name": "US control character (last C0 CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u007F", + "name": "DEL control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": " ", + "name": "space character (SP forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "a b", + "name": "embedded space in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "\"", + "name": "double-quote character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "'", + "name": "apostrophe forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "%", + "name": "lone percent sign without pct-encoded triplet", + "expected": "InvalidLiteralError" + }, + { + "template": "%1", + "name": "incomplete pct-encoded sequence", + "expected": "InvalidLiteralError" + }, + { + "template": "%G1", + "name": "invalid first hex digit in pct-encoded sequence", + "expected": "InvalidLiteralError" + }, + { + "template": "%1G", + "name": "invalid second hex digit in pct-encoded sequence", + "expected": "InvalidLiteralError" + }, + { + "template": "<", + "name": "less-than character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": ">", + "name": "greater-than character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "\\", + "name": "backslash character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "^", + "name": "caret character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "`", + "name": "backtick character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "|", + "name": "pipe character forbidden in literals", + "expected": "InvalidLiteralError" + } + ] + }, + { + "name": "Invalid characters in expression", + "cases": [ + { + "template": "{}", + "name": "empty expression (variable-list requires at least one varspec)", + "expected": "EmptyExpressionError" + }, + { + "template": "{,var}", + "name": "empty varname before comma in variable-list", + "expected": "ReservedOperatorError" + }, + { + "template": "{var,}", + "name": "empty varname after trailing comma in variable-list", + "expected": "TrailingCommaError" + }, + { + "template": "{var,,name}", + "name": "consecutive commas yielding an empty varspec", + "expected": "EmptyVarNameError" + }, + { + "template": "{var..name}", + "name": "consecutive dots in varname (varname allows at most one dot between varchars)", + "expected": "InvalidVarNameError" + }, + { + "template": "{var.}", + "name": "trailing dot in varname (dot must be followed by a varchar)", + "expected": "InvalidVarNameError" + }, + { + "template": "{var-name}", + "name": "hyphen is not in the varchar set", + "expected": "InvalidVarNameError" + }, + { + "template": "{var name}", + "name": "space is not in the varchar set", + "expected": "InvalidVarNameError" + }, + { + "template": "{var$name}", + "name": "dollar sign is not in the varchar set", + "expected": "InvalidVarNameError" + }, + { + "template": "{var^name}", + "name": "caret is not in the varchar set", + "expected": "InvalidVarNameError" + }, + { + "template": "{var%G1}", + "name": "invalid hex in pct-encoded varchar", + "expected": "InvalidVarNameError" + }, + { + "template": "{=var}", + "name": "operator '=' is reserved (op-reserve, unimplemented)", + "expected": "ReservedOperatorError" + }, + { + "template": "{!var}", + "name": "operator '!' is reserved (op-reserve, unimplemented)", + "expected": "ReservedOperatorError" + }, + { + "template": "{@var}", + "name": "operator '@' is reserved (op-reserve, unimplemented)", + "expected": "ReservedOperatorError" + }, + { + "template": "{|var}", + "name": "operator '|' is reserved (op-reserve, unimplemented)", + "expected": "ReservedOperatorError" + }, + { + "template": "{++var}", + "name": "duplicated operator characters", + "expected": "EmptyVarNameError" + }, + { + "template": "{+#var}", + "name": "two operators in a single expression", + "expected": "EmptyVarNameError" + } + ] + } +] diff --git a/packages/uri-template/src/tests/lib.ts b/packages/uri-template/src/tests/lib.ts new file mode 100644 index 000000000..6d10d4a9a --- /dev/null +++ b/packages/uri-template/src/tests/lib.ts @@ -0,0 +1,213 @@ +import { equal, throws } from "node:assert/strict"; +import * as ERROR_CLASSES from "../errors.ts"; +import type { ExpandContext } from "../types.ts"; +import testVars from "./json/references/vars.json" with { type: "json" }; + +interface TemplateConstructor { + new (template: string): { + expand(context: ExpandContext): string; + }; +} + +type PairTestCase = readonly [template: string, expanded: string]; + +export interface PairTestSuite { + name: string; + cases: readonly PairTestCase[]; +} + +export function createTemplatePairTest( + Template: TemplateConstructor, +): ( + cases: readonly PairTestCase[], + context?: ExpandContext, +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly PairTestCase[], + context: ExpandContext = testVars, + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const [template, expected] of cases) { + await t.step( + `${template} => ${expected}`, + () => equal(new Template(template).expand(context), expected), + ); + } + }; +} + +export interface FixedTemplateTestSuite { + name: string; + template: string; + cases: FixedTemplateTestCase[]; +} + +interface FixedTemplateTestCase { + name: string; + context: ExpandContext; + expected: string; +} + +export const createFixedTemplateTest: ( + Template: TemplateConstructor, +) => ( + template: string, +) => ( + cases: FixedTemplateTestCase[], +) => (t: Deno.TestContext) => Promise = + (Template: TemplateConstructor) => (template: string) => { + const instance = new Template(template); + return ( + cases: FixedTemplateTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, context, expected } of cases) { + await t.step(name, () => equal(instance.expand(context), expected)); + } + }; + }; + +type ErrorName = keyof typeof ERROR_CLASSES; + +/** + * A single negative test case asserting that a given URI template MUST cause + * the parser to throw a specific error class. + * + * Unlike {@link PairTestSuite} or {@link FixedTemplateTestSuite}, which assert + * successful expansion, a {@link WrongTemplateTestCase} pins down the exact + * error class that the parser is expected to raise. Pinning the class — rather + * than merely asserting "something throws" — catches regressions where the + * parser still rejects the input but with a less precise diagnostic. + */ +interface WrongTemplateTestCase { + /** + * Human-readable label of the case, used as the test step name. + * Should explain *why* the template is invalid (e.g. + * "unclosed opening brace"), not what the error is. + */ + name: string; + /** + * The URI template string that the parser MUST reject. + */ + template: string; + /** + * The `name` of the error class that the parser MUST throw, taken from + * the concrete classes exported by *src/errors.ts* (e.g. + * `"UnclosedExpressionError"`, `"InvalidLiteralError"`). + * + * The runner compares the thrown error's `instanceof` against the class + * resolved from this name; subclasses count as a match. + */ + expected: ErrorName; +} + +export interface WrongTestSuite { + name: string; + cases: readonly WrongTemplateTestCase[]; +} + +export function createWrongTemplateTest( + Template: TemplateConstructor, +): ( + cases: readonly WrongTemplateTestCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly WrongTemplateTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, template, expected } of cases) { + await t.step( + `${template} — ${name}`, + () => throws(() => new Template(template), ERROR_CLASSES[expected]), + ); + } + }; +} + +/** + * A single hard-mode test case. Each case carries its own template (unlike + * {@link FixedTemplateTestSuite} where the suite shares one template). + * + * The shape of {@link HardTestCase.expected} is determined by + * {@link HardTestCase.success}: + * + * - `success: true` → `expected` is the exact expansion result string. + * - `success: false` → `expected` is the `name` of the error class that the + * parser or expander MUST throw, taken from the concrete classes exported + * by *src/errors.ts*. The runner verifies the thrown error via + * `instanceof` against the resolved class. + * + * Use {@link HardTestCase.reason} for the human-readable rationale. + */ +type HardTestCase = HardSuccessCase | HardFailureCase; + +interface HardCaseBase { + /** Human-readable name of the case, used as the test step label. */ + name: string; + /** URI template string to parse and expand. */ + template: string; + /** Optional rationale explaining why this case behaves as specified. */ + reason?: string; +} + +interface HardSuccessCase extends HardCaseBase { + /** The exact string that the template MUST expand to. */ + expected: string; + /** Marks this as a success case. */ + success: true; +} + +interface HardFailureCase extends HardCaseBase { + /** + * The `name` of the error class that the parser or expander MUST throw, + * taken from the concrete classes exported by *src/errors.ts* (e.g. + * `"UnclosedExpressionError"`, `"PrefixModifierNotApplicableError"`). + * + * The runner compares the thrown error's `instanceof` against the class + * resolved from this name; subclasses count as a match. + */ + expected: ErrorName; + /** Marks this as a failure case. */ + success: false; +} + +/** + * A suite of {@link HardTestCase}s grouped under a common theme. + * + * Unlike {@link PairTestSuite} (uniform success cases) or + * {@link FixedTemplateTestSuite} (one fixed template, varying contexts), + * a hard-mode suite mixes per-case templates and may include both + * success and failure cases in the same suite. + */ +export interface HardTestSuite { + /** Human-readable name of the suite. */ + name: string; + /** Cases belonging to this suite. */ + cases: readonly HardTestCase[]; +} + +export function createTemplateHardTest( + Template: TemplateConstructor, +): ( + cases: readonly HardTestCase[], + context?: ExpandContext, +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly HardTestCase[], + context: ExpandContext = testVars, + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const c of cases) { + await t.step(c.name, () => { + if (c.success) { + equal(new Template(c.template).expand(context), c.expected); + } else { + throws( + () => new Template(c.template).expand(context), + ERROR_CLASSES[c.expected], + ); + } + }); + } + }; +} diff --git a/packages/uri-template/src/tests/mod.ts b/packages/uri-template/src/tests/mod.ts new file mode 100644 index 000000000..065b73013 --- /dev/null +++ b/packages/uri-template/src/tests/mod.ts @@ -0,0 +1,36 @@ +import { + assertHardTestSuite, + assertPairTestSuite, + assertWrongTestSuite, +} from "./assert.ts"; +import _hardTestSuites from "./json/hard.json" with { + type: "json", +}; +import _fixedTestSuites from "./json/references/fixed.json" with { + type: "json", +}; +import _pairTestSuites from "./json/references/pairs.json" with { + type: "json", +}; +import _wrongTestSuites from "./json/wrong.json" with { type: "json" }; +import type { + FixedTemplateTestSuite, + HardTestSuite, + PairTestSuite, + WrongTestSuite, +} from "./lib.ts"; + +assertPairTestSuite(_pairTestSuites); +assertWrongTestSuite(_wrongTestSuites); +assertHardTestSuite(_hardTestSuites); +export const pairTestSuites: readonly PairTestSuite[] = _pairTestSuites; +export const fixedTestSuites: readonly FixedTemplateTestSuite[] = + _fixedTestSuites; +export const wrongTestSuites: readonly WrongTestSuite[] = _wrongTestSuites; +export const hardTestSuites: readonly HardTestSuite[] = _hardTestSuites; +export { + createFixedTemplateTest, + createTemplateHardTest, + createTemplatePairTest, + createWrongTemplateTest, +} from "./lib.ts"; From 8759b52c876d827f492bc838dbb077f17e8bc7e6 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 2 May 2026 03:29:30 +0000 Subject: [PATCH 07/49] Report URI template expansion errors Thread TemplateOptions through URI template expansion so runtime diagnostics use the same report and strict behavior as parsing. The Template instance now uses its normalized options by default while allowing callers to pass explicit expansion options. Also let Template.parse accept parse options and add coverage for non-strict expansion reporting. Assisted-by: Codex:GPT-5 --- packages/uri-template/src/template/expand.ts | 21 +++++++++----- packages/uri-template/src/template/mod.ts | 23 +++++++++------ .../src/template/template.test.ts | 28 +++++++++++++++++++ packages/uri-template/src/types.ts | 10 +++---- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/packages/uri-template/src/template/expand.ts b/packages/uri-template/src/template/expand.ts index 4bc73e1aa..92f40ea5c 100644 --- a/packages/uri-template/src/template/expand.ts +++ b/packages/uri-template/src/template/expand.ts @@ -4,6 +4,7 @@ import type { AssociativeValue, ExpandContext, PrimitiveValue, + TemplateOptions, VarSpec, } from "../types.ts"; import { encodeName, encodeValue, truncateValue } from "./encoding.ts"; @@ -16,10 +17,11 @@ export default function expand( vars: 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) + expandValue(varSpec, context[varSpec.name], spec, options) ); return parts.length < 1 ? "" : `${spec.first}${parts.join(spec.sep)}`; } @@ -28,18 +30,19 @@ function expandValue( varSpec: VarSpec, value: ExpandContext[string], spec: typeof operatorSpecs[keyof typeof operatorSpecs], + options: TemplateOptions, ): string[] { if (isUndefined(value)) return []; if (isList(value)) { const encoded = encodeListMembers(value, spec.allowReserved); if (encoded.length < 1) return []; - assertNoPrefixModifier(varSpec, "list"); + 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 []; - assertNoPrefixModifier(varSpec, "associative"); + if (!reportPrefixModifierError(varSpec, "associative", options)) return []; return expandAssociative(varSpec, pairs, spec); } return expandPrimitive(varSpec, value, spec); @@ -159,14 +162,18 @@ function isUndefined(value: unknown): value is null | undefined { return value == null; } -function assertNoPrefixModifier( +function reportPrefixModifierError( varSpec: VarSpec, valueType: "list" | "associative", -): void { - if (varSpec.prefix == null) return; - throw new PrefixModifierNotApplicableError( + { 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/mod.ts b/packages/uri-template/src/template/mod.ts index 10011445e..cf377b49b 100644 --- a/packages/uri-template/src/template/mod.ts +++ b/packages/uri-template/src/template/mod.ts @@ -15,7 +15,7 @@ import tokenize from "./token.ts"; */ export default class Template { readonly #tokens: Token[]; - readonly fullOptions: TemplateOptions; + readonly #fullOptions: TemplateOptions; constructor( /** @@ -33,15 +33,18 @@ export default class Template { */ readonly options: Partial = {}, ) { - this.fullOptions = fillOptions(options); - this.#tokens = tokenize(uriTemplate, this.fullOptions); + this.#fullOptions = fillOptions(options); + this.#tokens = tokenize(uriTemplate, this.#fullOptions); } /** * Parses a URI Template using default strict parsing options. */ - static parse(uriTemplate: string): Template { - return new Template(uriTemplate); + static parse( + uriTemplate: string, + options: Partial = {}, + ): Template { + return new Template(uriTemplate, options); } /** @@ -51,17 +54,21 @@ export default class Template { return this.#tokens; } - #expand(context: ExpandContext): string { + #expand( + context: ExpandContext, + options: TemplateOptions = this.#fullOptions, + ): string { return this.#tokens.map((token) => token.kind === "literal" ? token.text - : expand(token.vars, token.operator, context) + : expand(token.vars, token.operator, context, options) ).join(""); } /** * Expands this template against a variable context. */ - expand: (context: ExpandContext) => string = this.#expand.bind(this); + expand: (context: ExpandContext, options?: TemplateOptions) => string = this + .#expand.bind(this); } const defaultReporter = (_error: Error): void => {}; diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts index 92f8510c1..51e438605 100644 --- a/packages/uri-template/src/template/template.test.ts +++ b/packages/uri-template/src/template/template.test.ts @@ -4,6 +4,7 @@ import { throws } from "node:assert/strict"; import { InvalidLiteralError, InvalidPrefixError, + PrefixModifierNotApplicableError, ReservedOperatorError, UnclosedExpressionError, } from "../errors.ts"; @@ -58,6 +59,33 @@ test("reports parse errors without throwing in non-strict mode", () => { equal(errors[0] instanceof ReservedOperatorError, true); }); +test("reports expansion errors without throwing in non-strict mode", () => { + const errors: Error[] = []; + const template = new Template("{list:3}/{ok}", { + strict: false, + report: (error: Error) => errors.push(error), + }); + + equal(template.expand({ list: ["red"], ok: "value" }), "/value"); + equal(errors.length, 1); + equal(errors[0] instanceof PrefixModifierNotApplicableError, true); +}); + +test("uses explicit expand options when provided", () => { + const errors: Error[] = []; + const template = new Template("{list:3}/{ok}"); + + equal( + template.expand( + { list: ["red"], ok: "value" }, + { strict: false, report: (error: Error) => errors.push(error) }, + ), + "/value", + ); + equal(errors.length, 1); + equal(errors[0] instanceof PrefixModifierNotApplicableError, true); +}); + test("parses reusable template instances", () => { const template = Template.parse("/mapper{?address*}"); equal( diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index f31942d90..f1ae07a9f 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -93,14 +93,14 @@ export type Token = | { kind: "expression"; operator: Operator; vars: VarSpec[] }; /** - * Options controlling URI Template parsing diagnostics. + * Options controlling URI Template parsing and expansion diagnostics. */ export interface TemplateOptions { /** - * If `true`, the first error in the template will be automatically thrown - * while parsing after being reported. `true` is the default value. - * If `false`, all errors will be reported to by the `report` function, - * but none will be thrown unless the `report` function itself throws. + * If `true`, the first parse or expansion error will be automatically + * thrown after being reported. `true` is the default value. If `false`, + * errors will be reported to by the `report` function, but none will be + * thrown unless the `report` function itself throws. */ strict: boolean; /** From 2372a598ed4e9b252590203733fae59c8e485d41 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 2 May 2026 05:56:04 +0000 Subject: [PATCH 08/49] Document URI template compatibility Explain why Fedify ships its own RFC 6570 implementation instead of wrapping url-template. Document the observed compliance gaps and add a bench test that records url-template behavior against the shared test suites. Assisted-by: Codex:gpt-5 --- packages/uri-template/README.md | 47 ++++++++++ .../uri-template/bench/url-template.test.ts | 90 +++++++++++++++++++ packages/uri-template/deno.json | 5 +- 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 packages/uri-template/bench/url-template.test.ts diff --git a/packages/uri-template/README.md b/packages/uri-template/README.md index 68c0aa8ae..9b6af47ef 100644 --- a/packages/uri-template/README.md +++ b/packages/uri-template/README.md @@ -18,6 +18,53 @@ behavior. It is part of the [Fedify] framework but can be used independently. [Fedify]: https://fedify.dev/ +Why `@fedify/url-template`? +--------------------------- + +Fedify used [url-template] as the baseline implementation before writing +`@fedify/url-template`. That package 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 benchmark in *bench/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/url-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. + +`@fedify/url-template` was written as a new implementation instead of wrapping +[url-template] because Fedify needs strict RFC 6570 expansion, typed syntax +errors, and symmetric matching behavior. Applications that need a looser +parser can opt in explicitly: `strict: false` reports parse and expansion +errors without throwing, and a custom `report` function can allow all errors or +throw only for selected error classes. + +[url-template]: https://www.npmjs.com/package/url-template +[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 + + Features -------- diff --git a/packages/uri-template/bench/url-template.test.ts b/packages/uri-template/bench/url-template.test.ts new file mode 100644 index 000000000..56c03fede --- /dev/null +++ b/packages/uri-template/bench/url-template.test.ts @@ -0,0 +1,90 @@ +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 bench`. + * These are the compatibility gaps that motivated the strict + * @fedify/url-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/url-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; + } +} + +const runPairCases = createTemplatePairTest(Template); +for (const { name, cases } of pairTestSuites) { + test(name, runPairCases(cases as unknown as readonly [string, string][])); +} + +const runFixedCases = createFixedTemplateTest(Template); +for (const { template, name, cases } of fixedTestSuites) { + test(name, runFixedCases(template)(cases)); +} + +const runWrongCases = createWrongTemplateTest(Template); +for (const { name, cases } of wrongTestSuites) { + test(name, runWrongCases(cases)); +} + +const runHardCases = createTemplateHardTest(Template); +for (const { name, cases } of hardTestSuites) { + test(name, runHardCases(cases)); +} diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json index 4ed62efae..b980d7c0e 100644 --- a/packages/uri-template/deno.json +++ b/packages/uri-template/deno.json @@ -21,7 +21,8 @@ ] }, "tasks": { - "check": "deno fmt --check && deno lint && deno check src/*.ts", - "test": "deno test --allow-env" + "bench": "deno test --allow-env bench/", + "check": "deno fmt --check && deno lint && deno check src/", + "test": "deno test --allow-env src/" } } From 1491e21901bd101163f3891b7ecb0be85cbc66c4 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 4 May 2026 18:05:49 +0000 Subject: [PATCH 09/49] Refine URI template expansion internals Move expansion over parsed tokens so Template.expand delegates the full token stream at once. Simplify URI character predicates with reusable predicate helpers, and keep URI Template spelling words in the root cspell dictionary. Also make Template expose a string form and route default diagnostics through the package logger. Assisted-by: Codex:GPT-5 --- cspell.json | 5 + packages/uri-template/cspell.json | 9 - .../uri-template/src/template/encoding.ts | 179 ++++++++++-------- packages/uri-template/src/template/expand.ts | 121 ++++++------ packages/uri-template/src/template/mod.ts | 26 +-- packages/uri-template/src/types.ts | 11 +- 6 files changed, 182 insertions(+), 169 deletions(-) delete mode 100644 packages/uri-template/cspell.json diff --git a/cspell.json b/cspell.json index c7516d114..25fb5937a 100644 --- a/cspell.json +++ b/cspell.json @@ -121,6 +121,11 @@ "urlpattern", "uuidv7", "valueparser", + "varspec", + "varname", + "varnames", + "varchar", + "varchars", "Vinxi", "vitepress", "vtsls", diff --git a/packages/uri-template/cspell.json b/packages/uri-template/cspell.json deleted file mode 100644 index 4cd64f2ed..000000000 --- a/packages/uri-template/cspell.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "words": [ - "varspec", - "varname", - "varnames", - "varchar", - "varchars" - ] -} diff --git a/packages/uri-template/src/template/encoding.ts b/packages/uri-template/src/template/encoding.ts index b4ca49077..96c2f4372 100644 --- a/packages/uri-template/src/template/encoding.ts +++ b/packages/uri-template/src/template/encoding.ts @@ -7,24 +7,26 @@ const hexDigits = "0123456789ABCDEF"; * * Used by parsers and encoders when recognizing pct-encoded triplets. */ -export function isHexDigit(char: string): boolean { - const code = char.charCodeAt(0); - return ( - code >= 0x30 && code <= 0x39 || - code >= 0x41 && code <= 0x46 || - code >= 0x61 && code <= 0x66 - ); -} +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 function isPctEncodedAt(value: string, index: number): boolean { - return value[index] === "%" && - index + 2 < value.length && - isHexDigit(value[index + 1]) && - isHexDigit(value[index + 2]); -} +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 @@ -33,7 +35,7 @@ export function isPctEncodedAt(value: string, index: number): boolean { export function isVarcharAt(value: string, index: number): number { const char = value[index]; if (char == null) return 0; - if (isAlpha(char) || isDigit(char) || char === "_") return 1; + if (some(isAlpha, isDigit, eq("_"))(char)) return 1; return isPctEncodedAt(value, index) ? 3 : 0; } @@ -64,7 +66,12 @@ export function readCodePoint( * Percent-encodes an expanded variable value according to the operator's * allowed-character rule. */ -export function encodeValue(value: string, allowReserved: boolean): string { +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)) { @@ -74,13 +81,16 @@ export function encodeValue(value: string, allowReserved: boolean): string { } const { char, size } = readCodePoint(value, index); - encoded += isUnreserved(char) || allowReserved && isReserved(char) + 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. @@ -122,70 +132,83 @@ export function truncateValue(value: string, length: number): string { return truncated; } -function isAlpha(char: string): boolean { - const code = char.charCodeAt(0); - return code >= 0x41 && code <= 0x5a || code >= 0x61 && code <= 0x7a; -} - -function isDigit(char: string): boolean { - const code = char.charCodeAt(0); - return code >= 0x30 && code <= 0x39; -} - -function isUnreserved(char: string): boolean { - return isAlpha(char) || isDigit(char) || - char === "-" || char === "." || char === "_" || char === "~"; -} - -function isReserved(char: string): boolean { - return ":/?#[]@!$&'()*+,;=".includes(char); -} - -function isLiteralChar(char: string): boolean { - const code = char.codePointAt(0); - if (code == null) return false; - return code === 0x21 || - code >= 0x23 && code <= 0x24 || - code === 0x26 || - code >= 0x28 && code <= 0x3b || - code === 0x3d || - code >= 0x3f && code <= 0x5b || - code === 0x5d || - code === 0x5f || - code >= 0x61 && code <= 0x7a || - code === 0x7e || - isUcsChar(code) || - isIPrivate(code); -} +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 { - return code >= 0xa0 && code <= 0xd7ff || - code >= 0xf900 && code <= 0xfdcf || - code >= 0xfdf0 && code <= 0xffef || - code >= 0x10000 && code <= 0x1fffd || - code >= 0x20000 && code <= 0x2fffd || - code >= 0x30000 && code <= 0x3fffd || - code >= 0x40000 && code <= 0x4fffd || - code >= 0x50000 && code <= 0x5fffd || - code >= 0x60000 && code <= 0x6fffd || - code >= 0x70000 && code <= 0x7fffd || - code >= 0x80000 && code <= 0x8fffd || - code >= 0x90000 && code <= 0x9fffd || - code >= 0xa0000 && code <= 0xafffd || - code >= 0xb0000 && code <= 0xbfffd || - code >= 0xc0000 && code <= 0xcfffd || - code >= 0xd0000 && code <= 0xdfffd || - code >= 0xe1000 && code <= 0xefffd; -} + if (code < 0x10000) { + return some( + between(0xa0, 0xd7ff), + between(0xf900, 0xfdcf), + between(0xfdf0, 0xffef), + )(code); + } -function isIPrivate(code: number): boolean { - return code >= 0xe000 && code <= 0xf8ff || - code >= 0xf0000 && code <= 0xffffd || - code >= 0x100000 && code <= 0x10fffd; + if (code > 0xefffd) return false; + const offset = code % 0x10000; + return offset <= 0xfffd && (code < 0xe0000 || offset >= 0x1000); } -function percentEncode(char: string): string { - return Array.from(textEncoder.encode(char)) +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/expand.ts b/packages/uri-template/src/template/expand.ts index 92f40ea5c..1eeaf6e62 100644 --- a/packages/uri-template/src/template/expand.ts +++ b/packages/uri-template/src/template/expand.ts @@ -3,8 +3,10 @@ import { PrefixModifierNotApplicableError } from "../errors.ts"; import type { AssociativeValue, ExpandContext, + OperatorSpec, PrimitiveValue, TemplateOptions, + Token, VarSpec, } from "../types.ts"; import { encodeName, encodeValue, truncateValue } from "./encoding.ts"; @@ -14,6 +16,18 @@ import { encodeName, encodeValue, truncateValue } from "./encoding.ts"; * 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: VarSpec[], operator: keyof typeof operatorSpecs, context: ExpandContext, @@ -29,11 +43,11 @@ export default function expand( function expandValue( varSpec: VarSpec, value: ExpandContext[string], - spec: typeof operatorSpecs[keyof typeof operatorSpecs], + spec: OperatorSpec, options: TemplateOptions, ): string[] { - if (isUndefined(value)) return []; - if (isList(value)) { + if (value == null) return []; + if (isPrimitiveList(value)) { const encoded = encodeListMembers(value, spec.allowReserved); if (encoded.length < 1) return []; if (!reportPrefixModifierError(varSpec, "list", options)) return []; @@ -51,116 +65,93 @@ function expandValue( function expandPrimitive( varSpec: VarSpec, value: Exclude, - spec: typeof operatorSpecs[keyof typeof operatorSpecs], + spec: OperatorSpec, ): string[] { const text = String(value); const prefixed = varSpec.prefix == null ? text : truncateValue(text, varSpec.prefix); - const encoded = encodeValue(prefixed, spec.allowReserved); + const encoded = encodeValue(spec.allowReserved)(prefixed); if (!spec.named) return [encoded]; const name = encodeName(varSpec.name); - return [encoded === "" ? `${name}${spec.ifEmpty}` : `${name}=${encoded}`]; + return [expandNamedPair(name, encoded, spec)]; } function expandList( varSpec: VarSpec, encoded: readonly string[], - spec: typeof operatorSpecs[keyof typeof operatorSpecs], + spec: OperatorSpec, ): string[] { const name = encodeName(varSpec.name); if (varSpec.explode) { return spec.named - ? encoded.map((item) => - item === "" ? `${name}${spec.ifEmpty}` : `${name}=${item}` - ) + ? encoded.map((item) => expandNamedPair(name, item, spec)) : [...encoded]; } const joined = encoded.join(","); - return spec.named - ? [joined === "" ? `${name}${spec.ifEmpty}` : `${name}=${joined}`] - : [joined]; + return spec.named ? [expandNamedPair(name, joined, spec)] : [joined]; } function expandAssociative( varSpec: VarSpec, pairs: readonly (readonly [key: string, value: string])[], - spec: typeof operatorSpecs[keyof typeof operatorSpecs], + spec: OperatorSpec, ): string[] { if (varSpec.explode) { - return pairs.map(([key, item]) => { - return item === "" ? `${key}${spec.ifEmpty}` : `${key}=${item}`; - }); + return pairs.map(([key, item]) => expandNamedPair(key, item, spec)); } - const joined = pairs.flatMap(([key, item]) => [ - key, - item, - ]).join(","); - - if (!spec.named) return [joined]; + const item = pairs.flat(1).join(","); + if (!spec.named) return [item]; - const name = encodeName(varSpec.name); - return [joined === "" ? `${name}${spec.ifEmpty}` : `${name}=${joined}`]; + const key = encodeName(varSpec.name); + return [expandNamedPair(key, item, spec)]; } -function encodeListMembers( +const expandNamedPair = ( + key: string, + item: string, + spec: OperatorSpec, +): string => item === "" ? `${key}${spec.ifEmpty}` : `${key}=${item}`; + +const encodeListMembers = ( value: readonly PrimitiveValue[], allowReserved: boolean, -): string[] { - return value - .filter((item): item is Exclude => - !isUndefined(item) - ) - .map((item) => encodeValue(String(item), allowReserved)); -} +): string[] => + value + .filter((item) => item != null) + .map(String) + .map(encodeValue(allowReserved)); -function encodeAssociativePairs( +const encodeAssociativePairs = ( value: AssociativeValue, allowReserved: boolean, -): (readonly [key: string, value: string])[] { - return Object.entries(value).flatMap(([key, item]) => { - const normalized = normalizePairValue(item); - return normalized == null ? [] : [ - [ - encodeValue(key, allowReserved), - encodeValue(normalized, allowReserved), - ] as const, - ]; - }); -} +): (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 | undefined { - if (isUndefined(value)) return undefined; +): string | null { + if (value == null) return null; if (!Array.isArray(value)) return String(value); - const items = value - .filter((item): item is Exclude => - !isUndefined(item) - ) - .map(String); - return items.length < 1 ? undefined : items.join(","); + const items = value.filter((item) => item != null).map(String); + return items.length < 1 ? null : items.join(","); } -function isAssociative( +const isAssociative = ( value: ExpandContext[string], -): value is AssociativeValue { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +): value is AssociativeValue => + typeof value === "object" && value !== null && !Array.isArray(value); -function isList( +const isPrimitiveList = ( value: ExpandContext[string], -): value is readonly PrimitiveValue[] { - return Array.isArray(value); -} - -function isUndefined(value: unknown): value is null | undefined { - return value == null; -} +): value is readonly PrimitiveValue[] => Array.isArray(value); function reportPrefixModifierError( varSpec: VarSpec, diff --git a/packages/uri-template/src/template/mod.ts b/packages/uri-template/src/template/mod.ts index cf377b49b..4d4c5add2 100644 --- a/packages/uri-template/src/template/mod.ts +++ b/packages/uri-template/src/template/mod.ts @@ -1,3 +1,4 @@ +import { getLogger } from "@logtape/logtape"; import type { ExpandContext, Reporter, @@ -54,24 +55,23 @@ export default class Template { return this.#tokens; } - #expand( - context: ExpandContext, - options: TemplateOptions = this.#fullOptions, - ): string { - return this.#tokens.map((token) => - token.kind === "literal" - ? token.text - : expand(token.vars, token.operator, context, options) - ).join(""); - } /** * Expands this template against a variable context. */ - expand: (context: ExpandContext, options?: TemplateOptions) => string = this - .#expand.bind(this); + expand: ( + context: ExpandContext, + options?: TemplateOptions, + ) => string = ( + context: ExpandContext, + options: TemplateOptions = this.#fullOptions, + ): string => expand(this.#tokens, context, options); + + toString = (): string => this.uriTemplate; } -const defaultReporter = (_error: Error): void => {}; +const logger = getLogger(["fedify", "uri-template", "Template"]); + +const defaultReporter: Reporter = (error: Error) => logger.error(error); const fillOptions = ( { strict, report }: Partial, diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index f1ae07a9f..c718a05e0 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -1,13 +1,15 @@ export type { Operator, OperatorSpec } from "./const.ts"; import type { Operator } from "./const.ts"; +import type _Template from "./template/mod.ts"; /** - * Primitive value accepted by {@link Template.expand}. + * Primitive value accepted by {@link _Template.expand Template.expand}. */ export type PrimitiveValue = string | number | boolean | null | undefined; /** - * Associative composite value accepted by {@link Template.expand}. + * Associative composite value accepted by + * {@link _Template.expand Template.expand}. * * Keys are expanded as URI Template associative names. Values may be primitive * values or primitive lists. @@ -26,8 +28,9 @@ export type ExpandValue = | AssociativeValue; /** - * Context object accepted by {@link Template.expand}. Each variable resolves - * to a primitive, an ordered list of primitives, or an associative map. + * Context object accepted by {@link _Template.expand Template.expand}. + * Each variable resolves to a primitive, an ordered list of primitives, + * or an associative map. */ export type ExpandContext = Record; From 8d35a151e888090e792125a11bf430aa4a3d450c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 4 May 2026 18:08:04 +0000 Subject: [PATCH 10/49] Add URI template matching Add Template.match() and the matcher implementation for URI Template expressions. The matcher backtracks over expression boundaries and value interpretations, then verifies candidates by re-expanding the template. It handles named and unnamed operators, exploded lists and associative values, prefix bindings, and separator-sensitive named associative pairs. Assisted-by: Codex:gpt-5 --- packages/uri-template/src/template/match.ts | 761 ++++++++++++++++++++ packages/uri-template/src/template/mod.ts | 13 + 2 files changed, 774 insertions(+) create mode 100644 packages/uri-template/src/template/match.ts diff --git a/packages/uri-template/src/template/match.ts b/packages/uri-template/src/template/match.ts new file mode 100644 index 000000000..01cfc5277 --- /dev/null +++ b/packages/uri-template/src/template/match.ts @@ -0,0 +1,761 @@ +import { operatorSpecs } from "../const.ts"; +import type { + AssociativeValue, + ExpandContext, + ExpandValue, + OperatorSpec, + TemplateOptions, + Token, + VarSpec, +} from "../types.ts"; +import { encodeName, isVarcharAt, 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, vars) + ) { + 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 between `minLength` and `maxLength` of the + * remaining parts. + * + * The length range is intentionally broad — neither bound is tightened to the + * exact count of variables remaining after the current one — and the + * recursive matching in {@link matchUnnamedFrom} discards invalid + * distributions. + */ +function* consumeUnnamed( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly string[], + partIndex: number, + vars: readonly VarSpec[], +): Generator { + if (partIndex >= parts.length) return; + + const maxLength = parts.length - partIndex; + const minLength = Math.max(1, parts.length - partIndex - remainingVars(vars)); + for (let length = minLength; length <= maxLength; length++) { + const slice = parts.slice(partIndex, partIndex + length); + for (const bindings of parseUnnamedValue(varSpec, spec, slice)) { + yield { bindings, index: partIndex + length }; + } + } +} + +const remainingVars = (vars: readonly VarSpec[]): number => + Math.max(0, vars.length - 1); + +/** + * 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; + const keyEnd = readPairKeyEnd(body, keyStart); + return keyEnd > keyStart && body[keyEnd] === "="; +}; + +function readPairKeyEnd(body: string, start: number): number { + let index = start; + let expectVarchar = true; + while (index < body.length) { + const varcharLength = isVarcharAt(body, index); + if (varcharLength > 0) { + index += varcharLength; + expectVarchar = false; + continue; + } + if (body[index] !== ".") break; + if (expectVarchar || isVarcharAt(body, index + 1) < 1) break; + index++; + expectVarchar = true; + } + return index; +} + +/** + * 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 { + const name = encodeName(varSpec.name); + const values = [...namedListValues(name, spec, parts, partIndex)]; + + if (values) { + yield { + bindings: bindValue(varSpec, values), + index: partIndex + values.length, + }; + } +} + +function* namedListValues( + name: string, + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, +): Generator { + for ( + let index = partIndex; + index < parts.length && parts[index].name === name; + index++ + ) { + const value = decodeValue(parts[index].value, spec.allowReserved); + if (value == null) return; + yield value; + } +} + +/** + * Reads consecutive parts as `key=value` entries of an associative array under + * one named exploded variable. + * + * After the first part, stops as soon as a part's name matches any declared + * variable so that those parts remain available for their own variables. The + * first part is always consumed regardless of name to bootstrap the + * association. + */ +function* consumeNamedAssociative( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, + vars: readonly VarSpec[], +): Generator { + const reservedNames = new Set(vars.map((item) => encodeName(item.name))); + const entries = [ + ...namedAssociativeEntries(spec, parts, partIndex, reservedNames), + ]; + + if (entries.length > 0) { + yield { + bindings: bindValue(varSpec, Object.fromEntries(entries)), + index: partIndex + entries.length, + }; + } +} + +function* namedAssociativeEntries( + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, + reservedNames: ReadonlySet, +): Generator { + for (let index = partIndex; index < parts.length; index++) { + const part = parts[index]; + if (index > partIndex && reservedNames.has(part.name)) return; + const key = decodeValue(part.name, spec.allowReserved); + const value = decodeValue(part.value, spec.allowReserved); + if (key == null || value == null) return; + yield [key, value]; + } +} + +function splitNamedPart(part: string): NamedPart { + const equals = part.indexOf("="); + return equals < 0 + ? { name: part, value: "" } + : { name: part.slice(0, equals), value: part.slice(equals + 1) }; +} + +function parseAssociative( + parts: readonly string[], + allowReserved: boolean, +): AssociativeValue | null { + const entries: [string, string][] = []; + for (let index = 0; index < parts.length; index += 2) { + const key = decodeValue(parts[index], allowReserved); + const value = decodeValue(parts[index + 1], allowReserved); + if (key == null || value == null) return null; + entries.push([key, value]); + } + return Object.fromEntries(entries); +} + +function decodeValues( + values: readonly string[], + allowReserved: boolean, +): string[] | null { + const decoded: string[] = []; + for (const value of values) { + const item = decodeValue(value, allowReserved); + if (item == null) return null; + decoded.push(item); + } + return decoded; +} + +function decodeValue(value: string, allowReserved: boolean): string | null { + if (allowReserved) return value; + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +const bindValue = ( + { name, prefix }: VarSpec, + value: ExpandValue, +): Bindings => ({ [name]: { value, prefix } }); + +/** + * Combines two binding sets, returning `null` if any shared variable receives + * incompatible values across them. + * + * Equality of values and prefix-aware compatibility (one binding being a + * truncation of the other) are both delegated to {@link mergeBinding}. + */ +function mergeBindings(left: Bindings, right: Bindings): Bindings | null { + const merged: Bindings = { ...left }; + for (const [name, binding] of Object.entries(right)) { + const existing = merged[name]; + if (existing == null) { + merged[name] = binding; + continue; + } + + const next = mergeBinding(existing, binding); + if (next == null) return null; + merged[name] = next; + } + return merged; +} + +const mergeBinding = (left: Binding, right: Binding): Binding | null => + isPrefixBinding(left) || isPrefixBinding(right) + ? mergePrefixBinding(left, right) + : equalExpandValue(left.value, right.value) + ? left + : null; + +/** + * Reconciles two bindings when at least one carries a prefix limit, by + * checking that the truncation of the longer (or unrestricted) value matches + * the shorter prefixed value. + * + * Returns the binding that carries the more complete information, or `null` + * when no consistent reading exists or non-string values are involved. + */ +function mergePrefixBinding(left: Binding, right: Binding): Binding | null { + const leftValue = primitiveString(left.value); + const rightValue = primitiveString(right.value); + if (leftValue == null || rightValue == null) return null; + + const isLeftPrefix = isPrefixBinding(left); + const isRightPrefix = isPrefixBinding(right); + if (!isLeftPrefix && !isRightPrefix) return null; + + if (isLeftPrefix && !isRightPrefix) { + return truncateValue(rightValue, left.prefix) === leftValue ? right : null; + } + + if (!isLeftPrefix && isRightPrefix) { + return truncateValue(leftValue, right.prefix) === rightValue ? left : null; + } + + const leftPrefix = left.prefix!; + const rightPrefix = right.prefix!; + if (leftPrefix <= rightPrefix) { + return truncateValue(rightValue, leftPrefix) === leftValue ? right : null; + } + return truncateValue(leftValue, rightPrefix) === rightValue ? left : null; +} + +const isPrefixBinding = ( + binding: Binding, +): binding is Binding & { readonly prefix: number } => binding.prefix != null; + +const primitiveString = (value: ExpandValue): string | null => + typeof value === "string" ? value : null; + +function equalExpandValue(left: ExpandValue, right: ExpandValue): boolean { + if (Array.isArray(left)) { + return Array.isArray(right) && + left.length === right.length && + left.every((item, index) => item === right[index]); + } + + if (isAssociative(left)) { + return isAssociative(right) && + equalEntries(Object.entries(left), Object.entries(right)); + } + + return left === right; +} + +const equalEntries = ( + left: readonly (readonly [string, unknown])[], + right: readonly (readonly [string, unknown])[], +): boolean => + left.length === right.length && + left.every(([key, value], index) => { + const [rightKey, rightValue] = right[index]; + return key === rightKey && value === rightValue; + }); + +const isAssociative = (value: ExpandValue): value is AssociativeValue => + typeof value === "object" && value !== null && !Array.isArray(value); + +const toExpandContext = (bindings: Bindings): ExpandContext => + Object.fromEntries( + Object.entries(bindings) + .map(([key, binding]) => [key, binding.value]), + ); + +const split = (value: string, separator: string): string[] => + separator === "" ? [value] : value.split(separator); diff --git a/packages/uri-template/src/template/mod.ts b/packages/uri-template/src/template/mod.ts index 4d4c5add2..526b5c1b6 100644 --- a/packages/uri-template/src/template/mod.ts +++ b/packages/uri-template/src/template/mod.ts @@ -6,6 +6,7 @@ import type { Token, } from "../types.ts"; import expand from "./expand.ts"; +import match from "./match.ts"; import tokenize from "./token.ts"; /** @@ -66,6 +67,18 @@ export default class Template { options: TemplateOptions = this.#fullOptions, ): string => expand(this.#tokens, context, options); + /** + * Matches a URI against this template, returning the variable context if the + * URI matches or `null` if it does not. + */ + match: ( + uri: string, + options?: TemplateOptions, + ) => ExpandContext | null = ( + uri: string, + options: TemplateOptions = this.#fullOptions, + ): ExpandContext | null => match(this.#tokens, uri, options); + toString = (): string => this.uriTemplate; } From 4f66ded855d138e6c2456b78a1068bfe6c45901d Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 4 May 2026 18:10:29 +0000 Subject: [PATCH 11/49] Add test suite for URI template matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `match.json` carrying match-only test cases that exercise RFC 6570 match semantics: required literals, operator allow-sets, prefix-modifier consistency, boundary disambiguation, named- operator edge cases, list vs. associative composite disambiguation, decoding failures, and multi-expression URL parsing patterns (ActivityPub URIs, REST resource routes, form-style query continuation, fragment-anchored deep links). Add the `MatchTestSuite` interface and JSON validator alongside runner factories — `createMatchOnlyTest`, `createTemplateMatchTest`, `createFixedTemplateMatchTest`, and `createTemplateMatchHardTest` — and wire them into `template.test.ts` so the existing pair, fixed, and hard suites also round-trip through `match` + `expand`. Assisted-by: Claude Code:claude-opus-4-7 --- .../src/template/template.test.ts | 28 + packages/uri-template/src/tests/assert.ts | 22 +- .../uri-template/src/tests/json/match.json | 582 ++++++++++++++++++ packages/uri-template/src/tests/lib.ts | 101 ++- packages/uri-template/src/tests/mod.ts | 9 + 5 files changed, 740 insertions(+), 2 deletions(-) create mode 100644 packages/uri-template/src/tests/json/match.json diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts index 51e438605..cd683ebde 100644 --- a/packages/uri-template/src/template/template.test.ts +++ b/packages/uri-template/src/template/template.test.ts @@ -9,12 +9,17 @@ import { UnclosedExpressionError, } from "../errors.ts"; import { + createFixedTemplateMatchTest, createFixedTemplateTest, + createMatchOnlyTest, createTemplateHardTest, + createTemplateMatchHardTest, + createTemplateMatchTest, createTemplatePairTest, createWrongTemplateTest, fixedTestSuites, hardTestSuites, + matchTestSuites, pairTestSuites, wrongTestSuites, } from "../tests/mod.ts"; @@ -40,6 +45,29 @@ for (const { name, cases } of hardTestSuites) { test(name, runHardCases(cases)); } +const runMatchCases = createTemplateMatchTest(Template); +for (const { name, cases } of pairTestSuites) { + test( + `match: ${name}`, + runMatchCases(cases as unknown as readonly [string, string][]), + ); +} + +const runFixedMatchCases = createFixedTemplateMatchTest(Template); +for (const { template, name, cases } of fixedTestSuites) { + test(`match: ${name}`, runFixedMatchCases(template)(cases)); +} + +const runHardMatchCases = createTemplateMatchHardTest(Template); +for (const { name, cases } of hardTestSuites) { + test(`match: ${name}`, runHardMatchCases(cases)); +} + +const runMatchOnlyCases = createMatchOnlyTest(Template); +for (const { name, cases } of matchTestSuites) { + test(`match-only: ${name}`, runMatchOnlyCases(cases)); +} + test("throws parse errors in strict mode", () => { throws(() => new Template("{var"), UnclosedExpressionError); throws(() => new Template("bad literal"), InvalidLiteralError); diff --git a/packages/uri-template/src/tests/assert.ts b/packages/uri-template/src/tests/assert.ts index 9eb2dd3c1..988fbf052 100644 --- a/packages/uri-template/src/tests/assert.ts +++ b/packages/uri-template/src/tests/assert.ts @@ -1,5 +1,10 @@ import * as ERROR_CLASSES from "../errors.ts"; -import type { HardTestSuite, PairTestSuite, WrongTestSuite } from "./lib.ts"; +import type { + HardTestSuite, + MatchTestSuite, + PairTestSuite, + WrongTestSuite, +} from "./lib.ts"; const ERROR_NAMES: ReadonlySet = new Set(Object.keys(ERROR_CLASSES)); @@ -21,6 +26,12 @@ export function assertHardTestSuite( validateSuites(suites, validateHardCase); } +export function assertMatchTestSuite( + suites: unknown, +): asserts suites is readonly MatchTestSuite[] { + validateSuites(suites, validateMatchCase); +} + function validateSuites( suites: unknown, validateCase: (c: unknown) => void, @@ -62,6 +73,15 @@ function validateHardCase(c: unknown): void { if (!c.success) assertErrorName(c.expected, "case.expected"); } +function validateMatchCase(c: unknown): void { + assertObject(c, "case"); + assertString(c.name, "case.name"); + assertString(c.template, "case.template"); + assertString(c.uri, "case.uri"); + if (c.expected !== null) assertObject(c.expected, "case.expected"); + if (c.reason !== undefined) assertString(c.reason, "case.reason"); +} + function assertString( value: unknown, label: string, diff --git a/packages/uri-template/src/tests/json/match.json b/packages/uri-template/src/tests/json/match.json new file mode 100644 index 000000000..d8461932b --- /dev/null +++ b/packages/uri-template/src/tests/json/match.json @@ -0,0 +1,582 @@ +[ + { + "name": "Required literals must be present in the URI", + "cases": [ + { + "name": "Form-style query operator requires the leading `?`", + "template": "{?x}", + "uri": "x=value", + "expected": null, + "reason": "The `?` operator unconditionally emits `?` as its first character when any variable is defined. A URI lacking `?` cannot be the round-trip result of any binding." + }, + { + "name": "Fragment operator requires the leading `#`", + "template": "X{#var}", + "uri": "Xvalue", + "expected": null, + "reason": "Fragment expansion always begins with `#` when the variable is defined. The only no-`#` alternative would have var undefined, which would round-trip to just `X`, not `Xvalue`." + }, + { + "name": "Path-style parameter requires the leading `;`", + "template": "{;who}", + "uri": "who=fred", + "expected": null, + "reason": "The `;` operator unconditionally emits `;` before the parameter. Without that prefix the URI cannot be a valid expansion." + }, + { + "name": "Trailing literal that is not present in the URI causes match to fail", + "template": "{+path}/end", + "uri": "/a/b/c", + "expected": null, + "reason": "The required trailing literal `/end` is not present anywhere in the URI; no slicing of `+path` can produce a valid round-trip." + }, + { + "name": "Surrounding literal at wrong position rejects the URI", + "template": "/api/{id}/profile", + "uri": "/api/123/page", + "expected": null, + "reason": "After consuming `/api/` and the `{id}` expression, the tail must equal `/profile`. `/page` cannot match because simple expansion forbids `/` in `id`." + }, + { + "name": "Pct-encoded literal does not match its decoded form", + "template": "%2F{var}", + "uri": "/value", + "expected": null, + "reason": "Literal text in a template is matched verbatim. The literal `%2F` only matches the three-character sequence `%2F`, not the single byte `/`." + } + ] + }, + { + "name": "Operator allow-sets gate which characters can appear in the captured value", + "cases": [ + { + "name": "Simple expansion does not permit a raw `/` in the captured value", + "template": "{var}", + "uri": "a/b", + "expected": null, + "reason": "Simple expansion percent-encodes `/`. A URI containing a raw `/` cannot be the round-trip result of any binding because expansion would emit `%2F`." + }, + { + "name": "Reserved expansion captures raw `/` as part of the value", + "template": "{+path}", + "uri": "/foo/bar/baz", + "expected": { + "path": "/foo/bar/baz" + }, + "reason": "Reserved expansion (`+`) allows reserved characters unencoded. The whole URI is the value of `path`." + }, + { + "name": "Reserved expansion preserves pct-encoded triplets verbatim", + "template": "{+var}", + "uri": "a%2Fb", + "expected": { + "var": "a%2Fb" + }, + "reason": "Per RFC 6570 §3.2.3, reserved expansion emits already-encoded triplets without re-encoding. Match must retain the encoded form rather than decode it to `a/b`." + }, + { + "name": "Fragment expansion treats already-encoded triplets verbatim", + "template": "{#var}", + "uri": "#a%2Fb", + "expected": { + "var": "a%2Fb" + }, + "reason": "Like `+`, the `#` operator allows reserved + pct-encoding (U+R). The captured value retains its encoded form." + } + ] + }, + { + "name": "Prefix modifier must yield a binding consistent with the truncated value", + "cases": [ + { + "name": "Prefix length equal to the value length is a no-op", + "template": "{var:5}", + "uri": "value", + "expected": { + "var": "value" + }, + "reason": "A 5-char prefix on a 5-char value truncates to the original. Match binds the full string." + }, + { + "name": "Prefix length larger than the value is also a no-op", + "template": "{var:30}", + "uri": "value", + "expected": { + "var": "value" + }, + "reason": "A prefix length larger than the value returns the full value unchanged. Match binds var to the literal URI content." + }, + { + "name": "URI longer than the declared prefix length cannot round-trip", + "template": "{var:3}", + "uri": "value", + "expected": null, + "reason": "Any binding for `var` would expand to its first 3 characters. The URI `value` has length 5, so no binding can round-trip." + }, + { + "name": "Prefix and full reference of the same variable must agree", + "template": "{/var:1,var}", + "uri": "/v/wrong", + "expected": null, + "reason": "First occurrence forces var[:1] === 'v'; second forces var === 'wrong'. truncate('wrong', 1) === 'w' ≠ 'v', so no binding satisfies both." + }, + { + "name": "Encoded prefix segment is consistent with the full encoded segment", + "template": "{/semi:2,semi}", + "uri": "/%3B/%3B", + "expected": { + "semi": ";" + }, + "reason": "semi=';'. truncate(';', 2) === ';'. Both occurrences expand to `%3B`, so the URI matches with semi=';'." + } + ] + }, + { + "name": "Boundary disambiguation across multiple literal occurrences", + "cases": [ + { + "name": "Reserved expansion picks the boundary that lets the trailing literal match the URI tail", + "template": "{+path}/end", + "uri": "/a/b/end/end", + "expected": { + "path": "/a/b/end" + }, + "reason": "`/end` appears twice. Only when `+path` consumes through the first `/end` (binding `path` to `/a/b/end`) does the remaining tail equal the trailing literal." + }, + { + "name": "Simple expansion forbids `/`, eliminating all candidate boundaries", + "template": "{var}/end", + "uri": "value/middle/end", + "expected": null, + "reason": "Simple expansion cannot capture a `/`. Both possible boundaries assign a value containing `/`, so no binding round-trips." + }, + { + "name": "Empty captured value between fixed literals is allowed when ifemp permits it", + "template": "O{empty}X", + "uri": "OX", + "expected": { + "empty": "" + }, + "reason": "Simple expansion of an empty string emits nothing. Match recovers `empty` as the empty string." + } + ] + }, + { + "name": "Named operators: empty values, missing variables, and name mismatches", + "cases": [ + { + "name": "Empty value with `=` separator binds the variable to empty string", + "template": "{?x}", + "uri": "?x=", + "expected": { + "x": "" + }, + "reason": "Form-style query expansion of an empty string emits `name=` per the `ifemp` table entry. Match recovers `x` as the empty string." + }, + { + "name": "Path-style flag form (`;name` without `=`) binds the variable to empty string", + "template": "{;empty}", + "uri": ";empty", + "expected": { + "empty": "" + }, + "reason": "The `;` operator with an empty value emits `;name` (no `=`) per `ifemp`. Match recovers `empty` as the empty string." + }, + { + "name": "Form-style with one of two variables undefined", + "template": "{?x,y}", + "uri": "?y=value", + "expected": { + "y": "value" + }, + "reason": "Undefined variables are skipped during expansion. Match must allow the same: `x` is unbound, `y` is bound to `value`." + }, + { + "name": "Named query with a mismatched parameter name fails to match", + "template": "{?x}", + "uri": "?other=value", + "expected": null, + "reason": "The `?` operator emits the varname literally. `other` is not the varname `x`, so no binding can produce this URI." + }, + { + "name": "Named explode of a list emits the varname per element", + "template": "{?list*}", + "uri": "?list=red&list=green&list=blue", + "expected": { + "list": [ + "red", + "green", + "blue" + ] + }, + "reason": "Per RFC 6570 §3.2.8, an exploded list under `?` emits `list=red&list=green&...`. Repetition of the same name signals list semantics." + } + ] + }, + { + "name": "Composite values: list vs. associative disambiguation", + "cases": [ + { + "name": "Exploded named pairs decode as an associative array", + "template": "{?keys*}", + "uri": "?a=1&b=2", + "expected": { + "keys": { + "a": "1", + "b": "2" + } + }, + "reason": "Each `name=value` part with a unique name decodes as an entry of the associative form. The list interpretation would require all parts to share `name === 'keys'`." + }, + { + "name": "Non-exploded composite under a path segment serializes as comma-separated key,value pairs", + "template": "{/keys}", + "uri": "/semi,%3B,dot,.,comma,%2C", + "expected": { + "keys": { + "semi": ";", + "dot": ".", + "comma": "," + } + }, + "reason": "An even-numbered comma list pairs to a valid associative form whose round-trip equals the URI. The associative interpretation is preferred over list when both round-trip identically." + }, + { + "name": "Exploded path segments decode as a list", + "template": "{/list*}", + "uri": "/red/green/blue", + "expected": { + "list": [ + "red", + "green", + "blue" + ] + }, + "reason": "`{/list*}` distributes list elements as `/`-separated segments. Match recovers the list of three elements." + } + ] + }, + { + "name": "Decoding failures bubble up as match failure", + "cases": [ + { + "name": "Invalid pct-encoded byte sequence cannot be decoded under simple expansion", + "template": "{var}", + "uri": "bad%FFseq", + "expected": null, + "reason": "decodeURIComponent('bad%FFseq') throws because `%FF` is not a valid UTF-8 byte. No binding can produce this URI under simple expansion (which percent-decodes captured values)." + }, + { + "name": "Truncated pct-encoded triplet at end of value", + "template": "{var}", + "uri": "abc%2", + "expected": null, + "reason": "`%2` is an incomplete pct-encoded triplet. decodeURIComponent throws, so no binding can round-trip under simple expansion." + }, + { + "name": "Reserved expansion accepts an otherwise-undecodable triplet verbatim", + "template": "{+var}", + "uri": "bad%FFseq", + "expected": { + "var": "bad%FFseq" + }, + "reason": "The same URI fails under simple expansion because decodeURIComponent rejects the `%FF` byte. Under reserved expansion, captured triplets are emitted verbatim — no decoding is performed — so the binding round-trips." + } + ] + }, + { + "name": "ActivityPub-style URIs combine host and resource paths", + "cases": [ + { + "name": "Actor URI splits host and username across two simple expansions", + "template": "https://{host}/users/{user}", + "uri": "https://example.com/users/alice", + "expected": { + "host": "example.com", + "user": "alice" + }, + "reason": "Simple expansion captures `example.com` for `host` and `alice` for `user`. The intervening literal `/users/` fixes the boundary the first expression must stop at." + }, + { + "name": "Three-level actor object URI binds host, user, and object id", + "template": "https://{host}/users/{user}/notes/{id}", + "uri": "https://example.com/users/alice/notes/abc-123", + "expected": { + "host": "example.com", + "user": "alice", + "id": "abc-123" + }, + "reason": "Each simple expansion is bounded by a surrounding literal. `-` is in the unreserved set, so `abc-123` round-trips for `id` without encoding." + }, + { + "name": "Reserved expansion captures host with port while trailing path remains literal", + "template": "https://{+host}/users/{user}", + "uri": "https://example.com:3000/users/alice", + "expected": { + "host": "example.com:3000", + "user": "alice" + }, + "reason": "Simple expansion would percent-encode `:`. Reserved expansion (`+`) allows reserved characters unencoded, so `host` retains the port suffix verbatim." + }, + { + "name": "Pct-encoded UTF-8 username decodes to its Unicode form", + "template": "https://{host}/users/{user}", + "uri": "https://example.com/users/%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4", + "expected": { + "host": "example.com", + "user": "페디파이" + }, + "reason": "Simple expansion percent-decodes the captured triplets as UTF-8. Re-expanding `페디파이` produces the same byte sequence, so the binding round-trips." + }, + { + "name": "WebFinger discovery URL captures host literal and form-style query parameter", + "template": "https://{host}/.well-known/webfinger{?resource}", + "uri": "https://example.com/.well-known/webfinger?resource=acct%3Aalice%40example.com", + "expected": { + "host": "example.com", + "resource": "acct:alice@example.com" + }, + "reason": "Form-style expansion (`?`) percent-encodes `:` and `@`. The captured value decodes to `acct:alice@example.com`; expanding it re-encodes the same octets to round-trip identically." + }, + { + "name": "Mismatched trailing literal between expressions rejects the URI", + "template": "https://{host}/users/{user}/inbox", + "uri": "https://example.com/users/alice/outbox", + "expected": null, + "reason": "After consuming `host` and `user`, the trailing literal `/inbox` does not appear in the URI. Simple expansion cannot capture `/`, so no boundary lets both literals align." + } + ] + }, + { + "name": "REST resource routes mix path segments and query parameters", + "cases": [ + { + "name": "Versioned API path joins literal with two simple expansions and a path segment", + "template": "/api/v{version}/users{/id}", + "uri": "/api/v1/users/42", + "expected": { + "version": "1", + "id": "42" + }, + "reason": "`{version}` is bounded by the literal `/users`; `{/id}` consumes the final `/42`. Both values are unreserved, so they round-trip without encoding." + }, + { + "name": "Form-style query with two parameters captures both as separate bindings", + "template": "/posts{?page,limit}", + "uri": "/posts?page=2&limit=20", + "expected": { + "page": "2", + "limit": "20" + }, + "reason": "The `?` operator emits `?page=2&limit=20`. Each named part decodes into its declared variable in declaration order." + }, + { + "name": "Path-style segment combined with explode list query", + "template": "/posts{/id}{?include*}", + "uri": "/posts/42?include=author&include=tags", + "expected": { + "id": "42", + "include": [ + "author", + "tags" + ] + }, + "reason": "`{?include*}` distributes a list as repeated `include=…` parameters per RFC 6570 §3.2.8. Match recovers the list of two members; `{/id}` separately captures `42`." + }, + { + "name": "Exploded path segments yield a list across multiple URI segments", + "template": "/files{/path*}", + "uri": "/files/a/b/c", + "expected": { + "path": [ + "a", + "b", + "c" + ] + }, + "reason": "`{/path*}` distributes list members as `/`-separated segments. The string interpretation `path = \"a/b/c\"` would round-trip to `/a%2Fb%2Fc`, so only the list binding is consistent." + }, + { + "name": "Optional query expression is absent and matches with the variable unbound", + "template": "/api/users{/id}{?fields}", + "uri": "/api/users/42", + "expected": { + "id": "42" + }, + "reason": "When all variables in a `{?…}` expression are undefined, the expansion is the empty string. The URI ends right after `{/id}`, so `fields` remains unbound." + }, + { + "name": "Form-style query with an unrecognized parameter name fails to match", + "template": "/api/users{/id}{?fields}", + "uri": "/api/users/42?wrong=value", + "expected": null, + "reason": "`{?fields}` requires the literal name `fields`. The named operator must consume every part of its expression body, so the unknown `wrong` part cannot be discarded." + } + ] + }, + { + "name": "Form-style query continuation appends parameters after a fixed query prefix", + "cases": [ + { + "name": "Continuation operator binds two variables after a literal query prefix", + "template": "?fixed=yes{&x,y}", + "uri": "?fixed=yes&x=1024&y=768", + "expected": { + "x": "1024", + "y": "768" + }, + "reason": "The `&` operator emits `&x=1024&y=768` after the literal `?fixed=yes`. Each named part decodes into its declared variable in order." + }, + { + "name": "Form-style query feeds into a continuation across two expressions", + "template": "/search{?q}{&page}", + "uri": "/search?q=cats&page=2", + "expected": { + "q": "cats", + "page": "2" + }, + "reason": "`{?q}` emits `?q=cats`; `{&page}` emits `&page=2`. Match must split the URI tail at the start of `&page` so each expression sees only its own parts." + }, + { + "name": "Two consecutive continuation expressions split distinct parameters", + "template": "?type=user{&q}{&page}", + "uri": "?type=user&q=alice&page=2", + "expected": { + "q": "alice", + "page": "2" + }, + "reason": "Each `&…` expression declares exactly one named variable, so the only consistent split places `&q=alice` in the first expression and `&page=2` in the second." + }, + { + "name": "Continuation operator with a mismatched parameter name fails", + "template": "/search{?q}{&page}", + "uri": "/search?q=cats&pg=2", + "expected": null, + "reason": "`{&page}` requires the literal name `page`. The shortened `pg` cannot satisfy any binding under the named operator, so no boundary between the two expressions succeeds." + } + ] + }, + { + "name": "Fragment-anchored deep links carry path and fragment in separate expressions", + "cases": [ + { + "name": "Path expression precedes fragment expression", + "template": "/docs/{page}{#anchor}", + "uri": "/docs/intro#getting-started", + "expected": { + "page": "intro", + "anchor": "getting-started" + }, + "reason": "Simple expansion would percent-encode `#`, so `{page}` cannot consume past the fragment delimiter. `{#anchor}` then absorbs the rest, including the leading `#`." + }, + { + "name": "Section and id captured as path segments before a fragment", + "template": "/{section}/{id}{#anchor}", + "uri": "/posts/42#comments", + "expected": { + "section": "posts", + "id": "42", + "anchor": "comments" + }, + "reason": "Three expressions cooperate: `{section}` and `{id}` are simple expansions bounded by `/` literals; `{#anchor}` captures the fragment with the `#` prefix as its own first character." + }, + { + "name": "Missing fragment leaves the variable unbound", + "template": "/docs/{page}{#anchor}", + "uri": "/docs/intro", + "expected": { + "page": "intro" + }, + "reason": "An undefined variable under `#` produces no output. `{#anchor}` matches the empty tail of the URI, leaving `anchor` unbound." + }, + { + "name": "Acct URI splits user and host around a literal `@`", + "template": "acct:{user}@{host}", + "uri": "acct:alice@example.com", + "expected": { + "user": "alice", + "host": "example.com" + }, + "reason": "The literal `@` between the two simple expansions disambiguates the boundary. Both captured values contain only unreserved characters, so they round-trip without encoding." + } + ] + }, + { + "name": "Repeated variable names across expressions must agree on a single value", + "cases": [ + { + "name": "Same simple variable appearing twice with the same value succeeds", + "template": "/{lang}/docs/{lang}", + "uri": "/en/docs/en", + "expected": { + "lang": "en" + }, + "reason": "RFC 6570 §3.2.1 requires a variable's value to remain static throughout the expansion. Both occurrences capture `en`, so a single binding `lang = 'en'` satisfies both." + }, + { + "name": "Same simple variable appearing twice with conflicting values fails", + "template": "/{lang}/docs/{lang}", + "uri": "/en/docs/ko", + "expected": null, + "reason": "The first occurrence forces `lang = 'en'` and the second forces `lang = 'ko'`. No single binding can satisfy both, so the merge step rejects the candidate." + }, + { + "name": "Same variable shared between simple and reserved expansions, both consistent", + "template": "/{path}/raw/{+path}", + "uri": "/notes/raw/notes", + "expected": { + "path": "notes" + }, + "reason": "Simple expansion of `notes` is `notes`; reserved expansion of `notes` is also `notes`. Both occurrences agree on `path = 'notes'`." + }, + { + "name": "Same variable shared between simple and reserved expansions diverges on encoding", + "template": "/{path}/raw/{+path}", + "uri": "/foo%2Fbar/raw/foo/bar", + "expected": { + "path": "foo/bar" + }, + "reason": "`{path}` decodes `foo%2Fbar` to `foo/bar`; `{+path}` captures `foo/bar` verbatim. Both occurrences resolve to the same `path = 'foo/bar'`, and re-expanding under each operator reproduces the URI." + }, + { + "name": "Same variable shared between simple and reserved expansions with mismatched payloads fails", + "template": "/{path}/raw/{+path}", + "uri": "/foo/raw/bar", + "expected": null, + "reason": "Simple expansion forces `path = 'foo'`; reserved expansion forces `path = 'bar'`. The two bindings cannot be reconciled, so the URI does not round-trip under any single context." + }, + { + "name": "Variable repeated across path and form-style query with the same value", + "template": "/users/{id}/profile{?id}", + "uri": "/users/42/profile?id=42", + "expected": { + "id": "42" + }, + "reason": "The first `{id}` consumes `42` from the path; the second emits `?id=42` under the `?` operator. Both occurrences agree, so a single binding `id = '42'` satisfies the whole template." + }, + { + "name": "Variable repeated across path and query diverges on the captured value", + "template": "/users/{id}/profile{?id}", + "uri": "/users/42/profile?id=99", + "expected": null, + "reason": "The path occurrence forces `id = '42'` and the query occurrence forces `id = '99'`. RFC 6570 §3.2.1 forbids the variable from taking two values within the same expansion." + }, + { + "name": "Prefix and full reference of the same variable both contribute to the binding", + "template": "/{slug:3}/{slug}", + "uri": "/int/intro", + "expected": { + "slug": "intro" + }, + "reason": "`{slug:3}` truncates `slug` to its first three characters (`int`); `{slug}` emits the full value. The full-value occurrence determines `slug = 'intro'`, and `truncate('intro', 3) === 'int'` confirms the prefix occurrence." + }, + { + "name": "Prefix and full reference of the same variable disagree on the truncation", + "template": "/{slug:3}/{slug}", + "uri": "/abc/intro", + "expected": null, + "reason": "Full reference forces `slug = 'intro'`, but `truncate('intro', 3)` is `int`, not `abc`. The prefix and full bindings cannot agree, so no candidate round-trips." + } + ] + } +] diff --git a/packages/uri-template/src/tests/lib.ts b/packages/uri-template/src/tests/lib.ts index 6d10d4a9a..145194063 100644 --- a/packages/uri-template/src/tests/lib.ts +++ b/packages/uri-template/src/tests/lib.ts @@ -1,4 +1,4 @@ -import { equal, throws } from "node:assert/strict"; +import { deepEqual, equal, ok, throws } from "node:assert/strict"; import * as ERROR_CLASSES from "../errors.ts"; import type { ExpandContext } from "../types.ts"; import testVars from "./json/references/vars.json" with { type: "json" }; @@ -6,6 +6,7 @@ import testVars from "./json/references/vars.json" with { type: "json" }; interface TemplateConstructor { new (template: string): { expand(context: ExpandContext): string; + match(uri: string): ExpandContext | null; }; } @@ -36,6 +37,60 @@ export function createTemplatePairTest( }; } +export function createTemplateMatchTest( + Template: TemplateConstructor, +): ( + cases: readonly PairTestCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly PairTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const [template, expanded] of cases) { + await t.step( + `${expanded} => ${template}`, + () => { + const instance = new Template(template); + const matched = instance.match(expanded); + ok(matched != null, `match returned null for ${expanded}`); + equal(instance.expand(matched), expanded); + }, + ); + } + }; +} + +export interface MatchTestSuite { + name: string; + cases: readonly MatchTestCase[]; +} + +interface MatchTestCase { + name: string; + template: string; + uri: string; + expected: ExpandContext | null; + reason?: string; +} + +export function createMatchOnlyTest( + Template: TemplateConstructor, +): ( + cases: readonly MatchTestCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly MatchTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const c of cases) { + await t.step(c.name, () => { + const got = new Template(c.template).match(c.uri); + deepEqual(got, c.expected); + }); + } + }; +} + export interface FixedTemplateTestSuite { name: string; template: string; @@ -67,6 +122,29 @@ export const createFixedTemplateTest: ( }; }; +export const createFixedTemplateMatchTest: ( + Template: TemplateConstructor, +) => ( + template: string, +) => ( + cases: FixedTemplateTestCase[], +) => (t: Deno.TestContext) => Promise = + (Template: TemplateConstructor) => (template: string) => { + const instance = new Template(template); + return ( + cases: FixedTemplateTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, expected } of cases) { + await t.step(name, () => { + const matched = instance.match(expected); + ok(matched != null, `match returned null for ${expected}`); + equal(instance.expand(matched), expected); + }); + } + }; + }; + type ErrorName = keyof typeof ERROR_CLASSES; /** @@ -211,3 +289,24 @@ export function createTemplateHardTest( } }; } + +export function createTemplateMatchHardTest( + Template: TemplateConstructor, +): ( + cases: readonly HardTestCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly HardTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const c of cases) { + if (!c.success) continue; + await t.step(c.name, () => { + const instance = new Template(c.template); + const matched = instance.match(c.expected); + ok(matched != null, `match returned null for ${c.expected}`); + equal(instance.expand(matched), c.expected); + }); + } + }; +} diff --git a/packages/uri-template/src/tests/mod.ts b/packages/uri-template/src/tests/mod.ts index 065b73013..2c7916e4c 100644 --- a/packages/uri-template/src/tests/mod.ts +++ b/packages/uri-template/src/tests/mod.ts @@ -1,11 +1,13 @@ import { assertHardTestSuite, + assertMatchTestSuite, assertPairTestSuite, assertWrongTestSuite, } from "./assert.ts"; import _hardTestSuites from "./json/hard.json" with { type: "json", }; +import _matchTestSuites from "./json/match.json" with { type: "json" }; import _fixedTestSuites from "./json/references/fixed.json" with { type: "json", }; @@ -16,6 +18,7 @@ import _wrongTestSuites from "./json/wrong.json" with { type: "json" }; import type { FixedTemplateTestSuite, HardTestSuite, + MatchTestSuite, PairTestSuite, WrongTestSuite, } from "./lib.ts"; @@ -23,14 +26,20 @@ import type { assertPairTestSuite(_pairTestSuites); assertWrongTestSuite(_wrongTestSuites); assertHardTestSuite(_hardTestSuites); +assertMatchTestSuite(_matchTestSuites); export const pairTestSuites: readonly PairTestSuite[] = _pairTestSuites; export const fixedTestSuites: readonly FixedTemplateTestSuite[] = _fixedTestSuites; export const wrongTestSuites: readonly WrongTestSuite[] = _wrongTestSuites; export const hardTestSuites: readonly HardTestSuite[] = _hardTestSuites; +export const matchTestSuites: readonly MatchTestSuite[] = _matchTestSuites; export { + createFixedTemplateMatchTest, createFixedTemplateTest, + createMatchOnlyTest, createTemplateHardTest, + createTemplateMatchHardTest, + createTemplateMatchTest, createTemplatePairTest, createWrongTemplateTest, } from "./lib.ts"; From 02406c77318ecdbc036ab0e9e5c7b230a9a4c8b8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 19:49:24 +0000 Subject: [PATCH 12/49] Add RFC 6570 Router class Introduce a new Router class for URI Template routing. It is intended to replace the existing federation router implementation while sharing the strict RFC 6570 parser across build, match, and variables. That shared parser preserves percent-encoded variable names and keeps reserved expansion values intact instead of normalizing them through a separate routing parser. Assisted-by: Codex:gpt-5 --- packages/uri-template/src/mod.ts | 12 +- packages/uri-template/src/router.ts | 43 ---- packages/uri-template/src/router/errors.ts | 27 ++ packages/uri-template/src/router/mod.ts | 257 +++++++++++++++++++ packages/uri-template/src/router/node.ts | 65 +++++ packages/uri-template/src/router/priority.ts | 40 +++ packages/uri-template/src/router/trie.ts | 56 ++++ packages/uri-template/src/types.ts | 42 +-- 8 files changed, 461 insertions(+), 81 deletions(-) delete mode 100644 packages/uri-template/src/router.ts create mode 100644 packages/uri-template/src/router/errors.ts create mode 100644 packages/uri-template/src/router/mod.ts create mode 100644 packages/uri-template/src/router/node.ts create mode 100644 packages/uri-template/src/router/priority.ts create mode 100644 packages/uri-template/src/router/trie.ts diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts index 9b36c9ebd..bd5902634 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -25,18 +25,22 @@ export { UnexpectedCharacterError, UnknownOperatorError, } from "./errors.ts"; -export { Router } from "./router.ts"; +export { RouterError, RouteTemplatePathError } from "./router/errors.ts"; +export { default as Router } from "./router/mod.ts"; +export type { + RouterOptions, + RouterPathPattern, + RouterRouteResult, +} from "./router/mod.ts"; export { default as Template } from "./template/mod.ts"; export type { AssociativeValue, ExpandContext, ExpandValue, - HierarchyNode, Operator, + Path, PrimitiveValue, Reporter, - Result, - Route, TemplateOptions, Token, VariableSpec, diff --git a/packages/uri-template/src/router.ts b/packages/uri-template/src/router.ts deleted file mode 100644 index 1f3e1f3b9..000000000 --- a/packages/uri-template/src/router.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NOT_IMPLEMENTED } from "./constants.ts"; -import type { HierarchyNode, Result, Route } from "./types.ts"; - -/** - * Router that resolves URIs against a set of registered RFC 6570 templates. - */ -export class Router { - nid: number; - fsm: unknown[]; - routeSet: Set; - templateRouteMap: Map; - valueRouteMap: Map; - hierarchy: HierarchyNode; - - constructor() { - this.nid = 0; - this.fsm = []; - this.routeSet = new Set(); - this.templateRouteMap = new Map(); - this.valueRouteMap = new Map(); - this.hierarchy = { children: [] }; - } - - /** - * Registers a URI template under the given match value and returns the - * resulting {@link Route}. - */ - addTemplate( - _uriTemplate: string, - _options: Record, - _matchValue: unknown, - ): Route { - throw new Error(NOT_IMPLEMENTED); - } - - /** - * Resolves a URI against the registered templates, returning a - * {@link Result} when a match is found, or `undefined` otherwise. - */ - resolveURI(_uri: string): Result | undefined { - throw new Error(NOT_IMPLEMENTED); - } -} diff --git a/packages/uri-template/src/router/errors.ts b/packages/uri-template/src/router/errors.ts new file mode 100644 index 000000000..e945e5b83 --- /dev/null +++ b/packages/uri-template/src/router/errors.ts @@ -0,0 +1,27 @@ +/** + * 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"; + } +} diff --git a/packages/uri-template/src/router/mod.ts b/packages/uri-template/src/router/mod.ts new file mode 100644 index 000000000..683eb4d94 --- /dev/null +++ b/packages/uri-template/src/router/mod.ts @@ -0,0 +1,257 @@ +import Template from "../template/mod.ts"; +import type { ExpandContext, Path, Token } from "../types.ts"; +import { RouteTemplatePathError } from "./errors.ts"; +import Trie from "./trie.ts"; + +/** + * Options for the {@link Router}. + */ +export interface RouterOptions { + /** + * Whether to ignore trailing slashes when matching paths. + */ + trailingSlashInsensitive?: boolean; +} + +/** + * The result of {@link Router.route}. + */ +export interface RouterRouteResult { + /** + * The matched route name. + */ + name: string; + + /** + * The URI template of the matched route. + */ + template: Path; + + /** + * The values extracted from the URI. + */ + values: Record; +} + +/** + * Parsed path template ready to be registered in a {@link Router}. + */ +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; +} + +interface RouteEntry { + readonly index: number; + readonly name: string; + readonly pattern: RouterPathPattern; + readonly initialLiteralPrefix: string; + readonly literalLength: number; + readonly variableCount: number; +} + +/** + * Router that resolves URIs against registered RFC 6570 templates. + */ +export default class Router { + readonly #trie: Trie; + readonly #routesByName: Map; + #nextIndex: number; + + /** + * Whether to ignore trailing slashes when matching paths. + */ + trailingSlashInsensitive: boolean; + + /** + * Create a new {@link Router}. + * @param options Options for the router. + */ + constructor(options: RouterOptions = {}) { + this.#trie = new Trie(); + this.#routesByName = new Map(); + this.#nextIndex = 0; + this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; + } + + clone(): Router { + const clone = new Router({ + trailingSlashInsensitive: this.trailingSlashInsensitive, + }); + + for (const entry of this.#activeEntries()) { + clone.add(entry.pattern.path, entry.name); + } + + return clone; + } + + /** + * 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 { + 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 pattern The compiled path pattern. + * @param name The name of the path. + */ + add = (template: Path, name: string): void => { + const pattern = Router.compile(template); + const entry = createRouteEntry({ index: this.#nextIndex++, name, pattern }); + + this.#routesByName.set(name, entry); + this.#trie.insert(entry); + }; + + /** + * 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 = (url: Path): RouterRouteResult | null => { + const match = this.#route(url); + if (match != null || !this.trailingSlashInsensitive) return match; + + const retryUrl = toggleTrailingSlash(url); + return retryUrl == null ? null : this.#route(retryUrl); + }; + + /** + * 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 = (name: string, values: Record): Path | null => + (this.#routesByName.get(name) + ?.pattern.template.expand(values) ?? null) as Path | null; + + #route(url: Path): RouterRouteResult | null { + for (const entry of this.#trie.candidates(url, this.#isActiveEntry)) { + const context = entry.pattern.template.match(url); + if (context == null) continue; + + const values = toRouteValues(context); + if (values == null) continue; + + return { + name: entry.name, + template: entry.pattern.path, + values, + }; + } + + return null; + } + + #activeEntries = (): RouteEntry[] => + Array.from(this.#routesByName.values()) + .sort((left, right) => left.index - right.index); + + #isActiveEntry = (entry: RouteEntry): boolean => + this.#routesByName.get(entry.name) === entry; +} + +interface CreateRouteEntryOptions { + readonly index: number; + readonly name: string; + readonly pattern: RouterPathPattern; +} + +const createRouteEntry = ({ + index, + name, + pattern, +}: CreateRouteEntryOptions): RouteEntry => ({ + index, + name, + pattern, + initialLiteralPrefix: getInitialLiteralPrefix(pattern.template.tokens), + literalLength: getLiteralLength(pattern.template.tokens), + variableCount: pattern.variables.size, +}); + +const toggleTrailingSlash = (path: Path): Path | null => { + if (!path.endsWith("/")) return `${path}/`; + + const trimmed = path.replace(/\/+$/, ""); + return isPath(trimmed) ? trimmed : null; +}; + +const collectVariables = (tokens: readonly Token[]): Set => + new Set( + tokens + .filter(isExpression) + .flatMap((token) => token.vars.map((varSpec) => varSpec.name)), + ); + +const isExpression = ( + token: T, +): token is Extract => token.kind === "expression"; + +const getInitialLiteralPrefix = (tokens: readonly Token[]): string => + tokens[0]?.kind === "literal" ? tokens[0].text : ""; + +const getLiteralLength = (tokens: readonly Token[]): number => + tokens.reduce( + (sum, token) => token.kind === "literal" ? sum + token.text.length : sum, + 0, + ); + +const isPath = (path: string): path is Path => + path.startsWith("/") || /^\{\/[^}]+\}\//.test(path); + +const toRouteValues = ( + context: ExpandContext, +): Record | null => { + const values: Record = {}; + + for (const [key, value] of Object.entries(context)) { + if (typeof value !== "string") return null; + values[key] = value; + } + + return values; +}; diff --git a/packages/uri-template/src/router/node.ts b/packages/uri-template/src/router/node.ts new file mode 100644 index 000000000..0a648536d --- /dev/null +++ b/packages/uri-template/src/router/node.ts @@ -0,0 +1,65 @@ +import { + compareRouteEntries, + mergeRouteEntries, + type PrioritizedRouteEntry, +} from "./priority.ts"; + +/** + * Trie node used by the router index. + */ +export default class Node { + readonly #children = new Map>(); + readonly #entries: TEntry[] = []; + #candidates: readonly TEntry[] = []; + + get entries(): readonly TEntry[] { + return this.#entries; + } + + get candidates(): readonly TEntry[] { + return this.#candidates; + } + + child = (key: string): Node | undefined => this.#children.get(key); + + childOrInsert = (key: string): Node => { + const existing = this.#children.get(key); + if (existing != null) return existing; + + const inserted = new Node(); + this.#children.set(key, inserted); + return inserted; + }; + + insert = (entry: TEntry): void => { + this.#entries.splice(this.#insertionIndex(entry), 0, entry); + }; + + rebuildCandidates = (parentCandidates: readonly TEntry[]): void => { + this.#candidates = mergeRouteEntries(parentCandidates, this.#entries); + + for (const child of this.#children.values()) { + child.rebuildCandidates(this.#candidates); + } + }; + + #insertionIndex(entry: TEntry): number { + let low = 0; + let high = this.#entries.length; + + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (this.#compare(this.#entries[middle], entry) <= 0) { + low = middle + 1; + } else { + high = middle; + } + } + + return low; + } + + #compare(left: TEntry, right: TEntry): number { + return compareRouteEntries(left, right); + } +} diff --git a/packages/uri-template/src/router/priority.ts b/packages/uri-template/src/router/priority.ts new file mode 100644 index 000000000..91a8bcb16 --- /dev/null +++ b/packages/uri-template/src/router/priority.ts @@ -0,0 +1,40 @@ +export interface PrioritizedRouteEntry { + readonly index: number; + readonly initialLiteralPrefix: string; + readonly literalLength: number; + readonly variableCount: number; +} + +export const compareRouteEntries = ( + left: PrioritizedRouteEntry, + right: PrioritizedRouteEntry, +): 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.ts b/packages/uri-template/src/router/trie.ts new file mode 100644 index 000000000..f21258bfe --- /dev/null +++ b/packages/uri-template/src/router/trie.ts @@ -0,0 +1,56 @@ +import type { Path } from "../types.ts"; +import Node from "./node.ts"; + +interface TrieEntry { + readonly index: number; + readonly initialLiteralPrefix: string; + readonly literalLength: number; + readonly variableCount: number; +} + +/** + * Prefix trie for registered route candidates. + */ +export default class Trie { + readonly #root = new Node(); + #dirty = true; + + insert = (entry: TEntry): void => { + let node = this.#root; + + for (const char of entry.initialLiteralPrefix) { + node = node.childOrInsert(char); + } + + node.insert(entry); + this.#dirty = true; + }; + + *candidates( + path: Path, + isActive: (entry: TEntry) => boolean, + ): Generator { + if (this.#dirty) this.#rebuildCandidates(); + + for (const entry of this.#deepestNode(path).candidates) { + if (isActive(entry)) yield entry; + } + } + + #deepestNode(path: Path): Node { + let node = this.#root; + + for (const char of path) { + const child = node.child(char); + if (child == null) return node; + node = child; + } + + return node; + } + + #rebuildCandidates = (): void => { + this.#root.rebuildCandidates([]); + this.#dirty = false; + }; +} diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index c718a05e0..0be284049 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -1,15 +1,19 @@ export type { Operator, OperatorSpec } from "./const.ts"; import type { Operator } from "./const.ts"; -import type _Template from "./template/mod.ts"; /** - * Primitive value accepted by {@link _Template.expand Template.expand}. + * Path-shaped URI Template accepted by the router. + */ +export type Path = `/${string}` | `{/${string}}/${string}`; + +/** + * Primitive value accepted by {@link Template.expand}. */ export type PrimitiveValue = string | number | boolean | null | undefined; /** * Associative composite value accepted by - * {@link _Template.expand Template.expand}. + * {@link Template.expand}. * * Keys are expanded as URI Template associative names. Values may be primitive * values or primitive lists. @@ -28,7 +32,7 @@ export type ExpandValue = | AssociativeValue; /** - * Context object accepted by {@link _Template.expand Template.expand}. + * Context object accepted by {@link Template.expand}. * Each variable resolves to a primitive, an ordered list of primitives, * or an associative map. */ @@ -41,36 +45,6 @@ export interface VariableSpec { varname: string; } -/** - * Route entry returned by {@link Router.addTemplate}. - */ -export interface Route { - uriTemplate: string; - matchValue: unknown; - variables: VariableSpec[]; -} - -/** - * Hierarchy node tracked internally by a {@link Router}, exposed as a mutable - * field so that callers may clone routers via structural copy. - */ -export interface HierarchyNode { - children: HierarchyNode[]; - node?: Route; - uriTemplate?: string; -} - -/** - * Result returned by {@link Router.resolveURI} when a URI matches a registered - * template. - */ -export interface Result { - matchValue: string; - params: Record; - uri: string; - uriTemplate: string; -} - /** * Parsed RFC 6570 variable specification inside an expression. * From 3b46270c426a1196e01d6209cd45849cb64224c0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 19:52:05 +0000 Subject: [PATCH 13/49] Add Router conformance tests Add reusable JSON-backed Router test suites for add, compile errors, variables, clone, route, and build behavior. Include benchmark helpers that exercise bulk routes, deep common prefixes, root-adjacent dynamic routes, and inactive route replacement. Assisted-by: Codex:gpt-5 --- .../uri-template/src/router/router.bench.ts | 68 +++ .../uri-template/src/router/router.test.ts | 51 ++ packages/uri-template/src/tests/assert.ts | 226 +++++++- .../src/tests/json/router/build-cases.json | 100 ++++ .../src/tests/json/router/build-suites.json | 182 +++++++ .../src/tests/json/router/clone-suites.json | 41 ++ .../json/router/compile-error-cases.json | 18 + .../src/tests/json/router/hit-paths.json | 27 + .../src/tests/json/router/miss-paths.json | 15 + .../tests/json/router/route-definitions.json | 102 ++++ .../src/tests/json/router/route-suites.json | 357 +++++++++++++ .../tests/json/router/variables-cases.json | 58 +++ packages/uri-template/src/tests/mod.ts | 129 ++++- packages/uri-template/src/tests/router.ts | 491 ++++++++++++++++++ 14 files changed, 1856 insertions(+), 9 deletions(-) create mode 100644 packages/uri-template/src/router/router.bench.ts create mode 100644 packages/uri-template/src/router/router.test.ts create mode 100644 packages/uri-template/src/tests/json/router/build-cases.json create mode 100644 packages/uri-template/src/tests/json/router/build-suites.json create mode 100644 packages/uri-template/src/tests/json/router/clone-suites.json create mode 100644 packages/uri-template/src/tests/json/router/compile-error-cases.json create mode 100644 packages/uri-template/src/tests/json/router/hit-paths.json create mode 100644 packages/uri-template/src/tests/json/router/miss-paths.json create mode 100644 packages/uri-template/src/tests/json/router/route-definitions.json create mode 100644 packages/uri-template/src/tests/json/router/route-suites.json create mode 100644 packages/uri-template/src/tests/json/router/variables-cases.json create mode 100644 packages/uri-template/src/tests/router.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..74976f2b5 --- /dev/null +++ b/packages/uri-template/src/router/router.bench.ts @@ -0,0 +1,68 @@ +import { + createRouterBuildPathsBench, + createRouterCompileAndAddBench, + createRouterDeepCommonPrefixScenario, + createRouterFirstRouteAfterBuildBench, + createRouterHundredsOfRoutesScenario, + createRouterInactiveEntriesScenario, + createRouterRootAdjacentDynamicRoutesScenario, + createRouterRouteHitsBench, + createRouterRouteMissesBench, + routerBuildCases, + routerHitPaths, + type RouterMemoryPressureScenario, + routerMissPaths, + routerRouteDefinitions, +} from "../tests/mod.ts"; +import Router from "./mod.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 [ + createRouterHundredsOfRoutesScenario(), + createRouterDeepCommonPrefixScenario(), + createRouterRootAdjacentDynamicRoutesScenario(), + createRouterInactiveEntriesScenario(), + ] satisfies readonly RouterMemoryPressureScenario[] +) { + 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..d15aa657c --- /dev/null +++ b/packages/uri-template/src/router/router.test.ts @@ -0,0 +1,51 @@ +import { test } from "@fedify/fixture"; +import { + createRouterAddTest, + createRouterBuildTest, + createRouterCloneTest, + createRouterCompileErrorTest, + createRouterRouteTest, + createRouterVariablesTest, + routerBuildTestSuites, + routerCloneTestSuites, + routerCompileErrorCases, + routerRouteDefinitions, + routerRouteTestSuites, + routerVariablesCases, +} from "../tests/mod.ts"; +import Router from "./mod.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), + ); +} diff --git a/packages/uri-template/src/tests/assert.ts b/packages/uri-template/src/tests/assert.ts index 988fbf052..8a307bc16 100644 --- a/packages/uri-template/src/tests/assert.ts +++ b/packages/uri-template/src/tests/assert.ts @@ -1,12 +1,26 @@ import * as ERROR_CLASSES from "../errors.ts"; +import * as ROUTER_ERROR_CLASSES from "../router/errors.ts"; +import type { Path } from "../types.ts"; import type { HardTestSuite, MatchTestSuite, PairTestSuite, WrongTestSuite, } from "./lib.ts"; +import type { + RouterBuildCase, + RouterBuildTestSuite, + RouterCloneTestSuite, + RouterCompileErrorCase, + RouterRouteDefinition, + RouterRouteTestSuite, + RouterVariablesCase, +} from "./router.ts"; -const ERROR_NAMES: ReadonlySet = new Set(Object.keys(ERROR_CLASSES)); +const ERROR_NAMES: ReadonlySet = new Set([ + ...Object.keys(ERROR_CLASSES), + ...Object.keys(ROUTER_ERROR_CLASSES), +]); export function assertPairTestSuite( suites: unknown, @@ -32,6 +46,62 @@ export function assertMatchTestSuite( validateSuites(suites, validateMatchCase); } +export function assertRouterRouteDefinitions( + definitions: unknown, +): asserts definitions is readonly RouterRouteDefinition[] { + assertArray(definitions, "router route definitions"); + for (const definition of definitions) validateRouteDefinition(definition); +} + +export function assertRouterPaths( + paths: unknown, +): asserts paths is readonly Path[] { + assertArray(paths, "router paths"); + for (const path of paths) assertPath(path, "router path"); +} + +export function assertRouterBuildCases( + cases: unknown, +): asserts cases is readonly RouterBuildCase[] { + assertArray(cases, "router build cases"); + for (const c of cases) validateRouterBuildCase(c); +} + +export function assertRouterRouteTestSuites( + suites: unknown, +): asserts suites is readonly RouterRouteTestSuite[] { + assertArray(suites, "router route test suites"); + for (const suite of suites) validateRouterRouteTestSuite(suite); +} + +export function assertRouterBuildTestSuites( + suites: unknown, +): asserts suites is readonly RouterBuildTestSuite[] { + assertArray(suites, "router build test suites"); + for (const suite of suites) validateRouterBuildTestSuite(suite); +} + +export function assertRouterVariablesCases( + cases: unknown, +): asserts cases is readonly RouterVariablesCase[] { + assertArray(cases, "router variables cases"); + for (const c of cases) validateRouterVariablesCase(c); +} + +export function assertRouterCompileErrorCases( + cases: unknown, +): asserts cases is readonly RouterCompileErrorCase[] { + assertArray(cases, "router compile error cases"); + for (const c of cases) validateRouterCompileErrorCase(c); +} + +export function assertRouterCloneTestSuites( + suites: unknown, +): asserts suites is readonly RouterCloneTestSuite[] { + assertArray(suites, "router clone test suites"); + for (const suite of suites) validateRouterCloneTestSuite(suite); +} + function validateSuites( suites: unknown, validateCase: (c: unknown) => void, @@ -82,6 +152,125 @@ function validateMatchCase(c: unknown): void { if (c.reason !== undefined) assertString(c.reason, "case.reason"); } +function validateRouteDefinition(value: unknown): void { + if ( + !Array.isArray(value) || value.length !== 2 || + typeof value[1] !== "string" + ) { + throw new TypeError( + "each route definition must be a [path: string, name: string] tuple", + ); + } + + assertPath(value[0], "router route definition path"); +} + +function validateRouterBuildCase(value: unknown): void { + if ( + !Array.isArray(value) || value.length !== 2 || + typeof value[0] !== "string" + ) { + throw new TypeError( + "each router build case must be a [name: string, values: object] tuple", + ); + } + + assertStringRecord(value[1], "router build case values"); +} + +function validateRouterRouteTestSuite(value: unknown): void { + assertObject(value, "router route test suite"); + assertString(value.name, "router route test suite.name"); + if (value.options !== undefined) { + validateRouterOptions(value.options, "router route test suite.options"); + } + assertRouterRouteDefinitions(value.routeDefinitions); + assertArray(value.cases, "router route test suite.cases"); + for (const c of value.cases) validateRouterRouteCase(c); +} + +function validateRouterBuildTestSuite(value: unknown): void { + assertObject(value, "router build test suite"); + assertString(value.name, "router build test suite.name"); + if (value.options !== undefined) { + validateRouterOptions(value.options, "router build test suite.options"); + } + assertRouterRouteDefinitions(value.routeDefinitions); + assertArray(value.cases, "router build test suite.cases"); + for (const c of value.cases) validateRouterBuildTestCase(c); +} + +function validateRouterCloneTestSuite(value: unknown): void { + assertObject(value, "router clone test suite"); + assertString(value.name, "router clone test suite.name"); + if (value.options !== undefined) { + validateRouterOptions(value.options, "router clone test suite.options"); + } + assertRouterRouteDefinitions(value.routeDefinitions); + assertRouterRouteDefinitions(value.clonedRouteDefinitions); + assertArray( + value.originalRouteCases, + "router clone test suite.originalRouteCases", + ); + for (const c of value.originalRouteCases) validateRouterRouteCase(c); + assertArray( + value.clonedRouteCases, + "router clone test suite.clonedRouteCases", + ); + for (const c of value.clonedRouteCases) validateRouterRouteCase(c); +} + +function validateRouterRouteCase(value: unknown): void { + assertObject(value, "router route case"); + assertString(value.name, "router route case.name"); + assertPath(value.path, "router route case.path"); + validateRouterRouteResult(value.expected, "router route case.expected"); +} + +function validateRouterBuildTestCase(value: unknown): void { + assertObject(value, "router build test case"); + assertString(value.name, "router build test case.name"); + assertString(value.routeName, "router build test case.routeName"); + assertStringRecord(value.values, "router build test case.values"); + assertStringOrNull(value.expected, "router build test case.expected"); +} + +function validateRouterVariablesCase(value: unknown): void { + assertObject(value, "router variables case"); + assertString(value.name, "router variables case.name"); + assertPath(value.path, "router variables case.path"); + assertStringArray(value.expected, "router variables case.expected"); +} + +function validateRouterCompileErrorCase(value: unknown): void { + assertObject(value, "router compile error case"); + assertString(value.name, "router compile error case.name"); + assertString(value.path, "router compile error case.path"); + assertStringArray(value.expected, "router compile error case.expected"); + for (const errorName of value.expected) { + assertErrorName(errorName, "router compile error case.expected"); + } +} + +function validateRouterOptions(value: unknown, label: string): void { + assertObject(value, label); + if (value.trailingSlashInsensitive !== undefined) { + assertBoolean( + value.trailingSlashInsensitive, + `${label}.trailingSlashInsensitive`, + ); + } +} + +function validateRouterRouteResult(value: unknown, label: string): void { + if (value === null) return; + + assertObject(value, label); + assertString(value.name, `${label}.name`); + assertPath(value.template, `${label}.template`); + assertStringRecord(value.values, `${label}.values`); +} + function assertString( value: unknown, label: string, @@ -118,6 +307,41 @@ function assertArray( } } +function assertStringArray( + value: unknown, + label: string, +): asserts value is string[] { + assertArray(value, label); + for (const item of value) assertString(item, label); +} + +function assertPath( + value: unknown, + label: string, +): asserts value is Path { + assertString(value, label); + if (!value.startsWith("/") && !/^\{\/[^}]+\}\//.test(value)) { + throw new TypeError(`${label} must be a router path`); + } +} + +function assertStringRecord( + value: unknown, + label: string, +): asserts value is Record { + assertObject(value, label); + for (const [key, item] of Object.entries(value)) { + assertString(item, `${label}.${key}`); + } +} + +function assertStringOrNull( + value: unknown, + label: string, +): asserts value is string | null { + if (value !== null) assertString(value, label); +} + function assertErrorName( value: unknown, label: string, diff --git a/packages/uri-template/src/tests/json/router/build-cases.json b/packages/uri-template/src/tests/json/router/build-cases.json new file mode 100644 index 000000000..b5f5668f2 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/build-cases.json @@ -0,0 +1,100 @@ +[ + [ + "actor", + { + "identifier": "alice" + } + ], + [ + "inbox", + { + "identifier": "alice" + } + ], + [ + "outbox", + { + "identifier": "alice" + } + ], + [ + "followers", + { + "identifier": "alice" + } + ], + [ + "following", + { + "identifier": "alice" + } + ], + [ + "collection", + { + "identifier": "alice", + "collection": "bookmarks" + } + ], + [ + "note", + { + "identifier": "alice", + "statusId": "2026050501" + } + ], + [ + "activity", + { + "identifier": "alice", + "statusId": "2026050501" + } + ], + [ + "replies", + { + "identifier": "alice", + "statusId": "2026050501" + } + ], + [ + "media", + { + "identifier": "alice", + "mediaId": "avatar" + } + ], + [ + "object", + { + "type": "note", + "id": "2026050501" + } + ], + [ + "collectionItem", + { + "collection": "public", + "itemId": "2026050501" + } + ], + [ + "tag", + { + "tag": "activitypub" + } + ], + [ + "search", + { + "q": "fedify", + "page": "2" + } + ], + [ + "file", + { + "path": "images/avatar/original.png" + } + ] +] diff --git a/packages/uri-template/src/tests/json/router/build-suites.json b/packages/uri-template/src/tests/json/router/build-suites.json new file mode 100644 index 000000000..15b4cd174 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/build-suites.json @@ -0,0 +1,182 @@ +[ + { + "name": "default routes", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/{name}/posts/{postId}", + "post" + ] + ], + "cases": [ + { + "name": "builds user path", + "routeName": "user", + "values": { + "name": "alice" + }, + "expected": "/users/alice" + }, + { + "name": "builds post path", + "routeName": "post", + "values": { + "name": "alice", + "postId": "123" + }, + "expected": "/users/alice/posts/123" + }, + { + "name": "returns null for missing route name", + "routeName": "missing", + "values": {}, + "expected": null + } + ] + }, + { + "name": "leading path expansion templates", + "routeDefinitions": [ + [ + "{/identifier}/inbox", + "inbox" + ] + ], + "cases": [ + { + "name": "builds leading path expansion", + "routeName": "inbox", + "values": { + "identifier": "alice" + }, + "expected": "/alice/inbox" + } + ] + }, + { + "name": "template matching symmetry", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/files/{+path}", + "file" + ], + [ + "/actors{/identifier}", + "actor" + ] + ], + "cases": [ + { + "name": "escapes slash in simple variable", + "routeName": "user", + "values": { + "name": "a/b" + }, + "expected": "/users/a%2Fb" + }, + { + "name": "preserves slash in reserved expansion", + "routeName": "file", + "values": { + "path": "a/b" + }, + "expected": "/files/a/b" + }, + { + "name": "preserves pct-encoded slash in reserved expansion", + "routeName": "file", + "values": { + "path": "a%2Fb" + }, + "expected": "/files/a%2Fb" + }, + { + "name": "preserves pct-encoded reserved characters in reserved expansion", + "routeName": "file", + "values": { + "path": "%30%23" + }, + "expected": "/files/%30%23" + }, + { + "name": "preserves pct-encoded UTF-8 sequence in reserved expansion", + "routeName": "file", + "values": { + "path": "%E3%81%82" + }, + "expected": "/files/%E3%81%82" + }, + { + "name": "builds path expansion operator", + "routeName": "actor", + "values": { + "identifier": "alice" + }, + "expected": "/actors/alice" + } + ] + }, + { + "name": "pct-encoded query variable names", + "routeDefinitions": [ + [ + "/encoded-query{?abc%20def}", + "encodedQuery" + ], + [ + "/encoded-letters{?%41,%42}", + "encodedLetters" + ], + [ + "/matrix{;abc%20def}", + "matrix" + ], + [ + "/continuation?fixed=true{&abc%20def}", + "continuation" + ] + ], + "cases": [ + { + "name": "preserves pct-encoded triplet in query variable name", + "routeName": "encodedQuery", + "values": { + "abc%20def": "spaced" + }, + "expected": "/encoded-query?abc%20def=spaced" + }, + { + "name": "preserves pct-encoded query variable names", + "routeName": "encodedLetters", + "values": { + "%41": "a", + "%42": "b" + }, + "expected": "/encoded-letters?%41=a&%42=b" + }, + { + "name": "preserves pct-encoded matrix variable name", + "routeName": "matrix", + "values": { + "abc%20def": "spaced" + }, + "expected": "/matrix;abc%20def=spaced" + }, + { + "name": "preserves pct-encoded continuation variable name", + "routeName": "continuation", + "values": { + "abc%20def": "spaced" + }, + "expected": "/continuation?fixed=true&abc%20def=spaced" + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/router/clone-suites.json b/packages/uri-template/src/tests/json/router/clone-suites.json new file mode 100644 index 000000000..b07b3b837 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/clone-suites.json @@ -0,0 +1,41 @@ +[ + { + "name": "clone keeps existing routes and isolates new routes", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/{name}/posts/{postId}", + "post" + ] + ], + "clonedRouteDefinitions": [ + [ + "/users/{name}/friends", + "friends" + ] + ], + "originalRouteCases": [ + { + "name": "original does not match cloned route", + "path": "/users/alice/friends", + "expected": null + } + ], + "clonedRouteCases": [ + { + "name": "clone matches cloned route", + "path": "/users/alice/friends", + "expected": { + "name": "friends", + "template": "/users/{name}/friends", + "values": { + "name": "alice" + } + } + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/router/compile-error-cases.json b/packages/uri-template/src/tests/json/router/compile-error-cases.json new file mode 100644 index 000000000..895bd3572 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/compile-error-cases.json @@ -0,0 +1,18 @@ +[ + { + "name": "relative path template", + "path": "foo", + "expected": [ + "RouterError", + "RouteTemplatePathError" + ] + }, + { + "name": "invalid URI template literal", + "path": "/bad path", + "expected": [ + "TemplateParseError", + "InvalidLiteralError" + ] + } +] diff --git a/packages/uri-template/src/tests/json/router/hit-paths.json b/packages/uri-template/src/tests/json/router/hit-paths.json new file mode 100644 index 000000000..1cbe6c1a7 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/hit-paths.json @@ -0,0 +1,27 @@ +[ + "/.well-known/webfinger", + "/.well-known/nodeinfo", + "/users/alice", + "/users/alice/inbox", + "/users/alice/outbox", + "/users/alice/followers", + "/users/alice/following", + "/users/alice/liked", + "/users/alice/collections/featured", + "/users/alice/collections/tags", + "/users/alice/collections/bookmarks", + "/users/alice/statuses/2026050501", + "/users/alice/statuses/2026050501/activity", + "/users/alice/statuses/2026050501/replies", + "/users/alice/media/avatar", + "/objects/note/2026050501", + "/collections/public/2026050501", + "/tags/activitypub", + "/search?q=fedify&page=2", + "/files/images/avatar/original.png", + "/bob/inbox", + "/bob/outbox", + "/bob/followers", + "/bob/following", + "/bob/collections/articles" +] diff --git a/packages/uri-template/src/tests/json/router/miss-paths.json b/packages/uri-template/src/tests/json/router/miss-paths.json new file mode 100644 index 000000000..78e565bf8 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/miss-paths.json @@ -0,0 +1,15 @@ +[ + "/.well-known/host-meta", + "/users", + "/users/alice/statuses", + "/users/alice/statuses/2026050501/context", + "/users/alice/media", + "/objects/note", + "/collections/public", + "/tags", + "/search", + "/files", + "/bob", + "/bob/liked", + "/bob/collections" +] diff --git a/packages/uri-template/src/tests/json/router/route-definitions.json b/packages/uri-template/src/tests/json/router/route-definitions.json new file mode 100644 index 000000000..1b80c1f67 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/route-definitions.json @@ -0,0 +1,102 @@ +[ + [ + "/.well-known/webfinger", + "webfinger" + ], + [ + "/.well-known/nodeinfo", + "nodeInfoJrd" + ], + [ + "/users/{identifier}", + "actor" + ], + [ + "/users/{identifier}/inbox", + "inbox" + ], + [ + "/users/{identifier}/outbox", + "outbox" + ], + [ + "/users/{identifier}/following", + "following" + ], + [ + "/users/{identifier}/followers", + "followers" + ], + [ + "/users/{identifier}/liked", + "liked" + ], + [ + "/users/{identifier}/collections/featured", + "featured" + ], + [ + "/users/{identifier}/collections/tags", + "featuredTags" + ], + [ + "/users/{identifier}/collections/{collection}", + "collection" + ], + [ + "/users/{identifier}/statuses/{statusId}", + "note" + ], + [ + "/users/{identifier}/statuses/{statusId}/activity", + "activity" + ], + [ + "/users/{identifier}/statuses/{statusId}/replies", + "replies" + ], + [ + "/users/{identifier}/media/{mediaId}", + "media" + ], + [ + "/objects/{type}/{id}", + "object" + ], + [ + "/collections/{collection}/{itemId}", + "collectionItem" + ], + [ + "/tags/{tag}", + "tag" + ], + [ + "/search{?q,page}", + "search" + ], + [ + "/files/{+path}", + "file" + ], + [ + "{/identifier}/inbox", + "rootInbox" + ], + [ + "{/identifier}/outbox", + "rootOutbox" + ], + [ + "{/identifier}/followers", + "rootFollowers" + ], + [ + "{/identifier}/following", + "rootFollowing" + ], + [ + "{/identifier}/collections/{collection}", + "rootCollection" + ] +] diff --git a/packages/uri-template/src/tests/json/router/route-suites.json b/packages/uri-template/src/tests/json/router/route-suites.json new file mode 100644 index 000000000..61d4dfe9c --- /dev/null +++ b/packages/uri-template/src/tests/json/router/route-suites.json @@ -0,0 +1,357 @@ +[ + { + "name": "default trailing slash behavior", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/{name}/posts/{postId}", + "post" + ] + ], + "cases": [ + { + "name": "matches user path", + "path": "/users/alice", + "expected": { + "name": "user", + "template": "/users/{name}", + "values": { + "name": "alice" + } + } + }, + { + "name": "rejects user path with trailing slash", + "path": "/users/bob/", + "expected": null + }, + { + "name": "matches post path", + "path": "/users/alice/posts/123", + "expected": { + "name": "post", + "template": "/users/{name}/posts/{postId}", + "values": { + "name": "alice", + "postId": "123" + } + } + }, + { + "name": "rejects post path with trailing slash", + "path": "/users/bob/posts/456/", + "expected": null + } + ] + }, + { + "name": "trailing slash insensitive behavior", + "options": { + "trailingSlashInsensitive": true + }, + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/{name}/posts/{postId}/", + "post" + ] + ], + "cases": [ + { + "name": "matches user path without trailing slash", + "path": "/users/alice", + "expected": { + "name": "user", + "template": "/users/{name}", + "values": { + "name": "alice" + } + } + }, + { + "name": "matches user path with trailing slash", + "path": "/users/bob/", + "expected": { + "name": "user", + "template": "/users/{name}", + "values": { + "name": "bob" + } + } + }, + { + "name": "matches post path without trailing slash", + "path": "/users/alice/posts/123", + "expected": { + "name": "post", + "template": "/users/{name}/posts/{postId}/", + "values": { + "name": "alice", + "postId": "123" + } + } + }, + { + "name": "matches post path with trailing slash", + "path": "/users/bob/posts/456/", + "expected": { + "name": "post", + "template": "/users/{name}/posts/{postId}/", + "values": { + "name": "bob", + "postId": "456" + } + } + } + ] + }, + { + "name": "leading path expansion templates", + "routeDefinitions": [ + [ + "{/identifier}/inbox", + "inbox" + ] + ], + "cases": [ + { + "name": "matches expanded leading path segment", + "path": "/alice/inbox", + "expected": { + "name": "inbox", + "template": "{/identifier}/inbox", + "values": { + "identifier": "alice" + } + } + } + ] + }, + { + "name": "literal route priority", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/settings", + "settings" + ] + ], + "cases": [ + { + "name": "prefers more specific literal route", + "path": "/users/settings", + "expected": { + "name": "settings", + "template": "/users/settings", + "values": {} + } + } + ] + }, + { + "name": "template matching symmetry", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/files/{+path}", + "file" + ], + [ + "/actors{/identifier}", + "actor" + ] + ], + "cases": [ + { + "name": "decodes escaped slash in simple variable", + "path": "/users/a%2Fb", + "expected": { + "name": "user", + "template": "/users/{name}", + "values": { + "name": "a/b" + } + } + }, + { + "name": "rejects raw slash in simple variable", + "path": "/users/a/b", + "expected": null + }, + { + "name": "preserves raw slash in reserved expansion", + "path": "/files/a/b", + "expected": { + "name": "file", + "template": "/files/{+path}", + "values": { + "path": "a/b" + } + } + }, + { + "name": "preserves pct-encoded slash in reserved expansion", + "path": "/files/a%2Fb", + "expected": { + "name": "file", + "template": "/files/{+path}", + "values": { + "path": "a%2Fb" + } + } + }, + { + "name": "preserves pct-encoded reserved characters in reserved expansion", + "path": "/files/%30%23", + "expected": { + "name": "file", + "template": "/files/{+path}", + "values": { + "path": "%30%23" + } + } + }, + { + "name": "preserves pct-encoded UTF-8 sequence in reserved expansion", + "path": "/files/%E3%81%82", + "expected": { + "name": "file", + "template": "/files/{+path}", + "values": { + "path": "%E3%81%82" + } + } + }, + { + "name": "matches path expansion operator", + "path": "/actors/alice", + "expected": { + "name": "actor", + "template": "/actors{/identifier}", + "values": { + "identifier": "alice" + } + } + } + ] + }, + { + "name": "pct-encoded variable names", + "routeDefinitions": [ + [ + "/encoded-query{?%41,%42}", + "encodedQuery" + ], + [ + "/matrix{;abc%20def}", + "matrix" + ], + [ + "/continuation?fixed=true{&abc%20def}", + "continuation" + ] + ], + "cases": [ + { + "name": "matches query expansion with pct-encoded variable names", + "path": "/encoded-query?%41=a&%42=b", + "expected": { + "name": "encodedQuery", + "template": "/encoded-query{?%41,%42}", + "values": { + "%41": "a", + "%42": "b" + } + } + }, + { + "name": "matches matrix expansion with pct-encoded variable name", + "path": "/matrix;abc%20def=spaced%20value", + "expected": { + "name": "matrix", + "template": "/matrix{;abc%20def}", + "values": { + "abc%20def": "spaced value" + } + } + }, + { + "name": "matches continuation expansion with pct-encoded variable name", + "path": "/continuation?fixed=true&abc%20def=spaced%20value", + "expected": { + "name": "continuation", + "template": "/continuation?fixed=true{&abc%20def}", + "values": { + "abc%20def": "spaced value" + } + } + } + ] + }, + { + "name": "query expansion with optional variables", + "routeDefinitions": [ + [ + "/search{?q,page}", + "search" + ] + ], + "cases": [ + { + "name": "matches query expansion with no variables", + "path": "/search", + "expected": { + "name": "search", + "template": "/search{?q,page}", + "values": {} + } + }, + { + "name": "matches first optional query variable", + "path": "/search?q=fedify", + "expected": { + "name": "search", + "template": "/search{?q,page}", + "values": { + "q": "fedify" + } + } + }, + { + "name": "matches later optional query variable without first variable", + "path": "/search?page=2", + "expected": { + "name": "search", + "template": "/search{?q,page}", + "values": { + "page": "2" + } + } + }, + { + "name": "matches unicode query variable value", + "path": "/search?q=%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4&page=42", + "expected": { + "name": "search", + "template": "/search{?q,page}", + "values": { + "page": "42", + "q": "페디파이" + } + } + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/router/variables-cases.json b/packages/uri-template/src/tests/json/router/variables-cases.json new file mode 100644 index 000000000..236ff09e1 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/variables-cases.json @@ -0,0 +1,58 @@ +[ + { + "name": "literal path", + "path": "/users", + "expected": [] + }, + { + "name": "single variable", + "path": "/users/{name}", + "expected": [ + "name" + ] + }, + { + "name": "multiple variables", + "path": "/users/{name}/posts/{postId}", + "expected": [ + "name", + "postId" + ] + }, + { + "name": "leading path expansion", + "path": "{/identifier}/inbox", + "expected": [ + "identifier" + ] + }, + { + "name": "pct-encoded query variable name", + "path": "/encoded-query{?abc%20def}", + "expected": [ + "abc%20def" + ] + }, + { + "name": "pct-encoded query variable names", + "path": "/encoded-query{?%41,%42}", + "expected": [ + "%41", + "%42" + ] + }, + { + "name": "pct-encoded matrix variable name", + "path": "/matrix{;abc%20def}", + "expected": [ + "abc%20def" + ] + }, + { + "name": "pct-encoded continuation variable name", + "path": "/continuation?fixed=true{&abc%20def}", + "expected": [ + "abc%20def" + ] + } +] diff --git a/packages/uri-template/src/tests/mod.ts b/packages/uri-template/src/tests/mod.ts index 2c7916e4c..33e6d6f70 100644 --- a/packages/uri-template/src/tests/mod.ts +++ b/packages/uri-template/src/tests/mod.ts @@ -1,7 +1,16 @@ +import type { Path } from "../types.ts"; import { assertHardTestSuite, assertMatchTestSuite, assertPairTestSuite, + assertRouterBuildCases, + assertRouterBuildTestSuites, + assertRouterCloneTestSuites, + assertRouterCompileErrorCases, + assertRouterPaths, + assertRouterRouteDefinitions, + assertRouterRouteTestSuites, + assertRouterVariablesCases, assertWrongTestSuite, } from "./assert.ts"; import _hardTestSuites from "./json/hard.json" with { @@ -14,6 +23,33 @@ import _fixedTestSuites from "./json/references/fixed.json" with { import _pairTestSuites from "./json/references/pairs.json" with { type: "json", }; +import _routerBuildCases from "./json/router/build-cases.json" with { + type: "json", +}; +import _routerBuildTestSuites from "./json/router/build-suites.json" with { + type: "json", +}; +import _routerCloneTestSuites from "./json/router/clone-suites.json" with { + type: "json", +}; +import _routerCompileErrorCases from "./json/router/compile-error-cases.json" with { + type: "json", +}; +import _routerHitPaths from "./json/router/hit-paths.json" with { + type: "json", +}; +import _routerMissPaths from "./json/router/miss-paths.json" with { + type: "json", +}; +import _routerRouteDefinitions from "./json/router/route-definitions.json" with { + type: "json", +}; +import _routerRouteTestSuites from "./json/router/route-suites.json" with { + type: "json", +}; +import _routerVariablesCases from "./json/router/variables-cases.json" with { + type: "json", +}; import _wrongTestSuites from "./json/wrong.json" with { type: "json" }; import type { FixedTemplateTestSuite, @@ -22,17 +58,76 @@ import type { PairTestSuite, WrongTestSuite, } from "./lib.ts"; +import type { + RouterBuildCase, + RouterBuildTestSuite, + RouterCloneTestSuite, + RouterCompileErrorCase, + RouterRouteDefinition, + RouterRouteTestSuite, + RouterVariablesCase, +} from "./router.ts"; + +type JsonAssertion = (value: unknown) => asserts value is T; -assertPairTestSuite(_pairTestSuites); -assertWrongTestSuite(_wrongTestSuites); -assertHardTestSuite(_hardTestSuites); -assertMatchTestSuite(_matchTestSuites); -export const pairTestSuites: readonly PairTestSuite[] = _pairTestSuites; +const validate = (validate: JsonAssertion, value: unknown): T => { + validate(value); + return value; +}; + +export const pairTestSuites: readonly PairTestSuite[] = validate( + assertPairTestSuite, + _pairTestSuites, +); export const fixedTestSuites: readonly FixedTemplateTestSuite[] = _fixedTestSuites; -export const wrongTestSuites: readonly WrongTestSuite[] = _wrongTestSuites; -export const hardTestSuites: readonly HardTestSuite[] = _hardTestSuites; -export const matchTestSuites: readonly MatchTestSuite[] = _matchTestSuites; +export const wrongTestSuites: readonly WrongTestSuite[] = validate( + assertWrongTestSuite, + _wrongTestSuites, +); +export const hardTestSuites: readonly HardTestSuite[] = validate( + assertHardTestSuite, + _hardTestSuites, +); +export const matchTestSuites: readonly MatchTestSuite[] = validate( + assertMatchTestSuite, + _matchTestSuites, +); +export const routerRouteDefinitions: readonly RouterRouteDefinition[] = + validate( + assertRouterRouteDefinitions, + _routerRouteDefinitions, + ); +export const routerHitPaths: readonly Path[] = validate( + assertRouterPaths, + _routerHitPaths, +); +export const routerMissPaths: readonly Path[] = validate( + assertRouterPaths, + _routerMissPaths, +); +export const routerBuildCases: readonly RouterBuildCase[] = validate( + assertRouterBuildCases, + _routerBuildCases, +); +export const routerRouteTestSuites: readonly RouterRouteTestSuite[] = validate( + assertRouterRouteTestSuites, + _routerRouteTestSuites, +); +export const routerBuildTestSuites: readonly RouterBuildTestSuite[] = validate( + assertRouterBuildTestSuites, + _routerBuildTestSuites, +); +export const routerVariablesCases: readonly RouterVariablesCase[] = validate( + assertRouterVariablesCases, + _routerVariablesCases, +); +export const routerCompileErrorCases: readonly RouterCompileErrorCase[] = + validate(assertRouterCompileErrorCases, _routerCompileErrorCases); +export const routerCloneTestSuites: readonly RouterCloneTestSuite[] = validate( + assertRouterCloneTestSuites, + _routerCloneTestSuites, +); export { createFixedTemplateMatchTest, createFixedTemplateTest, @@ -43,3 +138,21 @@ export { createTemplatePairTest, createWrongTemplateTest, } from "./lib.ts"; +export { + createRouterAddTest, + createRouterBuildPathsBench, + createRouterBuildTest, + createRouterCloneTest, + createRouterCompileAndAddBench, + createRouterCompileErrorTest, + createRouterDeepCommonPrefixScenario, + createRouterFirstRouteAfterBuildBench, + createRouterHundredsOfRoutesScenario, + createRouterInactiveEntriesScenario, + createRouterRootAdjacentDynamicRoutesScenario, + createRouterRouteHitsBench, + createRouterRouteMissesBench, + createRouterRouteTest, + createRouterVariablesTest, + type RouterMemoryPressureScenario, +} from "./router.ts"; diff --git a/packages/uri-template/src/tests/router.ts b/packages/uri-template/src/tests/router.ts new file mode 100644 index 000000000..71d3eabd1 --- /dev/null +++ b/packages/uri-template/src/tests/router.ts @@ -0,0 +1,491 @@ +import { deepEqual, equal, throws } from "node:assert/strict"; +import * as ERROR_CLASS_BY_NAME from "../router/errors.ts"; +import type { Path } from "../types.ts"; + +type ErrorName = keyof typeof ERROR_CLASS_BY_NAME; + +interface RouterTestOptions { + readonly trailingSlashInsensitive?: boolean; +} + +interface RouterRouteResult { + readonly name: string; + readonly template: Path; + readonly values: Record; +} + +interface RouterInstance { + add(pattern: Path, name: string): void; + build(name: string, values: Record): Path | null; + has(name: string): boolean; + route(path: Path): RouterRouteResult | null; + clone(): RouterInstance; +} + +interface RouterConstructor { + new (options?: RouterTestOptions): RouterInstance; +} + +interface RouterExtendedConstructor + extends RouterConstructor { + compile(path: Path): TPattern; + variables(path: Path): Set; +} + +export type RouterRouteDefinition = readonly [path: Path, name: string]; + +export type RouterBuildCase = readonly [ + routeName: string, + values: Record, +]; + +export interface RouterRouteCase { + readonly name: string; + readonly path: Path; + readonly expected: RouterRouteResult | null; +} + +export interface RouterRouteTestSuite { + readonly name: string; + readonly options?: RouterTestOptions; + readonly routeDefinitions: readonly RouterRouteDefinition[]; + readonly cases: readonly RouterRouteCase[]; +} + +export interface RouterBuildTestCase { + readonly name: string; + readonly routeName: string; + readonly values: Record; + readonly expected: string | null; +} + +export interface RouterBuildTestSuite { + readonly name: string; + readonly options?: RouterTestOptions; + readonly routeDefinitions: readonly RouterRouteDefinition[]; + readonly cases: readonly RouterBuildTestCase[]; +} + +export interface RouterVariablesCase { + readonly name: string; + readonly path: Path; + readonly expected: readonly string[]; +} + +export interface RouterCompileErrorCase { + readonly name: string; + readonly path: string; + readonly expected: readonly ErrorName[]; +} + +export interface RouterCloneTestSuite { + readonly name: string; + readonly options?: RouterTestOptions; + readonly routeDefinitions: readonly RouterRouteDefinition[]; + readonly clonedRouteDefinitions: readonly RouterRouteDefinition[]; + readonly originalRouteCases: readonly RouterRouteCase[]; + readonly clonedRouteCases: readonly RouterRouteCase[]; +} + +export interface RouterMemoryPressureScenario { + readonly name: string; + readonly routeDefinitions: readonly RouterRouteDefinition[]; + readonly hitPaths: readonly Path[]; + readonly missPaths: readonly Path[]; + readonly routeName: string; +} + +let routerBenchSink = 0; + +const consumeRouterBenchValue = (value: number): void => { + routerBenchSink = (routerBenchSink + value) | 0; +}; + +const consumeRouterRoute = (result: RouterRouteResult | null): void => { + consumeRouterBenchValue( + (result?.name.length ?? 0) + (result?.template.length ?? 0), + ); +}; + +const consumeRouterPath = (path: Path | null): void => { + consumeRouterBenchValue(path?.length ?? 0); +}; + +const createRouterDefinitions = ( + count: number, + createDefinition: (index: number) => RouterRouteDefinition, +): readonly RouterRouteDefinition[] => + Array.from({ length: count }, (_, index) => createDefinition(index)); + +const createSampledIndexes = (count: number, sampleCount: number): number[] => + Array.from( + { length: sampleCount }, + (_, index) => Math.floor(index * count / sampleCount), + ); + +const padRouterIndex = (index: number): string => + index.toString().padStart(4, "0"); + +export function createRouterHundredsOfRoutesScenario(): RouterMemoryPressureScenario { + const routeDefinitions = createRouterDefinitions( + 512, + (index): RouterRouteDefinition => [ + `/bulk/group-${index % 16}/items/${padRouterIndex(index)}/{id}`, + `bulk${padRouterIndex(index)}`, + ], + ); + const sampledIndexes = createSampledIndexes(512, 16); + + return { + name: "hundreds of routes", + routeDefinitions, + hitPaths: sampledIndexes.map((index) => + `/bulk/group-${index % 16}/items/${padRouterIndex(index)}/value` as Path + ), + missPaths: sampledIndexes.map((index) => + `/bulk/group-${index % 16}/missing/${padRouterIndex(index)}/value` as Path + ), + routeName: `bulk${padRouterIndex(511)}`, + }; +} + +export function createRouterDeepCommonPrefixScenario(): RouterMemoryPressureScenario { + const segments = Array.from( + { length: 128 }, + (_, index) => `segment-${padRouterIndex(index)}`, + ); + const routeDefinitions = segments.map(( + _, + index, + ): RouterRouteDefinition => [ + `/deep/${segments.slice(0, index + 1).join("/")}` as Path, + `deep${padRouterIndex(index)}`, + ]); + const deepestPath = `/deep/${segments.join("/")}` as Path; + + return { + name: "deep common prefix", + routeDefinitions, + hitPaths: [deepestPath], + missPaths: [`${deepestPath}/missing` as Path], + routeName: `deep${padRouterIndex(127)}`, + }; +} + +export function createRouterRootAdjacentDynamicRoutesScenario(): RouterMemoryPressureScenario { + const routeDefinitions = createRouterDefinitions( + 384, + (index): RouterRouteDefinition => [ + `/{tenant}/resource-${padRouterIndex(index)}/{id}`, + `rootDynamic${padRouterIndex(index)}`, + ], + ); + const sampledIndexes = createSampledIndexes(384, 16); + + return { + name: "root-adjacent dynamic routes", + routeDefinitions, + hitPaths: sampledIndexes.map((index) => + `/alice/resource-${padRouterIndex(index)}/value` as Path + ), + missPaths: sampledIndexes.map((index) => + `/alice/missing-${padRouterIndex(index)}/value` as Path + ), + routeName: `rootDynamic${padRouterIndex(383)}`, + }; +} + +export function createRouterInactiveEntriesScenario(): RouterMemoryPressureScenario { + return { + name: "inactive entries", + routeDefinitions: createRouterDefinitions( + 512, + (index): RouterRouteDefinition => [ + `/{tenant}/inactive-${padRouterIndex(index)}/{id}`, + "replaced", + ], + ), + hitPaths: [`/alice/inactive-${padRouterIndex(511)}/value`], + missPaths: ["/alice/inactive-missing/value"], + routeName: "replaced", + }; +} + +const createRouterFromDefinitions = ( + Router: RouterExtendedConstructor, + definitions: readonly RouterRouteDefinition[], + options: RouterTestOptions = {}, +): RouterInstance => { + const router = new Router(options); + + for (const [path, name] of definitions) { + router.add(path, name); + } + + return router; +}; + +export function createRouterAddTest( + Router: RouterExtendedConstructor, +): ( + definitions: readonly RouterRouteDefinition[], +) => (t: Deno.TestContext) => Promise { + return ( + definitions: readonly RouterRouteDefinition[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + const router = new Router(); + + for (const [path, name] of definitions) { + await t.step(`${path} as ${name}`, () => { + equal(router.add(path, name), undefined); + equal(router.has(name), true); + }); + } + }; +} + +export function createRouterCompileErrorTest( + Router: RouterExtendedConstructor, +): ( + cases: readonly RouterCompileErrorCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly RouterCompileErrorCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, path, expected } of cases) { + await t.step(name, () => { + for (const errorName of expected) { + throws( + () => Router.compile(path as Path), + ERROR_CLASS_BY_NAME[errorName], + ); + } + }); + } + }; +} + +export function createRouterVariablesTest( + Router: RouterExtendedConstructor, +): ( + cases: readonly RouterVariablesCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly RouterVariablesCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, path, expected } of cases) { + await t.step( + name, + () => deepEqual(Router.variables(path), new Set(expected)), + ); + } + }; +} + +export function createRouterRouteTest( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + options?: RouterTestOptions, +) => ( + cases: readonly RouterRouteCase[], +) => (t: Deno.TestContext) => Promise { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + options: RouterTestOptions = {}, + ): ( + cases: readonly RouterRouteCase[], + ) => (t: Deno.TestContext) => Promise => { + const router = createRouterFromDefinitions( + Router, + routeDefinitions, + options, + ); + + return ( + cases: readonly RouterRouteCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, path, expected } of cases) { + await t.step( + name, + () => deepEqual(router.route(path), expected), + ); + } + }; + }; +} + +export function createRouterBuildTest( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + options?: RouterTestOptions, +) => ( + cases: readonly RouterBuildTestCase[], +) => (t: Deno.TestContext) => Promise { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + options: RouterTestOptions = {}, + ): ( + cases: readonly RouterBuildTestCase[], + ) => (t: Deno.TestContext) => Promise => { + const router = createRouterFromDefinitions( + Router, + routeDefinitions, + options, + ); + + return ( + cases: readonly RouterBuildTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, routeName, values, expected } of cases) { + await t.step( + name, + () => equal(router.build(routeName, values), expected), + ); + } + }; + }; +} + +export function createRouterCloneTest( + Router: RouterConstructor, +): ( + suites: readonly RouterCloneTestSuite[], +) => (t: Deno.TestContext) => Promise { + return ( + suites: readonly RouterCloneTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const suite of suites) { + await t.step(suite.name, () => { + const original = new Router(suite.options); + for (const [path, name] of suite.routeDefinitions) { + original.add(path, name); + } + const clone = original.clone(); + + for (const [path, name] of suite.clonedRouteDefinitions) { + clone.add(path, name); + } + for (const [, name] of suite.routeDefinitions) { + equal(original.has(name), true); + equal(clone.has(name), true); + } + for (const [, name] of suite.clonedRouteDefinitions) { + equal(original.has(name), false); + equal(clone.has(name), true); + } + for (const { path, expected } of suite.originalRouteCases) { + deepEqual(original.route(path), expected); + } + for (const { path, expected } of suite.clonedRouteCases) { + deepEqual(clone.route(path), expected); + } + }); + } + }; +} + +export function createRouterCompileAndAddBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + routeName: string, +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + routeName: string, + ): () => void => { + const createRouter = (): RouterInstance => + createRouterFromDefinitions(Router, routeDefinitions); + + return (): void => { + for (let count = 0; count < 100; count++) { + const router = createRouter(); + consumeRouterBenchValue(router.has(routeName) ? 1 : 0); + } + }; + }; +} + +export function createRouterFirstRouteAfterBuildBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + path: Path, +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + path: Path, + ): () => void => + (): void => { + const router = createRouterFromDefinitions(Router, routeDefinitions); + consumeRouterRoute(router.route(path)); + }; +} + +export function createRouterRouteHitsBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + paths: readonly Path[], +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + paths: readonly Path[], + ): () => void => { + const router = createRouterFromDefinitions(Router, routeDefinitions); + + return (): void => { + for (const path of paths) { + consumeRouterRoute(router.route(path)); + } + }; + }; +} + +export function createRouterRouteMissesBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + paths: readonly Path[], +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + paths: readonly Path[], + ): () => void => { + const router = createRouterFromDefinitions(Router, routeDefinitions); + + return (): void => { + for (const path of paths) { + consumeRouterRoute(router.route(path)); + } + }; + }; +} + +export function createRouterBuildPathsBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + cases: readonly RouterBuildCase[], +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + cases: readonly RouterBuildCase[], + ): () => void => { + const router = createRouterFromDefinitions(Router, routeDefinitions); + + return (): void => { + for (const [routeName, values] of cases) { + consumeRouterPath(router.build(routeName, values)); + } + }; + }; +} From fd37b88ba26c7b828c219262995b8500ecb59027 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 19:56:17 +0000 Subject: [PATCH 14/49] Capture legacy Router failures Add a benchmark test copy of the previously used Router from the federation package so its behavior can be checked against the shared Router suites. Keep inline notes for the known correctness gaps: reserved expansion matches decode pct-encoded values, and url-template double-encodes pct-encoded variable names when building URIs. These tests make the old Router failures reproducible while the new Router implementation replaces that approach. Assisted-by: Codex:gpt-5 --- .../bench/uri-template-router.test.ts | 211 ++++++++++++++++++ .../uri-template/bench/url-template.test.ts | 1 + 2 files changed, 212 insertions(+) create mode 100644 packages/uri-template/bench/uri-template-router.test.ts diff --git a/packages/uri-template/bench/uri-template-router.test.ts b/packages/uri-template/bench/uri-template-router.test.ts new file mode 100644 index 000000000..1a70fc98a --- /dev/null +++ b/packages/uri-template/bench/uri-template-router.test.ts @@ -0,0 +1,211 @@ +// 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 test --allow-env packages/uri-template/bench/uri-template-router.test.ts`. + * 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. + */ +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 runAddCases = createRouterAddTest(Router); +test("Router.add()", runAddCases(routerRouteDefinitions)); + +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), + ); +} diff --git a/packages/uri-template/bench/url-template.test.ts b/packages/uri-template/bench/url-template.test.ts index 56c03fede..edcd3ab24 100644 --- a/packages/uri-template/bench/url-template.test.ts +++ b/packages/uri-template/bench/url-template.test.ts @@ -67,6 +67,7 @@ class Template { const { expand } = parseTemplate(template); this.expand = expand; } + match = (_: string) => null; } const runPairCases = createTemplatePairTest(Template); From f3f83cf054c3fa0628d79a8973ef4f15176a31c2 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 22:28:00 +0000 Subject: [PATCH 15/49] Reorganize uri-template into per-feature module layouts Splits each feature module into a thin entry plus dedicated implementation file: - src/template/mod.ts now re-exports from template.ts and errors.ts. Template class implementation moves to src/template/template.ts. - src/router/mod.ts now re-exports from router.ts and errors.ts. Router class implementation moves to src/router/router.ts. Consolidates parse/expansion errors with the template feature: - src/errors.ts moves to src/template/errors.ts with internal callers updated. Reorganizes test fixtures and helpers per feature: - src/tests/lib.ts moves to src/tests/template.ts. - src/tests/json/{hard,match,wrong}.json move under src/tests/json/template/. All imports across the package are updated to the new locations. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/mod.ts | 17 +- packages/uri-template/src/router/mod.ts | 259 +----------------- .../uri-template/src/router/router.bench.ts | 2 +- .../uri-template/src/router/router.test.ts | 2 +- packages/uri-template/src/router/router.ts | 257 +++++++++++++++++ .../uri-template/src/{ => template}/errors.ts | 4 +- packages/uri-template/src/template/expand.ts | 2 +- .../uri-template/src/template/expression.ts | 6 +- packages/uri-template/src/template/mod.ts | 103 +------ .../src/template/template.bench.ts | 2 +- .../src/template/template.test.ts | 16 +- .../uri-template/src/template/template.ts | 99 +++++++ packages/uri-template/src/template/token.ts | 6 +- packages/uri-template/src/tests/assert.ts | 24 +- .../src/tests/json/{ => template}/hard.json | 12 +- .../src/tests/json/{ => template}/match.json | 4 +- .../src/tests/json/{ => template}/wrong.json | 2 +- packages/uri-template/src/tests/mod.ts | 26 +- .../src/tests/{lib.ts => template.ts} | 8 +- 19 files changed, 426 insertions(+), 425 deletions(-) create mode 100644 packages/uri-template/src/router/router.ts rename packages/uri-template/src/{ => template}/errors.ts (98%) create mode 100644 packages/uri-template/src/template/template.ts rename packages/uri-template/src/tests/json/{ => template}/hard.json (95%) rename packages/uri-template/src/tests/json/{ => template}/match.json (99%) rename packages/uri-template/src/tests/json/{ => template}/wrong.json (99%) rename packages/uri-template/src/tests/{lib.ts => template.ts} (97%) diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts index bd5902634..1a956f8a9 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -7,6 +7,12 @@ * @module */ +export { Router, RouterError, RouteTemplatePathError } from "./router/mod.ts"; +export type { + RouterOptions, + RouterPathPattern, + RouterRouteResult, +} from "./router/router.ts"; export { EmptyExpressionError, EmptyVarNameError, @@ -18,21 +24,14 @@ export { PrefixModifierNotApplicableError, ReservedOperatorError, StrayClosingBraceError, + Template, TemplateExpansionError, TemplateParseError, TrailingCommaError, UnclosedExpressionError, UnexpectedCharacterError, UnknownOperatorError, -} from "./errors.ts"; -export { RouterError, RouteTemplatePathError } from "./router/errors.ts"; -export { default as Router } from "./router/mod.ts"; -export type { - RouterOptions, - RouterPathPattern, - RouterRouteResult, -} from "./router/mod.ts"; -export { default as Template } from "./template/mod.ts"; +} from "./template/mod.ts"; export type { AssociativeValue, ExpandContext, diff --git a/packages/uri-template/src/router/mod.ts b/packages/uri-template/src/router/mod.ts index 683eb4d94..c3a894222 100644 --- a/packages/uri-template/src/router/mod.ts +++ b/packages/uri-template/src/router/mod.ts @@ -1,257 +1,2 @@ -import Template from "../template/mod.ts"; -import type { ExpandContext, Path, Token } from "../types.ts"; -import { RouteTemplatePathError } from "./errors.ts"; -import Trie from "./trie.ts"; - -/** - * Options for the {@link Router}. - */ -export interface RouterOptions { - /** - * Whether to ignore trailing slashes when matching paths. - */ - trailingSlashInsensitive?: boolean; -} - -/** - * The result of {@link Router.route}. - */ -export interface RouterRouteResult { - /** - * The matched route name. - */ - name: string; - - /** - * The URI template of the matched route. - */ - template: Path; - - /** - * The values extracted from the URI. - */ - values: Record; -} - -/** - * Parsed path template ready to be registered in a {@link Router}. - */ -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; -} - -interface RouteEntry { - readonly index: number; - readonly name: string; - readonly pattern: RouterPathPattern; - readonly initialLiteralPrefix: string; - readonly literalLength: number; - readonly variableCount: number; -} - -/** - * Router that resolves URIs against registered RFC 6570 templates. - */ -export default class Router { - readonly #trie: Trie; - readonly #routesByName: Map; - #nextIndex: number; - - /** - * Whether to ignore trailing slashes when matching paths. - */ - trailingSlashInsensitive: boolean; - - /** - * Create a new {@link Router}. - * @param options Options for the router. - */ - constructor(options: RouterOptions = {}) { - this.#trie = new Trie(); - this.#routesByName = new Map(); - this.#nextIndex = 0; - this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; - } - - clone(): Router { - const clone = new Router({ - trailingSlashInsensitive: this.trailingSlashInsensitive, - }); - - for (const entry of this.#activeEntries()) { - clone.add(entry.pattern.path, entry.name); - } - - return clone; - } - - /** - * 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 { - 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 pattern The compiled path pattern. - * @param name The name of the path. - */ - add = (template: Path, name: string): void => { - const pattern = Router.compile(template); - const entry = createRouteEntry({ index: this.#nextIndex++, name, pattern }); - - this.#routesByName.set(name, entry); - this.#trie.insert(entry); - }; - - /** - * 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 = (url: Path): RouterRouteResult | null => { - const match = this.#route(url); - if (match != null || !this.trailingSlashInsensitive) return match; - - const retryUrl = toggleTrailingSlash(url); - return retryUrl == null ? null : this.#route(retryUrl); - }; - - /** - * 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 = (name: string, values: Record): Path | null => - (this.#routesByName.get(name) - ?.pattern.template.expand(values) ?? null) as Path | null; - - #route(url: Path): RouterRouteResult | null { - for (const entry of this.#trie.candidates(url, this.#isActiveEntry)) { - const context = entry.pattern.template.match(url); - if (context == null) continue; - - const values = toRouteValues(context); - if (values == null) continue; - - return { - name: entry.name, - template: entry.pattern.path, - values, - }; - } - - return null; - } - - #activeEntries = (): RouteEntry[] => - Array.from(this.#routesByName.values()) - .sort((left, right) => left.index - right.index); - - #isActiveEntry = (entry: RouteEntry): boolean => - this.#routesByName.get(entry.name) === entry; -} - -interface CreateRouteEntryOptions { - readonly index: number; - readonly name: string; - readonly pattern: RouterPathPattern; -} - -const createRouteEntry = ({ - index, - name, - pattern, -}: CreateRouteEntryOptions): RouteEntry => ({ - index, - name, - pattern, - initialLiteralPrefix: getInitialLiteralPrefix(pattern.template.tokens), - literalLength: getLiteralLength(pattern.template.tokens), - variableCount: pattern.variables.size, -}); - -const toggleTrailingSlash = (path: Path): Path | null => { - if (!path.endsWith("/")) return `${path}/`; - - const trimmed = path.replace(/\/+$/, ""); - return isPath(trimmed) ? trimmed : null; -}; - -const collectVariables = (tokens: readonly Token[]): Set => - new Set( - tokens - .filter(isExpression) - .flatMap((token) => token.vars.map((varSpec) => varSpec.name)), - ); - -const isExpression = ( - token: T, -): token is Extract => token.kind === "expression"; - -const getInitialLiteralPrefix = (tokens: readonly Token[]): string => - tokens[0]?.kind === "literal" ? tokens[0].text : ""; - -const getLiteralLength = (tokens: readonly Token[]): number => - tokens.reduce( - (sum, token) => token.kind === "literal" ? sum + token.text.length : sum, - 0, - ); - -const isPath = (path: string): path is Path => - path.startsWith("/") || /^\{\/[^}]+\}\//.test(path); - -const toRouteValues = ( - context: ExpandContext, -): Record | null => { - const values: Record = {}; - - for (const [key, value] of Object.entries(context)) { - if (typeof value !== "string") return null; - values[key] = value; - } - - return values; -}; +export * from "./errors.ts"; +export { default as Router } from "./router.ts"; diff --git a/packages/uri-template/src/router/router.bench.ts b/packages/uri-template/src/router/router.bench.ts index 74976f2b5..4f51bce1b 100644 --- a/packages/uri-template/src/router/router.bench.ts +++ b/packages/uri-template/src/router/router.bench.ts @@ -14,7 +14,7 @@ import { routerMissPaths, routerRouteDefinitions, } from "../tests/mod.ts"; -import Router from "./mod.ts"; +import Router from "./router.ts"; const runCompileAndAddRoutes = createRouterCompileAndAddBench(Router); Deno.bench( diff --git a/packages/uri-template/src/router/router.test.ts b/packages/uri-template/src/router/router.test.ts index d15aa657c..6e8a2b63d 100644 --- a/packages/uri-template/src/router/router.test.ts +++ b/packages/uri-template/src/router/router.test.ts @@ -13,7 +13,7 @@ import { routerRouteTestSuites, routerVariablesCases, } from "../tests/mod.ts"; -import Router from "./mod.ts"; +import Router from "./router.ts"; const runAddCases = createRouterAddTest(Router); test("Router.add()", runAddCases(routerRouteDefinitions)); diff --git a/packages/uri-template/src/router/router.ts b/packages/uri-template/src/router/router.ts new file mode 100644 index 000000000..e3c80c16c --- /dev/null +++ b/packages/uri-template/src/router/router.ts @@ -0,0 +1,257 @@ +import { Template } from "../template/mod.ts"; +import type { ExpandContext, Path, Token } from "../types.ts"; +import { RouteTemplatePathError } from "./errors.ts"; +import Trie from "./trie.ts"; + +/** + * Options for the {@link Router}. + */ +export interface RouterOptions { + /** + * Whether to ignore trailing slashes when matching paths. + */ + trailingSlashInsensitive?: boolean; +} + +/** + * The result of {@link Router.route}. + */ +export interface RouterRouteResult { + /** + * The matched route name. + */ + name: string; + + /** + * The URI template of the matched route. + */ + template: Path; + + /** + * The values extracted from the URI. + */ + values: Record; +} + +/** + * Parsed path template ready to be registered in a {@link Router}. + */ +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; +} + +interface RouteEntry { + readonly index: number; + readonly name: string; + readonly pattern: RouterPathPattern; + readonly initialLiteralPrefix: string; + readonly literalLength: number; + readonly variableCount: number; +} + +/** + * Router that resolves URIs against registered RFC 6570 templates. + */ +export default class Router { + readonly #trie: Trie; + readonly #routesByName: Map; + #nextIndex: number; + + /** + * Whether to ignore trailing slashes when matching paths. + */ + trailingSlashInsensitive: boolean; + + /** + * Create a new {@link Router}. + * @param options Options for the router. + */ + constructor(options: RouterOptions = {}) { + this.#trie = new Trie(); + this.#routesByName = new Map(); + this.#nextIndex = 0; + this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; + } + + clone(): Router { + const clone = new Router({ + trailingSlashInsensitive: this.trailingSlashInsensitive, + }); + + for (const entry of this.#activeEntries()) { + clone.add(entry.pattern.path, entry.name); + } + + return clone; + } + + /** + * 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 { + 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 template The path template to add. + * @param name The name of the path. + */ + add = (template: Path, name: string): void => { + const pattern = Router.compile(template); + const entry = createRouteEntry({ index: this.#nextIndex++, name, pattern }); + + this.#routesByName.set(name, entry); + this.#trie.insert(entry); + }; + + /** + * 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 = (url: Path): RouterRouteResult | null => { + const match = this.#route(url); + if (match != null || !this.trailingSlashInsensitive) return match; + + const retryUrl = toggleTrailingSlash(url); + return retryUrl == null ? null : this.#route(retryUrl); + }; + + /** + * 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 = (name: string, values: Record): Path | null => + (this.#routesByName.get(name) + ?.pattern.template.expand(values) ?? null) as Path | null; + + #route(url: Path): RouterRouteResult | null { + for (const entry of this.#trie.candidates(url, this.#isActiveEntry)) { + const context = entry.pattern.template.match(url); + if (context == null) continue; + + const values = toRouteValues(context); + if (values == null) continue; + + return { + name: entry.name, + template: entry.pattern.path, + values, + }; + } + + return null; + } + + #activeEntries = (): RouteEntry[] => + Array.from(this.#routesByName.values()) + .sort((left, right) => left.index - right.index); + + #isActiveEntry = (entry: RouteEntry): boolean => + this.#routesByName.get(entry.name) === entry; +} + +interface CreateRouteEntryOptions { + readonly index: number; + readonly name: string; + readonly pattern: RouterPathPattern; +} + +const createRouteEntry = ({ + index, + name, + pattern, +}: CreateRouteEntryOptions): RouteEntry => ({ + index, + name, + pattern, + initialLiteralPrefix: getInitialLiteralPrefix(pattern.template.tokens), + literalLength: getLiteralLength(pattern.template.tokens), + variableCount: pattern.variables.size, +}); + +const toggleTrailingSlash = (path: Path): Path | null => { + if (!path.endsWith("/")) return `${path}/`; + + const trimmed = path.replace(/\/+$/, ""); + return isPath(trimmed) ? trimmed : null; +}; + +const collectVariables = (tokens: readonly Token[]): Set => + new Set( + tokens + .filter(isExpression) + .flatMap((token) => token.vars.map((varSpec) => varSpec.name)), + ); + +const isExpression = ( + token: T, +): token is Extract => token.kind === "expression"; + +const getInitialLiteralPrefix = (tokens: readonly Token[]): string => + tokens[0]?.kind === "literal" ? tokens[0].text : ""; + +const getLiteralLength = (tokens: readonly Token[]): number => + tokens.reduce( + (sum, token) => token.kind === "literal" ? sum + token.text.length : sum, + 0, + ); + +const isPath = (path: string): path is Path => + path.startsWith("/") || /^\{\/[^}]+\}\//.test(path); + +const toRouteValues = ( + context: ExpandContext, +): Record | null => { + const values: Record = {}; + + for (const [key, value] of Object.entries(context)) { + if (typeof value !== "string") return null; + values[key] = value; + } + + return values; +}; diff --git a/packages/uri-template/src/errors.ts b/packages/uri-template/src/template/errors.ts similarity index 98% rename from packages/uri-template/src/errors.ts rename to packages/uri-template/src/template/errors.ts index 0a8de702c..9bad699a5 100644 --- a/packages/uri-template/src/errors.ts +++ b/packages/uri-template/src/template/errors.ts @@ -63,8 +63,8 @@ export class TemplateParseError extends Error { /** * Raised when an opening `{` has no matching `}` before the template ends. * - * Fix: close the expression with `}` or escape the literal `{` (RFC 6570 - * does not define an escape; remove the stray brace). + * 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) { diff --git a/packages/uri-template/src/template/expand.ts b/packages/uri-template/src/template/expand.ts index 1eeaf6e62..4e7d61aaa 100644 --- a/packages/uri-template/src/template/expand.ts +++ b/packages/uri-template/src/template/expand.ts @@ -1,5 +1,4 @@ import { operatorSpecs } from "../const.ts"; -import { PrefixModifierNotApplicableError } from "../errors.ts"; import type { AssociativeValue, ExpandContext, @@ -10,6 +9,7 @@ import type { 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 diff --git a/packages/uri-template/src/template/expression.ts b/packages/uri-template/src/template/expression.ts index 84d1807b3..3d382b6a4 100644 --- a/packages/uri-template/src/template/expression.ts +++ b/packages/uri-template/src/template/expression.ts @@ -1,4 +1,6 @@ import { OPERATORS } from "../const.ts"; +import type { Operator, TemplateOptions, Token, VarSpec } from "../types.ts"; +import { isVarcharAt } from "./encoding.ts"; import { EmptyExpressionError, EmptyVarNameError, @@ -8,9 +10,7 @@ import { TrailingCommaError, UnexpectedCharacterError, UnknownOperatorError, -} from "../errors.ts"; -import type { Operator, TemplateOptions, Token, VarSpec } from "../types.ts"; -import { isVarcharAt } from "./encoding.ts"; +} from "./errors.ts"; const reservedOperators = ["=", ",", "!", "@", "|"] as const; diff --git a/packages/uri-template/src/template/mod.ts b/packages/uri-template/src/template/mod.ts index 526b5c1b6..876612910 100644 --- a/packages/uri-template/src/template/mod.ts +++ b/packages/uri-template/src/template/mod.ts @@ -1,101 +1,2 @@ -import { getLogger } from "@logtape/logtape"; -import type { - ExpandContext, - Reporter, - TemplateOptions, - Token, -} from "../types.ts"; -import expand from "./expand.ts"; -import match from "./match.ts"; -import tokenize from "./token.ts"; - -/** - * Parsed RFC 6570 URI Template that can be expanded repeatedly. - * - * This class owns tokenization and delegates expression expansion to the - * expansion module. - */ -export default class Template { - readonly #tokens: Token[]; - readonly #fullOptions: TemplateOptions; - - constructor( - /** - * URI template string to parse. See [RFC 6570] for syntax details. - * - * [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 - */ - public readonly uriTemplate: string, - /** - * Options for parsing the template. By default, `strict` is `true` and - * `report` ignores parse errors. If `strict` is `true`, the first error - * encountered while parsing will be automatically thrown after being - * reported. If `strict` is `false`, errors will be reported but none will - * be thrown unless the `report` function itself throws. - */ - readonly options: Partial = {}, - ) { - this.#fullOptions = fillOptions(options); - this.#tokens = tokenize(uriTemplate, this.#fullOptions); - } - - /** - * Parses a URI Template using default strict parsing options. - */ - static parse( - uriTemplate: string, - options: Partial = {}, - ): Template { - return new Template(uriTemplate, options); - } - - /** - * Parsed token stream for diagnostics and router integration. - */ - get tokens(): readonly Token[] { - return this.#tokens; - } - - /** - * Expands this template against a variable context. - */ - expand: ( - context: ExpandContext, - options?: TemplateOptions, - ) => string = ( - context: ExpandContext, - options: TemplateOptions = this.#fullOptions, - ): string => expand(this.#tokens, context, options); - - /** - * Matches a URI against this template, returning the variable context if the - * URI matches or `null` if it does not. - */ - match: ( - uri: string, - options?: TemplateOptions, - ) => ExpandContext | null = ( - uri: string, - options: TemplateOptions = this.#fullOptions, - ): ExpandContext | null => match(this.#tokens, uri, options); - - toString = (): string => this.uriTemplate; -} - -const logger = getLogger(["fedify", "uri-template", "Template"]); - -const defaultReporter: Reporter = (error: Error) => logger.error(error); - -const fillOptions = ( - { strict, report }: Partial, -): TemplateOptions => { - report ??= defaultReporter; - strict ??= true; - report = strict ? strictWrapper(report) : report; - return { strict, report }; -}; - -const strictWrapper = (reporter: Reporter) => (error: Error): never => { - reporter(error); - throw error; -}; +export * from "./errors.ts"; +export { default as Template } from "./template.ts"; diff --git a/packages/uri-template/src/template/template.bench.ts b/packages/uri-template/src/template/template.bench.ts index 5722c57bc..a6470740f 100644 --- a/packages/uri-template/src/template/template.bench.ts +++ b/packages/uri-template/src/template/template.bench.ts @@ -1,6 +1,6 @@ import { test } from "@fedify/fixture"; import { createTemplatePairTest, pairTestSuites } from "../tests/mod.ts"; -import Template from "./mod.ts"; +import Template from "./template.ts"; Deno.bench("Template using RegExp", (b) => { const runPairCases = createTemplatePairTest(Template); diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts index cd683ebde..582dd00b7 100644 --- a/packages/uri-template/src/template/template.test.ts +++ b/packages/uri-template/src/template/template.test.ts @@ -1,13 +1,6 @@ import { test } from "@fedify/fixture"; import { deepEqual, equal } from "node:assert"; import { throws } from "node:assert/strict"; -import { - InvalidLiteralError, - InvalidPrefixError, - PrefixModifierNotApplicableError, - ReservedOperatorError, - UnclosedExpressionError, -} from "../errors.ts"; import { createFixedTemplateMatchTest, createFixedTemplateTest, @@ -23,7 +16,14 @@ import { pairTestSuites, wrongTestSuites, } from "../tests/mod.ts"; -import Template from "./mod.ts"; +import { + InvalidLiteralError, + InvalidPrefixError, + PrefixModifierNotApplicableError, + ReservedOperatorError, + UnclosedExpressionError, +} from "./errors.ts"; +import Template from "./template.ts"; const runPairCases = createTemplatePairTest(Template); for (const { name, cases } of pairTestSuites) { diff --git a/packages/uri-template/src/template/template.ts b/packages/uri-template/src/template/template.ts new file mode 100644 index 000000000..7433d3810 --- /dev/null +++ b/packages/uri-template/src/template/template.ts @@ -0,0 +1,99 @@ +import type { + ExpandContext, + Reporter, + TemplateOptions, + Token, +} from "../types.ts"; +import expand from "./expand.ts"; +import match from "./match.ts"; +import tokenize from "./token.ts"; + +/** + * Parsed RFC 6570 URI Template that can be expanded repeatedly. + * + * This class owns tokenization and delegates expression expansion to the + * expansion module. + */ +export default class Template { + readonly #tokens: Token[]; + readonly #fullOptions: TemplateOptions; + + constructor( + /** + * URI template string to parse. See [RFC 6570] for syntax details. + * + * [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 + */ + public readonly uriTemplate: string, + /** + * Options for parsing the template. By default, `strict` is `true` and + * `report` ignores parse and expansion errors. If `strict` is `true`, the + * first error encountered while parsing or expanding will be automatically + * thrown after being reported. If `strict` is `false`, errors will be + * reported but none will be thrown unless the `report` function itself + * throws. + */ + readonly options: Partial = {}, + ) { + this.#fullOptions = fillOptions(options); + this.#tokens = tokenize(uriTemplate, this.#fullOptions); + } + + /** + * Parses a URI Template using default strict parsing options. + */ + static parse( + uriTemplate: string, + options: Partial = {}, + ): Template { + return new Template(uriTemplate, options); + } + + /** + * Parsed token stream for diagnostics and router integration. + */ + get tokens(): readonly Token[] { + return this.#tokens; + } + + /** + * Expands this template against a variable context. + */ + expand: ( + context: ExpandContext, + options?: TemplateOptions, + ) => string = ( + context: ExpandContext, + options: TemplateOptions = this.#fullOptions, + ): string => expand(this.#tokens, context, options); + + /** + * Matches a URI against this template, returning the variable context if the + * URI matches or `null` if it does not. + */ + match: ( + uri: string, + options?: TemplateOptions, + ) => ExpandContext | null = ( + uri: string, + options: TemplateOptions = this.#fullOptions, + ): ExpandContext | null => match(this.#tokens, uri, options); + + toString = (): string => this.uriTemplate; +} + +const defaultReporter: Reporter = (_error: Error) => undefined; + +const fillOptions = ( + { strict, report }: Partial, +): TemplateOptions => { + report ??= defaultReporter; + strict ??= true; + report = strict ? strictWrapper(report) : report; + return { strict, report }; +}; + +const strictWrapper = (reporter: Reporter) => (error: Error): never => { + reporter(error); + throw error; +}; diff --git a/packages/uri-template/src/template/token.ts b/packages/uri-template/src/template/token.ts index 12b8c25f4..b8ba13094 100644 --- a/packages/uri-template/src/template/token.ts +++ b/packages/uri-template/src/template/token.ts @@ -1,11 +1,11 @@ +import type { TemplateOptions, Token } from "../types.ts"; +import { isLiteralAt, readCodePoint } from "./encoding.ts"; import { InvalidLiteralError, NestedOpeningBraceError, StrayClosingBraceError, UnclosedExpressionError, -} from "../errors.ts"; -import type { TemplateOptions, Token } from "../types.ts"; -import { isLiteralAt, readCodePoint } from "./encoding.ts"; +} from "./errors.ts"; import parseExpression from "./expression.ts"; /** diff --git a/packages/uri-template/src/tests/assert.ts b/packages/uri-template/src/tests/assert.ts index 8a307bc16..99fa56f8e 100644 --- a/packages/uri-template/src/tests/assert.ts +++ b/packages/uri-template/src/tests/assert.ts @@ -1,12 +1,6 @@ -import * as ERROR_CLASSES from "../errors.ts"; import * as ROUTER_ERROR_CLASSES from "../router/errors.ts"; +import * as ERROR_CLASSES from "../template/errors.ts"; import type { Path } from "../types.ts"; -import type { - HardTestSuite, - MatchTestSuite, - PairTestSuite, - WrongTestSuite, -} from "./lib.ts"; import type { RouterBuildCase, RouterBuildTestSuite, @@ -16,11 +10,12 @@ import type { RouterRouteTestSuite, RouterVariablesCase, } from "./router.ts"; - -const ERROR_NAMES: ReadonlySet = new Set([ - ...Object.keys(ERROR_CLASSES), - ...Object.keys(ROUTER_ERROR_CLASSES), -]); +import type { + HardTestSuite, + MatchTestSuite, + PairTestSuite, + WrongTestSuite, +} from "./template.ts"; export function assertPairTestSuite( suites: unknown, @@ -342,6 +337,11 @@ function assertStringOrNull( if (value !== null) assertString(value, label); } +const ERROR_NAMES: ReadonlySet = new Set([ + ...Object.keys(ERROR_CLASSES), + ...Object.keys(ROUTER_ERROR_CLASSES), +]); + function assertErrorName( value: unknown, label: string, diff --git a/packages/uri-template/src/tests/json/hard.json b/packages/uri-template/src/tests/json/template/hard.json similarity index 95% rename from packages/uri-template/src/tests/json/hard.json rename to packages/uri-template/src/tests/json/template/hard.json index 22aa139b5..14df39a3f 100644 --- a/packages/uri-template/src/tests/json/hard.json +++ b/packages/uri-template/src/tests/json/template/hard.json @@ -34,14 +34,14 @@ "name": "Pct-encoded operator-like characters in a literal do not introduce another expression", "template": "%23section{?var}", "expected": "%23section?var=value", - "reason": "`%23` is the encoded `#`, which is forbidden as a bare literal but ALLOWED as a pct-encoded triplet. It must NOT trigger fragment-style expansion behavior.", + "reason": "`%23` is the encoded `#`. It appears in literal text, not in the operator slot immediately after `{`, so it must NOT trigger fragment-style expansion behavior.", "success": true }, { - "name": "Pct-encoded reserved characters appear in literal even though their decoded forms are forbidden", + "name": "Pct-encoded forbidden literal characters appear in literal text", "template": "%3C%3E%5C%5E%60%7C{var}", "expected": "%3C%3E%5C%5E%60%7Cvalue", - "reason": "The bare characters `<`, `>`, `\\`, `^`, `` ` ``, `|` are forbidden in literals, but their pct-encoded forms `%3C`, `%3E`, `%5C`, `%5E`, `%60`, `%7C` are allowed verbatim.", + "reason": "The bare characters `<`, `>`, `\\`, `^`, `` ` ``, `|` are forbidden by RFC 6570 literal syntax, but their pct-encoded forms `%3C`, `%3E`, `%5C`, `%5E`, `%60`, `%7C` are allowed verbatim.", "success": true } ] @@ -142,7 +142,7 @@ "name": "Varname that looks like an encoded open-brace resolves to its defined value", "template": "{%7B}", "expected": "open-brace", - "reason": "Symmetric to the undefined `%7D` case: `%7B` is a single pct-encoded varchar and a complete varname. With `%7B` mapped to `open-brace` in the variable set, the expression expands to that defined value — the encoded-bracket appearance does NOT prevent normal lookup.", + "reason": "Analogous to the undefined `%7D` case: `%7B` is a single pct-encoded varchar and a complete varname. With `%7B` mapped to `open-brace` in the variable set, the expression expands to that defined value — the encoded-bracket appearance does NOT prevent normal lookup.", "success": true }, { @@ -168,7 +168,7 @@ "name": "Null entries in a list are skipped during expansion", "template": "{sparse}", "expected": "one,three", - "reason": "RFC 6570 §2.3 says \"a list... a null... is considered undefined\". The null at index 1 is skipped; only `one` and `three` are joined by `,`.", + "reason": "RFC 6570 §3.2.1 expands only defined list member string values. The null at index 1 is treated as undefined and skipped; only `one` and `three` are joined by `,`.", "success": true }, { @@ -276,7 +276,7 @@ "name": "Real opening brace nested inside a varname-shaped sequence is invalid", "template": "{var%7B%7D", "expected": "UnclosedExpressionError", - "reason": "The expression body becomes `var%7B%7D` — three pct-encoded characters all valid as varchars — but the expression never sees a literal `}`. Unclosed.", + "reason": "The expression body becomes `var%7B%7D` — two pct-encoded characters all valid as varchars — but the expression never sees a literal `}`. Unclosed.", "success": false } ] diff --git a/packages/uri-template/src/tests/json/match.json b/packages/uri-template/src/tests/json/template/match.json similarity index 99% rename from packages/uri-template/src/tests/json/match.json rename to packages/uri-template/src/tests/json/template/match.json index d8461932b..e240be9dc 100644 --- a/packages/uri-template/src/tests/json/match.json +++ b/packages/uri-template/src/tests/json/template/match.json @@ -467,7 +467,7 @@ "page": "intro", "anchor": "getting-started" }, - "reason": "Simple expansion would percent-encode `#`, so `{page}` cannot consume past the fragment delimiter. `{#anchor}` then absorbs the rest, including the leading `#`." + "reason": "Simple expansion would percent-encode `#`, so `{page}` cannot consume past the fragment delimiter. `{#anchor}` consumes the leading `#` as the fragment operator prefix and binds `anchor` to the value after it." }, { "name": "Section and id captured as path segments before a fragment", @@ -478,7 +478,7 @@ "id": "42", "anchor": "comments" }, - "reason": "Three expressions cooperate: `{section}` and `{id}` are simple expansions bounded by `/` literals; `{#anchor}` captures the fragment with the `#` prefix as its own first character." + "reason": "Three expressions cooperate: `{section}` and `{id}` are simple expansions bounded by `/` literals; `{#anchor}` consumes the `#` prefix and captures the fragment value after it." }, { "name": "Missing fragment leaves the variable unbound", diff --git a/packages/uri-template/src/tests/json/wrong.json b/packages/uri-template/src/tests/json/template/wrong.json similarity index 99% rename from packages/uri-template/src/tests/json/wrong.json rename to packages/uri-template/src/tests/json/template/wrong.json index b7b9e551e..fbac4c69a 100644 --- a/packages/uri-template/src/tests/json/wrong.json +++ b/packages/uri-template/src/tests/json/template/wrong.json @@ -324,7 +324,7 @@ }, { "template": "{,var}", - "name": "empty varname before comma in variable-list", + "name": "reserved comma operator before variable-list", "expected": "ReservedOperatorError" }, { diff --git a/packages/uri-template/src/tests/mod.ts b/packages/uri-template/src/tests/mod.ts index 33e6d6f70..a19d8ae37 100644 --- a/packages/uri-template/src/tests/mod.ts +++ b/packages/uri-template/src/tests/mod.ts @@ -13,10 +13,6 @@ import { assertRouterVariablesCases, assertWrongTestSuite, } from "./assert.ts"; -import _hardTestSuites from "./json/hard.json" with { - type: "json", -}; -import _matchTestSuites from "./json/match.json" with { type: "json" }; import _fixedTestSuites from "./json/references/fixed.json" with { type: "json", }; @@ -50,14 +46,11 @@ import _routerRouteTestSuites from "./json/router/route-suites.json" with { import _routerVariablesCases from "./json/router/variables-cases.json" with { type: "json", }; -import _wrongTestSuites from "./json/wrong.json" with { type: "json" }; -import type { - FixedTemplateTestSuite, - HardTestSuite, - MatchTestSuite, - PairTestSuite, - WrongTestSuite, -} from "./lib.ts"; +import _hardTestSuites from "./json/template/hard.json" with { + type: "json", +}; +import _matchTestSuites from "./json/template/match.json" with { type: "json" }; +import _wrongTestSuites from "./json/template/wrong.json" with { type: "json" }; import type { RouterBuildCase, RouterBuildTestSuite, @@ -67,6 +60,13 @@ import type { RouterRouteTestSuite, RouterVariablesCase, } from "./router.ts"; +import type { + FixedTemplateTestSuite, + HardTestSuite, + MatchTestSuite, + PairTestSuite, + WrongTestSuite, +} from "./template.ts"; type JsonAssertion = (value: unknown) => asserts value is T; @@ -137,7 +137,7 @@ export { createTemplateMatchTest, createTemplatePairTest, createWrongTemplateTest, -} from "./lib.ts"; +} from "./template.ts"; export { createRouterAddTest, createRouterBuildPathsBench, diff --git a/packages/uri-template/src/tests/lib.ts b/packages/uri-template/src/tests/template.ts similarity index 97% rename from packages/uri-template/src/tests/lib.ts rename to packages/uri-template/src/tests/template.ts index 145194063..3dffe9b53 100644 --- a/packages/uri-template/src/tests/lib.ts +++ b/packages/uri-template/src/tests/template.ts @@ -1,5 +1,5 @@ import { deepEqual, equal, ok, throws } from "node:assert/strict"; -import * as ERROR_CLASSES from "../errors.ts"; +import * as ERROR_CLASSES from "../template/errors.ts"; import type { ExpandContext } from "../types.ts"; import testVars from "./json/references/vars.json" with { type: "json" }; @@ -170,7 +170,7 @@ interface WrongTemplateTestCase { template: string; /** * The `name` of the error class that the parser MUST throw, taken from - * the concrete classes exported by *src/errors.ts* (e.g. + * the concrete classes exported by *src/template/errors.ts* (e.g. * `"UnclosedExpressionError"`, `"InvalidLiteralError"`). * * The runner compares the thrown error's `instanceof` against the class @@ -212,7 +212,7 @@ export function createWrongTemplateTest( * - `success: true` → `expected` is the exact expansion result string. * - `success: false` → `expected` is the `name` of the error class that the * parser or expander MUST throw, taken from the concrete classes exported - * by *src/errors.ts*. The runner verifies the thrown error via + * by *src/template/errors.ts*. The runner verifies the thrown error via * `instanceof` against the resolved class. * * Use {@link HardTestCase.reason} for the human-readable rationale. @@ -238,7 +238,7 @@ interface HardSuccessCase extends HardCaseBase { interface HardFailureCase extends HardCaseBase { /** * The `name` of the error class that the parser or expander MUST throw, - * taken from the concrete classes exported by *src/errors.ts* (e.g. + * taken from the concrete classes exported by *src/template/errors.ts* (e.g. * `"UnclosedExpressionError"`, `"PrefixModifierNotApplicableError"`). * * The runner compares the thrown error's `instanceof` against the class From e923449690dfa9d7cf51becfcf582503066f3023 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 22:32:07 +0000 Subject: [PATCH 16/49] Rename "symmetric" to "round-trip" in user-facing prose Updates package descriptions, the README, and the npm:url-template compatibility test to describe the matching behavior as "round-trip" rather than "symmetric", and corrects stale "@fedify/url-template" references to "@fedify/uri-template". Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/README.md | 105 ++++++++++++++---- .../uri-template/bench/url-template.test.ts | 4 +- packages/uri-template/deno.json | 2 +- packages/uri-template/package.json | 6 +- 4 files changed, 88 insertions(+), 29 deletions(-) diff --git a/packages/uri-template/README.md b/packages/uri-template/README.md index 9b6af47ef..479f5cd69 100644 --- a/packages/uri-template/README.md +++ b/packages/uri-template/README.md @@ -1,14 +1,14 @@ -@fedify/uri-template: Symmetric RFC 6570 URI Template library -============================================================= +@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 guaranteed symmetric (round-trip) -behavior. It is part of the [Fedify] framework but can be used independently. +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 @@ -18,14 +18,23 @@ behavior. It is part of the [Fedify] framework but can be used independently. [Fedify]: https://fedify.dev/ -Why `@fedify/url-template`? +Why `@fedify/uri-template`? --------------------------- -Fedify used [url-template] as the baseline implementation before writing -`@fedify/url-template`. That package 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 benchmark in *bench/url-template.test.ts* -records the differences against `npm:url-template@^3.1.1`. +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 benchmark in *bench/url-template.test.ts* records the differences against +`npm:url-template@^3.1.1`. The important failures are: @@ -42,21 +51,20 @@ The important failures are: [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/url-template` reports these cases as + 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. -`@fedify/url-template` was written as a new implementation instead of wrapping +`Template` was written as a new implementation instead of wrapping [url-template] because Fedify needs strict RFC 6570 expansion, typed syntax -errors, and symmetric matching behavior. Applications that need a looser -parser can opt in explicitly: `strict: false` reports parse and expansion -errors without throwing, and a custom `report` function can allow all errors or -throw only for selected error classes. +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. -[url-template]: https://www.npmjs.com/package/url-template [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 @@ -64,6 +72,57 @@ throw only for selected error classes. [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. *bench/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 new router supports ordinary slash-prefixed paths and the leading path + expansion form used by Fedify routes, such as `{/identifier}/inbox`. + - `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 uses a prefix trie keyed by the initial literal prefix of + each route. Candidates are 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 old implementation differences can be checked by running the compatibility +tests in *bench/url-template.test.ts* and *bench/uri-template-router.test.ts*. +These tests intentionally run Fedify's expected behavior against the old +libraries, so the failing cases show the gaps: + +~~~~ bash +deno task bench +~~~~ + Features -------- @@ -71,8 +130,8 @@ Features - Full RFC 6570 expansion for all expression types (`{var}`, `{+var}`, `{#var}`, `{.var}`, `{/var}`, `{;var}`, `{?var}`, `{&var}`) - - Symmetric pattern matching that mirrors expansion to guarantee - `expand(parse(url)) === url` and `parse(expand(value)) === value` + - Round-trip pattern matching that mirrors expansion: when `match(uri)` + returns values, `expand(values) === uri` - Strict TypeScript types with no `any` in the public surface - Zero runtime dependencies @@ -82,8 +141,8 @@ 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 +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/bench/url-template.test.ts b/packages/uri-template/bench/url-template.test.ts index edcd3ab24..89779829a 100644 --- a/packages/uri-template/bench/url-template.test.ts +++ b/packages/uri-template/bench/url-template.test.ts @@ -15,7 +15,7 @@ import { /** * Known failures for npm:url-template@^3.1.1, checked with `deno task bench`. * These are the compatibility gaps that motivated the strict - * @fedify/url-template implementation. + * @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 @@ -39,7 +39,7 @@ import { * - 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/url-template reports these + * 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. * diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json index b980d7c0e..c85b3f714 100644 --- a/packages/uri-template/deno.json +++ b/packages/uri-template/deno.json @@ -5,7 +5,7 @@ "exports": { ".": "./src/mod.ts" }, - "description": "Symmetric RFC 6570 URI Template expansion and pattern matching for Fedify", + "description": "RFC 6570 URI Template expansion and round-trip pattern matching for Fedify", "author": { "name": "Chanhaeng Lee" }, diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json index 11653ff3e..b5bdf9f21 100644 --- a/packages/uri-template/package.json +++ b/packages/uri-template/package.json @@ -19,7 +19,7 @@ "node": ">=22.0.0", "bun": ">=1.1.0" }, - "description": "Symmetric RFC 6570 URI Template expansion and pattern matching", + "description": "RFC 6570 URI Template expansion and round-trip pattern matching", "type": "module", "main": "./dist/mod.cjs", "module": "./dist/mod.js", @@ -42,8 +42,8 @@ "build": "pnpm --filter @fedify/uri-template... run build:self", "prepack": "pnpm build", "prepublish": "pnpm build", - "test:bun": "bun test", - "test": "node --experimental-transform-types --test" + "test:bun": "bun test src/**/*.test.ts", + "test": "node --experimental-transform-types --test src/**/*.test.ts" }, "keywords": [ "Fedify", From a5045e0bb687bbe3139e9f80586b5be4d9651bc0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 22:32:54 +0000 Subject: [PATCH 17/49] Rename router memory pressure scenario factories Drops the "Scenario" suffix from the router memory pressure factory names and shortens them so they read alongside the other test helpers in src/tests/mod.ts: - createRouterHundredsOfRoutesScenario -> createRoutesPressureTest - createRouterDeepCommonPrefixScenario -> createDeepPrefixRouterTest - createRouterRootAdjacentDynamicRoutesScenario -> createDynamicRoutesTest - createRouterInactiveEntriesScenario -> createInactiveEntriesTest Also unexports the RouterMemoryPressureScenario interface, which was only used internally by the bench, and renames the ERROR_CLASS_BY_NAME alias in src/tests/router.ts to ERROR_CLASSES so it matches the convention used by src/tests/assert.ts. Assisted-by: Codex:GPT-5.5 Assisted-by: Claude Code:claude-opus-4-7 --- .../uri-template/src/router/router.bench.ts | 19 ++++++------ packages/uri-template/src/tests/mod.ts | 29 +++++++++---------- packages/uri-template/src/tests/router.ts | 16 +++++----- 3 files changed, 31 insertions(+), 33 deletions(-) diff --git a/packages/uri-template/src/router/router.bench.ts b/packages/uri-template/src/router/router.bench.ts index 4f51bce1b..5d94c9db6 100644 --- a/packages/uri-template/src/router/router.bench.ts +++ b/packages/uri-template/src/router/router.bench.ts @@ -1,16 +1,15 @@ import { + createDeepPrefixRouterTest, + createDynamicRoutesTest, + createInactiveEntriesTest, createRouterBuildPathsBench, createRouterCompileAndAddBench, - createRouterDeepCommonPrefixScenario, createRouterFirstRouteAfterBuildBench, - createRouterHundredsOfRoutesScenario, - createRouterInactiveEntriesScenario, - createRouterRootAdjacentDynamicRoutesScenario, createRouterRouteHitsBench, createRouterRouteMissesBench, + createRoutesPressureTest, routerBuildCases, routerHitPaths, - type RouterMemoryPressureScenario, routerMissPaths, routerRouteDefinitions, } from "../tests/mod.ts"; @@ -43,11 +42,11 @@ Deno.bench( const runFirstRouteAfterBuild = createRouterFirstRouteAfterBuildBench(Router); for ( const scenario of [ - createRouterHundredsOfRoutesScenario(), - createRouterDeepCommonPrefixScenario(), - createRouterRootAdjacentDynamicRoutesScenario(), - createRouterInactiveEntriesScenario(), - ] satisfies readonly RouterMemoryPressureScenario[] + createRoutesPressureTest(), + createDeepPrefixRouterTest(), + createDynamicRoutesTest(), + createInactiveEntriesTest(), + ] ) { Deno.bench( `Router: ${scenario.name}: compile and add routes`, diff --git a/packages/uri-template/src/tests/mod.ts b/packages/uri-template/src/tests/mod.ts index a19d8ae37..4f4cdef2b 100644 --- a/packages/uri-template/src/tests/mod.ts +++ b/packages/uri-template/src/tests/mod.ts @@ -129,30 +129,29 @@ export const routerCloneTestSuites: readonly RouterCloneTestSuite[] = validate( _routerCloneTestSuites, ); export { - createFixedTemplateMatchTest, - createFixedTemplateTest, - createMatchOnlyTest, - createTemplateHardTest, - createTemplateMatchHardTest, - createTemplateMatchTest, - createTemplatePairTest, - createWrongTemplateTest, -} from "./template.ts"; -export { + createDeepPrefixRouterTest, + createDynamicRoutesTest, + createInactiveEntriesTest, createRouterAddTest, createRouterBuildPathsBench, createRouterBuildTest, createRouterCloneTest, createRouterCompileAndAddBench, createRouterCompileErrorTest, - createRouterDeepCommonPrefixScenario, createRouterFirstRouteAfterBuildBench, - createRouterHundredsOfRoutesScenario, - createRouterInactiveEntriesScenario, - createRouterRootAdjacentDynamicRoutesScenario, createRouterRouteHitsBench, createRouterRouteMissesBench, createRouterRouteTest, createRouterVariablesTest, - type RouterMemoryPressureScenario, + createRoutesPressureTest, } from "./router.ts"; +export { + createFixedTemplateMatchTest, + createFixedTemplateTest, + createMatchOnlyTest, + createTemplateHardTest, + createTemplateMatchHardTest, + createTemplateMatchTest, + createTemplatePairTest, + createWrongTemplateTest, +} from "./template.ts"; diff --git a/packages/uri-template/src/tests/router.ts b/packages/uri-template/src/tests/router.ts index 71d3eabd1..35e678098 100644 --- a/packages/uri-template/src/tests/router.ts +++ b/packages/uri-template/src/tests/router.ts @@ -1,8 +1,8 @@ import { deepEqual, equal, throws } from "node:assert/strict"; -import * as ERROR_CLASS_BY_NAME from "../router/errors.ts"; +import * as ERROR_CLASSES from "../router/errors.ts"; import type { Path } from "../types.ts"; -type ErrorName = keyof typeof ERROR_CLASS_BY_NAME; +type ErrorName = keyof typeof ERROR_CLASSES; interface RouterTestOptions { readonly trailingSlashInsensitive?: boolean; @@ -87,7 +87,7 @@ export interface RouterCloneTestSuite { readonly clonedRouteCases: readonly RouterRouteCase[]; } -export interface RouterMemoryPressureScenario { +interface RouterMemoryPressureSuite { readonly name: string; readonly routeDefinitions: readonly RouterRouteDefinition[]; readonly hitPaths: readonly Path[]; @@ -126,7 +126,7 @@ const createSampledIndexes = (count: number, sampleCount: number): number[] => const padRouterIndex = (index: number): string => index.toString().padStart(4, "0"); -export function createRouterHundredsOfRoutesScenario(): RouterMemoryPressureScenario { +export function createRoutesPressureTest(): RouterMemoryPressureSuite { const routeDefinitions = createRouterDefinitions( 512, (index): RouterRouteDefinition => [ @@ -149,7 +149,7 @@ export function createRouterHundredsOfRoutesScenario(): RouterMemoryPressureScen }; } -export function createRouterDeepCommonPrefixScenario(): RouterMemoryPressureScenario { +export function createDeepPrefixRouterTest(): RouterMemoryPressureSuite { const segments = Array.from( { length: 128 }, (_, index) => `segment-${padRouterIndex(index)}`, @@ -172,7 +172,7 @@ export function createRouterDeepCommonPrefixScenario(): RouterMemoryPressureScen }; } -export function createRouterRootAdjacentDynamicRoutesScenario(): RouterMemoryPressureScenario { +export function createDynamicRoutesTest(): RouterMemoryPressureSuite { const routeDefinitions = createRouterDefinitions( 384, (index): RouterRouteDefinition => [ @@ -195,7 +195,7 @@ export function createRouterRootAdjacentDynamicRoutesScenario(): RouterMemoryPre }; } -export function createRouterInactiveEntriesScenario(): RouterMemoryPressureScenario { +export function createInactiveEntriesTest(): RouterMemoryPressureSuite { return { name: "inactive entries", routeDefinitions: createRouterDefinitions( @@ -259,7 +259,7 @@ export function createRouterCompileErrorTest( for (const errorName of expected) { throws( () => Router.compile(path as Path), - ERROR_CLASS_BY_NAME[errorName], + ERROR_CLASSES[errorName], ); } }); From 64d18cee5aaf1083cc956acdd15812f77bdbab76 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 22:33:06 +0000 Subject: [PATCH 18/49] Document operator behavior table and drop dead constant Inlines the RFC 6570 Appendix A operator behavior table next to operatorSpecs so the values for first, sep, named, ifEmpty, and allowReserved are easy to cross-check against the spec. Removes NOT_IMPLEMENTED, which is no longer referenced anywhere now that the matching and router modules are implemented. Assisted-by: Codex:GPT-5.5 Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/const.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/uri-template/src/const.ts b/packages/uri-template/src/const.ts index 2452261d5..959597fcc 100644 --- a/packages/uri-template/src/const.ts +++ b/packages/uri-template/src/const.ts @@ -30,6 +30,15 @@ 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 }, @@ -66,7 +75,4 @@ export const operatorSpecs: Record = { "#": { first: "#", sep: ",", named: false, ifEmpty: "", allowReserved: true }, }; -/** - * Placeholder message used by modules whose behavior is still unimplemented. - */ -export const NOT_IMPLEMENTED = "@fedify/uri-template is not implemented yet"; +// cspell: ignore ifemp From f04d7564a1f64eda5a960f9d195edeff70e2d501 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 22:33:30 +0000 Subject: [PATCH 19/49] Drop unused VariableSpec and align reporter docs with expansion VariableSpec was carried over from an earlier router design and is no longer constructed or referenced anywhere; remove the interface and its re-export from src/mod.ts. Updates the TemplateOptions.report and Reporter doc comments to reflect that the reporter is also invoked for expansion errors (PrefixModifierNotApplicableError), not just parse errors. Also cleans up the line-broken module-level header comment in src/mod.ts. Assisted-by: Codex:GPT-5.5 Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/mod.ts | 4 +--- packages/uri-template/src/types.ts | 18 ++++++------------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts index 1a956f8a9..1fdc26eaa 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -1,6 +1,5 @@ /** - * Symmetric [RFC 6570] URI - * Template expansion and pattern matching. + * [RFC 6570] URI Template expansion and round-trip pattern matching. * * [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 * @@ -42,6 +41,5 @@ export type { Reporter, TemplateOptions, Token, - VariableSpec, VarSpec, } from "./types.ts"; diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index 0be284049..a4a62b3e1 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -38,13 +38,6 @@ export type ExpandValue = */ export type ExpandContext = Record; -/** - * Variable specification produced when a template is added to a {@link Router}. - */ -export interface VariableSpec { - varname: string; -} - /** * Parsed RFC 6570 variable specification inside an expression. * @@ -81,16 +74,17 @@ export interface TemplateOptions { */ strict: boolean; /** - * A function that will be called with any errors encountered while parsing. - * By default, errors are ignored. In strict mode, they are still thrown - * after this reporter runs. - * @param error The error that was encountered while parsing the template. + * A function that will be called with any errors encountered while parsing + * or expanding. By default, errors are ignored. In strict mode, they are + * still thrown after this reporter runs. + * @param error The error that was encountered while parsing or expanding the + * template. * @returns The result of the report function. */ report: Reporter; } /** - * Callback used by the parser to report recoverable parse diagnostics. + * Callback used to report recoverable parse and expansion diagnostics. */ export type Reporter = (error: Error) => void; From a14d5bb88c8b6040681207ccb2e17ba4a7968d99 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 22:33:43 +0000 Subject: [PATCH 20/49] Document route-shape gaps in uri-template-router compatibility test Extends the header comment with the route-shape differences that the compatibility run records against the previous router: - Leading path expansion templates such as {/identifier}/inbox are rejected when they overlap with slash-prefixed routes. - Optional form-style query templates such as /search{?q,page} miss when only one of the query variables is present. These behaviors matter for Fedify routes, so calling them out in the bench file makes the gap visible alongside the encoding/decoding differences already noted. Assisted-by: Codex:GPT-5.5 Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/bench/uri-template-router.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/uri-template/bench/uri-template-router.test.ts b/packages/uri-template/bench/uri-template-router.test.ts index 1a70fc98a..cbdc1450d 100644 --- a/packages/uri-template/bench/uri-template-router.test.ts +++ b/packages/uri-template/bench/uri-template-router.test.ts @@ -41,6 +41,12 @@ import type { Path } from "../src/types.ts"; * 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; From 14a4b29a5bdabf4b4df8f7bd5982a414850156c7 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 6 May 2026 22:35:10 +0000 Subject: [PATCH 21/49] Drop "using RegExp" qualifier from Template bench label The Template implementation no longer uses RegExp for matching, so the bench label "Template using RegExp" is misleading. Use plain "Template" instead. Assisted-by: Codex:GPT-5.5 Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/template/template.bench.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uri-template/src/template/template.bench.ts b/packages/uri-template/src/template/template.bench.ts index a6470740f..d4f12f061 100644 --- a/packages/uri-template/src/template/template.bench.ts +++ b/packages/uri-template/src/template/template.bench.ts @@ -2,7 +2,7 @@ import { test } from "@fedify/fixture"; import { createTemplatePairTest, pairTestSuites } from "../tests/mod.ts"; import Template from "./template.ts"; -Deno.bench("Template using RegExp", (b) => { +Deno.bench("Template", (b) => { const runPairCases = createTemplatePairTest(Template); b.start(); for (const _ of Array(10000)) { From 64eb2dc10d60a85b41344dc4954a7f8aa326e03c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 7 May 2026 00:27:06 +0000 Subject: [PATCH 22/49] Add Router#register, batch trie insert, and constructor routes The Router previously offered only a single-route `add(path, name)` entry point. Bulk registration paid the cost of repeated trie inserts, each running an insertion-sort splice on the destination node, and the public surface had no ergonomic way to seed routes at construction time. Changes: - Add `Router#register(routes)` that accepts an iterable of `[pathOrPattern, name]` tuples and routes them through a new batch insertion path. `Trie#insertAll` groups entries by destination node so each node performs one sort-and-merge instead of N insertion-sort splices. `Node#insertAll` reuses the existing `mergeRouteEntries` linear merge over the already-sorted entries. - Overload the `Router` constructor to accept `(routes, options?)`, `(options?)`, or no arguments. The first argument is dispatched by `Symbol.iterator` presence so plain options objects and arbitrary iterables (arrays, generators, `Map.entries()`) are both handled. Add `Router.from(...)` as a static factory mirroring the same signature. - Widen `Router#add` and `Router#register` to accept a pre-parsed `RouterPathPattern` in addition to a `Path` string, so callers that already hold a `Router.compile(...)` result can skip re-parsing. - Rebuild `Router#clone` on top of the constructor's batch path. The clone now hands its active patterns straight to a new router instead of replaying `add` for every entry, sharing the parsed templates with the original. - Export the new `RouterRoute` type plus the existing `RouterOptions`, `RouterPathPattern`, and `RouterRouteResult` types from `router/mod.ts` and re-route the public `mod.ts` re-exports through that module entry point. - Cover the new surface with unit tests: bulk registration parity with repeated `add`, generator inputs, pre-parsed pattern inputs on every entry point (`add`, `register`, constructor, `from`), and all four constructor and `from` argument variants. Assisted-by: Claude Code:claude-opus-4-7[1m] --- packages/uri-template/src/mod.ts | 3 +- packages/uri-template/src/router/mod.ts | 6 + packages/uri-template/src/router/node.ts | 14 ++ .../uri-template/src/router/router.test.ts | 139 +++++++++++++++++- packages/uri-template/src/router/router.ts | 113 +++++++++++--- packages/uri-template/src/router/trie.ts | 22 +++ 6 files changed, 278 insertions(+), 19 deletions(-) diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts index 1fdc26eaa..487e788df 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -10,8 +10,9 @@ export { Router, RouterError, RouteTemplatePathError } from "./router/mod.ts"; export type { RouterOptions, RouterPathPattern, + RouterRoute, RouterRouteResult, -} from "./router/router.ts"; +} from "./router/mod.ts"; export { EmptyExpressionError, EmptyVarNameError, diff --git a/packages/uri-template/src/router/mod.ts b/packages/uri-template/src/router/mod.ts index c3a894222..a0e828a15 100644 --- a/packages/uri-template/src/router/mod.ts +++ b/packages/uri-template/src/router/mod.ts @@ -1,2 +1,8 @@ export * from "./errors.ts"; export { default as Router } from "./router.ts"; +export type { + RouterOptions, + RouterPathPattern, + RouterRoute, + RouterRouteResult, +} from "./router.ts"; diff --git a/packages/uri-template/src/router/node.ts b/packages/uri-template/src/router/node.ts index 0a648536d..0ac79cabc 100644 --- a/packages/uri-template/src/router/node.ts +++ b/packages/uri-template/src/router/node.ts @@ -35,6 +35,20 @@ export default class Node { this.#entries.splice(this.#insertionIndex(entry), 0, entry); }; + insertAll = (entries: TEntry[]): void => { + if (entries.length === 0) return; + if (entries.length === 1) { + this.insert(entries[0]); + return; + } + + entries.sort(compareRouteEntries); + const merged = mergeRouteEntries(this.#entries, entries); + + this.#entries.length = 0; + for (const entry of merged) this.#entries.push(entry); + }; + rebuildCandidates = (parentCandidates: readonly TEntry[]): void => { this.#candidates = mergeRouteEntries(parentCandidates, this.#entries); diff --git a/packages/uri-template/src/router/router.test.ts b/packages/uri-template/src/router/router.test.ts index 6e8a2b63d..9283d69e8 100644 --- a/packages/uri-template/src/router/router.test.ts +++ b/packages/uri-template/src/router/router.test.ts @@ -1,4 +1,5 @@ import { test } from "@fedify/fixture"; +import { deepEqual, equal } from "node:assert/strict"; import { createRouterAddTest, createRouterBuildTest, @@ -13,7 +14,8 @@ import { routerRouteTestSuites, routerVariablesCases, } from "../tests/mod.ts"; -import Router from "./router.ts"; +import type { Path } from "../types.ts"; +import Router, { type RouterRoute } from "./router.ts"; const runAddCases = createRouterAddTest(Router); test("Router.add()", runAddCases(routerRouteDefinitions)); @@ -49,3 +51,138 @@ for ( runBuildCases(routeDefinitions, options)(cases), ); } + +const sampleRoutes: readonly RouterRoute[] = [ + ["/users/{id}", "user"] as const, + ["/posts/{id}", "post"] as const, + ["/users/{id}/posts/{postId}", "userPost"] as const, +]; + +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" as Path), { + name: "user", + template: "/users/{id}", + values: { id: "42" }, + }); + deepEqual(router.route("/users/42/posts/7" as Path), { + 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" as Path), + reference.route("/users/42" as Path), + ); + }); + + 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}" as Path); + + await t.step("via add()", () => { + const router = new Router(); + router.add(pattern, "item"); + equal(router.has("item"), true); + deepEqual(router.route("/items/9" as Path), { + 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 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.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); + }); +}); diff --git a/packages/uri-template/src/router/router.ts b/packages/uri-template/src/router/router.ts index e3c80c16c..ff297fbee 100644 --- a/packages/uri-template/src/router/router.ts +++ b/packages/uri-template/src/router/router.ts @@ -53,6 +53,17 @@ export interface RouterPathPattern { readonly variables: ReadonlySet; } +/** + * 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. + */ +export type RouterRoute = readonly [ + pathOrPattern: Path | RouterPathPattern, + name: string, +]; + interface RouteEntry { readonly index: number; readonly name: string; @@ -77,27 +88,55 @@ export default class Router { /** * 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(options: RouterOptions = {}) { + 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.#nextIndex = 0; - this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; - } + this.trailingSlashInsensitive = options?.trailingSlashInsensitive ?? false; - clone(): Router { - const clone = new Router({ - trailingSlashInsensitive: this.trailingSlashInsensitive, - }); - - for (const entry of this.#activeEntries()) { - clone.add(entry.pattern.path, entry.name); - } + if (routes != null) this.register(routes); + } - return clone; + /** + * 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); } + clone = (): Router => + new Router( + this.#activeEntries(), + { trailingSlashInsensitive: this.trailingSlashInsensitive }, + ); + /** * Compiles a path template without registering it in a router. * @param path The path pattern. @@ -133,17 +172,43 @@ export default class Router { /** * Adds a new path rule to the router. - * @param template The path template to add. + * @param pathOrPattern The path template, or a pre-parsed + * {@link RouterPathPattern} produced by + * {@link Router.compile}. * @param name The name of the path. */ - add = (template: Path, name: string): void => { - const pattern = Router.compile(template); + add = (pathOrPattern: Path | RouterPathPattern, name: string): void => { + const pattern = resolvePathPattern(pathOrPattern); const entry = createRouteEntry({ index: this.#nextIndex++, name, pattern }); this.#routesByName.set(name, entry); this.#trie.insert(entry); }; + /** + * Registers multiple path rules at once. Compared to calling {@link add} + * in a loop, this batches trie insertions into one sorted merge per + * affected node, which lowers the asymptotic cost of bulk registration. + * @param routes Iterable of `[pathOrPattern, name]` pairs to register. + */ + register = (routes: Iterable): void => { + const entries: RouteEntry[] = []; + + for (const [pathOrPattern, name] of routes) { + const pattern = resolvePathPattern(pathOrPattern); + const entry = createRouteEntry({ + index: this.#nextIndex++, + name, + pattern, + }); + + this.#routesByName.set(name, entry); + entries.push(entry); + } + + this.#trie.insertAll(entries); + }; + /** * Resolves a path name and values from a URI, if any match. * @param url The URI to resolve. @@ -186,9 +251,11 @@ export default class Router { return null; } - #activeEntries = (): RouteEntry[] => + #activeEntries = (): RouterRoute[] => Array.from(this.#routesByName.values()) - .sort((left, right) => left.index - right.index); + .sort((left, right) => left.index - right.index) + .filter(this.#isActiveEntry) + .map((entry): RouterRoute => [entry.pattern, entry.name]); #isActiveEntry = (entry: RouteEntry): boolean => this.#routesByName.get(entry.name) === entry; @@ -213,6 +280,18 @@ const createRouteEntry = ({ variableCount: pattern.variables.size, }); +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 | null => { if (!path.endsWith("/")) return `${path}/`; diff --git a/packages/uri-template/src/router/trie.ts b/packages/uri-template/src/router/trie.ts index f21258bfe..bc1e78952 100644 --- a/packages/uri-template/src/router/trie.ts +++ b/packages/uri-template/src/router/trie.ts @@ -26,6 +26,28 @@ export default class Trie { this.#dirty = true; }; + insertAll = (entries: readonly TEntry[]): void => { + if (entries.length === 0) return; + + const buckets = new Map, TEntry[]>(); + + for (const entry of entries) { + let node = this.#root; + for (const char of entry.initialLiteralPrefix) { + node = node.childOrInsert(char); + } + const bucket = buckets.get(node); + if (bucket == null) buckets.set(node, [entry]); + else bucket.push(entry); + } + + for (const [node, bucket] of buckets) { + node.insertAll(bucket); + } + + this.#dirty = true; + }; + *candidates( path: Path, isActive: (entry: TEntry) => boolean, From dd27de35990f8276ace89112a7a6507dcf87951b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 8 May 2026 07:34:22 +0000 Subject: [PATCH 23/49] Move URI template old tests Move the compatibility tests for url-template and uri-template-router from the benchmark task into the old test area. Gate the old-library comparison tests behind OLD=true and expose them through deno task test:old, so the regular test task only runs the current src tests. Update the README and Deno package metadata to use the new command and location. Assisted-by: Codex:gpt-5 --- packages/uri-template/README.md | 8 +-- packages/uri-template/deno.json | 10 ++-- .../uri-template-router.test.ts | 56 ++++++++++--------- .../{bench => old}/url-template.test.ts | 37 ++++++------ 4 files changed, 61 insertions(+), 50 deletions(-) rename packages/uri-template/{bench => old}/uri-template-router.test.ts (84%) rename packages/uri-template/{bench => old}/url-template.test.ts (78%) diff --git a/packages/uri-template/README.md b/packages/uri-template/README.md index 479f5cd69..02e54d9d3 100644 --- a/packages/uri-template/README.md +++ b/packages/uri-template/README.md @@ -33,7 +33,7 @@ parser and one expansion/matching model. [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 benchmark in *bench/url-template.test.ts* records the differences against +The test in *old/url-template.test.ts* records the differences against `npm:url-template@^3.1.1`. The important failures are: @@ -76,7 +76,7 @@ all errors or throw only for selected error classes. The previous router shape combined two independent third-party implementations: [url-template] for building URLs and [uri-template-router] for -matching URLs. *bench/uri-template-router.test.ts* defines that old shape as +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: @@ -115,12 +115,12 @@ The important differences are: and dependency-free at runtime. The old implementation differences can be checked by running the compatibility -tests in *bench/url-template.test.ts* and *bench/uri-template-router.test.ts*. +tests in *old/url-template.test.ts* and *old/uri-template-router.test.ts*. These tests intentionally run Fedify's expected behavior against the old libraries, so the failing cases show the gaps: ~~~~ bash -deno task bench +deno task test:old ~~~~ diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json index c85b3f714..6e510ceae 100644 --- a/packages/uri-template/deno.json +++ b/packages/uri-template/deno.json @@ -7,7 +7,9 @@ }, "description": "RFC 6570 URI Template expansion and round-trip pattern matching for Fedify", "author": { - "name": "Chanhaeng Lee" + "name": "Chanhaeng Lee", + "email": "2chanhaeng@gmail.com", + "url": "https://chomu.dev" }, "imports": {}, "exclude": [ @@ -21,8 +23,8 @@ ] }, "tasks": { - "bench": "deno test --allow-env bench/", - "check": "deno fmt --check && deno lint && deno check src/", - "test": "deno test --allow-env src/" + "check": "deno fmt --check && deno lint && deno check", + "test": "deno test --allow-env src/", + "test:old": "OLD=true deno test --allow-env old/" } } diff --git a/packages/uri-template/bench/uri-template-router.test.ts b/packages/uri-template/old/uri-template-router.test.ts similarity index 84% rename from packages/uri-template/bench/uri-template-router.test.ts rename to packages/uri-template/old/uri-template-router.test.ts index cbdc1450d..b30a9ab67 100644 --- a/packages/uri-template/bench/uri-template-router.test.ts +++ b/packages/uri-template/old/uri-template-router.test.ts @@ -22,9 +22,9 @@ import type { Path } from "../src/types.ts"; /** * Known failures for npm:uri-template-router@^1.0.0, checked with - * `deno test --allow-env packages/uri-template/bench/uri-template-router.test.ts`. - * These pct-encoding gaps are the main routing correctness issues that - * motivated the @fedify/uri-template Router implementation. + * `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 @@ -187,31 +187,35 @@ export class RouterError extends Error { } } -const runAddCases = createRouterAddTest(Router); -test("Router.add()", runAddCases(routerRouteDefinitions)); +const isTest = Deno.env.get("OLD") === "true"; -const runVariablesCases = createRouterVariablesTest(Router); -test("Router.variables()", runVariablesCases(routerVariablesCases)); +if (isTest) { + const runAddCases = createRouterAddTest(Router); + test("Router.add()", runAddCases(routerRouteDefinitions)); -const runCloneCases = createRouterCloneTest(Router); -test("Router.clone()", runCloneCases(routerCloneTestSuites)); + const runVariablesCases = createRouterVariablesTest(Router); + test("Router.variables()", runVariablesCases(routerVariablesCases)); -const runRouteCases = createRouterRouteTest(Router); -for ( - const { name, options, routeDefinitions, cases } of routerRouteTestSuites -) { - test( - `Router.route(): ${name}`, - runRouteCases(routeDefinitions, options)(cases), - ); -} + 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 runBuildCases = createRouterBuildTest(Router); + for ( + const { name, options, routeDefinitions, cases } of routerBuildTestSuites + ) { + test( + `Router.build(): ${name}`, + runBuildCases(routeDefinitions, options)(cases), + ); + } } diff --git a/packages/uri-template/bench/url-template.test.ts b/packages/uri-template/old/url-template.test.ts similarity index 78% rename from packages/uri-template/bench/url-template.test.ts rename to packages/uri-template/old/url-template.test.ts index 89779829a..166a87c90 100644 --- a/packages/uri-template/bench/url-template.test.ts +++ b/packages/uri-template/old/url-template.test.ts @@ -13,7 +13,8 @@ import { } from "../src/tests/mod.ts"; /** - * Known failures for npm:url-template@^3.1.1, checked with `deno task bench`. + * 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. * @@ -70,22 +71,26 @@ class Template { match = (_: string) => null; } -const runPairCases = createTemplatePairTest(Template); -for (const { name, cases } of pairTestSuites) { - test(name, runPairCases(cases as unknown as readonly [string, string][])); -} +const isTest = Deno.env.get("OLD") === "true"; -const runFixedCases = createFixedTemplateTest(Template); -for (const { template, name, cases } of fixedTestSuites) { - test(name, runFixedCases(template)(cases)); -} +if (isTest) { + const runPairCases = createTemplatePairTest(Template); + for (const { name, cases } of pairTestSuites) { + test(name, runPairCases(cases as unknown as readonly [string, string][])); + } -const runWrongCases = createWrongTemplateTest(Template); -for (const { name, cases } of wrongTestSuites) { - test(name, runWrongCases(cases)); -} + const runFixedCases = createFixedTemplateTest(Template); + for (const { template, name, cases } of fixedTestSuites) { + test(name, runFixedCases(template)(cases)); + } + + const runWrongCases = createWrongTemplateTest(Template); + for (const { name, cases } of wrongTestSuites) { + test(name, runWrongCases(cases)); + } -const runHardCases = createTemplateHardTest(Template); -for (const { name, cases } of hardTestSuites) { - test(name, runHardCases(cases)); + const runHardCases = createTemplateHardTest(Template); + for (const { name, cases } of hardTestSuites) { + test(name, runHardCases(cases)); + } } From 3fb239675fb26dd6008a57e347c0c4cad110953a Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 8 May 2026 13:26:54 +0000 Subject: [PATCH 24/49] Group uri-template tests by suite via nested t.step Restructure the test factories in src/tests/template.ts to accept arrays of test suites instead of flat case lists. Each suite now becomes an outer t.step containing per-case inner steps, so the test reporter mirrors the suite/case shape declared in the data files instead of the call-site for-loop. In the old/ test files, replace the `if (isTest) { ... }` wrapper with the `{ ignore: !isOldTest }` test option so every test is registered unconditionally and is visible to the reporter even when skipped. Update deno.json to exclude bench files, the shared src/tests/ helpers, and summary.txt from the published artifact, and drop the `src/` filter from the default `test` task so all tests under the package are picked up. Update template.bench.ts to pass the suite array through the revised factory signature. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/deno.json | 5 +- .../old/uri-template-router.test.ts | 72 ++++--- .../uri-template/old/url-template.test.ts | 44 +++-- .../src/template/template.bench.ts | 7 +- .../src/template/template.test.ts | 31 +-- packages/uri-template/src/tests/template.ts | 178 ++++++++++-------- 6 files changed, 181 insertions(+), 156 deletions(-) diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json index 6e510ceae..b1a87213c 100644 --- a/packages/uri-template/deno.json +++ b/packages/uri-template/deno.json @@ -18,13 +18,16 @@ ], "publish": { "exclude": [ + "**/*.bench.ts", "**/*.test.ts", + "src/tests/", + "summary.txt", "tsdown.config.ts" ] }, "tasks": { "check": "deno fmt --check && deno lint && deno check", - "test": "deno test --allow-env src/", + "test": "deno test --allow-env", "test:old": "OLD=true deno test --allow-env old/" } } diff --git a/packages/uri-template/old/uri-template-router.test.ts b/packages/uri-template/old/uri-template-router.test.ts index b30a9ab67..2c82dfb98 100644 --- a/packages/uri-template/old/uri-template-router.test.ts +++ b/packages/uri-template/old/uri-template-router.test.ts @@ -187,35 +187,47 @@ export class RouterError extends Error { } } -const isTest = Deno.env.get("OLD") === "true"; - -if (isTest) { - const runAddCases = createRouterAddTest(Router); - test("Router.add()", runAddCases(routerRouteDefinitions)); - - 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 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}`, - runBuildCases(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 index 166a87c90..0e5851a17 100644 --- a/packages/uri-template/old/url-template.test.ts +++ b/packages/uri-template/old/url-template.test.ts @@ -71,26 +71,32 @@ class Template { match = (_: string) => null; } -const isTest = Deno.env.get("OLD") === "true"; +const isOldTest = Deno.env.get("OLD") === "true"; -if (isTest) { - const runPairCases = createTemplatePairTest(Template); - for (const { name, cases } of pairTestSuites) { - test(name, runPairCases(cases as unknown as readonly [string, string][])); - } +const runPairCases = createTemplatePairTest(Template); +test( + "old expand: examples", + { ignore: !isOldTest }, + runPairCases(pairTestSuites), +); - const runFixedCases = createFixedTemplateTest(Template); - for (const { template, name, cases } of fixedTestSuites) { - test(name, runFixedCases(template)(cases)); - } +const runFixedCases = createFixedTemplateTest(Template); +test( + "old expand: fixed templates", + { ignore: !isOldTest }, + runFixedCases(fixedTestSuites), +); - const runWrongCases = createWrongTemplateTest(Template); - for (const { name, cases } of wrongTestSuites) { - test(name, runWrongCases(cases)); - } +const runWrongCases = createWrongTemplateTest(Template); +test( + "old parse: invalid templates", + { ignore: !isOldTest }, + runWrongCases(wrongTestSuites), +); - const runHardCases = createTemplateHardTest(Template); - for (const { name, cases } of hardTestSuites) { - test(name, runHardCases(cases)); - } -} +const runHardCases = createTemplateHardTest(Template); +test( + "old expand: hard cases", + { ignore: !isOldTest }, + runHardCases(hardTestSuites), +); diff --git a/packages/uri-template/src/template/template.bench.ts b/packages/uri-template/src/template/template.bench.ts index d4f12f061..7477c51ca 100644 --- a/packages/uri-template/src/template/template.bench.ts +++ b/packages/uri-template/src/template/template.bench.ts @@ -6,12 +6,7 @@ Deno.bench("Template", (b) => { const runPairCases = createTemplatePairTest(Template); b.start(); for (const _ of Array(10000)) { - for (const { name, cases } of pairTestSuites) { - test( - name, - runPairCases(cases as unknown as readonly [string, string][]), - ); - } + test("expand: examples", runPairCases(pairTestSuites)); } b.end(); }); diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts index 582dd00b7..1d28f7b41 100644 --- a/packages/uri-template/src/template/template.test.ts +++ b/packages/uri-template/src/template/template.test.ts @@ -26,37 +26,22 @@ import { import Template from "./template.ts"; const runPairCases = createTemplatePairTest(Template); -for (const { name, cases } of pairTestSuites) { - test(name, runPairCases(cases as unknown as readonly [string, string][])); -} +test("expand: examples", runPairCases(pairTestSuites)); const runFixedCases = createFixedTemplateTest(Template); -for (const { template, name, cases } of fixedTestSuites) { - test(name, runFixedCases(template)(cases)); -} +test("expand: fixed templates", runFixedCases(fixedTestSuites)); const runWrongCases = createWrongTemplateTest(Template); -for (const { name, cases } of wrongTestSuites) { - test(name, runWrongCases(cases)); -} +test("parse: invalid templates", runWrongCases(wrongTestSuites)); const runHardCases = createTemplateHardTest(Template); -for (const { name, cases } of hardTestSuites) { - test(name, runHardCases(cases)); -} +test("expand: hard cases", runHardCases(hardTestSuites)); const runMatchCases = createTemplateMatchTest(Template); -for (const { name, cases } of pairTestSuites) { - test( - `match: ${name}`, - runMatchCases(cases as unknown as readonly [string, string][]), - ); -} +test("match: examples", runMatchCases(pairTestSuites)); const runFixedMatchCases = createFixedTemplateMatchTest(Template); -for (const { template, name, cases } of fixedTestSuites) { - test(`match: ${name}`, runFixedMatchCases(template)(cases)); -} +test("match: fixed templates", runFixedMatchCases(fixedTestSuites)); const runHardMatchCases = createTemplateMatchHardTest(Template); for (const { name, cases } of hardTestSuites) { @@ -64,9 +49,7 @@ for (const { name, cases } of hardTestSuites) { } const runMatchOnlyCases = createMatchOnlyTest(Template); -for (const { name, cases } of matchTestSuites) { - test(`match-only: ${name}`, runMatchOnlyCases(cases)); -} +test("match-only", runMatchOnlyCases(matchTestSuites)); test("throws parse errors in strict mode", () => { throws(() => new Template("{var"), UnclosedExpressionError); diff --git a/packages/uri-template/src/tests/template.ts b/packages/uri-template/src/tests/template.ts index 3dffe9b53..60ce1b8df 100644 --- a/packages/uri-template/src/tests/template.ts +++ b/packages/uri-template/src/tests/template.ts @@ -20,19 +20,23 @@ export interface PairTestSuite { export function createTemplatePairTest( Template: TemplateConstructor, ): ( - cases: readonly PairTestCase[], + suites: readonly PairTestSuite[], context?: ExpandContext, ) => (t: Deno.TestContext) => Promise { return ( - cases: readonly PairTestCase[], + suites: readonly PairTestSuite[], context: ExpandContext = testVars, ): (t: Deno.TestContext) => Promise => async (t: Deno.TestContext): Promise => { - for (const [template, expected] of cases) { - await t.step( - `${template} => ${expected}`, - () => equal(new Template(template).expand(context), expected), - ); + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const [template, expected] of cases) { + await t.step( + `${template} => ${expected}`, + () => equal(new Template(template).expand(context), expected), + ); + } + }); } }; } @@ -40,22 +44,26 @@ export function createTemplatePairTest( export function createTemplateMatchTest( Template: TemplateConstructor, ): ( - cases: readonly PairTestCase[], + suites: readonly PairTestSuite[], ) => (t: Deno.TestContext) => Promise { return ( - cases: readonly PairTestCase[], + suites: readonly PairTestSuite[], ): (t: Deno.TestContext) => Promise => async (t: Deno.TestContext): Promise => { - for (const [template, expanded] of cases) { - await t.step( - `${expanded} => ${template}`, - () => { - const instance = new Template(template); - const matched = instance.match(expanded); - ok(matched != null, `match returned null for ${expanded}`); - equal(instance.expand(matched), expanded); - }, - ); + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const [template, expanded] of cases) { + await t.step( + `${expanded} => ${template}`, + () => { + const instance = new Template(template); + const matched = instance.match(expanded); + ok(matched != null, `match returned null for ${expanded}`); + equal(instance.expand(matched), expanded); + }, + ); + } + }); } }; } @@ -76,16 +84,20 @@ interface MatchTestCase { export function createMatchOnlyTest( Template: TemplateConstructor, ): ( - cases: readonly MatchTestCase[], + suites: readonly MatchTestSuite[], ) => (t: Deno.TestContext) => Promise { return ( - cases: readonly MatchTestCase[], + suites: readonly MatchTestSuite[], ): (t: Deno.TestContext) => Promise => async (t: Deno.TestContext): Promise => { - for (const c of cases) { - await t.step(c.name, () => { - const got = new Template(c.template).match(c.uri); - deepEqual(got, c.expected); + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const c of cases) { + await t.step(c.name, () => { + const got = new Template(c.template).match(c.uri); + deepEqual(got, c.expected); + }); + } }); } }; @@ -94,7 +106,7 @@ export function createMatchOnlyTest( export interface FixedTemplateTestSuite { name: string; template: string; - cases: FixedTemplateTestCase[]; + cases: readonly FixedTemplateTestCase[]; } interface FixedTemplateTestCase { @@ -106,44 +118,50 @@ interface FixedTemplateTestCase { export const createFixedTemplateTest: ( Template: TemplateConstructor, ) => ( - template: string, -) => ( - cases: FixedTemplateTestCase[], -) => (t: Deno.TestContext) => Promise = - (Template: TemplateConstructor) => (template: string) => { - const instance = new Template(template); - return ( - cases: FixedTemplateTestCase[], - ): (t: Deno.TestContext) => Promise => - async (t: Deno.TestContext): Promise => { - for (const { name, context, expected } of cases) { - await t.step(name, () => equal(instance.expand(context), expected)); - } - }; + suites: readonly FixedTemplateTestSuite[], +) => (t: Deno.TestContext) => Promise = ( + Template: TemplateConstructor, +) => { + return ( + suites: readonly FixedTemplateTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { template, name, cases } of suites) { + await t.step(name, async (t) => { + const instance = new Template(template); + for (const { name, context, expected } of cases) { + await t.step(name, () => equal(instance.expand(context), expected)); + } + }); + } }; +}; export const createFixedTemplateMatchTest: ( Template: TemplateConstructor, ) => ( - template: string, -) => ( - cases: FixedTemplateTestCase[], -) => (t: Deno.TestContext) => Promise = - (Template: TemplateConstructor) => (template: string) => { - const instance = new Template(template); - return ( - cases: FixedTemplateTestCase[], - ): (t: Deno.TestContext) => Promise => - async (t: Deno.TestContext): Promise => { - for (const { name, expected } of cases) { - await t.step(name, () => { - const matched = instance.match(expected); - ok(matched != null, `match returned null for ${expected}`); - equal(instance.expand(matched), expected); - }); - } - }; + suites: readonly FixedTemplateTestSuite[], +) => (t: Deno.TestContext) => Promise = ( + Template: TemplateConstructor, +) => { + return ( + suites: readonly FixedTemplateTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { template, name, cases } of suites) { + await t.step(name, async (t) => { + const instance = new Template(template); + for (const { name, expected } of cases) { + await t.step(name, () => { + const matched = instance.match(expected); + ok(matched != null, `match returned null for ${expected}`); + equal(instance.expand(matched), expected); + }); + } + }); + } }; +}; type ErrorName = keyof typeof ERROR_CLASSES; @@ -187,17 +205,21 @@ export interface WrongTestSuite { export function createWrongTemplateTest( Template: TemplateConstructor, ): ( - cases: readonly WrongTemplateTestCase[], + suites: readonly WrongTestSuite[], ) => (t: Deno.TestContext) => Promise { return ( - cases: readonly WrongTemplateTestCase[], + suites: readonly WrongTestSuite[], ): (t: Deno.TestContext) => Promise => async (t: Deno.TestContext): Promise => { - for (const { name, template, expected } of cases) { - await t.step( - `${template} — ${name}`, - () => throws(() => new Template(template), ERROR_CLASSES[expected]), - ); + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const { name, template, expected } of cases) { + await t.step( + `${template} — ${name}`, + () => throws(() => new Template(template), ERROR_CLASSES[expected]), + ); + } + }); } }; } @@ -267,23 +289,27 @@ export interface HardTestSuite { export function createTemplateHardTest( Template: TemplateConstructor, ): ( - cases: readonly HardTestCase[], + suites: readonly HardTestSuite[], context?: ExpandContext, ) => (t: Deno.TestContext) => Promise { return ( - cases: readonly HardTestCase[], + suites: readonly HardTestSuite[], context: ExpandContext = testVars, ): (t: Deno.TestContext) => Promise => async (t: Deno.TestContext): Promise => { - for (const c of cases) { - await t.step(c.name, () => { - if (c.success) { - equal(new Template(c.template).expand(context), c.expected); - } else { - throws( - () => new Template(c.template).expand(context), - ERROR_CLASSES[c.expected], - ); + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const c of cases) { + await t.step(c.name, () => { + if (c.success) { + equal(new Template(c.template).expand(context), c.expected); + } else { + throws( + () => new Template(c.template).expand(context), + ERROR_CLASSES[c.expected], + ); + } + }); } }); } From bdf5f54d0ee58c10e9fdd5c8d2500933e42fdb73 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 8 May 2026 13:58:28 +0000 Subject: [PATCH 25/49] Export `isExpression` from @fedify/uri-template --- packages/uri-template/src/mod.ts | 1 + packages/uri-template/src/router/router.ts | 5 +---- packages/uri-template/src/utils.ts | 3 +++ 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 packages/uri-template/src/utils.ts diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts index 487e788df..81b3c4adc 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -44,3 +44,4 @@ export type { Token, VarSpec, } from "./types.ts"; +export { isExpression } from "./utils.ts"; diff --git a/packages/uri-template/src/router/router.ts b/packages/uri-template/src/router/router.ts index ff297fbee..245512298 100644 --- a/packages/uri-template/src/router/router.ts +++ b/packages/uri-template/src/router/router.ts @@ -1,5 +1,6 @@ import { Template } from "../template/mod.ts"; import type { ExpandContext, Path, Token } from "../types.ts"; +import { isExpression } from "../utils.ts"; import { RouteTemplatePathError } from "./errors.ts"; import Trie from "./trie.ts"; @@ -306,10 +307,6 @@ const collectVariables = (tokens: readonly Token[]): Set => .flatMap((token) => token.vars.map((varSpec) => varSpec.name)), ); -const isExpression = ( - token: T, -): token is Extract => token.kind === "expression"; - const getInitialLiteralPrefix = (tokens: readonly Token[]): string => tokens[0]?.kind === "literal" ? tokens[0].text : ""; diff --git a/packages/uri-template/src/utils.ts b/packages/uri-template/src/utils.ts new file mode 100644 index 000000000..dd486df99 --- /dev/null +++ b/packages/uri-template/src/utils.ts @@ -0,0 +1,3 @@ +export const isExpression = ( + token: T, +): token is Extract => token.kind === "expression"; From 6a3576d64bebf5d171bc1994f852d23f66fd8923 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 8 May 2026 13:59:52 +0000 Subject: [PATCH 26/49] Migrate @fedify/fedify to @fedify/uri-template router Replace the internal `Router`/`RouterError` from *src/federation/router.ts* with the implementations from the new `@fedify/uri-template` workspace package across `FederationBuilder`, middleware, NodeInfo handler, and testing context. The legacy *router.ts* exports are kept as a thin compatibility shim with `@deprecated` JSDoc directing callers to the new package. The new `Router` splits route registration from variable extraction: `router.add(template, name)` now returns `void`, while the static `Router.variables(template)` returns the variable names. All call sites are updated accordingly. `validateSingleIdentifierVariablePath` is rewritten to use `Router.compile` and `isExpression` so it can inspect the parsed `VariableSpec` directly, which lets it reject explode (`{var*}`) and prefix (`{var:N}`) modifiers in addition to query/fragment operators. Corresponding regression tests are added in *builder.test.ts*. Also list `@fedify/uri-template` in the packages table in *packages/fedify/README.md* and add the workspace dependency to *packages/fedify/package.json* / *pnpm-lock.yaml*. Assisted-by: Codex:GPT-5.5 Assisted-by: Claude Code:claude-opus-4-7 --- packages/fedify/README.md | 3 + packages/fedify/package.json | 1 + .../fedify/src/federation/builder.test.ts | 33 +++++++- packages/fedify/src/federation/builder.ts | 83 +++++++++++-------- .../fedify/src/federation/middleware.test.ts | 2 +- packages/fedify/src/federation/middleware.ts | 10 +-- packages/fedify/src/federation/router.ts | 29 +++++++ packages/fedify/src/nodeinfo/handler.ts | 2 +- packages/fedify/src/testing/context.ts | 2 +- pnpm-lock.yaml | 3 + 10 files changed, 125 insertions(+), 43 deletions(-) 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/package.json b/packages/fedify/package.json index 4883c51e7..bd6cc8faa 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:*", diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index 9d691c40d..159141650 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -1,4 +1,5 @@ import { test } from "@fedify/fixture"; +import { RouterError } 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 +14,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( @@ -211,6 +211,27 @@ test("FederationBuilder", async (t) => { ), RouterError, ); + assertThrows( + () => + builderAfterInvalid.setOutboxListeners( + "/users/{identifier:3}/outbox" as `${string}{identifier}${string}`, + ), + RouterError, + ); + assertThrows( + () => + builderAfterInvalid.setOutboxListeners( + "/users/{identifier*}/outbox" as `${string}{identifier}${string}`, + ), + RouterError, + ); + assertThrows( + () => + builderAfterInvalid.setOutboxListeners( + "/users/{identifier,identifier}/outbox" as `${string}{identifier}${string}`, + ), + RouterError, + ); builderAfterInvalid.setOutboxListeners("/users/{identifier}/outbox"); const builder2 = createFederationBuilder(); @@ -240,6 +261,16 @@ test("FederationBuilder", async (t) => { ), RouterError, ); + + const builder5 = createFederationBuilder(); + assertThrows( + () => + builder5.setOutboxDispatcher( + "/users/{identifier:3}/outbox" as `${string}{identifier}${string}`, + () => ({ items: [] }), + ), + RouterError, + ); }); await t.step("should pass build options correctly", async () => { diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index da5cf9845..42fcffe2a 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -1,3 +1,9 @@ +import { + isExpression, + 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,7 +67,6 @@ import type { CollectionCallbacks, CustomCollectionCallbacks, } from "./handler.ts"; -import { Router, RouterError } from "./router.ts"; export const ACTOR_ALIAS_PREFIX = "actorAlias:"; @@ -69,24 +74,24 @@ 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" - ) { + const pattern = Router.compile(path as Path); + if (pattern.variables.size !== 1 || !pattern.variables.has("identifier")) { throw new RouterError(errorMessage); } - if ( - operatorMatches.some((match) => - ["?", "&", "#"].includes(match[1]) && match[2] === "identifier" - ) - ) { + const expressions = pattern.template.tokens + .filter(isExpression) + .filter((token) => token.vars.some(({ name }) => name === "identifier")); + + if (expressions.length !== 1 || expressions[0].vars.length !== 1) { throw new RouterError(errorMessage); } - const variables = new Router().add(path, "outbox"); - if (variables.size !== 1 || !variables.has("identifier")) { + + const { operator, vars: [varSpec] } = expressions[0]; + if ( + ["?", "&", "#"].includes(operator) || + varSpec.explode || + varSpec.prefix != null + ) { throw new RouterError(errorMessage); } } @@ -251,7 +256,8 @@ export class FederationBuilderImpl if (this.router.has("actor")) { throw new RouterError("Actor dispatcher already set."); } - const variables = this.router.add(path, "actor"); + const variables = Router.variables(path as Path); + this.router.add(path as Path, "actor"); if ( variables.size !== 1 || !variables.has("identifier") @@ -524,7 +530,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 +539,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 +569,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 +631,13 @@ 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); + const variables = Router.variables(path as Path); if (variables.size < 1) { throw new RouterError( "Path for object dispatcher must have at least one variable.", ); } + this.router.add(path as Path, routeName); const callbacks: ObjectCallbacks = { dispatcher: (ctx, values) => { const tracer = this._getTracer(); @@ -711,7 +719,7 @@ export class FederationBuilderImpl ); } } else { - const variables = this.router.add(path, "inbox"); + const variables = Router.variables(path as Path); if ( variables.size !== 1 || !variables.has("identifier") @@ -720,6 +728,7 @@ export class FederationBuilderImpl "Path for inbox dispatcher must have one variable: {identifier}", ); } + this.router.add(path as Path, "inbox"); this.inboxPath = path; } const callbacks: CollectionCallbacks< @@ -793,7 +802,7 @@ export class FederationBuilderImpl path, "Path for outbox dispatcher must have one variable: {identifier}", ); - this.router.add(path, "outbox"); + this.router.add(path as Path, "outbox"); this.outboxPath = path; } const callbacks: CollectionCallbacks< @@ -857,7 +866,7 @@ export class FederationBuilderImpl outboxPath, "Path for outbox must have one variable: {identifier}", ); - this.router.add(outboxPath, "outbox"); + this.router.add(outboxPath as Path, "outbox"); this.outboxPath = outboxPath; } const listeners = this.outboxListeners = new ActivityListenerSet< @@ -904,7 +913,7 @@ export class FederationBuilderImpl if (this.router.has("following")) { throw new RouterError("Following collection dispatcher already set."); } - const variables = this.router.add(path, "following"); + const variables = Router.variables(path as Path); if ( variables.size !== 1 || !variables.has("identifier") @@ -914,6 +923,7 @@ export class FederationBuilderImpl "{identifier}", ); } + this.router.add(path as Path, "following"); const callbacks: CollectionCallbacks< Actor | URL, RequestContext, @@ -970,7 +980,7 @@ export class FederationBuilderImpl if (this.router.has("followers")) { throw new RouterError("Followers collection dispatcher already set."); } - const variables = this.router.add(path, "followers"); + const variables = Router.variables(path as Path); if ( variables.size !== 1 || !variables.has("identifier") @@ -980,6 +990,7 @@ export class FederationBuilderImpl "{identifier}", ); } + this.router.add(path as Path, "followers"); const callbacks: CollectionCallbacks< Recipient, Context, @@ -1032,7 +1043,7 @@ export class FederationBuilderImpl if (this.router.has("liked")) { throw new RouterError("Liked collection dispatcher already set."); } - const variables = this.router.add(path, "liked"); + const variables = Router.variables(path as Path); if ( variables.size !== 1 || !variables.has("identifier") @@ -1042,6 +1053,7 @@ export class FederationBuilderImpl "{identifier}", ); } + this.router.add(path as Path, "liked"); const callbacks: CollectionCallbacks< Like, RequestContext, @@ -1102,7 +1114,7 @@ export class FederationBuilderImpl if (this.router.has("featured")) { throw new RouterError("Featured collection dispatcher already set."); } - const variables = this.router.add(path, "featured"); + const variables = Router.variables(path as Path); if ( variables.size !== 1 || !variables.has("identifier") @@ -1112,6 +1124,7 @@ export class FederationBuilderImpl "{identifier}", ); } + this.router.add(path as Path, "featured"); const callbacks: CollectionCallbacks< Object, RequestContext, @@ -1172,7 +1185,7 @@ export class FederationBuilderImpl if (this.router.has("featuredTags")) { throw new RouterError("Featured tags collection dispatcher already set."); } - const variables = this.router.add(path, "featuredTags"); + const variables = Router.variables(path as Path); if ( variables.size !== 1 || !variables.has("identifier") @@ -1182,6 +1195,7 @@ export class FederationBuilderImpl "variable: {identifier}", ); } + this.router.add(path as Path, "featuredTags"); const callbacks: CollectionCallbacks< Hashtag, RequestContext, @@ -1240,7 +1254,7 @@ export class FederationBuilderImpl ); } } else { - const variables = this.router.add(inboxPath, "inbox"); + const variables = Router.variables(inboxPath as Path); if ( variables.size !== 1 || !variables.has("identifier") @@ -1249,15 +1263,17 @@ export class FederationBuilderImpl "Path for inbox must have one variable: {identifier}", ); } + this.router.add(inboxPath as Path, "inbox"); 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,12 +1549,13 @@ export class FederationBuilderImpl ); } - const variables = this.router.add(path, routeName); + const variables = Router.variables(path as Path); if (variables.size < 1) { throw new RouterError( "Path for collection dispatcher must have at least one variable.", ); } + this.router.add(path as Path, routeName); const callbacks: CustomCollectionCallbacks< TObject, diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index c850745dc..389f19aad 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"; @@ -58,7 +59,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; diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index f29e3a84c..693fd41c0 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 { @@ -118,9 +119,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, @@ -1483,7 +1481,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); @@ -2090,7 +2088,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.ts b/packages/fedify/src/federation/router.ts index b1d542ff9..623f4ffe7 100644 --- a/packages/fedify/src/federation/router.ts +++ b/packages/fedify/src/federation/router.ts @@ -6,6 +6,7 @@ import { parseTemplate, type Template } from "url-template"; /** * Options for the {@link Router}. * @since 0.12.0 + * @deprecated Import `RouterOptions` from `@fedify/uri-template` instead. */ export interface RouterOptions { /** @@ -17,6 +18,7 @@ export interface RouterOptions { /** * The result of {@link Router.route} method. * @since 1.3.0 + * @deprecated Import `RouterRouteResult` from `@fedify/uri-template` instead. */ export interface RouterRouteResult { /** @@ -49,6 +51,11 @@ function cloneInnerRouter(router: InnerRouter): InnerRouter { /** * 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; @@ -58,12 +65,15 @@ export class Router { /** * Whether to ignore trailing slashes when matching paths. * @since 1.6.0 + * @deprecated Use `Router` from `@fedify/uri-template` instead. */ trailingSlashInsensitive: boolean; /** * Create a new {@link Router}. * @param options Options for the router. + * @deprecated Use `new Router(options)` from `@fedify/uri-template` + * instead. */ constructor(options: RouterOptions = {}) { this.#router = new InnerRouter(); @@ -72,6 +82,10 @@ export class Router { this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; } + /** + * Clones this router. + * @deprecated Use `Router` from `@fedify/uri-template` instead. + */ clone(): Router { const clone = new Router({ trailingSlashInsensitive: this.trailingSlashInsensitive, @@ -86,6 +100,7 @@ export class Router { * 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; @@ -96,6 +111,16 @@ 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("/")) { @@ -112,6 +137,7 @@ 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. */ route(url: string): RouterRouteResult | null { let match = this.#router.resolveURI(url); @@ -133,6 +159,7 @@ 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) { @@ -144,11 +171,13 @@ export class Router { /** * An error thrown by the {@link Router}. + * @deprecated Import `RouterError` from `@fedify/uri-template` instead. */ export class RouterError extends Error { /** * Create a new {@link RouterError}. * @param message The error message. + * @deprecated Import `RouterError` from `@fedify/uri-template` instead. */ constructor(message: string) { super(message); 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/pnpm-lock.yaml b/pnpm-lock.yaml index e3d2c679c..70381e462 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1146,6 +1146,9 @@ importers: packages/fedify: dependencies: + '@fedify/uri-template': + specifier: workspace:* + version: link:../uri-template '@fedify/vocab': specifier: workspace:* version: link:../vocab From 319ead3bd1e72676928569cc06d22c98e9202f53 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 8 May 2026 14:01:09 +0000 Subject: [PATCH 27/49] Note uri-template router migration in CHANGES.md Add changelog entries for the new *@fedify/uri-template* package and for replacing Fedify's internal federation routing with it. Both entries reference https://github.com/fedify-dev/fedify/issues/418. Assisted-by: Claude Code:claude-opus-4-7 --- CHANGES.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 7fe388fd0..5823ac806 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -51,7 +51,13 @@ To be released. deliberately exclude raw URLs, query strings, and identifier values to keep cardinality bounded. [[#316], [#736], [#757]] + - 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]] + [#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 @@ -72,6 +78,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]] + ### @fedify/amqp - Added `AmqpMessageQueue.getDepth()` for reporting queued, ready, and From 97b15f768dcadb207d146a08386d7d2fb81391e3 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 8 May 2026 16:06:14 +0000 Subject: [PATCH 28/49] Export isPath helper from @fedify/uri-template Move the path-template predicate out of the router and into the public utilities module so callers can validate `Path` values without reaching into router internals. The new implementation parses the input as a `Template` and inspects the first token, which accepts multi-variable path expansions like `{/a,b}/...` that the previous regex-based check rejected. Also adds a sibling `isLiteral` helper alongside the existing `isExpression` token guard. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/mod.ts | 2 +- packages/uri-template/src/router/router.ts | 5 +---- packages/uri-template/src/utils.ts | 26 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts index 81b3c4adc..f40423f6e 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -44,4 +44,4 @@ export type { Token, VarSpec, } from "./types.ts"; -export { isExpression } from "./utils.ts"; +export { isExpression, isPath } from "./utils.ts"; diff --git a/packages/uri-template/src/router/router.ts b/packages/uri-template/src/router/router.ts index 245512298..494823b6d 100644 --- a/packages/uri-template/src/router/router.ts +++ b/packages/uri-template/src/router/router.ts @@ -1,6 +1,6 @@ import { Template } from "../template/mod.ts"; import type { ExpandContext, Path, Token } from "../types.ts"; -import { isExpression } from "../utils.ts"; +import { isExpression, isPath } from "../utils.ts"; import { RouteTemplatePathError } from "./errors.ts"; import Trie from "./trie.ts"; @@ -316,9 +316,6 @@ const getLiteralLength = (tokens: readonly Token[]): number => 0, ); -const isPath = (path: string): path is Path => - path.startsWith("/") || /^\{\/[^}]+\}\//.test(path); - const toRouteValues = ( context: ExpandContext, ): Record | null => { diff --git a/packages/uri-template/src/utils.ts b/packages/uri-template/src/utils.ts index dd486df99..9ae04e5e9 100644 --- a/packages/uri-template/src/utils.ts +++ b/packages/uri-template/src/utils.ts @@ -1,3 +1,29 @@ +import Template from "./template/template.ts"; +import type { Path } from "./types.ts"; + export const isExpression = ( token: T, ): token is Extract => token.kind === "expression"; + +export const isLiteral = ( + token: T, +): token is Extract => token.kind === "literal"; + +/** + * Returns whether `path` is a path-shaped URI Template accepted by the + * router. + * + * A path is either a literal string starting with `/`, or a path-expansion + * expression (`{/var}`) followed by a literal segment that starts with `/`. + * Templates that fail to parse — and therefore could never be routed — + * return `false`. + */ +export function isPath(path: string): path is Path { + const template = new Template(path); + + const [first] = template.tokens; + if (first == null) return false; + if (isLiteral(first)) return first.text.startsWith("/"); + if (first.operator === "/") return true; + return false; +} From 8a5d66b2c65a33e2c3bbd55d5e562ae32a9ad5a0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 8 May 2026 16:08:35 +0000 Subject: [PATCH 29/49] Tighten match backtracking bounds in @fedify/uri-template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small correctness/performance fixes in the match backtracker: - `consumeUnnamed`'s `minLength` heuristic called `remainingVars(vars)` with the full var list, which collapsed to `vars.length - 1` regardless of progress. Pass `varIndex` through so the bound actually reflects how many variables remain after the current one. - `consumeNamedList` checked `if (values)` against an array, which is always truthy. The intent was to skip empty matches; replace with `values.length > 0` so empty lists no longer yield no-progress bindings that the surrounding backtracker has to discard. Neither change affects matching correctness — round-trip verification filters out the spurious branches — but both reduce the search space the backtracker has to walk through. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/template/match.ts | 23 ++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/uri-template/src/template/match.ts b/packages/uri-template/src/template/match.ts index 01cfc5277..d8a2e30cc 100644 --- a/packages/uri-template/src/template/match.ts +++ b/packages/uri-template/src/template/match.ts @@ -222,7 +222,14 @@ function* matchUnnamedFrom( const varSpec = vars[varIndex]; for ( - const consumed of consumeUnnamed(varSpec, spec, parts, partIndex, vars) + const consumed of consumeUnnamed( + varSpec, + spec, + parts, + partIndex, + vars, + varIndex, + ) ) { for ( const rest of matchUnnamedFrom( @@ -257,11 +264,15 @@ function* consumeUnnamed( parts: readonly string[], partIndex: number, vars: readonly VarSpec[], + varIndex: number, ): Generator { if (partIndex >= parts.length) return; const maxLength = parts.length - partIndex; - const minLength = Math.max(1, parts.length - partIndex - remainingVars(vars)); + const minLength = Math.max( + 1, + parts.length - partIndex - remainingVars(vars, varIndex), + ); for (let length = minLength; length <= maxLength; length++) { const slice = parts.slice(partIndex, partIndex + length); for (const bindings of parseUnnamedValue(varSpec, spec, slice)) { @@ -270,8 +281,10 @@ function* consumeUnnamed( } } -const remainingVars = (vars: readonly VarSpec[]): number => - Math.max(0, vars.length - 1); +const remainingVars = ( + vars: readonly VarSpec[], + varIndex: number, +): number => Math.max(0, vars.length - varIndex - 1); /** * Yields every binding interpretation of a slice assigned to one unnamed @@ -535,7 +548,7 @@ function* consumeNamedList( const name = encodeName(varSpec.name); const values = [...namedListValues(name, spec, parts, partIndex)]; - if (values) { + if (values.length > 0) { yield { bindings: bindValue(varSpec, values), index: partIndex + values.length, From ea23a6a5e007166ec9b6f624c5b6bfa3b806ca64 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 8 May 2026 16:09:27 +0000 Subject: [PATCH 30/49] Replace match bench with backtracking-pressure cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the no-op `Template (match)` bench that ran the RFC 6570 example suite as a wrapper around `test()` — pair examples have variables of 1–3 parts with literal anchors on both sides, so the backtracker collapses to one branch per case and the result was indistinguishable from a constant-time loop. Replace it with two cases that actually exercise the matcher: - A 3-var unnamed expression against a 12-part body, where the comma-distribution backtracker has C(11, 2) = 55 candidate splits. - A `{/paths*}` exploded path expansion against 728 progressively deeper URIs, exercising the path-segment list reader. Factor the per-template benchmark loop into `createMatchBench` and the path-corpus generator into `createMatchBenchTestCases` under `packages/uri-template/src/tests/template.ts` so other Template implementations can reuse the same harness. Assisted-by: Claude Code:claude-opus-4-7 --- .../src/template/template.bench.ts | 31 +++++++++++++++-- packages/uri-template/src/tests/mod.ts | 3 ++ packages/uri-template/src/tests/template.ts | 33 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/uri-template/src/template/template.bench.ts b/packages/uri-template/src/template/template.bench.ts index 7477c51ca..f47744fc3 100644 --- a/packages/uri-template/src/template/template.bench.ts +++ b/packages/uri-template/src/template/template.bench.ts @@ -1,8 +1,13 @@ import { test } from "@fedify/fixture"; -import { createTemplatePairTest, pairTestSuites } from "../tests/mod.ts"; +import { + createMatchBench, + createMatchBenchTestCases, + createTemplatePairTest, + pairTestSuites, +} from "../tests/mod.ts"; import Template from "./template.ts"; -Deno.bench("Template", (b) => { +Deno.bench("Template (expand)", (b) => { const runPairCases = createTemplatePairTest(Template); b.start(); for (const _ of Array(10000)) { @@ -10,3 +15,25 @@ Deno.bench("Template", (b) => { } b.end(); }); + +const matchBench = createMatchBench(Template); +Deno.bench( + "Template (match) — 5-var unnamed, 8 parts", + (b) => { + const bench = matchBench("/items/{a,b,c}/end"); + b.start(); + bench(Array(1000).fill("/items/p1,p2,p3,p4,p5,p6,p7,p8,p9,p0,p11,p12/end")); + b.end(); + }, +); + +Deno.bench( + "Template (match) — 728 paths test", + (b) => { + const bench = matchBench("{/paths*}"); + const cases = createMatchBenchTestCases(); + b.start(); + bench(cases); + b.end(); + }, +); diff --git a/packages/uri-template/src/tests/mod.ts b/packages/uri-template/src/tests/mod.ts index 4f4cdef2b..38e882f05 100644 --- a/packages/uri-template/src/tests/mod.ts +++ b/packages/uri-template/src/tests/mod.ts @@ -128,6 +128,7 @@ export const routerCloneTestSuites: readonly RouterCloneTestSuite[] = validate( assertRouterCloneTestSuites, _routerCloneTestSuites, ); + export { createDeepPrefixRouterTest, createDynamicRoutesTest, @@ -148,6 +149,8 @@ export { export { createFixedTemplateMatchTest, createFixedTemplateTest, + createMatchBench, + createMatchBenchTestCases, createMatchOnlyTest, createTemplateHardTest, createTemplateMatchHardTest, diff --git a/packages/uri-template/src/tests/template.ts b/packages/uri-template/src/tests/template.ts index 60ce1b8df..f4a299f29 100644 --- a/packages/uri-template/src/tests/template.ts +++ b/packages/uri-template/src/tests/template.ts @@ -336,3 +336,36 @@ export function createTemplateMatchHardTest( } }; } + +export function createMatchBench( + Template: TemplateConstructor, +): (templateText: string) => (uris: readonly string[]) => void { + return (templateText: string): (uris: readonly string[]) => void => { + const template = new Template(templateText); + return (uris: readonly string[]): void => { + for (const uri of uris) template.match(uri); + }; + }; +} +const mod = (i: number, j: number) => Math.floor(i / j); + +export function createMatchBenchTestCases(): readonly string[] { + let a = "/A"; + const b = ["/A"]; + for (let i = 1; i < 26; i++) { + a += "/" + String.fromCharCode(0x41 + i); + b.push(a); + } + + for (let i = 0; i < 26; i++) { + a += "/" + String.fromCharCode(0x61 + i); + b.push(a); + } + for (let i = 0; i < 676; i++) { + a += "/" + + String.fromCharCode(0x61 + mod(i, 26)) + + String.fromCharCode(0x61 + i % 26); + b.push(a); + } + return b; +} From 10fc20b2f558672fa9489484fe5c04440c1c3418 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 9 May 2026 02:15:36 +0000 Subject: [PATCH 31/49] Add PR link --- CHANGES.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 5823ac806..0a0a20c57 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -83,7 +83,9 @@ To be released. - 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]] + *url-template* and *uri-template-router*. [[#418], [#758] by ChanHaeng Lee] + +[#758]: https://github.com/fedify-dev/fedify/pull/758 ### @fedify/amqp From 074a546490acb9def0e76d9218fa22447957edf0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 9 May 2026 11:21:02 +0000 Subject: [PATCH 32/49] Export `assertPath` from @fedify/uri-template --- packages/uri-template/src/mod.ts | 2 +- packages/uri-template/src/utils.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts index f40423f6e..e5b79cd4f 100644 --- a/packages/uri-template/src/mod.ts +++ b/packages/uri-template/src/mod.ts @@ -44,4 +44,4 @@ export type { Token, VarSpec, } from "./types.ts"; -export { isExpression, isPath } from "./utils.ts"; +export { assertPath, isExpression, isPath } from "./utils.ts"; diff --git a/packages/uri-template/src/utils.ts b/packages/uri-template/src/utils.ts index 9ae04e5e9..46e38718e 100644 --- a/packages/uri-template/src/utils.ts +++ b/packages/uri-template/src/utils.ts @@ -1,3 +1,4 @@ +import { RouterError } from "./router/errors.ts"; import Template from "./template/template.ts"; import type { Path } from "./types.ts"; @@ -27,3 +28,11 @@ export function isPath(path: string): path is Path { if (first.operator === "/") return true; return false; } + +export function assertPath(path: string): asserts path is Path { + if (!isPath(path)) { + throw new RouterError( + `"${path}" is not looks like a path. Is this start with slash(\`//\`?)`, + ); + } +} From 0e1bc073afcab88d3a8de71c6f5dedfa529e3333 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 9 May 2026 11:27:09 +0000 Subject: [PATCH 33/49] Replace `Router` from @fedify/fedify with the wrapper of `Router` from @fedify/uri-template --- deno.lock | 9 +- packages/fedify/deno.json | 4 +- packages/fedify/package.json | 2 - packages/fedify/src/federation/router.ts | 137 +++++++++-------------- pnpm-lock.yaml | 17 --- 5 files changed, 56 insertions(+), 113 deletions(-) diff --git a/deno.lock b/deno.lock index 6d41fb003..d528ef3c0 100644 --- a/deno.lock +++ b/deno.lock @@ -3448,7 +3448,8 @@ ] }, "@ungap/structured-clone@1.3.0": { - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "deprecated": true }, "@vercel/nft@1.5.0_rollup@4.60.1": { "integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==", @@ -9443,9 +9444,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": [ @@ -9457,8 +9456,6 @@ "npm:miniflare@^4.20250523.0", "npm:structured-field-values@^2.0.4", "npm:tsx@^4.19.4", - "npm:uri-template-router@1", - "npm:url-template@^3.1.1", "npm:wrangler@^4.17.0" ] } diff --git a/packages/fedify/deno.json b/packages/fedify/deno.json index daf267c6b..29c2199c3 100644 --- a/packages/fedify/deno.json +++ b/packages/fedify/deno.json @@ -23,9 +23,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 bd6cc8faa..27f1d8af3 100644 --- a/packages/fedify/package.json +++ b/packages/fedify/package.json @@ -154,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/router.ts b/packages/fedify/src/federation/router.ts index 623f4ffe7..cff88b726 100644 --- a/packages/fedify/src/federation/router.ts +++ b/packages/fedify/src/federation/router.ts @@ -1,52 +1,29 @@ -// @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, + Router as _Router, + RouterError as _RouterError, +} from "@fedify/uri-template"; +import { getLogger } from "@logtape/logtape"; + +const logger = getLogger(["fedify", "federation", "router", "deprecated"]); /** * 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 @@ -58,28 +35,15 @@ function cloneInnerRouter(router: InnerRouter): InnerRouter { * and should be used directly in new code. */ export class Router { - #router: InnerRouter; - #templates: Record; - #templateStrings: Record; - - /** - * Whether to ignore trailing slashes when matching paths. - * @since 1.6.0 - * @deprecated Use `Router` from `@fedify/uri-template` instead. - */ - trailingSlashInsensitive: boolean; - + #router: _Router; /** * Create a new {@link Router}. * @param options Options for the router. * @deprecated Use `new Router(options)` from `@fedify/uri-template` * instead. */ - constructor(options: RouterOptions = {}) { - this.#router = new InnerRouter(); - this.#templates = {}; - this.#templateStrings = {}; - this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; + constructor(options?: _RouterOptions) { + this.#router = convertRouterError(() => new _Router(options)); } /** @@ -87,13 +51,11 @@ export class 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; } /** @@ -103,7 +65,7 @@ export class Router { * @deprecated Use `Router` from `@fedify/uri-template` instead. */ has(name: string): boolean { - return name in this.#templates; + return convertRouterError(() => this.#router.has(name)); } /** @@ -123,13 +85,11 @@ export class Router { * 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); + }); } /** @@ -140,18 +100,10 @@ export class Router { * @deprecated Use `Router` from `@fedify/uri-template` instead. */ 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(() => { + assertPath(url); + return this.#router.route(url); + }); } /** @@ -162,10 +114,7 @@ export class Router { * @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)); } } @@ -173,7 +122,7 @@ export class Router { * 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 { /** * Create a new {@link RouterError}. * @param message The error message. @@ -181,6 +130,24 @@ export class RouterError extends Error { */ constructor(message: string) { super(message); - this.name = "RouterError"; + logger.warn( + "The `RouterError` class from `@fedify/fedify` is deprecated." + + " Please use `Router` from `@fedify/uri-template` instead.", + ); + } +} + +function convertRouterError(func: () => T): T { + try { + logger.warn( + "The `Router` class from `@fedify/fedify` is deprecated." + + " Please use `Router` from `@fedify/uri-template` instead.", + ); + return func(); + } catch (error) { + if (error instanceof _RouterError) { + throw new RouterError(error.message); + } + throw error; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70381e462..78a5bde8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1191,12 +1191,6 @@ importers: structured-field-values: specifier: ^2.0.4 version: 2.0.4 - uri-template-router: - specifier: ^1.0.0 - version: 1.0.0 - url-template: - specifier: ^3.1.1 - version: 3.1.1 urlpattern-polyfill: specifier: 'catalog:' version: 10.1.0 @@ -12476,16 +12470,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - uri-template-router@1.0.0: - resolution: {integrity: sha512-WKcL9ZSIEhHE3f5P4Z47Tf0nWbcgV1ISb/OBuF8YKEYi0SQOyTLCzM6B/gAKFWZhRhqA+C/Ks8UXe2qU5W0FVg==} - url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - url-template@3.1.1: - resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} @@ -25575,15 +25562,11 @@ snapshots: dependencies: punycode: 2.3.1 - uri-template-router@1.0.0: {} - url-parse@1.5.10: dependencies: querystringify: 2.2.0 requires-port: 1.0.0 - url-template@3.1.1: {} - urlpattern-polyfill@10.1.0: {} utif2@4.1.0: From 8de1d9c9c066e7ad7398c466e30180b32b2a766f Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 9 May 2026 12:32:18 +0000 Subject: [PATCH 34/49] Apply review feedback from PR #758 Address review comments from automated reviewers: - Harden isPath against malformed templates so it returns false instead of throwing, restoring its JSDoc contract. - Relax the Path type from `{/${string}}/${string}` to `{/${string}}${string}` so isPath is consistent with the router's handling of bare path-expansion templates like `{/var}`. - Run path validation before mutating the router in setActorDispatcher, so a failed registration does not leave a stale `actor` route that confuses subsequent attempts. - Reuse validateSingleIdentifierVariablePath across inbox, outbox, following, followers, liked, featured, featuredTags, and inbox listener registrations instead of the loose Router.variables check, so explode (`*`) and prefix (`:N`) modifiers cannot smuggle through `{identifier}` paths at runtime. - Mark @fedify/uri-template as side-effect free for tree-shaking. - Add a regression test for the unnamed consumeUnnamed minLength pruning bug. The matcher fix is intentionally left for a separate commit so the test stays red until then. https://github.com/fedify-dev/fedify/pull/758 Assisted-by: Claude Code:claude-opus-4-7[1m] --- packages/fedify/src/federation/builder.ts | 114 ++++++------------ packages/uri-template/package.json | 3 +- .../src/template/template.test.ts | 23 +++- packages/uri-template/src/tests/template.ts | 1 + packages/uri-template/src/types.ts | 2 +- packages/uri-template/src/utils.ts | 16 ++- 6 files changed, 73 insertions(+), 86 deletions(-) diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 42fcffe2a..ffa9ad9c4 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -256,16 +256,11 @@ export class FederationBuilderImpl if (this.router.has("actor")) { throw new RouterError("Actor dispatcher already set."); } - const variables = Router.variables(path as Path); + validateSingleIdentifierVariablePath( + path, + "Path for actor dispatcher must have one variable: {identifier}", + ); this.router.add(path as Path, "actor"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for actor dispatcher must have one variable: {identifier}", - ); - } const callbacks: ActorCallbacks = { dispatcher: async (context, identifier) => { const actor = await this._getTracer().startActiveSpan( @@ -719,15 +714,10 @@ export class FederationBuilderImpl ); } } else { - const variables = Router.variables(path as Path); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for inbox dispatcher must have one variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for inbox dispatcher must have one variable: {identifier}", + ); this.router.add(path as Path, "inbox"); this.inboxPath = path; } @@ -913,16 +903,11 @@ export class FederationBuilderImpl if (this.router.has("following")) { throw new RouterError("Following collection dispatcher already set."); } - const variables = Router.variables(path as Path); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for following collection dispatcher must have one variable: " + - "{identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for following collection dispatcher must have one variable: " + + "{identifier}", + ); this.router.add(path as Path, "following"); const callbacks: CollectionCallbacks< Actor | URL, @@ -980,16 +965,11 @@ export class FederationBuilderImpl if (this.router.has("followers")) { throw new RouterError("Followers collection dispatcher already set."); } - const variables = Router.variables(path as Path); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for followers collection dispatcher must have one variable: " + - "{identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for followers collection dispatcher must have one variable: " + + "{identifier}", + ); this.router.add(path as Path, "followers"); const callbacks: CollectionCallbacks< Recipient, @@ -1043,16 +1023,11 @@ export class FederationBuilderImpl if (this.router.has("liked")) { throw new RouterError("Liked collection dispatcher already set."); } - const variables = Router.variables(path as Path); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for liked collection dispatcher must have one variable: " + - "{identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for liked collection dispatcher must have one variable: " + + "{identifier}", + ); this.router.add(path as Path, "liked"); const callbacks: CollectionCallbacks< Like, @@ -1114,16 +1089,11 @@ export class FederationBuilderImpl if (this.router.has("featured")) { throw new RouterError("Featured collection dispatcher already set."); } - const variables = Router.variables(path as Path); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for featured collection dispatcher must have one variable: " + - "{identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for featured collection dispatcher must have one variable: " + + "{identifier}", + ); this.router.add(path as Path, "featured"); const callbacks: CollectionCallbacks< Object, @@ -1185,16 +1155,11 @@ export class FederationBuilderImpl if (this.router.has("featuredTags")) { throw new RouterError("Featured tags collection dispatcher already set."); } - const variables = Router.variables(path as Path); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for featured tags collection dispatcher must have one " + - "variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for featured tags collection dispatcher must have one " + + "variable: {identifier}", + ); this.router.add(path as Path, "featuredTags"); const callbacks: CollectionCallbacks< Hashtag, @@ -1254,15 +1219,10 @@ export class FederationBuilderImpl ); } } else { - const variables = Router.variables(inboxPath as Path); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for inbox must have one variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + inboxPath, + "Path for inbox must have one variable: {identifier}", + ); this.router.add(inboxPath as Path, "inbox"); this.inboxPath = inboxPath; } diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json index b5bdf9f21..ac2aed05a 100644 --- a/packages/uri-template/package.json +++ b/packages/uri-template/package.json @@ -64,5 +64,6 @@ "tsdown": "catalog:", "typescript": "catalog:" }, - "dependencies": {} + "dependencies": {}, + "sideEffects": false } diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts index 1d28f7b41..71a722acc 100644 --- a/packages/uri-template/src/template/template.test.ts +++ b/packages/uri-template/src/template/template.test.ts @@ -1,6 +1,6 @@ import { test } from "@fedify/fixture"; import { deepEqual, equal } from "node:assert"; -import { throws } from "node:assert/strict"; +import { ok, throws } from "node:assert/strict"; import { createFixedTemplateMatchTest, createFixedTemplateTest, @@ -112,3 +112,24 @@ test("parses reusable template instances", () => { }, ]); }); + +// Regression for the `consumeUnnamed` minLength bug: when an unnamed expression +// has more separated parts than variables, the matcher must let the *current* +// variable absorb fewer parts than the naive `parts - remainingVars` formula +// allows. With `{x:5,y}` against `abc,def,ghi` the only round-trippable +// binding has x consume one part (so prefix:5 truncation does not corrupt the +// joined string); under the buggy minLength formula the matcher only reaches +// the fallback `x undefined, y absorbs everything` decomposition, leaving +// `m.x` undefined. +test("Template#match — unnamed minLength must allow current var to consume one part", () => { + const template = new Template("{x:5,y}"); + const m = template.match("abc,def,ghi"); + + ok(m != null, "matcher returned null for a round-trippable URI"); + equal(template.expand(m), "abc,def,ghi"); + equal( + m.x, + "abc", + "matcher should reach the binding with x consuming one part", + ); +}); diff --git a/packages/uri-template/src/tests/template.ts b/packages/uri-template/src/tests/template.ts index f4a299f29..d5f718fcb 100644 --- a/packages/uri-template/src/tests/template.ts +++ b/packages/uri-template/src/tests/template.ts @@ -347,6 +347,7 @@ export function createMatchBench( }; }; } + const mod = (i: number, j: number) => Math.floor(i / j); export function createMatchBenchTestCases(): readonly string[] { diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index a4a62b3e1..b834a0d0b 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -4,7 +4,7 @@ import type { Operator } from "./const.ts"; /** * Path-shaped URI Template accepted by the router. */ -export type Path = `/${string}` | `{/${string}}/${string}`; +export type Path = `/${string}` | `{/${string}}${string}`; /** * Primitive value accepted by {@link Template.expand}. diff --git a/packages/uri-template/src/utils.ts b/packages/uri-template/src/utils.ts index 46e38718e..ad4b2c4c5 100644 --- a/packages/uri-template/src/utils.ts +++ b/packages/uri-template/src/utils.ts @@ -20,13 +20,17 @@ export const isLiteral = ( * return `false`. */ export function isPath(path: string): path is Path { - const template = new Template(path); + try { + const template = new Template(path); - const [first] = template.tokens; - if (first == null) return false; - if (isLiteral(first)) return first.text.startsWith("/"); - if (first.operator === "/") return true; - return false; + const [first] = template.tokens; + if (first == null) return false; + if (isLiteral(first)) return first.text.startsWith("/"); + if (first.operator === "/") return true; + return false; + } catch { + return false; + } } export function assertPath(path: string): asserts path is Path { From e49a92a0cdc66d08451a8ee14eb0f36ce635de91 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 9 May 2026 13:08:36 +0000 Subject: [PATCH 35/49] Fix `consumeUnnamed` over-pruning of valid match decompositions The `minLength` formula reserved one part per remaining unnamed variable, but later variables may also be skipped entirely (consuming zero parts) via the fallback in `matchUnnamedFrom`. Reserving a minimum part for each of them prevented the *current* variable from absorbing the parts that the skipped tail would otherwise have taken, discarding valid decompositions before round-trip verification could see them. Concretely, matching `{x:5,y}` against `abc,def,ghi` now correctly yields `x="abc"`, `y={"def":"ghi"}` instead of leaving `m.x` undefined and binding `y` to the entire list. Drop the lower bound to a constant `1`; the recursive matcher already discards distributions that fail to consume all parts, so no further tightening is necessary. Removes the now-unused `remainingVars` helper. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/template/match.ts | 30 +++------------------ 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/packages/uri-template/src/template/match.ts b/packages/uri-template/src/template/match.ts index d8a2e30cc..20c650164 100644 --- a/packages/uri-template/src/template/match.ts +++ b/packages/uri-template/src/template/match.ts @@ -222,14 +222,7 @@ function* matchUnnamedFrom( const varSpec = vars[varIndex]; for ( - const consumed of consumeUnnamed( - varSpec, - spec, - parts, - partIndex, - vars, - varIndex, - ) + const consumed of consumeUnnamed(varSpec, spec, parts, partIndex) ) { for ( const rest of matchUnnamedFrom( @@ -250,30 +243,18 @@ function* matchUnnamedFrom( /** * Enumerates every (binding, next-part-index) pair produced by letting one - * unnamed variable consume between `minLength` and `maxLength` of the - * remaining parts. - * - * The length range is intentionally broad — neither bound is tightened to the - * exact count of variables remaining after the current one — and the - * recursive matching in {@link matchUnnamedFrom} discards invalid - * distributions. + * unnamed variable consume any number of remaining parts. */ function* consumeUnnamed( varSpec: VarSpec, spec: OperatorSpec, parts: readonly string[], partIndex: number, - vars: readonly VarSpec[], - varIndex: number, ): Generator { if (partIndex >= parts.length) return; const maxLength = parts.length - partIndex; - const minLength = Math.max( - 1, - parts.length - partIndex - remainingVars(vars, varIndex), - ); - for (let length = minLength; length <= maxLength; length++) { + 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 }; @@ -281,11 +262,6 @@ function* consumeUnnamed( } } -const remainingVars = ( - vars: readonly VarSpec[], - varIndex: number, -): number => Math.max(0, vars.length - varIndex - 1); - /** * Yields every binding interpretation of a slice assigned to one unnamed * variable: scalar, comma-list, associative, and (for explode) an From 9b41938fa9cc83c385966bcb316eafd4743f4f8e Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 02:48:53 +0000 Subject: [PATCH 36/49] Fix `defaultReporter` --- packages/uri-template/src/template/template.ts | 9 ++++++--- packages/uri-template/src/types.ts | 5 ++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/uri-template/src/template/template.ts b/packages/uri-template/src/template/template.ts index 7433d3810..0c87aec94 100644 --- a/packages/uri-template/src/template/template.ts +++ b/packages/uri-template/src/template/template.ts @@ -1,3 +1,4 @@ +import { getLogger } from "@logtape/logtape"; import type { ExpandContext, Reporter, @@ -27,11 +28,11 @@ export default class Template { public readonly uriTemplate: string, /** * Options for parsing the template. By default, `strict` is `true` and - * `report` ignores parse and expansion errors. If `strict` is `true`, the + * `report` logs errors using the default logger. If `strict` is `true`, the * first error encountered while parsing or expanding will be automatically * thrown after being reported. If `strict` is `false`, errors will be * reported but none will be thrown unless the `report` function itself - * throws. + * throws. The rest of the part remains as literal text. */ readonly options: Partial = {}, ) { @@ -82,7 +83,9 @@ export default class Template { toString = (): string => this.uriTemplate; } -const defaultReporter: Reporter = (_error: Error) => undefined; +const logger = getLogger(["fedify", "uri-template", "template"]); + +const defaultReporter: Reporter = (error: Error) => logger.error(error); const fillOptions = ( { strict, report }: Partial, diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index b834a0d0b..ba302a054 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -75,11 +75,10 @@ export interface TemplateOptions { strict: boolean; /** * A function that will be called with any errors encountered while parsing - * or expanding. By default, errors are ignored. In strict mode, they are - * still thrown after this reporter runs. + * or expanding. By default, errors are logged using the default logger. + * In strict mode, they are still thrown after this reporter runs. * @param error The error that was encountered while parsing or expanding the * template. - * @returns The result of the report function. */ report: Reporter; } From 5f60402f0281106c531071cd71d29ce3b6cef8fb Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 05:11:55 +0000 Subject: [PATCH 37/49] Tighten identifier path validation in FederationBuilder Run assertPath() ahead of Router.compile() so the `as Path` assertion that bypassed runtime path validation is gone, and switch operator filtering from a denylist to an allowlist that only accepts a literal-prefix path or the path-style `/` operator. `{;identifier}`, `{.identifier}`, `{?identifier}`, `{#identifier}`, and `{+identifier}` are now rejected; regression tests cover the semicolon and label forms. Addresses CodeRabbit review threads on https://github.com/fedify-dev/fedify/pull/758. Assisted-by: Claude Code:claude-opus-4-7 --- packages/fedify/src/federation/builder.test.ts | 12 ++++++++++++ packages/fedify/src/federation/builder.ts | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index 159141650..d8f762931 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -252,6 +252,18 @@ test("FederationBuilder", async (t) => { RouterError, ); + const builder3a = createFederationBuilder(); + assertThrows( + () => builder3a.setOutboxListeners("/users{;identifier}/outbox"), + RouterError, + ); + + const builder3b = createFederationBuilder(); + assertThrows( + () => builder3b.setOutboxListeners("/users{.identifier}/outbox"), + RouterError, + ); + const builder4 = createFederationBuilder(); assertThrows( () => diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index ffa9ad9c4..c013ab174 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -1,4 +1,5 @@ import { + assertPath, isExpression, type Path, Router, @@ -74,7 +75,8 @@ function validateSingleIdentifierVariablePath( path: string, errorMessage: string, ): void { - const pattern = Router.compile(path as Path); + assertPath(path); + const pattern = Router.compile(path); if (pattern.variables.size !== 1 || !pattern.variables.has("identifier")) { throw new RouterError(errorMessage); } @@ -88,7 +90,7 @@ function validateSingleIdentifierVariablePath( const { operator, vars: [varSpec] } = expressions[0]; if ( - ["?", "&", "#"].includes(operator) || + !(operator === "" || operator === "/") || varSpec.explode || varSpec.prefix != null ) { From 1d8e02a2a77c3f384a6fc24be205447d5820b689 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 05:13:58 +0000 Subject: [PATCH 38/49] Accept the empty path so trailing-slash retry can match `/` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the empty string to the `Path` type and let `isPath()` and the fixture path validator accept it. With trailing-slash-insensitive routing, requesting `/` now retries against a route registered as the empty string instead of bailing out. Also rewrite the `assertPath()` error message: the old text was ungrammatical and the `//` example was misleading. The new message names the three accepted shapes — empty, `/`-prefixed, or a `{/var}` path-expansion expression. Addresses CodeRabbit review threads on https://github.com/fedify-dev/fedify/pull/758. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/router/router.test.ts | 12 ++++++++++++ packages/uri-template/src/tests/assert.ts | 2 +- packages/uri-template/src/types.ts | 5 ++++- packages/uri-template/src/utils.ts | 9 ++++++--- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/uri-template/src/router/router.test.ts b/packages/uri-template/src/router/router.test.ts index 9283d69e8..39de22b75 100644 --- a/packages/uri-template/src/router/router.test.ts +++ b/packages/uri-template/src/router/router.test.ts @@ -133,6 +133,18 @@ test("Router accepts pre-parsed RouterPathPattern", async (t) => { }); }); +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(); diff --git a/packages/uri-template/src/tests/assert.ts b/packages/uri-template/src/tests/assert.ts index 99fa56f8e..a9f6ea706 100644 --- a/packages/uri-template/src/tests/assert.ts +++ b/packages/uri-template/src/tests/assert.ts @@ -315,7 +315,7 @@ function assertPath( label: string, ): asserts value is Path { assertString(value, label); - if (!value.startsWith("/") && !/^\{\/[^}]+\}\//.test(value)) { + if (value !== "" && !value.startsWith("/") && !/^\{\/[^}]+\}/.test(value)) { throw new TypeError(`${label} must be a router path`); } } diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index ba302a054..aa9eaf92b 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -3,8 +3,11 @@ import type { Operator } from "./const.ts"; /** * Path-shaped URI Template accepted by the router. + * + * The empty path is accepted so trailing-slash-insensitive routing can retry + * the root path (`/`) as an empty path. */ -export type Path = `/${string}` | `{/${string}}${string}`; +export type Path = "" | `/${string}` | `{/${string}}${string}`; /** * Primitive value accepted by {@link Template.expand}. diff --git a/packages/uri-template/src/utils.ts b/packages/uri-template/src/utils.ts index ad4b2c4c5..09c682117 100644 --- a/packages/uri-template/src/utils.ts +++ b/packages/uri-template/src/utils.ts @@ -14,12 +14,14 @@ export const isLiteral = ( * Returns whether `path` is a path-shaped URI Template accepted by the * router. * - * A path is either a literal string starting with `/`, or a path-expansion - * expression (`{/var}`) followed by a literal segment that starts with `/`. + * A path is either an empty string, a literal string starting with `/`, or a + * path-expansion expression (`{/var}`). * Templates that fail to parse — and therefore could never be routed — * return `false`. */ export function isPath(path: string): path is Path { + if (path === "") return true; + try { const template = new Template(path); @@ -36,7 +38,8 @@ export function isPath(path: string): path is Path { export function assertPath(path: string): asserts path is Path { if (!isPath(path)) { throw new RouterError( - `"${path}" is not looks like a path. Is this start with slash(\`//\`?)`, + `"${path}" is not a router path. It must be empty, start with ` + + "`/`, or start with a expression with slash(`/`) like `{/id}`.", ); } } From 99da76ceeaa8d53040da80cee01760a8442ceb80 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 05:14:24 +0000 Subject: [PATCH 39/49] Polish naming and fixture comments in uri-template tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the helper formerly called `mod` to `div`: it computes `Math.floor(i / j)` (integer division), not a modulo, and the next line that uses `i % 26` for the actual modulo makes the misnomer worse. Also explain in tests/mod.ts that the per-suite `validate(...)` wrapper exists only for JSON fixtures whose inferred type is too broad — `fixedTestSuites` has a concrete enough shape that TypeScript can use it directly without a runtime validator. Addresses CodeRabbit review threads on https://github.com/fedify-dev/fedify/pull/758. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/tests/mod.ts | 2 ++ packages/uri-template/src/tests/template.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/uri-template/src/tests/mod.ts b/packages/uri-template/src/tests/mod.ts index 38e882f05..52d8ff9bc 100644 --- a/packages/uri-template/src/tests/mod.ts +++ b/packages/uri-template/src/tests/mod.ts @@ -1,3 +1,5 @@ +// Add runtime validators only when tsc cannot verify a test suite JSON import +// precisely enough to infer the exported type. import type { Path } from "../types.ts"; import { assertHardTestSuite, diff --git a/packages/uri-template/src/tests/template.ts b/packages/uri-template/src/tests/template.ts index d5f718fcb..b1e0df4b9 100644 --- a/packages/uri-template/src/tests/template.ts +++ b/packages/uri-template/src/tests/template.ts @@ -348,7 +348,7 @@ export function createMatchBench( }; } -const mod = (i: number, j: number) => Math.floor(i / j); +const div = (i: number, j: number) => Math.floor(i / j); export function createMatchBenchTestCases(): readonly string[] { let a = "/A"; @@ -364,7 +364,7 @@ export function createMatchBenchTestCases(): readonly string[] { } for (let i = 0; i < 676; i++) { a += "/" + - String.fromCharCode(0x61 + mod(i, 26)) + + String.fromCharCode(0x61 + div(i, 26)) + String.fromCharCode(0x61 + i % 26); b.push(a); } From 21953077e3aebbd0f27b86f2d01b2a42edbcd9e0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 05:14:56 +0000 Subject: [PATCH 40/49] Centralize expression parse error reporting in the tokenizer In strict mode `parseExpression()` previously called `report(error)` inside `reportExpressionError()` before returning, but the wrapped reporter threw, so the throw propagated to the tokenizer catch block which reported the same error a second time. Drop the intermediate report from `expression.ts` and let the tokenizer's catch path own reporting; the regression test asserts a single invocation for an empty expression in strict mode. Also revert the brief experiment that let `Template#expand()` and `Template#match()` accept per-call options. Options are normalized once at construction (or via `Template.parse()`); the methods now always use the stored full options, and the only-illustrative test that exercised the per-call form is removed. Finally, drop the mixed strict/non-strict `node:assert` import so that template.test.ts uses `node:assert/strict` exclusively. Addresses CodeRabbit review threads on https://github.com/fedify-dev/fedify/pull/758. Assisted-by: Claude Code:claude-opus-4-7 --- .../uri-template/src/template/expression.ts | 17 ++++------- .../src/template/template.test.ts | 30 ++++++++----------- .../uri-template/src/template/template.ts | 16 +++------- packages/uri-template/src/template/token.ts | 2 +- 4 files changed, 24 insertions(+), 41 deletions(-) diff --git a/packages/uri-template/src/template/expression.ts b/packages/uri-template/src/template/expression.ts index 3d382b6a4..d09f0a48d 100644 --- a/packages/uri-template/src/template/expression.ts +++ b/packages/uri-template/src/template/expression.ts @@ -1,5 +1,5 @@ import { OPERATORS } from "../const.ts"; -import type { Operator, TemplateOptions, Token, VarSpec } from "../types.ts"; +import type { Operator, Token, VarSpec } from "../types.ts"; import { isVarcharAt } from "./encoding.ts"; import { EmptyExpressionError, @@ -24,28 +24,23 @@ export default function parseExpression( source: string, template: string, position: number, - { report }: TemplateOptions, ): Token { - const reportExpressionError = (error: Error): Token => { - report(error); - return { - kind: "literal", - text: template.slice(position, position + source.length + 2), - }; + const raiseExpressionError = (error: Error): never => { + throw error; }; if (source.length < 1) { - return reportExpressionError(new EmptyExpressionError(template, position)); + return raiseExpressionError(new EmptyExpressionError(template, position)); } const first = source[0]; if (isReservedOperator(first)) { - return reportExpressionError( + return raiseExpressionError( new ReservedOperatorError(template, position + 1, first), ); } if (!isOperator(first) && isVarcharAt(source, 0) < 1) { - return reportExpressionError( + return raiseExpressionError( new UnknownOperatorError(template, position + 1, first), ); } diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts index 71a722acc..1ea634637 100644 --- a/packages/uri-template/src/template/template.test.ts +++ b/packages/uri-template/src/template/template.test.ts @@ -1,6 +1,5 @@ import { test } from "@fedify/fixture"; -import { deepEqual, equal } from "node:assert"; -import { ok, throws } from "node:assert/strict"; +import { deepEqual, equal, ok, throws } from "node:assert/strict"; import { createFixedTemplateMatchTest, createFixedTemplateTest, @@ -17,6 +16,7 @@ import { wrongTestSuites, } from "../tests/mod.ts"; import { + EmptyExpressionError, InvalidLiteralError, InvalidPrefixError, PrefixModifierNotApplicableError, @@ -58,6 +58,17 @@ test("throws parse errors in strict mode", () => { throws(() => new Template("{var:0}"), InvalidPrefixError); }); +test("reports expression parse errors once in strict mode", () => { + const errors: Error[] = []; + + throws( + () => new Template("{}", { report: (error: Error) => errors.push(error) }), + EmptyExpressionError, + ); + equal(errors.length, 1); + equal(errors[0] instanceof EmptyExpressionError, true); +}); + test("reports parse errors without throwing in non-strict mode", () => { const errors: Error[] = []; const template = new Template("{=bad}/{ok}", { @@ -82,21 +93,6 @@ test("reports expansion errors without throwing in non-strict mode", () => { equal(errors[0] instanceof PrefixModifierNotApplicableError, true); }); -test("uses explicit expand options when provided", () => { - const errors: Error[] = []; - const template = new Template("{list:3}/{ok}"); - - equal( - template.expand( - { list: ["red"], ok: "value" }, - { strict: false, report: (error: Error) => errors.push(error) }, - ), - "/value", - ); - equal(errors.length, 1); - equal(errors[0] instanceof PrefixModifierNotApplicableError, true); -}); - test("parses reusable template instances", () => { const template = Template.parse("/mapper{?address*}"); equal( diff --git a/packages/uri-template/src/template/template.ts b/packages/uri-template/src/template/template.ts index 0c87aec94..36a0bd75e 100644 --- a/packages/uri-template/src/template/template.ts +++ b/packages/uri-template/src/template/template.ts @@ -60,25 +60,17 @@ export default class Template { /** * Expands this template against a variable context. */ - expand: ( + expand: (context: ExpandContext) => string = ( context: ExpandContext, - options?: TemplateOptions, - ) => string = ( - context: ExpandContext, - options: TemplateOptions = this.#fullOptions, - ): string => expand(this.#tokens, context, options); + ): string => expand(this.#tokens, context, this.#fullOptions); /** * Matches a URI against this template, returning the variable context if the * URI matches or `null` if it does not. */ - match: ( - uri: string, - options?: TemplateOptions, - ) => ExpandContext | null = ( + match: (uri: string) => ExpandContext | null = ( uri: string, - options: TemplateOptions = this.#fullOptions, - ): ExpandContext | null => match(this.#tokens, uri, options); + ): ExpandContext | null => match(this.#tokens, uri, this.#fullOptions); toString = (): string => this.uriTemplate; } diff --git a/packages/uri-template/src/template/token.ts b/packages/uri-template/src/template/token.ts index b8ba13094..63f4d1465 100644 --- a/packages/uri-template/src/template/token.ts +++ b/packages/uri-template/src/template/token.ts @@ -50,7 +50,7 @@ export default function tokenize( const expression = template.slice(index + 1, closeIndex); try { - tokens.push(parseExpression(expression, template, index, options)); + tokens.push(parseExpression(expression, template, index)); } catch (error) { report(error instanceof Error ? error : new Error(String(error))); appendLiteral(template.slice(index, closeIndex + 1)); From 880d1e5be0dc1681de24ab219556d69a963fab98 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 06:49:16 +0000 Subject: [PATCH 41/49] Drop stale router trie entries on re-registration `Router.add` and `Router.register` overwrote `#routesByName` but left the previous entry inside the trie, so repeated registration of the same name (HMR, test setup, long-running processes) made `#trie.candidates` traversal cost grow without bound. The runtime filter `#isActiveEntry` masked the symptom without bounding the work. Make re-registration the authoritative cleanup point: - Add `Trie.remove` / `Node.remove` so a registered entry can be detached from the trie in O(node entries). - In `add`, remove the previously committed entry before inserting the new one. - In `register`, track entries pending in the current batch in a `Map`; re-occurrences within the batch are spliced from the pending array (they were never committed), while earlier entries from prior calls are removed from the trie. - Drop the now-redundant `#isActiveEntry` filter from both `Trie.candidates` and `#activeEntries`, shrinking the hot path. Behaviour is covered by a new "Router treats re-registration as replacement" suite that exercises `add`, `register`, intra-batch duplicates, constructor input, repeated re-registration, mixed `add`/`register`, sibling preservation, and `clone`. Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:GPT-5.5 --- packages/uri-template/src/router/node.ts | 6 + .../uri-template/src/router/router.test.ts | 122 ++++++++++++++++++ packages/uri-template/src/router/router.ts | 40 +++--- packages/uri-template/src/router/trie.ts | 20 ++- 4 files changed, 168 insertions(+), 20 deletions(-) diff --git a/packages/uri-template/src/router/node.ts b/packages/uri-template/src/router/node.ts index 0ac79cabc..9cd7833a5 100644 --- a/packages/uri-template/src/router/node.ts +++ b/packages/uri-template/src/router/node.ts @@ -35,6 +35,12 @@ export default class Node { 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); + }; + insertAll = (entries: TEntry[]): void => { if (entries.length === 0) return; if (entries.length === 1) { diff --git a/packages/uri-template/src/router/router.test.ts b/packages/uri-template/src/router/router.test.ts index 39de22b75..c1102175a 100644 --- a/packages/uri-template/src/router/router.test.ts +++ b/packages/uri-template/src/router/router.test.ts @@ -173,6 +173,128 @@ test("Router constructor argument variants", async (t) => { }); }); +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}" as Path, "user"); + router.add("/new/{id}" as Path, "user"); + + equal(router.route("/old/1" as Path), null); + deepEqual(router.route("/new/1" as Path), { + 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}" as Path, "user"], + ["/b/{id}" as Path, "post"], + ]); + router.register([ + ["/c/{id}" as Path, "user"], + ["/d/{id}" as Path, "post"], + ]); + + equal(router.route("/a/1" as Path), null); + equal(router.route("/b/1" as Path), null); + equal(router.route("/c/1" as Path)?.name, "user"); + equal(router.route("/d/1" as Path)?.name, "post"); + }); + + await t.step("register() de-duplicates names within a single call", () => { + const router = new Router(); + router.register([ + ["/a/{id}" as Path, "user"], + ["/b/{id}" as Path, "user"], + ]); + + equal(router.route("/a/1" as Path), null); + deepEqual(router.route("/b/1" as Path), { + 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}" as Path, "user"], + ["/v2/{id}" as Path, "user"], + ]); + + equal(router.route("/v1/1" as Path), null); + equal(router.route("/v2/1" as Path)?.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}` as Path, "user"); + } + + equal(router.route("/v0/1" as Path), null); + equal(router.route("/v25/1" as Path), null); + deepEqual(router.route("/v49/1" as Path), { + 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}" as Path, "a"); + router.add("/old-b/{id}" as Path, "b"); + router.register([ + ["/new-a/{id}" as Path, "a"], + ["/new-b/{id}" as Path, "b"], + ]); + + equal(router.route("/old-a/1" as Path), null); + equal(router.route("/old-b/1" as Path), null); + equal(router.route("/new-a/1" as Path)?.name, "a"); + equal(router.route("/new-b/1" as Path)?.name, "b"); + }, + ); + + await t.step("sibling routes survive re-registration of another name", () => { + const router = new Router(); + router.register([ + ["/users/{id}" as Path, "user"], + ["/posts/{id}" as Path, "post"], + ]); + router.add("/people/{id}" as Path, "user"); + + equal(router.route("/users/1" as Path), null); + equal(router.route("/people/1" as Path)?.name, "user"); + equal(router.route("/posts/9" as Path)?.name, "post"); + }); + + await t.step( + "clone() after re-registration reflects only active routes", + () => { + const router = new Router(); + router.add("/old/{id}" as Path, "user"); + router.add("/new/{id}" as Path, "user"); + + const cloned = router.clone(); + equal(cloned.has("user"), true); + equal(cloned.route("/old/1" as Path), null); + deepEqual(cloned.route("/new/1" as Path), { + name: "user", + template: "/new/{id}", + values: { id: "1" }, + }); + }, + ); +}); + test("Router.from() mirrors the constructor", async (t) => { await t.step("no arguments", () => { const router = Router.from(); diff --git a/packages/uri-template/src/router/router.ts b/packages/uri-template/src/router/router.ts index 494823b6d..8347dd152 100644 --- a/packages/uri-template/src/router/router.ts +++ b/packages/uri-template/src/router/router.ts @@ -180,6 +180,9 @@ export default class Router { */ add = (pathOrPattern: Path | RouterPathPattern, name: string): void => { const pattern = resolvePathPattern(pathOrPattern); + const previous = this.#routesByName.get(name); + if (previous != null) this.#trie.remove(previous); + const entry = createRouteEntry({ index: this.#nextIndex++, name, pattern }); this.#routesByName.set(name, entry); @@ -194,8 +197,18 @@ export default class Router { */ register = (routes: Iterable): void => { const entries: RouteEntry[] = []; + const pendingByName = new Map(); for (const [pathOrPattern, name] of routes) { + const pending = pendingByName.get(name); + if (pending != null) { + const index = entries.indexOf(pending); + if (index >= 0) entries.splice(index, 1); + } else { + const committed = this.#routesByName.get(name); + if (committed != null) this.#trie.remove(committed); + } + const pattern = resolvePathPattern(pathOrPattern); const entry = createRouteEntry({ index: this.#nextIndex++, @@ -204,6 +217,7 @@ export default class Router { }); this.#routesByName.set(name, entry); + pendingByName.set(name, entry); entries.push(entry); } @@ -224,18 +238,8 @@ export default class Router { return retryUrl == null ? null : this.#route(retryUrl); }; - /** - * 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 = (name: string, values: Record): Path | null => - (this.#routesByName.get(name) - ?.pattern.template.expand(values) ?? null) as Path | null; - #route(url: Path): RouterRouteResult | null { - for (const entry of this.#trie.candidates(url, this.#isActiveEntry)) { + for (const entry of this.#trie.candidates(url)) { const context = entry.pattern.template.match(url); if (context == null) continue; @@ -252,14 +256,20 @@ export default class Router { 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 = (name: string, values: Record): Path | null => + (this.#routesByName.get(name) + ?.pattern.template.expand(values) ?? null) as Path | null; + #activeEntries = (): RouterRoute[] => Array.from(this.#routesByName.values()) .sort((left, right) => left.index - right.index) - .filter(this.#isActiveEntry) .map((entry): RouterRoute => [entry.pattern, entry.name]); - - #isActiveEntry = (entry: RouteEntry): boolean => - this.#routesByName.get(entry.name) === entry; } interface CreateRouteEntryOptions { diff --git a/packages/uri-template/src/router/trie.ts b/packages/uri-template/src/router/trie.ts index bc1e78952..4eb27320c 100644 --- a/packages/uri-template/src/router/trie.ts +++ b/packages/uri-template/src/router/trie.ts @@ -26,6 +26,19 @@ export default class Trie { this.#dirty = true; }; + remove = (entry: TEntry): void => { + let node = this.#root; + + for (const char of entry.initialLiteralPrefix) { + const child = node.child(char); + if (child == null) return; + node = child; + } + + node.remove(entry); + this.#dirty = true; + }; + insertAll = (entries: readonly TEntry[]): void => { if (entries.length === 0) return; @@ -48,14 +61,11 @@ export default class Trie { this.#dirty = true; }; - *candidates( - path: Path, - isActive: (entry: TEntry) => boolean, - ): Generator { + *candidates(path: Path): Generator { if (this.#dirty) this.#rebuildCandidates(); for (const entry of this.#deepestNode(path).candidates) { - if (isActive(entry)) yield entry; + yield entry; } } From 04be48bb5bf6d229192e5df0b074ec96bb3a831c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 17:44:08 +0000 Subject: [PATCH 42/49] Serialize Deno check tasks behind install in `mise test` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running `mise run test` was intermittently failing in the install step with `NotFound: Unable to get CWD` while loading the yaml CJS package as part of `@fedify/vocab` codegen. Reproduction with `(deno lint &); deno task install` showed the race is triggered whenever another Deno process starts concurrently with the codegen process. Failure rates measured across stable releases: - Deno 2.6.10: 2/10 - Deno 2.7.7: 3/10 - Deno 2.7.13: 3–5/10 - Deno 2.7.14: 4/10 So neither upgrading nor downgrading Deno avoids it. The root cause is the well-known race in Deno's `nodeModulesDir: "auto"`. When multiple Deno processes start at once they each try to manage `node_modules/.deno/` and the `node_modules/@fedify/*` workspace symlinks (verified with `strace`: deno lint unlinks and recreates these symlinks at startup, and the `.deno.lock.poll` file is also rewritten). Under that race, the CJS loader for `yaml@2.8.3` can hit a state where `Deno.cwd()` returns NotFound from inside `Module._nodeModulePaths`, killing the codegen subprocess. See the related upstream report: https://github.com/denoland/deno/issues/33311 Add `wait_for = ["install"]` to the Deno-based `check:*` tasks (`check:fmt`, `check:lint`, `check:types`, `check-versions`, `check:fixture-usage`). `wait_for` is mise's optional dependency: when `install` is already in the queue (as it is via `prepare` from `test:deno`/`test:node`/`test:bun`), the check tasks wait for it to complete before starting their own Deno processes; when `install` is not queued (e.g. running `mise run check` on its own), the tasks proceed immediately. Empty Deno runs left alone (`check:md`, `check:manifest:workspace-protocol`) are unaffected. With this change the previously flaky reproducer succeeded 10/10 times under the same parallel load. Assisted-by: Claude Code:claude-opus-4-7 --- mise.toml | 5 +++++ 1 file changed, 5 insertions(+) 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"] From c611249def5bc84e5893638ca13b8316062ec999 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 18:30:29 +0000 Subject: [PATCH 43/49] Make @fedify/uri-template default reporter a no-op The package advertises zero runtime dependencies (in README.md, CHANGES.md, and the empty `dependencies`/`imports` fields of *package.json* and *deno.json*), but Template imported `@logtape/logtape` to back the default reporter. That contradicted the claim and caused tsdown to bundle the entire logger into *dist/*. Drop the import and let the default reporter be a no-op. The default `strict: true` still throws after the reporter runs, so error visibility is preserved on the common path. Callers that opt into non-strict mode can pass their own `report` callback (for example, one backed by their application's logger) to observe diagnostics. Assisted-by: Claude Code:claude-opus-4-7 --- packages/uri-template/src/template/template.ts | 15 ++++++--------- packages/uri-template/src/types.ts | 5 +++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/uri-template/src/template/template.ts b/packages/uri-template/src/template/template.ts index 36a0bd75e..70c946ade 100644 --- a/packages/uri-template/src/template/template.ts +++ b/packages/uri-template/src/template/template.ts @@ -1,4 +1,3 @@ -import { getLogger } from "@logtape/logtape"; import type { ExpandContext, Reporter, @@ -28,11 +27,11 @@ export default class Template { public readonly uriTemplate: string, /** * Options for parsing the template. By default, `strict` is `true` and - * `report` logs errors using the default logger. If `strict` is `true`, the - * first error encountered while parsing or expanding will be automatically - * thrown after being reported. If `strict` is `false`, errors will be - * reported but none will be thrown unless the `report` function itself - * throws. The rest of the part remains as literal text. + * `report` is a no-op. If `strict` is `true`, the first error encountered + * while parsing or expanding will be automatically thrown after being + * reported. If `strict` is `false`, errors will be reported but none will + * be thrown unless the `report` function itself throws. The rest of the + * part remains as literal text. */ readonly options: Partial = {}, ) { @@ -75,9 +74,7 @@ export default class Template { toString = (): string => this.uriTemplate; } -const logger = getLogger(["fedify", "uri-template", "template"]); - -const defaultReporter: Reporter = (error: Error) => logger.error(error); +const defaultReporter: Reporter = () => void 0; const fillOptions = ( { strict, report }: Partial, diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts index aa9eaf92b..870f2920d 100644 --- a/packages/uri-template/src/types.ts +++ b/packages/uri-template/src/types.ts @@ -78,8 +78,9 @@ export interface TemplateOptions { strict: boolean; /** * A function that will be called with any errors encountered while parsing - * or expanding. By default, errors are logged using the default logger. - * In strict mode, they are still thrown after this reporter runs. + * or expanding. Defaults to a no-op; pass a callback (for example, one + * backed by your application's logger) to observe diagnostics. In strict + * mode, errors are still thrown after this reporter runs. * @param error The error that was encountered while parsing or expanding the * template. */ From 5e96c8c2020c429d10dc018612800a253b1dc0a0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 11 May 2026 19:33:50 +0000 Subject: [PATCH 44/49] Recover round-trip for associative keys with non-varname characters `isExplodedPairBoundary` was using RFC 6570 varname rules (`isVarcharAt` plus the point-continuation logic in `readPairKeyEnd`) to detect the start of the next exploded associative entry. Associative keys are emitted through `encodeValue` on the expansion side, so the legal key character set is the full unreserved/reserved alphabet, not just varname characters. Round-trip matches against keys like `b-c`, `~x`, or any key containing `-`, `!`, `~`, etc. silently failed: the boundary detector skipped past the next separator and the entire tail of the body was treated as one value, leaving the round-trip check to reject the only decomposition the matcher had reached. Switch the boundary check to a value-driven scan: at each separator, walk forward until the next `=` (boundary) or the next separator (no boundary). The detector still fires only at separator positions, so an `=` inside an `allowReserved` value cannot be mistaken for a key/value split. The now-unused `readPairKeyEnd` helper and the `isVarcharAt` import are removed. Adds a regression test covering `{keys*}` against `a=1,b-c=2`, plus a companion test that pins the parser's rejection of the seven actual 1-byte control characters (NUL/SOH/TAB/LF/CR/US/DEL) via `String.fromCodePoint`. The CTL test documents the boundary that the `wrong.json` fixture *names* but does not exercise (since JSON parses the double-escaped `` sequences in those fixtures to 6-character backslash strings, not control bytes). Reported in PR https://github.com/fedify-dev/fedify/pull/758 (CodeRabbit, 2026-05-11). Assisted-by: Claude Opus 4.7 (1M context) --- packages/uri-template/src/template/match.ts | 27 ++++------------ .../src/template/template.test.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/packages/uri-template/src/template/match.ts b/packages/uri-template/src/template/match.ts index 20c650164..835cc05f8 100644 --- a/packages/uri-template/src/template/match.ts +++ b/packages/uri-template/src/template/match.ts @@ -8,7 +8,7 @@ import type { Token, VarSpec, } from "../types.ts"; -import { encodeName, isVarcharAt, truncateValue } from "./encoding.ts"; +import { encodeName, truncateValue } from "./encoding.ts"; import expand from "./expand.ts"; /** @@ -351,27 +351,12 @@ const isExplodedPairBoundary = ( if (!body.startsWith(separator, index)) return false; const keyStart = index + separator.length; - const keyEnd = readPairKeyEnd(body, keyStart); - return keyEnd > keyStart && body[keyEnd] === "="; -}; - -function readPairKeyEnd(body: string, start: number): number { - let index = start; - let expectVarchar = true; - while (index < body.length) { - const varcharLength = isVarcharAt(body, index); - if (varcharLength > 0) { - index += varcharLength; - expectVarchar = false; - continue; - } - if (body[index] !== ".") break; - if (expectVarchar || isVarcharAt(body, index + 1) < 1) break; - index++; - expectVarchar = true; + for (let i = keyStart; i < body.length; i++) { + if (body[i] === "=") return i > keyStart; + if (body.startsWith(separator, i)) return false; } - return index; -} + return false; +}; /** * Yields candidate readings of a single value string under non-exploded diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts index 1ea634637..b8643ce4f 100644 --- a/packages/uri-template/src/template/template.test.ts +++ b/packages/uri-template/src/template/template.test.ts @@ -129,3 +129,35 @@ test("Template#match — unnamed minLength must allow current var to consume one "matcher should reach the binding with x consuming one part", ); }); + +// Regression for PR #758 review item 36: `expand` encodes associative keys +// with the full unreserved/reserved set, but `isExplodedPairBoundary` in +// match.ts uses RFC 6570 varname rules to detect the next key. Keys that are +// valid in URIs but outside the varname class (e.g. containing `-` or `~`) +// expand cleanly yet fail to round-trip. +test("Template#match — associative keys with non-varname characters round-trip", () => { + const template = new Template("{keys*}"); + const uri = template.expand({ keys: { a: "1", "b-c": "2" } }); + equal(uri, "a=1,b-c=2"); + const m = template.match(uri); + ok(m != null, "matcher returned null for a round-trippable exploded URI"); + deepEqual(m, { keys: { a: "1", "b-c": "2" } }); +}); + +// Regression for PR #758 review item 37: the *wrong.json* fixtures for +// "Invalid characters in literals" store double-escaped sequences such as +// "\\u0000", which JSON-decode to 6-character backslash strings rather than +// the 1-byte control characters they claim to test. The parser does reject +// actual CTL bytes — this test pins that behavior directly, so future +// refactors cannot silently regress on the CTL branch. +test("Template — actual control-character literals throw InvalidLiteralError", () => { + for (const codePoint of [0x00, 0x01, 0x09, 0x0a, 0x0d, 0x1f, 0x7f]) { + throws( + () => new Template(String.fromCodePoint(codePoint)), + InvalidLiteralError, + `code point 0x${ + codePoint.toString(16).padStart(2, "0") + } should be rejected`, + ); + } +}); From 2c1d58acc740d336f617a0737417fd037232d2c8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 12 May 2026 05:19:07 +0000 Subject: [PATCH 45/49] Split identifier path validation into loose and strict asserts `validateSingleIdentifierVariablePath` was applied to every setter that takes a single-identifier path, which tightened upstream's inline `variables.size!==1 || !variables.has("identifier")` check into a stricter one that also rejected the named operators (`{+identifier}`, `{?identifier}`, etc.) and any explode/prefix modifier. That restriction only matched the runtime behaviour the outbox setters historically required; the other eight setters preserved the looser upstream rule and the TS signatures already allowed `Rfc6570Expression<"identifier">`. Split the helper: - `assertIdentifierPath` keeps the upstream rule (one variable named `identifier`, any operator). - `assertStrictIdentifierPath` layers the operator/explode/prefix guard on top by calling `assertIdentifierPath` first. Both helpers carry an `asserts path is Path` predicate so callers get the `Path` narrowing automatically. Apply `assertStrictIdentifierPath` to setOutboxDispatcher and setOutboxListeners (the two setters upstream already routed through the strict centralized validator) and `assertIdentifierPath` everywhere else. All existing "wrong variables in path" tests pass. Addresses the type-vs-runtime mismatch flagged by CodeRabbit in https://github.com/fedify-dev/fedify/pull/758 (2026-05-11). Assisted-by: Claude Opus 4.7 (1M context) --- packages/fedify/src/federation/builder.ts | 36 ++++++++++++++--------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index c013ab174..d307af457 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -71,15 +71,23 @@ import type { export const ACTOR_ALIAS_PREFIX = "actorAlias:"; -function validateSingleIdentifierVariablePath( +function assertIdentifierPath( path: string, errorMessage: string, -): void { +): asserts path is Path { assertPath(path); - const pattern = Router.compile(path); - if (pattern.variables.size !== 1 || !pattern.variables.has("identifier")) { + const variables = Router.variables(path); + if (variables.size !== 1 || !variables.has("identifier")) { throw new RouterError(errorMessage); } +} + +function assertStrictIdentifierPath( + path: string, + errorMessage: string, +): asserts path is Path { + assertIdentifierPath(path, errorMessage); + const pattern = Router.compile(path); const expressions = pattern.template.tokens .filter(isExpression) .filter((token) => token.vars.some(({ name }) => name === "identifier")); @@ -258,7 +266,7 @@ export class FederationBuilderImpl if (this.router.has("actor")) { throw new RouterError("Actor dispatcher already set."); } - validateSingleIdentifierVariablePath( + assertIdentifierPath( path, "Path for actor dispatcher must have one variable: {identifier}", ); @@ -716,7 +724,7 @@ export class FederationBuilderImpl ); } } else { - validateSingleIdentifierVariablePath( + assertIdentifierPath( path, "Path for inbox dispatcher must have one variable: {identifier}", ); @@ -790,7 +798,7 @@ export class FederationBuilderImpl ); } } else { - validateSingleIdentifierVariablePath( + assertStrictIdentifierPath( path, "Path for outbox dispatcher must have one variable: {identifier}", ); @@ -854,7 +862,7 @@ export class FederationBuilderImpl ); } } else { - validateSingleIdentifierVariablePath( + assertStrictIdentifierPath( outboxPath, "Path for outbox must have one variable: {identifier}", ); @@ -905,7 +913,7 @@ export class FederationBuilderImpl if (this.router.has("following")) { throw new RouterError("Following collection dispatcher already set."); } - validateSingleIdentifierVariablePath( + assertIdentifierPath( path, "Path for following collection dispatcher must have one variable: " + "{identifier}", @@ -967,7 +975,7 @@ export class FederationBuilderImpl if (this.router.has("followers")) { throw new RouterError("Followers collection dispatcher already set."); } - validateSingleIdentifierVariablePath( + assertIdentifierPath( path, "Path for followers collection dispatcher must have one variable: " + "{identifier}", @@ -1025,7 +1033,7 @@ export class FederationBuilderImpl if (this.router.has("liked")) { throw new RouterError("Liked collection dispatcher already set."); } - validateSingleIdentifierVariablePath( + assertIdentifierPath( path, "Path for liked collection dispatcher must have one variable: " + "{identifier}", @@ -1091,7 +1099,7 @@ export class FederationBuilderImpl if (this.router.has("featured")) { throw new RouterError("Featured collection dispatcher already set."); } - validateSingleIdentifierVariablePath( + assertIdentifierPath( path, "Path for featured collection dispatcher must have one variable: " + "{identifier}", @@ -1157,7 +1165,7 @@ export class FederationBuilderImpl if (this.router.has("featuredTags")) { throw new RouterError("Featured tags collection dispatcher already set."); } - validateSingleIdentifierVariablePath( + assertIdentifierPath( path, "Path for featured tags collection dispatcher must have one " + "variable: {identifier}", @@ -1221,7 +1229,7 @@ export class FederationBuilderImpl ); } } else { - validateSingleIdentifierVariablePath( + assertIdentifierPath( inboxPath, "Path for inbox must have one variable: {identifier}", ); From 996a371b53d2fb68bf178d0c56d16cb2b8e87185 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 12 May 2026 05:25:09 +0000 Subject: [PATCH 46/49] Exclude old/ compat tests from published @fedify/uri-template The package previously shipped the `old/` directory and pointed package consumers at `deno task test:old`, but those compatibility tests only make sense inside the Fedify monorepo: they import from `old/url-template.test.ts` and `old/uri-template-router.test.ts`, and the `test:old` task lives in the workspace's *deno.json*, not in the published artifact. Drop the directory from both publish paths and rewrite the README pointer so it directs readers to the source repository instead of to a task they cannot run: - `package.json` gains a `files` allowlist of `dist`, `package.json`, and `README.md`, mirroring the `@fedify/postgres` layout. - `deno.json`'s `publish.exclude` adds `old/` alongside the other development-only directories. - *README.md* replaces the `deno task test:old` snippet with a short note that the comparison tests live under *packages/uri-template/old/* in the source repository and are intentionally not part of the published package. Reported by CodeRabbit in https://github.com/fedify-dev/fedify/pull/758 (2026-05-11). Assisted-by: Claude Opus 4.7 (1M context) --- packages/uri-template/README.md | 13 +++++-------- packages/uri-template/deno.json | 1 + packages/uri-template/package.json | 5 +++++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/uri-template/README.md b/packages/uri-template/README.md index 02e54d9d3..3c8cdb599 100644 --- a/packages/uri-template/README.md +++ b/packages/uri-template/README.md @@ -114,14 +114,11 @@ The important differences are: active route entries directly, which keeps the implementation independent and dependency-free at runtime. -The old implementation differences can be checked by running the compatibility -tests in *old/url-template.test.ts* and *old/uri-template-router.test.ts*. -These tests intentionally run Fedify's expected behavior against the old -libraries, so the failing cases show the gaps: - -~~~~ bash -deno task test:old -~~~~ +The concrete differences from the previous [url-template] and +[uri-template-router] libraries are encoded as compatibility tests under +*packages/uri-template/old/* in the package's source repository. Refer to +those tests for behavior comparisons against the older libraries; they are +not shipped in the published package. Features diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json index b1a87213c..8f7262008 100644 --- a/packages/uri-template/deno.json +++ b/packages/uri-template/deno.json @@ -20,6 +20,7 @@ "exclude": [ "**/*.bench.ts", "**/*.test.ts", + "old/", "src/tests/", "summary.txt", "tsdown.config.ts" diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json index ac2aed05a..13a9b963c 100644 --- a/packages/uri-template/package.json +++ b/packages/uri-template/package.json @@ -45,6 +45,11 @@ "test:bun": "bun test src/**/*.test.ts", "test": "node --experimental-transform-types --test src/**/*.test.ts" }, + "files": [ + "dist", + "package.json", + "README.md" + ], "keywords": [ "Fedify", "URI Template", From cc67dd8e5c1b97a7d98048ed0051fff817efa1b1 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 12 May 2026 05:52:13 +0000 Subject: [PATCH 47/49] Reuse PrioritizedRouteEntry as the Trie entry constraint `Trie` declared its own `TrieEntry` interface with the same four fields (`index`, `initialLiteralPrefix`, `literalLength`, `variableCount`) as `PrioritizedRouteEntry` from *./priority.ts*. The duplicate would drift if the priority schema gained a field, since trie callers and priority helpers are already constrained together. Drop the local interface and tighten the class generic to `PrioritizedRouteEntry` so the trie and the ordering helpers share a single source of truth. Reported by CodeRabbit in https://github.com/fedify-dev/fedify/pull/758 (2026-05-11). Assisted-by: Claude Opus 4.7 (1M context) --- packages/uri-template/src/router/trie.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/uri-template/src/router/trie.ts b/packages/uri-template/src/router/trie.ts index 4eb27320c..472836869 100644 --- a/packages/uri-template/src/router/trie.ts +++ b/packages/uri-template/src/router/trie.ts @@ -1,17 +1,11 @@ import type { Path } from "../types.ts"; import Node from "./node.ts"; - -interface TrieEntry { - readonly index: number; - readonly initialLiteralPrefix: string; - readonly literalLength: number; - readonly variableCount: number; -} +import type { PrioritizedRouteEntry } from "./priority.ts"; /** * Prefix trie for registered route candidates. */ -export default class Trie { +export default class Trie { readonly #root = new Node(); #dirty = true; From 679f2c9492701b90ad442979fdbdf8ea7a8bb7a4 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 12 May 2026 05:58:02 +0000 Subject: [PATCH 48/49] Drop unused throw() helper from TemplateParseError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TemplateParseError` exposed a `throw(): never` method that was never called anywhere in the codebase, and the sibling base class `TemplateExpansionError` did not expose the same helper. The asymmetry is dead surface area — `throw error` is idiomatic — so remove the helper rather than mirror it onto the expansion side. Reported by CodeRabbit in https://github.com/fedify-dev/fedify/pull/758 (2026-05-11). Assisted-by: Claude Opus 4.7 (1M context) --- packages/uri-template/src/template/errors.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/uri-template/src/template/errors.ts b/packages/uri-template/src/template/errors.ts index 9bad699a5..13ee05fd4 100644 --- a/packages/uri-template/src/template/errors.ts +++ b/packages/uri-template/src/template/errors.ts @@ -55,9 +55,6 @@ export class TemplateParseError extends Error { super(`${message} (at position ${position}): ${hint}`); this.name = "TemplateParseError"; } - throw(): never { - throw this; - } } /** From 1a1de0debc6cf4dd72af0c50ed00f901077b3841 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 12 May 2026 11:55:44 +0000 Subject: [PATCH 49/49] Note compat Router shim breaking changes in changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #758 review surfaced two compat-shim behaviour changes that are breaking versus the previous `@fedify/fedify` `Router` and were not called out in the changelog: `Router#route()` now calls `assertPath()` and throws `RouterError` for non-path inputs (previously returned `null`), and `trailingSlashInsensitive` is fixed at construction time (post-construction assignments create an inert own property on the wrapper). Both are intentional — the shim is deprecated and its surface is intentionally constrained — but they need a migration note so consumers can either pass options at the constructor or move to `Router` from `@fedify/uri-template`. https://github.com/fedify-dev/fedify/pull/758#discussion_r3224098881 https://github.com/fedify-dev/fedify/pull/758#discussion_r3224098887 Assisted-by: Claude Code:claude-opus-4-7 --- CHANGES.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0a0a20c57..8f87bc4e8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -54,7 +54,16 @@ To be released. - 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]] + available for compatibility. [[#418], [#758] by ChanHaeng Lee] + + - *Breaking change*: Tightened the deprecated `Router` shim from + *@fedify/fedify/federation*: `route()` now throws `RouterError` for + inputs that are not router paths (previously such inputs returned `null` + on non-match), and `trailingSlashInsensitive` is no longer mutable + post-construction (assignments after construction create an inert own + property; the flag must be passed to the constructor). Callers should + validate inputs and pass options at construction, or migrate to `Router` + from *@fedify/uri-template*. [[#418], [#758] by ChanHaeng Lee] [#316]: https://github.com/fedify-dev/fedify/issues/316 [#418]: https://github.com/fedify-dev/fedify/issues/418 @@ -66,6 +75,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 ### @fedify/fixture @@ -85,8 +95,6 @@ To be released. matching. This package replaces Fedify's previous direct use of *url-template* and *uri-template-router*. [[#418], [#758] by ChanHaeng Lee] -[#758]: https://github.com/fedify-dev/fedify/pull/758 - ### @fedify/amqp - Added `AmqpMessageQueue.getDepth()` for reporting queued, ready, and