diff --git a/package.json b/package.json index c858e31..d8fe37c 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ ] }, "devDependencies": { - "@antelopejs/interface-core": ">=0.0.3 <1.0.0", + "@antelopejs/interface-core": ">=0.0.13 <1.0.0", "@biomejs/biome": "2.3.2", "@types/mocha": "^10.0.10", "@types/node": "^22.19.15", @@ -59,7 +59,7 @@ "ws": "^8.20.0" }, "peerDependencies": { - "@antelopejs/interface-core": ">=0.0.3 <1.0.0" + "@antelopejs/interface-core": ">=0.0.13 <1.0.0" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f49da8..40a2eeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@antelopejs/interface-core': - specifier: '>=0.0.3 <1.0.0' - version: 0.0.3 + specifier: '>=0.0.13 <1.0.0' + version: 0.0.13 '@biomejs/biome': specifier: 2.3.2 version: 2.3.2 @@ -47,8 +47,8 @@ importers: packages: - '@antelopejs/interface-core@0.0.3': - resolution: {integrity: sha512-Kw3ffGiQHKJ88AqdFfPuwzl+v0w6QqzqiqnLY7M0MYYw+YwdRNXh5WAJkep0ePudR9leLGXPU//FYizNaDG6yw==} + '@antelopejs/interface-core@0.0.13': + resolution: {integrity: sha512-/QdBq0jcEqQtjkzdZkmmkjn76T8RqiM9ZTFlh8+O6N3vxGW+UxJAGF9ZEJzAhOgtzuLDtnhSLrIYuEZmYQJH8g==} '@biomejs/biome@2.3.2': resolution: {integrity: sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==} @@ -1174,7 +1174,7 @@ packages: snapshots: - '@antelopejs/interface-core@0.0.3': + '@antelopejs/interface-core@0.0.13': dependencies: reflect-metadata: 0.2.2 diff --git a/src/antelope.test.ts b/src/antelope.test.ts index 2fff732..2bb5499 100644 --- a/src/antelope.test.ts +++ b/src/antelope.test.ts @@ -8,7 +8,7 @@ export default defineConfig({ source: { type: "package", package: "@antelopejs/api", - version: "1.0.0", + version: "1.2.4", }, config: { servers: [ diff --git a/src/index.ts b/src/index.ts index 6633449..b0f344a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,16 +12,16 @@ import { MakeParameterAndPropertyDecorator, MakeParameterDecorator, } from "@antelopejs/interface-core/decorators"; +import type { InterfaceFacadeScope } from "@antelopejs/interface-core/facades"; import { Logging } from "@antelopejs/interface-core/logging"; +import { GetModuleContext } from "@antelopejs/interface-core/modules"; -/** - * @internal - */ -export namespace internal { - export const routesProxy = new RegisteringProxy< +/** @internal */ +export const internal = {} as { + readonly routesProxy: RegisteringProxy< (id: string, handler: RouteHandler) => void - >(); -} + >; +}; export type ControllerClass> = Class & { /** @@ -692,12 +692,122 @@ export interface RouteHandler { module?: string; } +/** + * Observer for complete registered route handlers and their removal. + */ +export interface RegisteredRoutesObserver { + /** + * Receives a route registration. + * + * @param id Route identifier. + * @param handler Complete registered route handler. + */ + onRegister(id: string, handler: RouteHandler): void; + + /** + * Receives a route removal. + * + * @param id Route identifier. + */ + onUnregister(id: string): void; +} + +type RegisteredRoutesNotification = ( + observer: RegisteredRoutesObserver, +) => void; + +const REGISTERED_ROUTES_OBSERVER_ERROR = "Registered routes observer failed"; + /** * Registered route handlers indexed by their proxy id, mirroring the entries * held by {@link routesProxy}. Pruned by {@link RoutesProxy} so stale handlers * do not accumulate across module reloads. */ const routesList = new Map(); +const routeOwners = new Map(); +const registeredRoutesObservers = new Map(); + +function notifyRegisteredRoutesObserver( + observer: RegisteredRoutesObserver, + notification: RegisteredRoutesNotification, +): void { + try { + notification(observer); + } catch (error) { + Logging.Error(REGISTERED_ROUTES_OBSERVER_ERROR, error); + } +} + +function notifyRegisteredRoutesObservers( + notification: RegisteredRoutesNotification, +): void { + for (const [observer, subscription] of Array.from( + registeredRoutesObservers, + )) { + if (registeredRoutesObservers.get(observer) !== subscription) { + continue; + } + notifyRegisteredRoutesObserver(observer, notification); + } +} + +function notifyRouteRegistered(id: string, handler: RouteHandler): void { + notifyRegisteredRoutesObservers((observer) => { + if (routesList.get(id) === handler) { + observer.onRegister(id, handler); + } + }); +} + +function notifyRouteUnregistered(id: string): void { + notifyRegisteredRoutesObservers((observer) => observer.onUnregister(id)); +} + +function createRegisteredRoutesUnsubscribe( + observer: RegisteredRoutesObserver, + subscription: symbol, +): () => void { + return () => { + if (registeredRoutesObservers.get(observer) === subscription) { + registeredRoutesObservers.delete(observer); + } + }; +} + +/** + * Observes complete registered route handlers. + * + * Routes that already exist are replayed synchronously before this function + * returns. Later registrations and removals are multicast to every subscribed + * observer. Observer errors are logged without interrupting replay, other + * observers, or route lifecycle operations. Repeated calls with the same + * observer share one active subscription. + * + * @param observer Route lifecycle observer. + * @returns An idempotent function that stops future notifications. + */ +export function ObserveRegisteredRoutes( + observer: RegisteredRoutesObserver, +): () => void { + const existingSubscription = registeredRoutesObservers.get(observer); + if (existingSubscription) { + return createRegisteredRoutesUnsubscribe(observer, existingSubscription); + } + const subscription = Symbol(); + registeredRoutesObservers.set(observer, subscription); + for (const [id, handler] of Array.from(routesList)) { + if (registeredRoutesObservers.get(observer) !== subscription) { + break; + } + if (routesList.get(id) !== handler) { + continue; + } + notifyRegisteredRoutesObserver(observer, (current) => + current.onRegister(id, handler), + ); + } + return createRegisteredRoutesUnsubscribe(observer, subscription); +} /** * RegisteringProxy that also prunes {@link routesList} on the same lifecycle @@ -709,17 +819,45 @@ class RoutesProxy extends RegisteringProxy< (id: string, handler: RouteHandler) => void > { override unregister(id: string) { - routesList.delete(id); - super.unregister(id); + const wasRegistered = routesList.delete(id); + routeOwners.delete(id); + try { + super.unregister(id); + } finally { + if (wasRegistered) { + notifyRouteUnregistered(id); + } + } } override unregisterModule(mod: string) { - for (const [id, handler] of routesList) { - if (handler.module === mod) { - routesList.delete(id); - } + this.unregisterRoutes( + Array.from(routesList) + .filter(([, handler]) => handler.module === mod) + .map(([id]) => id), + () => super.unregisterModule(mod), + ); + } + + override unregisterOwner(owner: string) { + this.unregisterRoutes( + Array.from(routeOwners) + .filter(([, routeOwner]) => routeOwner === owner) + .map(([id]) => id), + () => super.unregisterOwner(owner), + ); + } + + private unregisterRoutes(ids: string[], unregister: () => void) { + ids.forEach((id) => { + routesList.delete(id); + routeOwners.delete(id); + }); + try { + unregister(); + } finally { + ids.forEach(notifyRouteUnregistered); } - super.unregisterModule(mod); } } @@ -729,6 +867,10 @@ class RoutesProxy extends RegisteringProxy< export const routesProxy: RegisteringProxy< (id: string, handler: RouteHandler) => void > = new RoutesProxy(); +Object.defineProperty(internal, "routesProxy", { + enumerable: false, + value: routesProxy, +}); let nextId = 0; /** * Register a RouteHandler to the API. @@ -738,17 +880,17 @@ let nextId = 0; */ export function RegisterRoute(handler: RouteHandler) { const id = nextId++; - // Resolve the owning module here, while the registering controller's frame is - // still on the stack (RegisterRoute runs synchronously during module load). - // Enrich a shallow copy rather than mutating the caller's handler object, so - // onRegister subscribers and getRegisteredRoutes both see `module` without the - // input object gaining an unexpected property. - const enriched: RouteHandler = { ...handler, module: GetResponsibleModule() }; + const context = GetModuleContext(); + const module = context?.module ?? GetResponsibleModule(); + const owner = context?.owner ?? module; + const enriched: RouteHandler = { ...handler, module }; Logging.Debug( `Registered ${enriched.method.toUpperCase()} ${enriched.location} (${enriched.callback.name || "anonymous"})`, ); routesProxy.register(id.toString(), enriched); routesList.set(id.toString(), enriched); + routeOwners.set(id.toString(), owner); + notifyRouteRegistered(id.toString(), enriched); return id; } @@ -1573,3 +1715,34 @@ export const MultiParameter = MakeParameterAndPropertyDecorator( }); }, ); + +type DecoratorFactory = (...args: any[]) => (...args: any[]) => unknown; + +function bindDecoratorFactory( + scope: InterfaceFacadeScope, + factory: T, +): T { + return ((...factoryArgs: Parameters) => { + const decorator = factory(...factoryArgs); + return (...decoratorArgs: Parameters>) => + scope.run(() => decorator(...decoratorArgs)); + }) as T; +} + +/** @internal */ +export function BuildInterfaceFacade(scope: InterfaceFacadeScope) { + return { + RegisterRoute: (handler: RouteHandler) => + scope.run(() => RegisterRoute(handler)), + UnregisterRoute: (id: number) => scope.run(() => UnregisterRoute(id)), + Route: bindDecoratorFactory(scope, Route), + Delete: bindDecoratorFactory(scope, Delete), + Get: bindDecoratorFactory(scope, Get), + Post: bindDecoratorFactory(scope, Post), + Put: bindDecoratorFactory(scope, Put), + Prefix: bindDecoratorFactory(scope, Prefix), + Postfix: bindDecoratorFactory(scope, Postfix), + Monitor: bindDecoratorFactory(scope, Monitor), + WebsocketHandler: bindDecoratorFactory(scope, WebsocketHandler), + }; +} diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index c068935..5a73656 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -1,12 +1,83 @@ import assert from "node:assert"; +import { GetInterfaceProxyIdentity } from "@antelopejs/interface-core"; +import { CreateInterfaceFacade } from "@antelopejs/interface-core/facades"; import { + type ModuleExecutionContext, + RunWithModuleContext, +} from "@antelopejs/interface-core/modules"; +import * as Api from "../index"; +import { + type ComputedParameter, getRegisteredRoutes, + HandlerPriority, + ObserveRegisteredRoutes, + type RegisteredRoutesObserver, RegisterRoute, type RouteHandler, routesProxy, UnregisterRoute, } from "../index"; +interface RegisteredRouteEvent { + handler: RouteHandler; + id: string; +} + +interface ProxyCall { + handler?: RouteHandler; + id: string; + kind: "register" | "unregister"; +} + +type RouteRegistrationCallback = (id: string, handler: RouteHandler) => void; + +class RecordingRoutesObserver implements RegisteredRoutesObserver { + registered: RegisteredRouteEvent[] = []; + unregistered: string[] = []; + + onRegister(id: string, handler: RouteHandler): void { + this.registered.push({ id, handler }); + } + + onUnregister(id: string): void { + this.unregistered.push(id); + } + + clear(): void { + this.registered.length = 0; + this.unregistered.length = 0; + } +} + +class CallbackRoutesObserver extends RecordingRoutesObserver { + constructor(private readonly callback: RouteRegistrationCallback) { + super(); + } + + override onRegister(id: string, handler: RouteHandler): void { + super.onRegister(id, handler); + this.callback(id, handler); + } +} + +class ThrowingRoutesObserver implements RegisteredRoutesObserver { + isThrowing = false; + + onRegister(): void { + if (this.isThrowing) { + throw new Error("Register observer failure"); + } + } + + onUnregister(): void { + if (this.isThrowing) { + throw new Error("Unregister observer failure"); + } + } +} + +const proxyCalls: ProxyCall[] = []; + function handlerAt(location: string): RouteHandler { return { location, @@ -19,6 +90,25 @@ function handlerAt(location: string): RouteHandler { } as unknown as RouteHandler; } +function completeHandlerAt(location: string): RouteHandler { + const parameter: ComputedParameter = { + provider: () => "parameter", + modifiers: [], + }; + return { + location, + method: "POST", + mode: "prefix", + parameters: [parameter, null], + properties: { property: parameter }, + proto: { controller: location }, + callback: function completeRouteCallback() { + return location; + }, + priority: HandlerPriority.HIGH, + }; +} + function routesAt(location: string): number { return getRegisteredRoutes().filter((route) => route.location === location) .length; @@ -29,11 +119,9 @@ describe("Route lifecycle", () => { // 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 }); + routesProxy.onRegister((id: string, handler: RouteHandler) => { + proxyCalls.push({ kind: "register", id, handler }); }, true); routesProxy.onUnregister((id: string) => { proxyCalls.push({ kind: "unregister", id }); @@ -81,3 +169,330 @@ describe("Route lifecycle", () => { assert.equal(getRegisteredRoutes().length, before); }); }); + +describe("Interface facade route ownership", () => { + const proxyIdentity = GetInterfaceProxyIdentity(routesProxy); + + it("keeps the legacy internal route proxy as a canonical alias", () => { + assert.strictEqual(Api.internal.routesProxy, routesProxy); + assert.equal(Object.keys(Api.internal).includes("routesProxy"), false); + }); + + function providerContext(provider: string): ModuleExecutionContext { + return { + module: provider, + owner: `${provider}#1`, + provider, + }; + } + + function consumerContext(owner: string, provider: string) { + assert(proxyIdentity); + return { + module: "facade-consumer", + owner, + providerRoutes: { [proxyIdentity]: provider }, + }; + } + + function applyGet( + api: typeof Api, + location: string, + callback: () => string, + ): void { + const target = { callback }; + const descriptor = Object.getOwnPropertyDescriptor(target, "callback"); + assert(descriptor); + api.Get(location)(target, "callback", descriptor); + } + + it("registers unchanged callbacks with the provider selected per generation", () => { + assert(proxyIdentity); + const registeredA: RegisteredRouteEvent[] = []; + const registeredB: RegisteredRouteEvent[] = []; + const unregisteredA: string[] = []; + const unregisteredB: string[] = []; + const leaseA = RunWithModuleContext(providerContext("api-provider-a"), () => + routesProxy.onHandlers( + (id, handler) => registeredA.push({ id, handler }), + (id) => unregisteredA.push(id), + true, + ), + ); + const leaseB = RunWithModuleContext(providerContext("api-provider-b"), () => + routesProxy.onHandlers( + (id, handler) => registeredB.push({ id, handler }), + (id) => unregisteredB.push(id), + true, + ), + ); + const oldFacade = CreateInterfaceFacade( + Api, + consumerContext("facade-consumer#old", "api-provider-a"), + ); + const newFacade = CreateInterfaceFacade( + Api, + consumerContext("facade-consumer#new", "api-provider-b"), + ); + const oldCallback = () => "old"; + const newCallback = () => "new"; + try { + applyGet(oldFacade, "/facade/old", oldCallback); + applyGet(newFacade, "/facade/new", newCallback); + + assert.equal(registeredA.length, 1); + assert.equal(registeredB.length, 1); + assert.strictEqual(registeredA[0].handler.callback, oldCallback); + assert.strictEqual(registeredB[0].handler.callback, newCallback); + + routesProxy.unregisterOwner("facade-consumer#old"); + + assert.deepEqual(unregisteredA, [registeredA[0].id]); + assert.deepEqual(unregisteredB, []); + assert.equal(routesAt(registeredA[0].handler.location), 0); + assert.equal(routesAt(registeredB[0].handler.location), 1); + } finally { + routesProxy.unregisterOwner("facade-consumer#old"); + routesProxy.unregisterOwner("facade-consumer#new"); + routesProxy.detach(leaseA); + routesProxy.detach(leaseB); + } + }); +}); + +describe("ObserveRegisteredRoutes", () => { + const routeIds: number[] = []; + const subscriptions: Array<() => void> = []; + + function register(handler: RouteHandler): number { + const id = RegisterRoute(handler); + routeIds.push(id); + return id; + } + + function observe(observer: RegisteredRoutesObserver): () => void { + const unsubscribe = ObserveRegisteredRoutes(observer); + subscriptions.push(unsubscribe); + return unsubscribe; + } + + afterEach(() => { + for (const unsubscribe of subscriptions.splice(0)) { + unsubscribe(); + } + for (const id of routeIds.splice(0)) { + UnregisterRoute(id); + } + }); + + it("synchronously replays complete registered handlers", () => { + const original = completeHandlerAt("/observer/replay"); + const id = register(original); + const observer = new RecordingRoutesObserver(); + + observe(observer); + + const event = observer.registered.find( + (registered) => registered.id === id.toString(), + ); + assert(event); + assert.equal(event.handler.mode, original.mode); + assert.strictEqual(event.handler.callback, original.callback); + assert.strictEqual(event.handler.proto, original.proto); + assert.strictEqual(event.handler.parameters, original.parameters); + assert.strictEqual(event.handler.properties, original.properties); + assert.equal(event.handler.priority, original.priority); + assert(Object.hasOwn(event.handler, "module")); + assert.equal(typeof event.handler.module, "string"); + }); + + it("emits live registrations without replacing the route provider", () => { + const observer = new RecordingRoutesObserver(); + observe(observer); + observer.clear(); + const original = completeHandlerAt("/observer/live"); + + const id = register(original); + + assert.equal(observer.registered.length, 1); + assert.equal(observer.registered[0].id, id.toString()); + const providerCall = proxyCalls.at(-1); + assert.equal(providerCall?.kind, "register"); + assert.equal(providerCall?.id, id.toString()); + assert.strictEqual(providerCall?.handler, observer.registered[0].handler); + assert.equal(Object.hasOwn(original, "module"), false); + }); + + it("emits explicit route removals", () => { + const observer = new RecordingRoutesObserver(); + observe(observer); + observer.clear(); + const id = register(handlerAt("/observer/unregister")); + observer.clear(); + + UnregisterRoute(id); + + assert.deepEqual(observer.unregistered, [id.toString()]); + }); + + it("emits removals caused by module unload", () => { + const observer = new RecordingRoutesObserver(); + observe(observer); + observer.clear(); + const id = register(handlerAt("/observer/module-unload")); + const module = observer.registered[0].handler.module; + assert(module); + observer.clear(); + + routesProxy.unregisterModule(module); + + assert.deepEqual(observer.unregistered, [id.toString()]); + assert.equal(routesAt("/observer/module-unload"), 0); + }); + + it("multicasts registrations and removals", () => { + const first = new RecordingRoutesObserver(); + const second = new RecordingRoutesObserver(); + observe(first); + observe(second); + first.clear(); + second.clear(); + + const id = register(handlerAt("/observer/multicast")); + UnregisterRoute(id); + + assert.deepEqual( + [first, second].map((observer) => observer.registered[0].id), + [id.toString(), id.toString()], + ); + assert.deepEqual( + [first, second].map((observer) => observer.unregistered[0]), + [id.toString(), id.toString()], + ); + }); + + it("does not duplicate live events for observers added during emission", () => { + const location = "/observer/reentrant-subscription"; + const second = new RecordingRoutesObserver(); + const first = new CallbackRoutesObserver((_id, handler) => { + if (handler.location === location) { + observe(second); + } + }); + observe(first); + + const id = register(handlerAt(location)); + + assert.equal( + second.registered.filter((event) => event.id === id.toString()).length, + 1, + ); + }); + + it("does not notify observers removed during emission", () => { + const location = "/observer/reentrant-unsubscribe"; + let unsubscribeSecond = () => {}; + const first = new CallbackRoutesObserver((_id, handler) => { + if (handler.location === location) { + unsubscribeSecond(); + } + }); + const second = new RecordingRoutesObserver(); + observe(first); + unsubscribeSecond = observe(second); + first.clear(); + second.clear(); + + register(handlerAt(location)); + + assert.deepEqual(second.registered, []); + }); + + it("does not emit stale registrations after reentrant removal", () => { + const location = "/observer/reentrant-removal"; + const first = new CallbackRoutesObserver((id, handler) => { + if (handler.location === location) { + UnregisterRoute(Number(id)); + } + }); + const second = new RecordingRoutesObserver(); + observe(first); + observe(second); + first.clear(); + second.clear(); + + const id = register(handlerAt(location)); + + assert.equal( + second.registered.some((event) => event.id === id.toString()), + false, + ); + assert(second.unregistered.includes(id.toString())); + }); + + it("skips routes removed during synchronous replay", () => { + const triggerLocation = "/observer/replay-trigger"; + register(handlerAt(triggerLocation)); + const removedId = register(handlerAt("/observer/replay-removed")); + const observer = new CallbackRoutesObserver((_id, handler) => { + if (handler.location === triggerLocation) { + UnregisterRoute(removedId); + } + }); + + observe(observer); + + assert.equal( + observer.registered.some((event) => event.id === removedId.toString()), + false, + ); + assert(observer.unregistered.includes(removedId.toString())); + }); + + it("returns an idempotent unsubscribe function", () => { + const observer = new RecordingRoutesObserver(); + const unsubscribe = observe(observer); + observer.clear(); + + unsubscribe(); + unsubscribe(); + register(handlerAt("/observer/unsubscribed")); + + assert.deepEqual(observer.registered, []); + assert.deepEqual(observer.unregistered, []); + }); + + it("shares one subscription for repeated observer objects", () => { + const observer = new RecordingRoutesObserver(); + const firstUnsubscribe = observe(observer); + const secondUnsubscribe = observe(observer); + observer.clear(); + + firstUnsubscribe(); + register(handlerAt("/observer/repeated-subscription")); + secondUnsubscribe(); + + assert.deepEqual(observer.registered, []); + assert.deepEqual(observer.unregistered, []); + }); + + it("isolates throwing observers from routing and other observers", () => { + const throwing = new ThrowingRoutesObserver(); + const recording = new RecordingRoutesObserver(); + observe(throwing); + observe(recording); + recording.clear(); + throwing.isThrowing = true; + + let id = -1; + assert.doesNotThrow(() => { + id = register(handlerAt("/observer/throwing")); + }); + assert.doesNotThrow(() => UnregisterRoute(id)); + + assert.equal(recording.registered[0].id, id.toString()); + assert.deepEqual(recording.unregistered, [id.toString()]); + assert.equal(proxyCalls.at(-1)?.kind, "unregister"); + assert.equal(proxyCalls.at(-1)?.id, id.toString()); + }); +});