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. 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); + }); +});