From 7e9c94636aee6b24f42e6d9bcad59ed490690603 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 9 Aug 2026 18:05:04 +0200 Subject: [PATCH 1/2] feat(routes): let a consumer unregister a single route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes are only released when their module unloads, so anything with a shorter life than its module keeps answering after the thing that registered it is gone — a CMS page unregistered while its module stays loaded still serves its own layout route. The proxy that could do it is exported, but interface resolution rebinds it, so the object a consumer imports is not the one holding the live registry: the handle has to come from here. --- src/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/index.ts b/src/index.ts index 0fd95d1..6a61fab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -718,6 +718,22 @@ export function RegisterRoute(handler: RouteHandler) { return id; } +/** + * Unregister a route registered with {@link RegisterRoute}. + * + * Routes are otherwise only released when their module unloads, so anything + * with a shorter life than its module — a CMS page taken down while the module + * stays up, a controller mounted for the duration of a job — kept answering + * after the thing that registered it was gone. The proxy is not reachable from + * a consumer: interface resolution rebinds it, so the handle has to come from + * here. + * + * @param id Route ID returned by {@link RegisterRoute}. + */ +export function UnregisterRoute(id: number): void { + routesProxy.unregister(id.toString()); +} + /** * Retrieves all registered routes with detailed information. * @returns {Array} An array of route information objects. From 53d61aec3e55130eb8e2211f219da94cfedef8b1 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Mon, 10 Aug 2026 16:21:59 +0200 Subject: [PATCH 2/2] test(routes): cover the route lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cases on the new UnregisterRoute: the id RegisterRoute returned releases exactly that route from the public listing while a bystander keeps serving, with the proxy hearing the string-keyed id; the same id tolerated twice; an id never registered ignored. The cases run against the local build, whose proxy no module attaches in the test harness — the api module binds the harness-distributed copy of the interface. A recording provider keeps the stub-mode proxy from throwing and lets the assertions see what reaches the real registry. --- src/tests/route-lifecycle.test.ts | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/tests/route-lifecycle.test.ts diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts new file mode 100644 index 0000000..c068935 --- /dev/null +++ b/src/tests/route-lifecycle.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert"; +import { + getRegisteredRoutes, + RegisterRoute, + type RouteHandler, + routesProxy, + UnregisterRoute, +} from "../index"; + +function handlerAt(location: string): RouteHandler { + return { + location, + method: "GET", + mode: "handler", + parameters: [], + properties: {}, + proto: {}, + callback: async () => new Response(), + } as unknown as RouteHandler; +} + +function routesAt(location: string): number { + return getRegisteredRoutes().filter((route) => route.location === location) + .length; +} + +describe("Route lifecycle", () => { + // These run against the local build, whose proxy no module attaches in the + // test harness (the api module binds the harness-distributed copy). A + // recording provider both keeps the stub-mode proxy from throwing and lets + // the cases assert what reaches the real registry. + const proxyCalls: Array<{ kind: "register" | "unregister"; id: string }> = []; + + before(() => { + routesProxy.onRegister((id: string) => { + proxyCalls.push({ kind: "register", id }); + }, true); + routesProxy.onUnregister((id: string) => { + proxyCalls.push({ kind: "unregister", id }); + }); + }); + + beforeEach(() => { + proxyCalls.length = 0; + }); + + it("releases a route by the id RegisterRoute returned", () => { + const id = RegisterRoute(handlerAt("/lifecycle/released")); + const bystander = RegisterRoute(handlerAt("/lifecycle/bystander")); + assert.equal(routesAt("/lifecycle/released"), 1); + + UnregisterRoute(id); + + // Gone from the public listing, and only the targeted route: the + // bystander keeps serving. + assert.equal(routesAt("/lifecycle/released"), 0); + assert.equal(routesAt("/lifecycle/bystander"), 1); + // The proxy heard the unregister under the id RegisterRoute handed out, + // as a string — the registry is keyed that way. + assert.deepEqual(proxyCalls.at(-1), { + kind: "unregister", + id: id.toString(), + }); + UnregisterRoute(bystander); + assert.equal(routesAt("/lifecycle/bystander"), 0); + }); + + it("tolerates unregistering the same id twice", () => { + const id = RegisterRoute(handlerAt("/lifecycle/twice")); + UnregisterRoute(id); + + assert.doesNotThrow(() => UnregisterRoute(id)); + assert.equal(routesAt("/lifecycle/twice"), 0); + }); + + it("ignores an id that was never registered", () => { + const before = getRegisteredRoutes().length; + + assert.doesNotThrow(() => UnregisterRoute(999_999)); + + assert.equal(getRegisteredRoutes().length, before); + }); +});