From 886ad2299c81e80f27e6909e75207790b54db395 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 15:49:12 +0000 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 4b7c5425497ed6a52f71ac2dedebf5cd8f34cde7 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 27 Aug 2026 17:53:49 +0000 Subject: [PATCH 6/6] feat(routes): expose complete registered handlers Co-authored-by: Upd4ting --- src/antelope.test.ts | 2 +- src/index.ts | 119 +------------ src/tests/route-lifecycle.test.ts | 282 ++++-------------------------- 3 files changed, 41 insertions(+), 362 deletions(-) diff --git a/src/antelope.test.ts b/src/antelope.test.ts index 2bb5499..2fff732 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.2.4", + version: "1.0.0", }, config: { servers: [ diff --git a/src/index.ts b/src/index.ts index c12147c..e2ca4b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -693,119 +693,27 @@ export interface RouteHandler { } /** - * Observer for complete registered route handlers and their removal. + * Complete registered route handler and its identifier. */ -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; +export interface RegisteredRouteHandler { + id: string; + handler: RouteHandler; } -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 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. + * Retrieves 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. + * @returns Snapshot of the current route registry. */ -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); +export function getRegisteredRouteHandlers(): RegisteredRouteHandler[] { + return Array.from(routesList, ([id, handler]) => ({ id, handler })); } /** @@ -818,25 +726,17 @@ class RoutesProxy extends RegisteringProxy< (id: string, handler: RouteHandler) => void > { override unregister(id: string) { - const wasRegistered = routesList.delete(id); + 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); - } } } @@ -866,7 +766,6 @@ 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 3ad64f3..2d7437c 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -1,74 +1,21 @@ import assert from "node:assert"; import { type ComputedParameter, + getRegisteredRouteHandlers, 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 { @@ -163,9 +110,8 @@ describe("Route lifecycle", () => { }); }); -describe("ObserveRegisteredRoutes", () => { +describe("getRegisteredRouteHandlers", () => { const routeIds: number[] = []; - const subscriptions: Array<() => void> = []; function register(handler: RouteHandler): number { const id = RegisterRoute(handler); @@ -173,229 +119,63 @@ describe("ObserveRegisteredRoutes", () => { 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"); + it("returns complete registered handlers without replacing the provider", () => { + const original = completeHandlerAt("/handlers/complete"); const id = register(original); - const observer = new RecordingRoutesObserver(); - - observe(observer); - const event = observer.registered.find( + const registered = getRegisteredRouteHandlers().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()); + assert(registered); + assert.equal(registered.handler.mode, original.mode); + assert.strictEqual(registered.handler.callback, original.callback); + assert.strictEqual(registered.handler.proto, original.proto); + assert.strictEqual(registered.handler.parameters, original.parameters); + assert.strictEqual(registered.handler.properties, original.properties); + assert.equal(registered.handler.priority, original.priority); + assert(Object.hasOwn(registered.handler, "module")); + assert.equal(typeof registered.handler.module, "string"); 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.strictEqual(providerCall?.handler, registered.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(); + it("excludes explicitly unregistered routes", () => { + const id = register(handlerAt("/handlers/unregister")); - 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()), + getRegisteredRouteHandlers().some( + (registered) => registered.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); - } - }); + it("excludes routes removed during module unload", () => { + const id = register(handlerAt("/handlers/module-unload")); + const registered = getRegisteredRouteHandlers().find( + (entry) => entry.id === id.toString(), + ); + assert(registered); + const module = registered.handler.module; + assert(module); - observe(observer); + routesProxy.unregisterModule(module); assert.equal( - observer.registered.some((event) => event.id === removedId.toString()), + getRegisteredRouteHandlers().some((entry) => entry.id === id.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()); + assert.equal(routesAt("/handlers/module-unload"), 0); }); });