From 886ad2299c81e80f27e6909e75207790b54db395 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 15:49:12 +0000 Subject: [PATCH 01/10] feat(routes): add registered route observer Expose a replayable multicast lifecycle API for complete route handlers without attaching another provider to the internal registering proxy. Keep explicit removals and module unloads synchronized with the public observer. --- src/antelope.test.ts | 2 +- src/index.ts | 92 ++++++++++++- src/tests/route-lifecycle.test.ts | 221 +++++++++++++++++++++++++++++- 3 files changed, 309 insertions(+), 6 deletions(-) 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..c0d4237 100644 --- a/src/index.ts +++ b/src/index.ts @@ -692,12 +692,93 @@ 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 registeredRoutesObservers = new Set(); + +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 of registeredRoutesObservers) { + notifyRegisteredRoutesObserver(observer, notification); + } +} + +function notifyRouteRegistered(id: string, handler: RouteHandler): void { + notifyRegisteredRoutesObservers((observer) => + observer.onRegister(id, handler), + ); +} + +function notifyRouteUnregistered(id: string): void { + notifyRegisteredRoutesObservers((observer) => observer.onUnregister(id)); +} + +/** + * 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. + * + * @param observer Route lifecycle observer. + * @returns An idempotent function that stops future notifications. + */ +export function ObserveRegisteredRoutes( + observer: RegisteredRoutesObserver, +): () => void { + registeredRoutesObservers.add(observer); + for (const [id, handler] of Array.from(routesList)) { + notifyRegisteredRoutesObserver(observer, (current) => + current.onRegister(id, handler), + ); + } + return () => { + registeredRoutesObservers.delete(observer); + }; +} /** * RegisteringProxy that also prunes {@link routesList} on the same lifecycle @@ -709,17 +790,25 @@ class RoutesProxy extends RegisteringProxy< (id: string, handler: RouteHandler) => void > { override unregister(id: string) { - routesList.delete(id); + const wasRegistered = routesList.delete(id); super.unregister(id); + if (wasRegistered) { + notifyRouteUnregistered(id); + } } override unregisterModule(mod: string) { + const removedIds: string[] = []; for (const [id, handler] of routesList) { if (handler.module === mod) { routesList.delete(id); + removedIds.push(id); } } super.unregisterModule(mod); + for (const id of removedIds) { + notifyRouteUnregistered(id); + } } } @@ -749,6 +838,7 @@ export function RegisterRoute(handler: RouteHandler) { ); routesProxy.register(id.toString(), enriched); routesList.set(id.toString(), enriched); + notifyRouteRegistered(id.toString(), enriched); return id; } diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index c068935..4a41a6a 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -1,12 +1,63 @@ import assert from "node:assert"; 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"; +} + +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 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 +70,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 +99,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 +149,148 @@ describe("Route lifecycle", () => { assert.equal(getRegisteredRoutes().length, before); }); }); + +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("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("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()); + }); +}); From eab5214daa4454f1a55af066e2efaa165820ba15 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 15:55:59 +0000 Subject: [PATCH 02/10] fix(routes): preserve reentrant observer ordering Snapshot live subscribers so nested subscriptions do not receive duplicate events. Skip stale replay entries removed by earlier observer callbacks. --- src/index.ts | 5 +++- src/tests/route-lifecycle.test.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index c0d4237..dfa3a30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -740,7 +740,7 @@ function notifyRegisteredRoutesObserver( function notifyRegisteredRoutesObservers( notification: RegisteredRoutesNotification, ): void { - for (const observer of registeredRoutesObservers) { + for (const observer of Array.from(registeredRoutesObservers)) { notifyRegisteredRoutesObserver(observer, notification); } } @@ -771,6 +771,9 @@ export function ObserveRegisteredRoutes( ): () => void { registeredRoutesObservers.add(observer); for (const [id, handler] of Array.from(routesList)) { + if (routesList.get(id) !== handler) { + continue; + } notifyRegisteredRoutesObserver(observer, (current) => current.onRegister(id, handler), ); diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index 4a41a6a..4b0f464 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -22,6 +22,8 @@ interface ProxyCall { kind: "register" | "unregister"; } +type RouteRegistrationCallback = (id: string, handler: RouteHandler) => void; + class RecordingRoutesObserver implements RegisteredRoutesObserver { registered: RegisteredRouteEvent[] = []; unregistered: string[] = []; @@ -40,6 +42,17 @@ class RecordingRoutesObserver implements RegisteredRoutesObserver { } } +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; @@ -261,6 +274,43 @@ describe("ObserveRegisteredRoutes", () => { ); }); + 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("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); From 8cb3e7475f4334b248e41a5a644013eccabad476 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 16:03:42 +0000 Subject: [PATCH 03/10] fix(routes): honor reentrant unsubscriptions Track observer subscription generations so callbacks removed during an emission are skipped, including remove-and-resubscribe sequences. --- src/index.ts | 19 +++++++++++++++---- src/tests/route-lifecycle.test.ts | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index dfa3a30..0ca5a1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -724,7 +724,7 @@ const REGISTERED_ROUTES_OBSERVER_ERROR = "Registered routes observer failed"; * do not accumulate across module reloads. */ const routesList = new Map(); -const registeredRoutesObservers = new Set(); +const registeredRoutesObservers = new Map(); function notifyRegisteredRoutesObserver( observer: RegisteredRoutesObserver, @@ -740,7 +740,12 @@ function notifyRegisteredRoutesObserver( function notifyRegisteredRoutesObservers( notification: RegisteredRoutesNotification, ): void { - for (const observer of Array.from(registeredRoutesObservers)) { + for (const [observer, subscription] of Array.from( + registeredRoutesObservers, + )) { + if (registeredRoutesObservers.get(observer) !== subscription) { + continue; + } notifyRegisteredRoutesObserver(observer, notification); } } @@ -769,8 +774,12 @@ function notifyRouteUnregistered(id: string): void { export function ObserveRegisteredRoutes( observer: RegisteredRoutesObserver, ): () => void { - registeredRoutesObservers.add(observer); + 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; } @@ -779,7 +788,9 @@ export function ObserveRegisteredRoutes( ); } return () => { - registeredRoutesObservers.delete(observer); + if (registeredRoutesObservers.get(observer) === subscription) { + registeredRoutesObservers.delete(observer); + } }; } diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index 4b0f464..9174d8d 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -292,6 +292,25 @@ describe("ObserveRegisteredRoutes", () => { ); }); + 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("skips routes removed during synchronous replay", () => { const triggerLocation = "/observer/replay-trigger"; register(handlerAt(triggerLocation)); From 83f2804be78d6939c016e773822481e47cf43c72 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 16:06:55 +0000 Subject: [PATCH 04/10] fix(routes): skip stale live registrations Revalidate the route before each live registration callback so reentrant removal cannot leave later observer caches stale. --- src/index.ts | 8 +++++--- src/tests/route-lifecycle.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0ca5a1e..faecf9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -751,9 +751,11 @@ function notifyRegisteredRoutesObservers( } function notifyRouteRegistered(id: string, handler: RouteHandler): void { - notifyRegisteredRoutesObservers((observer) => - observer.onRegister(id, handler), - ); + notifyRegisteredRoutesObservers((observer) => { + if (routesList.get(id) === handler) { + observer.onRegister(id, handler); + } + }); } function notifyRouteUnregistered(id: string): void { diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index 9174d8d..5450891 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -311,6 +311,28 @@ describe("ObserveRegisteredRoutes", () => { 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)); From 0eaf16ac83fc58c337a016014a062679d8bbdde4 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 16:14:08 +0000 Subject: [PATCH 05/10] fix(routes): deduplicate repeated observer subscriptions Reuse one active subscription per observer identity so either unsubscribe handle stops future notifications without weakening reentrant generation checks. --- src/index.ts | 24 ++++++++++++++++++------ src/tests/route-lifecycle.test.ts | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index faecf9e..c12147c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -762,13 +762,25 @@ 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. + * 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. @@ -776,6 +788,10 @@ function notifyRouteUnregistered(id: string): void { 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)) { @@ -789,11 +805,7 @@ export function ObserveRegisteredRoutes( current.onRegister(id, handler), ); } - return () => { - if (registeredRoutesObservers.get(observer) === subscription) { - registeredRoutesObservers.delete(observer); - } - }; + return createRegisteredRoutesUnsubscribe(observer, subscription); } /** diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index 5450891..3ad64f3 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -365,6 +365,20 @@ describe("ObserveRegisteredRoutes", () => { 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(); From 4eb568c2293fad18bc6e8f0c87e7d94a207a371a Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 18:12:21 +0000 Subject: [PATCH 06/10] fix(routes): preserve consumer execution context --- package.json | 4 +- pnpm-lock.yaml | 10 +- src/index.ts | 79 +++++++--- src/tests/route-lifecycle.test.ts | 249 +++++++++++++++++++++++++++++- 4 files changed, 306 insertions(+), 36 deletions(-) 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/index.ts b/src/index.ts index c12147c..15c13d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,10 @@ import { MakeParameterDecorator, } from "@antelopejs/interface-core/decorators"; import { Logging } from "@antelopejs/interface-core/logging"; +import { + BindToCurrentModuleContext, + GetModuleContext, +} from "@antelopejs/interface-core/modules"; /** * @internal @@ -724,6 +728,7 @@ const REGISTERED_ROUTES_OBSERVER_ERROR = "Registered routes observer failed"; * do not accumulate across module reloads. */ const routesList = new Map(); +const routeOwners = new Map(); const registeredRoutesObservers = new Map(); function notifyRegisteredRoutesObserver( @@ -819,23 +824,43 @@ class RoutesProxy extends RegisteringProxy< > { override unregister(id: string) { const wasRegistered = routesList.delete(id); - super.unregister(id); - if (wasRegistered) { - notifyRouteUnregistered(id); + routeOwners.delete(id); + try { + super.unregister(id); + } finally { + if (wasRegistered) { + notifyRouteUnregistered(id); + } } } override unregisterModule(mod: string) { - const removedIds: string[] = []; - for (const [id, handler] of routesList) { - if (handler.module === mod) { - routesList.delete(id); - removedIds.push(id); - } - } - super.unregisterModule(mod); - for (const id of removedIds) { - notifyRouteUnregistered(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); } } } @@ -847,6 +872,14 @@ export const routesProxy: RegisteringProxy< (id: string, handler: RouteHandler) => void > = new RoutesProxy(); let nextId = 0; + +function createProviderRouteHandler(handler: RouteHandler): RouteHandler { + return { + ...handler, + callback: BindToCurrentModuleContext(handler.callback), + }; +} + /** * Register a RouteHandler to the API. * @@ -855,18 +888,18 @@ 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 registeredHandler: RouteHandler = { ...handler, module }; + const providerHandler = createProviderRouteHandler(registeredHandler); Logging.Debug( - `Registered ${enriched.method.toUpperCase()} ${enriched.location} (${enriched.callback.name || "anonymous"})`, + `Registered ${registeredHandler.method.toUpperCase()} ${registeredHandler.location} (${registeredHandler.callback.name || "anonymous"})`, ); - routesProxy.register(id.toString(), enriched); - routesList.set(id.toString(), enriched); - notifyRouteRegistered(id.toString(), enriched); + routesProxy.register(id.toString(), providerHandler); + routesList.set(id.toString(), registeredHandler); + routeOwners.set(id.toString(), owner); + notifyRouteRegistered(id.toString(), registeredHandler); return id; } diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index 3ad64f3..dbc09f0 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -1,4 +1,14 @@ import assert from "node:assert"; +import { + AsyncProxy, + GetInterfaceProxyIdentity, + ModuleContextInvalidatedError, +} from "@antelopejs/interface-core"; +import { + Events, + type ModuleExecutionContext, + RunWithModuleContext, +} from "@antelopejs/interface-core/modules"; import { type ComputedParameter, getRegisteredRoutes, @@ -22,6 +32,11 @@ interface ProxyCall { kind: "register" | "unregister"; } +interface RoutedRegistration { + handler: RouteHandler; + id: number; +} + type RouteRegistrationCallback = (id: string, handler: RouteHandler) => void; class RecordingRoutesObserver implements RegisteredRoutesObserver { @@ -53,6 +68,17 @@ class CallbackRoutesObserver extends RecordingRoutesObserver { } } +class CleanupOrderObserver extends RecordingRoutesObserver { + constructor(private readonly assertCleanup: () => void) { + super(); + } + + override onUnregister(id: string): void { + this.assertCleanup(); + super.onUnregister(id); + } +} + class ThrowingRoutesObserver implements RegisteredRoutesObserver { isThrowing = false; @@ -70,6 +96,7 @@ class ThrowingRoutesObserver implements RegisteredRoutesObserver { } const proxyCalls: ProxyCall[] = []; +let throwingUnregisterId: string | undefined; function handlerAt(location: string): RouteHandler { return { @@ -107,6 +134,42 @@ function routesAt(location: string): number { .length; } +function attachProvider( + proxy: AsyncProxy<() => string>, + provider: string, +): void { + RunWithModuleContext( + { module: provider, owner: `${provider}#1`, provider }, + () => proxy.onCall(() => provider, true), + ); +} + +function registerRoutedHandler( + proxy: AsyncProxy<() => string>, + context: ModuleExecutionContext, + location: string, +): RoutedRegistration { + const handler = handlerAt(location); + handler.callback = () => proxy.call(); + const id = RunWithModuleContext(context, () => RegisterRoute(handler)); + return { handler, id }; +} + +function providerHandler(id: number): RouteHandler { + const call = proxyCalls.find( + (entry) => entry.kind === "register" && entry.id === id.toString(), + ); + assert(call?.handler); + return call.handler; +} + +function invokeFromApi(handler: RouteHandler): Promise { + return RunWithModuleContext( + { module: "api", owner: "api#1", provider: "api" }, + () => Promise.resolve(handler.callback()), + ); +} + 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 @@ -118,11 +181,15 @@ describe("Route lifecycle", () => { }, true); routesProxy.onUnregister((id: string) => { proxyCalls.push({ kind: "unregister", id }); + if (id === throwingUnregisterId) { + throw new Error("Provider unregister failure"); + } }); }); beforeEach(() => { proxyCalls.length = 0; + throwingUnregisterId = undefined; }); it("releases a route by the id RegisterRoute returned", () => { @@ -190,7 +257,10 @@ describe("ObserveRegisteredRoutes", () => { it("synchronously replays complete registered handlers", () => { const original = completeHandlerAt("/observer/replay"); - const id = register(original); + const id = RunWithModuleContext( + { module: "live-consumer", owner: "live-consumer#1" }, + () => register(original), + ); const observer = new RecordingRoutesObserver(); observe(observer); @@ -215,17 +285,134 @@ describe("ObserveRegisteredRoutes", () => { observer.clear(); const original = completeHandlerAt("/observer/live"); - const id = register(original); + const id = RunWithModuleContext( + { module: "live-consumer", owner: "live-consumer#2" }, + () => 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.notStrictEqual( + providerCall?.handler, + observer.registered[0].handler, + ); + assert.notStrictEqual(providerCall?.handler?.callback, original.callback); + assert.strictEqual( + observer.registered[0].handler.callback, + original.callback, + ); + assert.strictEqual(providerCall?.handler?.proto, original.proto); + assert.strictEqual(providerCall?.handler?.parameters, original.parameters); + assert.strictEqual(providerCall?.handler?.properties, original.properties); assert.equal(Object.hasOwn(original, "module"), false); }); + it("runs provider callbacks with each consumer provider route", async () => { + const routed = new AsyncProxy<() => string>("route-context.Verify"); + const identity = GetInterfaceProxyIdentity(routed) as string; + attachProvider(routed, "auth-a"); + attachProvider(routed, "auth-b"); + const observer = new RecordingRoutesObserver(); + observe(observer); + observer.clear(); + + const first = registerRoutedHandler( + routed, + { + module: "consumer-a", + owner: "consumer-a#1", + providerRoutes: { [identity]: "auth-a" }, + }, + "/observer/context-a", + ); + const second = registerRoutedHandler( + routed, + { + module: "consumer-b", + owner: "consumer-b#1", + providerRoutes: { [identity]: "auth-b" }, + }, + "/observer/context-b", + ); + + assert.equal(await invokeFromApi(providerHandler(first.id)), "auth-a"); + assert.equal(await invokeFromApi(providerHandler(second.id)), "auth-b"); + assert.strictEqual( + observer.registered.at(-2)?.handler.callback, + first.handler.callback, + ); + assert.strictEqual( + observer.registered.at(-1)?.handler.callback, + second.handler.callback, + ); + assert.equal(observer.registered.at(-2)?.handler.module, "consumer-a"); + assert.equal(observer.registered.at(-1)?.handler.module, "consumer-b"); + routed.detach(); + }); + + it("removes old context-bound routes before replaying a reload", async () => { + const routed = new AsyncProxy<() => string>("route-context.Reload"); + const identity = GetInterfaceProxyIdentity(routed) as string; + attachProvider(routed, "auth-old"); + attachProvider(routed, "auth-new"); + const observer = new RecordingRoutesObserver(); + observe(observer); + const oldRoute = registerRoutedHandler( + routed, + { + module: "consumer", + owner: "consumer#old", + providerRoutes: { [identity]: "auth-old" }, + }, + "/observer/reload-old", + ); + observer.clear(); + + const currentRoute = registerRoutedHandler( + routed, + { + module: "consumer", + owner: "consumer#new", + providerRoutes: { [identity]: "auth-new" }, + }, + "/observer/reload-new", + ); + observer.clear(); + RunWithModuleContext({ module: "consumer", owner: "consumer#old" }, () => + Events.ModuleDestroyed.emit("consumer"), + ); + const replay = new RecordingRoutesObserver(); + observe(replay); + + assert.deepEqual(observer.unregistered, [oldRoute.id.toString()]); + assert.throws( + () => providerHandler(oldRoute.id).callback(), + ModuleContextInvalidatedError, + ); + assert.equal( + replay.registered.some(({ id }) => id === oldRoute.id.toString()), + false, + ); + assert.strictEqual( + replay.registered.at(-1)?.handler.callback, + currentRoute.handler.callback, + ); + assert.equal( + await invokeFromApi(providerHandler(currentRoute.id)), + "auth-new", + ); + UnregisterRoute(currentRoute.id); + UnregisterRoute(currentRoute.id); + assert.deepEqual(observer.unregistered, [ + oldRoute.id.toString(), + currentRoute.id.toString(), + ]); + routed.detach(); + }); + it("emits explicit route removals", () => { const observer = new RecordingRoutesObserver(); observe(observer); @@ -238,21 +425,71 @@ describe("ObserveRegisteredRoutes", () => { assert.deepEqual(observer.unregistered, [id.toString()]); }); + it("completes public removal when the provider unregister throws", () => { + const observer = new RecordingRoutesObserver(); + observe(observer); + observer.clear(); + const id = register(handlerAt("/observer/provider-unregister-error")); + throwingUnregisterId = id.toString(); + + assert.throws(() => UnregisterRoute(id), /Provider unregister failure/); + assert.equal(routesAt("/observer/provider-unregister-error"), 0); + assert.deepEqual(observer.unregistered, [id.toString()]); + assert.doesNotThrow(() => 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 context = { + module: "unloaded-consumer", + owner: "unloaded-consumer#1", + }; + const id = RunWithModuleContext(context, () => + register(handlerAt("/observer/module-unload")), + ); const module = observer.registered[0].handler.module; - assert(module); + assert.equal(module, context.module); observer.clear(); - routesProxy.unregisterModule(module); + RunWithModuleContext(context, () => + Events.ModuleDestroyed.emit(context.module), + ); assert.deepEqual(observer.unregistered, [id.toString()]); assert.equal(routesAt("/observer/module-unload"), 0); }); + it("removes one generation before notifying its observers", () => { + const oldContext = { module: "overlap", owner: "overlap#old" }; + const currentContext = { module: "overlap", owner: "overlap#current" }; + const oldLocations = ["/observer/overlap-old-a", "/observer/overlap-old-b"]; + const currentLocation = "/observer/overlap-current"; + const observer = new CleanupOrderObserver(() => { + oldLocations.forEach((location) => { + assert.equal(routesAt(location), 0); + }); + assert.equal(routesAt(currentLocation), 1); + }); + observe(observer); + const oldIds = RunWithModuleContext(oldContext, () => + oldLocations.map((location) => register(handlerAt(location))), + ); + RunWithModuleContext(currentContext, () => + register(handlerAt(currentLocation)), + ); + observer.clear(); + + RunWithModuleContext(oldContext, () => + Events.ModuleDestroyed.emit(oldContext.module), + ); + + assert.deepEqual(observer.unregistered, oldIds.map(String)); + assert.equal(routesAt(currentLocation), 1); + }); + it("multicasts registrations and removals", () => { const first = new RecordingRoutesObserver(); const second = new RecordingRoutesObserver(); From 1d17022de906ab34a9e2b4e34e40ffb09617bef7 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 18:54:58 +0000 Subject: [PATCH 07/10] fix(routes): bind computed callback context --- src/index.ts | 28 ++++++++++++ src/tests/route-lifecycle.test.ts | 72 ++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 15c13d0..7d3fa1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -873,10 +873,38 @@ export const routesProxy: RegisteringProxy< > = new RoutesProxy(); let nextId = 0; +function bindComputedParameter( + parameter: ComputedParameter, +): ComputedParameter { + return { + provider: parameter.provider + ? BindToCurrentModuleContext(parameter.provider) + : undefined, + modifiers: parameter.modifiers.map((modifier) => + BindToCurrentModuleContext(modifier), + ), + }; +} + +function bindOptionalComputedParameter(parameter: ComputedParameter | null) { + return parameter ? bindComputedParameter(parameter) : null; +} + +function bindComputedProperties(properties: Record) { + return Object.fromEntries( + Object.entries(properties).map(([key, parameter]) => [ + key, + bindComputedParameter(parameter), + ]), + ); +} + function createProviderRouteHandler(handler: RouteHandler): RouteHandler { return { ...handler, callback: BindToCurrentModuleContext(handler.callback), + parameters: handler.parameters.map(bindOptionalComputedParameter), + properties: bindComputedProperties(handler.properties), }; } diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index dbc09f0..095712e 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -16,6 +16,7 @@ import { ObserveRegisteredRoutes, type RegisteredRoutesObserver, RegisterRoute, + type RequestContext, type RouteHandler, routesProxy, UnregisterRoute, @@ -170,6 +171,20 @@ function invokeFromApi(handler: RouteHandler): Promise { ); } +function invokeComputedParametersFromApi(handler: RouteHandler) { + return RunWithModuleContext( + { module: "api", owner: "api#1", provider: "api" }, + async () => { + const context = {} as RequestContext; + const parameter = handler.parameters[0] as ComputedParameter; + const value = await parameter.provider?.(context); + const modified = await parameter.modifiers[0](context, value); + const property = await handler.properties.property.provider?.(context); + return [value, modified, property]; + }, + ); +} + 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 @@ -305,8 +320,22 @@ describe("ObserveRegisteredRoutes", () => { original.callback, ); assert.strictEqual(providerCall?.handler?.proto, original.proto); - assert.strictEqual(providerCall?.handler?.parameters, original.parameters); - assert.strictEqual(providerCall?.handler?.properties, original.properties); + assert.notStrictEqual( + providerCall?.handler?.parameters, + original.parameters, + ); + assert.notStrictEqual( + providerCall?.handler?.properties, + original.properties, + ); + assert.strictEqual( + observer.registered[0].handler.parameters, + original.parameters, + ); + assert.strictEqual( + observer.registered[0].handler.properties, + original.properties, + ); assert.equal(Object.hasOwn(original, "module"), false); }); @@ -353,6 +382,45 @@ describe("ObserveRegisteredRoutes", () => { routed.detach(); }); + it("runs parameter providers and modifiers with the consumer route", async () => { + const routed = new AsyncProxy<() => string>("route-context.Parameter"); + const identity = GetInterfaceProxyIdentity(routed) as string; + attachProvider(routed, "auth-consumer"); + const observer = new RecordingRoutesObserver(); + observe(observer); + observer.clear(); + const handler = handlerAt("/observer/context-parameter"); + const parameter: ComputedParameter = { + provider: () => routed.call(), + modifiers: [() => routed.call()], + }; + handler.parameters = [parameter]; + handler.properties = { property: parameter }; + const context: ModuleExecutionContext = { + module: "consumer", + owner: "consumer#parameter", + providerRoutes: { [identity]: "auth-consumer" }, + }; + + const id = RunWithModuleContext(context, () => register(handler)); + const provider = providerHandler(id); + + assert.deepEqual(await invokeComputedParametersFromApi(provider), [ + "auth-consumer", + "auth-consumer", + "auth-consumer", + ]); + assert.strictEqual( + observer.registered.at(-1)?.handler.parameters, + handler.parameters, + ); + assert.strictEqual( + observer.registered.at(-1)?.handler.properties, + handler.properties, + ); + routed.detach(); + }); + it("removes old context-bound routes before replaying a reload", async () => { const routed = new AsyncProxy<() => string>("route-context.Reload"); const identity = GetInterfaceProxyIdentity(routed) as string; From 280a865e99b8a4eca547f744a13e2ce59b2e7d3c Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 25 Aug 2026 16:59:00 +0000 Subject: [PATCH 08/10] Revert "fix(routes): bind computed callback context" This reverts commit 1d17022de906ab34a9e2b4e34e40ffb09617bef7. Co-authored-by: Upd4ting --- src/index.ts | 28 ------------ src/tests/route-lifecycle.test.ts | 72 +------------------------------ 2 files changed, 2 insertions(+), 98 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7d3fa1b..15c13d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -873,38 +873,10 @@ export const routesProxy: RegisteringProxy< > = new RoutesProxy(); let nextId = 0; -function bindComputedParameter( - parameter: ComputedParameter, -): ComputedParameter { - return { - provider: parameter.provider - ? BindToCurrentModuleContext(parameter.provider) - : undefined, - modifiers: parameter.modifiers.map((modifier) => - BindToCurrentModuleContext(modifier), - ), - }; -} - -function bindOptionalComputedParameter(parameter: ComputedParameter | null) { - return parameter ? bindComputedParameter(parameter) : null; -} - -function bindComputedProperties(properties: Record) { - return Object.fromEntries( - Object.entries(properties).map(([key, parameter]) => [ - key, - bindComputedParameter(parameter), - ]), - ); -} - function createProviderRouteHandler(handler: RouteHandler): RouteHandler { return { ...handler, callback: BindToCurrentModuleContext(handler.callback), - parameters: handler.parameters.map(bindOptionalComputedParameter), - properties: bindComputedProperties(handler.properties), }; } diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index 095712e..dbc09f0 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -16,7 +16,6 @@ import { ObserveRegisteredRoutes, type RegisteredRoutesObserver, RegisterRoute, - type RequestContext, type RouteHandler, routesProxy, UnregisterRoute, @@ -171,20 +170,6 @@ function invokeFromApi(handler: RouteHandler): Promise { ); } -function invokeComputedParametersFromApi(handler: RouteHandler) { - return RunWithModuleContext( - { module: "api", owner: "api#1", provider: "api" }, - async () => { - const context = {} as RequestContext; - const parameter = handler.parameters[0] as ComputedParameter; - const value = await parameter.provider?.(context); - const modified = await parameter.modifiers[0](context, value); - const property = await handler.properties.property.provider?.(context); - return [value, modified, property]; - }, - ); -} - 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 @@ -320,22 +305,8 @@ describe("ObserveRegisteredRoutes", () => { original.callback, ); assert.strictEqual(providerCall?.handler?.proto, original.proto); - assert.notStrictEqual( - providerCall?.handler?.parameters, - original.parameters, - ); - assert.notStrictEqual( - providerCall?.handler?.properties, - original.properties, - ); - assert.strictEqual( - observer.registered[0].handler.parameters, - original.parameters, - ); - assert.strictEqual( - observer.registered[0].handler.properties, - original.properties, - ); + assert.strictEqual(providerCall?.handler?.parameters, original.parameters); + assert.strictEqual(providerCall?.handler?.properties, original.properties); assert.equal(Object.hasOwn(original, "module"), false); }); @@ -382,45 +353,6 @@ describe("ObserveRegisteredRoutes", () => { routed.detach(); }); - it("runs parameter providers and modifiers with the consumer route", async () => { - const routed = new AsyncProxy<() => string>("route-context.Parameter"); - const identity = GetInterfaceProxyIdentity(routed) as string; - attachProvider(routed, "auth-consumer"); - const observer = new RecordingRoutesObserver(); - observe(observer); - observer.clear(); - const handler = handlerAt("/observer/context-parameter"); - const parameter: ComputedParameter = { - provider: () => routed.call(), - modifiers: [() => routed.call()], - }; - handler.parameters = [parameter]; - handler.properties = { property: parameter }; - const context: ModuleExecutionContext = { - module: "consumer", - owner: "consumer#parameter", - providerRoutes: { [identity]: "auth-consumer" }, - }; - - const id = RunWithModuleContext(context, () => register(handler)); - const provider = providerHandler(id); - - assert.deepEqual(await invokeComputedParametersFromApi(provider), [ - "auth-consumer", - "auth-consumer", - "auth-consumer", - ]); - assert.strictEqual( - observer.registered.at(-1)?.handler.parameters, - handler.parameters, - ); - assert.strictEqual( - observer.registered.at(-1)?.handler.properties, - handler.properties, - ); - routed.detach(); - }); - it("removes old context-bound routes before replaying a reload", async () => { const routed = new AsyncProxy<() => string>("route-context.Reload"); const identity = GetInterfaceProxyIdentity(routed) as string; From b3ad781e1e8dcaf628de11f086e20a83d0f99a54 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 25 Aug 2026 16:59:00 +0000 Subject: [PATCH 09/10] Revert "fix(routes): preserve consumer execution context" This reverts commit 4eb568c2293fad18bc6e8f0c87e7d94a207a371a. Co-authored-by: Upd4ting --- package.json | 4 +- pnpm-lock.yaml | 10 +- src/index.ts | 79 +++------- src/tests/route-lifecycle.test.ts | 249 +----------------------------- 4 files changed, 36 insertions(+), 306 deletions(-) diff --git a/package.json b/package.json index d8fe37c..c858e31 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ ] }, "devDependencies": { - "@antelopejs/interface-core": ">=0.0.13 <1.0.0", + "@antelopejs/interface-core": ">=0.0.3 <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.13 <1.0.0" + "@antelopejs/interface-core": ">=0.0.3 <1.0.0" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a2eeb..9f49da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@antelopejs/interface-core': - specifier: '>=0.0.13 <1.0.0' - version: 0.0.13 + specifier: '>=0.0.3 <1.0.0' + version: 0.0.3 '@biomejs/biome': specifier: 2.3.2 version: 2.3.2 @@ -47,8 +47,8 @@ importers: packages: - '@antelopejs/interface-core@0.0.13': - resolution: {integrity: sha512-/QdBq0jcEqQtjkzdZkmmkjn76T8RqiM9ZTFlh8+O6N3vxGW+UxJAGF9ZEJzAhOgtzuLDtnhSLrIYuEZmYQJH8g==} + '@antelopejs/interface-core@0.0.3': + resolution: {integrity: sha512-Kw3ffGiQHKJ88AqdFfPuwzl+v0w6QqzqiqnLY7M0MYYw+YwdRNXh5WAJkep0ePudR9leLGXPU//FYizNaDG6yw==} '@biomejs/biome@2.3.2': resolution: {integrity: sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==} @@ -1174,7 +1174,7 @@ packages: snapshots: - '@antelopejs/interface-core@0.0.13': + '@antelopejs/interface-core@0.0.3': dependencies: reflect-metadata: 0.2.2 diff --git a/src/index.ts b/src/index.ts index 15c13d0..c12147c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,10 +13,6 @@ import { MakeParameterDecorator, } from "@antelopejs/interface-core/decorators"; import { Logging } from "@antelopejs/interface-core/logging"; -import { - BindToCurrentModuleContext, - GetModuleContext, -} from "@antelopejs/interface-core/modules"; /** * @internal @@ -728,7 +724,6 @@ const REGISTERED_ROUTES_OBSERVER_ERROR = "Registered routes observer failed"; * do not accumulate across module reloads. */ const routesList = new Map(); -const routeOwners = new Map(); const registeredRoutesObservers = new Map(); function notifyRegisteredRoutesObserver( @@ -824,43 +819,23 @@ class RoutesProxy extends RegisteringProxy< > { override unregister(id: string) { const wasRegistered = routesList.delete(id); - routeOwners.delete(id); - try { - super.unregister(id); - } finally { - if (wasRegistered) { - notifyRouteUnregistered(id); - } + super.unregister(id); + if (wasRegistered) { + notifyRouteUnregistered(id); } } override unregisterModule(mod: string) { - 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); + const removedIds: string[] = []; + for (const [id, handler] of routesList) { + if (handler.module === mod) { + routesList.delete(id); + removedIds.push(id); + } + } + super.unregisterModule(mod); + for (const id of removedIds) { + notifyRouteUnregistered(id); } } } @@ -872,14 +847,6 @@ export const routesProxy: RegisteringProxy< (id: string, handler: RouteHandler) => void > = new RoutesProxy(); let nextId = 0; - -function createProviderRouteHandler(handler: RouteHandler): RouteHandler { - return { - ...handler, - callback: BindToCurrentModuleContext(handler.callback), - }; -} - /** * Register a RouteHandler to the API. * @@ -888,18 +855,18 @@ function createProviderRouteHandler(handler: RouteHandler): RouteHandler { */ export function RegisterRoute(handler: RouteHandler) { const id = nextId++; - const context = GetModuleContext(); - const module = context?.module ?? GetResponsibleModule(); - const owner = context?.owner ?? module; - const registeredHandler: RouteHandler = { ...handler, module }; - const providerHandler = createProviderRouteHandler(registeredHandler); + // 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() }; Logging.Debug( - `Registered ${registeredHandler.method.toUpperCase()} ${registeredHandler.location} (${registeredHandler.callback.name || "anonymous"})`, + `Registered ${enriched.method.toUpperCase()} ${enriched.location} (${enriched.callback.name || "anonymous"})`, ); - routesProxy.register(id.toString(), providerHandler); - routesList.set(id.toString(), registeredHandler); - routeOwners.set(id.toString(), owner); - notifyRouteRegistered(id.toString(), registeredHandler); + routesProxy.register(id.toString(), enriched); + routesList.set(id.toString(), enriched); + notifyRouteRegistered(id.toString(), enriched); return id; } diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index dbc09f0..3ad64f3 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -1,14 +1,4 @@ import assert from "node:assert"; -import { - AsyncProxy, - GetInterfaceProxyIdentity, - ModuleContextInvalidatedError, -} from "@antelopejs/interface-core"; -import { - Events, - type ModuleExecutionContext, - RunWithModuleContext, -} from "@antelopejs/interface-core/modules"; import { type ComputedParameter, getRegisteredRoutes, @@ -32,11 +22,6 @@ interface ProxyCall { kind: "register" | "unregister"; } -interface RoutedRegistration { - handler: RouteHandler; - id: number; -} - type RouteRegistrationCallback = (id: string, handler: RouteHandler) => void; class RecordingRoutesObserver implements RegisteredRoutesObserver { @@ -68,17 +53,6 @@ class CallbackRoutesObserver extends RecordingRoutesObserver { } } -class CleanupOrderObserver extends RecordingRoutesObserver { - constructor(private readonly assertCleanup: () => void) { - super(); - } - - override onUnregister(id: string): void { - this.assertCleanup(); - super.onUnregister(id); - } -} - class ThrowingRoutesObserver implements RegisteredRoutesObserver { isThrowing = false; @@ -96,7 +70,6 @@ class ThrowingRoutesObserver implements RegisteredRoutesObserver { } const proxyCalls: ProxyCall[] = []; -let throwingUnregisterId: string | undefined; function handlerAt(location: string): RouteHandler { return { @@ -134,42 +107,6 @@ function routesAt(location: string): number { .length; } -function attachProvider( - proxy: AsyncProxy<() => string>, - provider: string, -): void { - RunWithModuleContext( - { module: provider, owner: `${provider}#1`, provider }, - () => proxy.onCall(() => provider, true), - ); -} - -function registerRoutedHandler( - proxy: AsyncProxy<() => string>, - context: ModuleExecutionContext, - location: string, -): RoutedRegistration { - const handler = handlerAt(location); - handler.callback = () => proxy.call(); - const id = RunWithModuleContext(context, () => RegisterRoute(handler)); - return { handler, id }; -} - -function providerHandler(id: number): RouteHandler { - const call = proxyCalls.find( - (entry) => entry.kind === "register" && entry.id === id.toString(), - ); - assert(call?.handler); - return call.handler; -} - -function invokeFromApi(handler: RouteHandler): Promise { - return RunWithModuleContext( - { module: "api", owner: "api#1", provider: "api" }, - () => Promise.resolve(handler.callback()), - ); -} - 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 @@ -181,15 +118,11 @@ describe("Route lifecycle", () => { }, true); routesProxy.onUnregister((id: string) => { proxyCalls.push({ kind: "unregister", id }); - if (id === throwingUnregisterId) { - throw new Error("Provider unregister failure"); - } }); }); beforeEach(() => { proxyCalls.length = 0; - throwingUnregisterId = undefined; }); it("releases a route by the id RegisterRoute returned", () => { @@ -257,10 +190,7 @@ describe("ObserveRegisteredRoutes", () => { it("synchronously replays complete registered handlers", () => { const original = completeHandlerAt("/observer/replay"); - const id = RunWithModuleContext( - { module: "live-consumer", owner: "live-consumer#1" }, - () => register(original), - ); + const id = register(original); const observer = new RecordingRoutesObserver(); observe(observer); @@ -285,134 +215,17 @@ describe("ObserveRegisteredRoutes", () => { observer.clear(); const original = completeHandlerAt("/observer/live"); - const id = RunWithModuleContext( - { module: "live-consumer", owner: "live-consumer#2" }, - () => register(original), - ); + 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.notStrictEqual( - providerCall?.handler, - observer.registered[0].handler, - ); - assert.notStrictEqual(providerCall?.handler?.callback, original.callback); - assert.strictEqual( - observer.registered[0].handler.callback, - original.callback, - ); - assert.strictEqual(providerCall?.handler?.proto, original.proto); - assert.strictEqual(providerCall?.handler?.parameters, original.parameters); - assert.strictEqual(providerCall?.handler?.properties, original.properties); + assert.strictEqual(providerCall?.handler, observer.registered[0].handler); assert.equal(Object.hasOwn(original, "module"), false); }); - it("runs provider callbacks with each consumer provider route", async () => { - const routed = new AsyncProxy<() => string>("route-context.Verify"); - const identity = GetInterfaceProxyIdentity(routed) as string; - attachProvider(routed, "auth-a"); - attachProvider(routed, "auth-b"); - const observer = new RecordingRoutesObserver(); - observe(observer); - observer.clear(); - - const first = registerRoutedHandler( - routed, - { - module: "consumer-a", - owner: "consumer-a#1", - providerRoutes: { [identity]: "auth-a" }, - }, - "/observer/context-a", - ); - const second = registerRoutedHandler( - routed, - { - module: "consumer-b", - owner: "consumer-b#1", - providerRoutes: { [identity]: "auth-b" }, - }, - "/observer/context-b", - ); - - assert.equal(await invokeFromApi(providerHandler(first.id)), "auth-a"); - assert.equal(await invokeFromApi(providerHandler(second.id)), "auth-b"); - assert.strictEqual( - observer.registered.at(-2)?.handler.callback, - first.handler.callback, - ); - assert.strictEqual( - observer.registered.at(-1)?.handler.callback, - second.handler.callback, - ); - assert.equal(observer.registered.at(-2)?.handler.module, "consumer-a"); - assert.equal(observer.registered.at(-1)?.handler.module, "consumer-b"); - routed.detach(); - }); - - it("removes old context-bound routes before replaying a reload", async () => { - const routed = new AsyncProxy<() => string>("route-context.Reload"); - const identity = GetInterfaceProxyIdentity(routed) as string; - attachProvider(routed, "auth-old"); - attachProvider(routed, "auth-new"); - const observer = new RecordingRoutesObserver(); - observe(observer); - const oldRoute = registerRoutedHandler( - routed, - { - module: "consumer", - owner: "consumer#old", - providerRoutes: { [identity]: "auth-old" }, - }, - "/observer/reload-old", - ); - observer.clear(); - - const currentRoute = registerRoutedHandler( - routed, - { - module: "consumer", - owner: "consumer#new", - providerRoutes: { [identity]: "auth-new" }, - }, - "/observer/reload-new", - ); - observer.clear(); - RunWithModuleContext({ module: "consumer", owner: "consumer#old" }, () => - Events.ModuleDestroyed.emit("consumer"), - ); - const replay = new RecordingRoutesObserver(); - observe(replay); - - assert.deepEqual(observer.unregistered, [oldRoute.id.toString()]); - assert.throws( - () => providerHandler(oldRoute.id).callback(), - ModuleContextInvalidatedError, - ); - assert.equal( - replay.registered.some(({ id }) => id === oldRoute.id.toString()), - false, - ); - assert.strictEqual( - replay.registered.at(-1)?.handler.callback, - currentRoute.handler.callback, - ); - assert.equal( - await invokeFromApi(providerHandler(currentRoute.id)), - "auth-new", - ); - UnregisterRoute(currentRoute.id); - UnregisterRoute(currentRoute.id); - assert.deepEqual(observer.unregistered, [ - oldRoute.id.toString(), - currentRoute.id.toString(), - ]); - routed.detach(); - }); - it("emits explicit route removals", () => { const observer = new RecordingRoutesObserver(); observe(observer); @@ -425,71 +238,21 @@ describe("ObserveRegisteredRoutes", () => { assert.deepEqual(observer.unregistered, [id.toString()]); }); - it("completes public removal when the provider unregister throws", () => { - const observer = new RecordingRoutesObserver(); - observe(observer); - observer.clear(); - const id = register(handlerAt("/observer/provider-unregister-error")); - throwingUnregisterId = id.toString(); - - assert.throws(() => UnregisterRoute(id), /Provider unregister failure/); - assert.equal(routesAt("/observer/provider-unregister-error"), 0); - assert.deepEqual(observer.unregistered, [id.toString()]); - assert.doesNotThrow(() => UnregisterRoute(id)); - assert.deepEqual(observer.unregistered, [id.toString()]); - }); - it("emits removals caused by module unload", () => { const observer = new RecordingRoutesObserver(); observe(observer); observer.clear(); - const context = { - module: "unloaded-consumer", - owner: "unloaded-consumer#1", - }; - const id = RunWithModuleContext(context, () => - register(handlerAt("/observer/module-unload")), - ); + const id = register(handlerAt("/observer/module-unload")); const module = observer.registered[0].handler.module; - assert.equal(module, context.module); + assert(module); observer.clear(); - RunWithModuleContext(context, () => - Events.ModuleDestroyed.emit(context.module), - ); + routesProxy.unregisterModule(module); assert.deepEqual(observer.unregistered, [id.toString()]); assert.equal(routesAt("/observer/module-unload"), 0); }); - it("removes one generation before notifying its observers", () => { - const oldContext = { module: "overlap", owner: "overlap#old" }; - const currentContext = { module: "overlap", owner: "overlap#current" }; - const oldLocations = ["/observer/overlap-old-a", "/observer/overlap-old-b"]; - const currentLocation = "/observer/overlap-current"; - const observer = new CleanupOrderObserver(() => { - oldLocations.forEach((location) => { - assert.equal(routesAt(location), 0); - }); - assert.equal(routesAt(currentLocation), 1); - }); - observe(observer); - const oldIds = RunWithModuleContext(oldContext, () => - oldLocations.map((location) => register(handlerAt(location))), - ); - RunWithModuleContext(currentContext, () => - register(handlerAt(currentLocation)), - ); - observer.clear(); - - RunWithModuleContext(oldContext, () => - Events.ModuleDestroyed.emit(oldContext.module), - ); - - assert.deepEqual(observer.unregistered, oldIds.map(String)); - assert.equal(routesAt(currentLocation), 1); - }); - it("multicasts registrations and removals", () => { const first = new RecordingRoutesObserver(); const second = new RecordingRoutesObserver(); From d2986f8d85633abb1acc0a58d32d4d0d8dab361c Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 25 Aug 2026 16:58:18 +0000 Subject: [PATCH 10/10] fix(routes): bind registrations to consumer generations Co-authored-by: Upd4ting --- package.json | 4 +- pnpm-lock.yaml | 10 +-- src/index.ts | 107 ++++++++++++++++++++++-------- src/tests/route-lifecycle.test.ts | 97 +++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 33 deletions(-) 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/index.ts b/src/index.ts index c12147c..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 & { /** @@ -724,6 +724,7 @@ const REGISTERED_ROUTES_OBSERVER_ERROR = "Registered routes observer failed"; * do not accumulate across module reloads. */ const routesList = new Map(); +const routeOwners = new Map(); const registeredRoutesObservers = new Map(); function notifyRegisteredRoutesObserver( @@ -819,23 +820,43 @@ class RoutesProxy extends RegisteringProxy< > { override unregister(id: string) { const wasRegistered = routesList.delete(id); - super.unregister(id); - if (wasRegistered) { - notifyRouteUnregistered(id); + routeOwners.delete(id); + try { + super.unregister(id); + } finally { + if (wasRegistered) { + notifyRouteUnregistered(id); + } } } override unregisterModule(mod: string) { - const removedIds: string[] = []; - for (const [id, handler] of routesList) { - if (handler.module === mod) { - routesList.delete(id); - removedIds.push(id); - } - } - super.unregisterModule(mod); - for (const id of removedIds) { - notifyRouteUnregistered(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); } } } @@ -846,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. @@ -855,17 +880,16 @@ 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; } @@ -1691,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 3ad64f3..5a73656 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -1,4 +1,11 @@ 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, @@ -163,6 +170,96 @@ describe("Route lifecycle", () => { }); }); +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> = [];