Skip to content

Commit e804c0d

Browse files
committed
Make registered router patterns immutable
clone() shared the same RouterPathPattern objects through #activeEntries(), and Template.match was a writable property, so a caller could compile a pattern, register it, clone the router, then mutate the pattern/template and change routing in both routers. This is the snapshot boundary FederationBuilder.build() relies on. Rather than make the router own a private copy of every compiled route expression, freeze the compiled artifact itself so a single shared instance is safe to hand out: - Router.compile() returns an Object.freeze()d RouterPathPattern. - Template freezes itself and its token stream (tokens/vars/VarSpec deep-frozen); expand/match/toString are readonly and the instance is frozen, so the entry points cannot be reassigned. - Token and VarSpec are readonly in the type surface; tokenize() replaces the trailing literal instead of mutating it in place. - Router.compile().variables is an ImmutableSet that throws on mutation. Add Router.clone() isolation and immutability tests, and a FederationBuilder snapshot test asserting build() does not keep observing later builder mutations and that two builds do not share mutable route state. #758 (comment) #758 (comment) #758 (comment) Assisted-by: Claude Code:claude-opus-4-7
1 parent 2048900 commit e804c0d

7 files changed

Lines changed: 200 additions & 25 deletions

File tree

packages/fedify/src/federation/builder.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,31 @@ test("FederationBuilder", async (t) => {
160160
},
161161
);
162162

163+
await t.step("should snapshot router state on build", async () => {
164+
const builder = createFederationBuilder<void>();
165+
const kv = new MemoryKvStore();
166+
const noteRouteName = `object:${Note.typeId.href}`;
167+
168+
builder.setActorDispatcher("/users/{identifier}", () => null);
169+
const federation1 = await builder.build({ kv });
170+
const impl1 = federation1 as FederationImpl<void>;
171+
172+
builder.setObjectDispatcher(Note, "/notes/{id}", () => null);
173+
assertEquals(impl1.router.route("/notes/1"), null);
174+
175+
const federation2 = await builder.build({ kv });
176+
const impl2 = federation2 as FederationImpl<void>;
177+
assertEquals(impl2.router.route("/notes/1")?.name, noteRouteName);
178+
179+
impl1.router.add("/leaked/{id}", "leaked");
180+
assertEquals(impl1.router.route("/leaked/1")?.name, "leaked");
181+
assertEquals(impl2.router.route("/leaked/1"), null);
182+
183+
const federation3 = await builder.build({ kv });
184+
const impl3 = federation3 as FederationImpl<void>;
185+
assertEquals(impl3.router.route("/leaked/1"), null);
186+
});
187+
163188
await t.step("should build with default options", async () => {
164189
const builder = createFederationBuilder<void>();
165190
const kv = new MemoryKvStore();

packages/uri-template/src/router/router.test.ts

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test } from "@fedify/fixture";
22
import { deepEqual, equal, throws } from "node:assert/strict";
3+
import type Template from "../template/template.ts";
34
import {
45
createRouterAddTest,
56
createRouterBuildTest,
@@ -64,11 +65,22 @@ const createCountingPattern = (
6465
): RouterPathPattern => {
6566
const pattern = Router.compile(path);
6667
const match = pattern.template.match;
67-
pattern.template.match = (uri: string): ExpandContext | null => {
68-
calls.set(path, (calls.get(path) ?? 0) + 1);
69-
return match(uri);
68+
const template = {
69+
get tokens(): typeof pattern.template.tokens {
70+
return pattern.template.tokens;
71+
},
72+
expand: pattern.template.expand,
73+
match: (uri: string): ExpandContext | null => {
74+
calls.set(path, (calls.get(path) ?? 0) + 1);
75+
return match(uri);
76+
},
77+
toString: pattern.template.toString,
78+
} as unknown as Template;
79+
return {
80+
path: pattern.path,
81+
template,
82+
variables: pattern.variables,
7083
};
71-
return pattern;
7284
};
7385

7486
test("Router indexes shared dynamic prefixes before template matching", () => {
@@ -219,6 +231,93 @@ test("Router accepts pre-parsed RouterPathPattern", async (t) => {
219231
});
220232
});
221233

234+
test("Router.compile() returns immutable path patterns", async (t) => {
235+
const pattern = Router.compile("/items/{id}");
236+
const router = new Router([[pattern, "item"]]);
237+
238+
await t.step("blocks Template entry point reassignment", () => {
239+
throws(() => {
240+
(pattern.template as {
241+
match: (uri: string) => ExpandContext | null;
242+
}).match = () => null;
243+
}, TypeError);
244+
throws(() => {
245+
(pattern.template as {
246+
expand: (context: ExpandContext) => string;
247+
}).expand = () => "/changed";
248+
}, TypeError);
249+
});
250+
251+
await t.step("blocks RouterPathPattern wrapper reassignment", () => {
252+
const otherTemplate = Router.compile("/other/{id}").template;
253+
throws(() => {
254+
(pattern as { path: Path }).path = "/other/{id}";
255+
}, TypeError);
256+
throws(() => {
257+
(pattern as { template: typeof otherTemplate }).template = otherTemplate;
258+
}, TypeError);
259+
});
260+
261+
await t.step("blocks variables mutation", () => {
262+
throws(() => {
263+
(pattern.variables as Set<string>).add("unexpected");
264+
}, TypeError);
265+
equal(pattern.variables.has("unexpected"), false);
266+
equal(pattern.variables.size, 1);
267+
});
268+
269+
await t.step("keeps registered routing behavior unchanged", () => {
270+
deepEqual(router.route("/items/9"), {
271+
name: "item",
272+
template: "/items/{id}",
273+
values: { id: "9" },
274+
});
275+
equal(router.build("item", { id: "9" }), "/items/9");
276+
});
277+
});
278+
279+
test("Router.clone() isolates route sets with shared pre-parsed patterns", () => {
280+
const pattern = Router.compile("/items/{id}");
281+
const original = new Router([[pattern, "item"]]);
282+
const clone = original.clone();
283+
const itemRoute = {
284+
name: "item",
285+
template: "/items/{id}",
286+
values: { id: "9" },
287+
};
288+
289+
deepEqual(original.route("/items/9"), itemRoute);
290+
deepEqual(clone.route("/items/9"), itemRoute);
291+
292+
original.add("/posts/{postId}", "post");
293+
equal(clone.route("/posts/3"), null);
294+
deepEqual(original.route("/posts/3"), {
295+
name: "post",
296+
template: "/posts/{postId}",
297+
values: { postId: "3" },
298+
});
299+
300+
clone.add("/users/{userId}", "user");
301+
equal(original.route("/users/5"), null);
302+
deepEqual(clone.route("/users/5"), {
303+
name: "user",
304+
template: "/users/{userId}",
305+
values: { userId: "5" },
306+
});
307+
308+
original.add("/people/{id}", "item");
309+
equal(original.route("/items/9"), null);
310+
deepEqual(original.route("/people/9"), {
311+
name: "item",
312+
template: "/people/{id}",
313+
values: { id: "9" },
314+
});
315+
equal(original.build("item", { id: "9" }), "/people/9");
316+
deepEqual(clone.route("/items/9"), itemRoute);
317+
equal(clone.route("/people/9"), null);
318+
equal(clone.build("item", { id: "9" }), "/items/9");
319+
});
320+
222321
test("Router trailing slash retry accepts empty root path", () => {
223322
const router = new Router([["", "root"]], {
224323
trailingSlashInsensitive: true,

packages/uri-template/src/router/router.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export interface RouterRouteResult {
3636

3737
/**
3838
* Parsed path template ready to be registered in a {@link Router}.
39+
*
40+
* Instances returned by {@link Router.compile} are immutable and may be shared
41+
* safely between routers and router clones.
3942
*/
4043
export interface RouterPathPattern {
4144
/**
@@ -143,11 +146,11 @@ export default class Router {
143146
}
144147

145148
const template = Template.parse(path);
146-
return {
149+
return Object.freeze({
147150
path,
148151
template,
149152
variables: collectVariables(template.tokens),
150-
};
153+
});
151154
}
152155

153156
/**
@@ -259,9 +262,10 @@ export default class Router {
259262
?.pattern.template.expand(values) ?? null) as Path | null;
260263

261264
/**
262-
* Creates a shallow clone of the router. The clone shares the same route
263-
* entries, but changes to the route set (adding, removing, or re-registering
264-
* routes) do not affect the other router.
265+
* Creates a shallow clone of the router. The clone shares immutable
266+
* registered path patterns with the original, but changes to the route set
267+
* (adding, removing, or re-registering routes) do not affect the other
268+
* router.
265269
* @returns A new router with the same routes and options as this one.
266270
*/
267271
clone = (): Router =>
@@ -311,13 +315,32 @@ const isRoutesArgument = (
311315
const toggleTrailingSlash = (path: Path): Path =>
312316
path.endsWith("/") ? (path.replace(/\/+$/, "") as Path) : `${path}/`;
313317

314-
const collectVariables = (tokens: readonly Token[]): Set<string> =>
315-
new Set(
318+
const collectVariables = (tokens: readonly Token[]): ImmutableSet<string> =>
319+
new ImmutableSet(
316320
tokens
317321
.filter(isExpression)
318322
.flatMap((token) => token.vars.map((varSpec) => varSpec.name)),
319323
);
320324

325+
class ImmutableSet<T> extends Set<T> implements ReadonlySet<T> {
326+
constructor(values?: Iterable<T>) {
327+
super();
328+
if (values != null) { for (const value of values) super.add(value); }
329+
}
330+
331+
override add(_value: T): this {
332+
throw new TypeError("ImmutableSet cannot be mutated.");
333+
}
334+
335+
override delete(_value: T): boolean {
336+
throw new TypeError("ImmutableSet cannot be mutated.");
337+
}
338+
339+
override clear(): void {
340+
throw new TypeError("ImmutableSet cannot be mutated.");
341+
}
342+
}
343+
321344
const getInitialLiteralPrefix = (tokens: readonly Token[]): string =>
322345
tokens[0] != null && isLiteral(tokens[0]) ? tokens[0].text : "";
323346

packages/uri-template/src/template/expand.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default function expand(
2828
}
2929

3030
function expandExpressions(
31-
vars: VarSpec[],
31+
vars: readonly VarSpec[],
3232
operator: keyof typeof operatorSpecs,
3333
context: ExpandContext,
3434
options: TemplateOptions,

packages/uri-template/src/template/template.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
Reporter,
44
TemplateOptions,
55
Token,
6+
VarSpec,
67
} from "../types.ts";
78
import expand from "./expand.ts";
89
import match from "./match.ts";
@@ -12,10 +13,10 @@ import tokenize from "./token.ts";
1213
* Parsed RFC 6570 URI Template that can be expanded repeatedly.
1314
*
1415
* This class owns tokenization and delegates expression expansion to the
15-
* expansion module.
16+
* expansion module. Instances are immutable after construction.
1617
*/
1718
export default class Template {
18-
readonly #tokens: Token[];
19+
readonly #tokens: readonly Token[];
1920
readonly #fullOptions: TemplateOptions;
2021

2122
constructor(
@@ -36,7 +37,8 @@ export default class Template {
3637
readonly options: Partial<TemplateOptions> = {},
3738
) {
3839
this.#fullOptions = fillOptions(options);
39-
this.#tokens = tokenize(uriTemplate, this.#fullOptions);
40+
this.#tokens = freezeTokens(tokenize(uriTemplate, this.#fullOptions));
41+
Object.freeze(this);
4042
}
4143

4244
/**
@@ -50,7 +52,7 @@ export default class Template {
5052
}
5153

5254
/**
53-
* Parsed token stream for diagnostics and router integration.
55+
* Immutable parsed token stream for diagnostics and router integration.
5456
*/
5557
get tokens(): readonly Token[] {
5658
return this.#tokens;
@@ -59,21 +61,40 @@ export default class Template {
5961
/**
6062
* Expands this template against a variable context.
6163
*/
62-
expand: (context: ExpandContext) => string = (
64+
readonly expand: (context: ExpandContext) => string = (
6365
context: ExpandContext,
6466
): string => expand(this.#tokens, context, this.#fullOptions);
6567

6668
/**
6769
* Matches a URI against this template, returning the variable context if the
6870
* URI matches or `null` if it does not.
6971
*/
70-
match: (uri: string) => ExpandContext | null = (
72+
readonly match: (uri: string) => ExpandContext | null = (
7173
uri: string,
7274
): ExpandContext | null => match(this.#tokens, uri, this.#fullOptions);
7375

74-
toString = (): string => this.uriTemplate;
76+
readonly toString = (): string => this.uriTemplate;
7577
}
7678

79+
const freezeTokens = (tokens: Token[]): readonly Token[] =>
80+
Object.freeze(tokens.map(freezeToken));
81+
82+
const freezeToken = (token: Token): Token =>
83+
token.kind === "literal"
84+
? Object.freeze({ kind: "literal", text: token.text })
85+
: Object.freeze({
86+
kind: "expression",
87+
operator: token.operator,
88+
vars: Object.freeze(token.vars.map(freezeVarSpec)),
89+
});
90+
91+
const freezeVarSpec = (varSpec: VarSpec): VarSpec =>
92+
Object.freeze({
93+
name: varSpec.name,
94+
explode: varSpec.explode,
95+
...(varSpec.prefix == null ? {} : { prefix: varSpec.prefix }),
96+
});
97+
7798
const defaultReporter: Reporter = () => void 0;
7899

79100
const fillOptions = (

packages/uri-template/src/template/token.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export default function tokenize(
2323
const appendLiteral = (text: string): void => {
2424
const previous = tokens.at(-1);
2525
if (previous?.kind === "literal") {
26-
previous.text += text;
26+
tokens[tokens.length - 1] = {
27+
kind: "literal",
28+
text: previous.text + text,
29+
};
2730
} else {
2831
tokens.push({ kind: "literal", text });
2932
}

packages/uri-template/src/types.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@ export type ExpandContext = Record<string, ExpandValue>;
4848
*/
4949
export interface VarSpec {
5050
/** Variable name to look up in the expansion context. */
51-
name: string;
51+
readonly name: string;
5252
/** Whether the varspec uses the Level 4 explode modifier (`*`). */
53-
explode: boolean;
53+
readonly explode: boolean;
5454
/** Prefix length from a Level 4 prefix modifier (`:N`), if present. */
55-
prefix?: number;
55+
readonly prefix?: number;
5656
}
5757

5858
/**
@@ -62,8 +62,12 @@ export interface VarSpec {
6262
* context object.
6363
*/
6464
export type Token =
65-
| { kind: "literal"; text: string }
66-
| { kind: "expression"; operator: Operator; vars: VarSpec[] };
65+
| { readonly kind: "literal"; readonly text: string }
66+
| {
67+
readonly kind: "expression";
68+
readonly operator: Operator;
69+
readonly vars: readonly VarSpec[];
70+
};
6771

6872
/**
6973
* Options controlling URI Template parsing and expansion diagnostics.

0 commit comments

Comments
 (0)