Skip to content
Open
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/antelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default defineConfig({
source: {
type: "package",
package: "@antelopejs/api",
version: "1.0.0",
version: "1.2.4",
},
config: {
servers: [
Expand Down
213 changes: 193 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = Record<string, any>> = Class<T> & {
/**
Expand Down Expand Up @@ -692,12 +692,122 @@ export interface RouteHandler {
module?: string;
}

/**
* Observer for complete registered route handlers and their removal.
*/
export interface RegisteredRoutesObserver {
/**
* Receives a route registration.
*
* @param id Route identifier.
* @param handler Complete registered route handler.
*/
onRegister(id: string, handler: RouteHandler): void;

/**
* Receives a route removal.
*
* @param id Route identifier.
*/
onUnregister(id: string): void;
}

type RegisteredRoutesNotification = (
observer: RegisteredRoutesObserver,
) => void;

const REGISTERED_ROUTES_OBSERVER_ERROR = "Registered routes observer failed";

/**
* Registered route handlers indexed by their proxy id, mirroring the entries
* held by {@link routesProxy}. Pruned by {@link RoutesProxy} so stale handlers
* do not accumulate across module reloads.
*/
const routesList = new Map<string, RouteHandler>();
const routeOwners = new Map<string, string | undefined>();
const registeredRoutesObservers = new Map<RegisteredRoutesObserver, symbol>();

function notifyRegisteredRoutesObserver(
observer: RegisteredRoutesObserver,
notification: RegisteredRoutesNotification,
): void {
try {
notification(observer);
} catch (error) {
Logging.Error(REGISTERED_ROUTES_OBSERVER_ERROR, error);
}
}

function notifyRegisteredRoutesObservers(
notification: RegisteredRoutesNotification,
): void {
for (const [observer, subscription] of Array.from(
registeredRoutesObservers,
)) {
if (registeredRoutesObservers.get(observer) !== subscription) {
continue;
}
notifyRegisteredRoutesObserver(observer, notification);
}
}

function notifyRouteRegistered(id: string, handler: RouteHandler): void {
notifyRegisteredRoutesObservers((observer) => {
if (routesList.get(id) === handler) {
observer.onRegister(id, handler);
}
});
}

function notifyRouteUnregistered(id: string): void {
notifyRegisteredRoutesObservers((observer) => observer.onUnregister(id));
}

function createRegisteredRoutesUnsubscribe(
observer: RegisteredRoutesObserver,
subscription: symbol,
): () => void {
return () => {
if (registeredRoutesObservers.get(observer) === subscription) {
registeredRoutesObservers.delete(observer);
}
};
}

/**
* Observes complete registered route handlers.
*
* Routes that already exist are replayed synchronously before this function
* returns. Later registrations and removals are multicast to every subscribed
* observer. Observer errors are logged without interrupting replay, other
* observers, or route lifecycle operations. Repeated calls with the same
* observer share one active subscription.
*
* @param observer Route lifecycle observer.
* @returns An idempotent function that stops future notifications.
*/
export function ObserveRegisteredRoutes(
observer: RegisteredRoutesObserver,
): () => void {
const existingSubscription = registeredRoutesObservers.get(observer);
if (existingSubscription) {
return createRegisteredRoutesUnsubscribe(observer, existingSubscription);
}
const subscription = Symbol();
registeredRoutesObservers.set(observer, subscription);
for (const [id, handler] of Array.from(routesList)) {
if (registeredRoutesObservers.get(observer) !== subscription) {
break;
}
if (routesList.get(id) !== handler) {
continue;
}
notifyRegisteredRoutesObserver(observer, (current) =>
current.onRegister(id, handler),
);
}
return createRegisteredRoutesUnsubscribe(observer, subscription);
}

/**
* RegisteringProxy that also prunes {@link routesList} on the same lifecycle
Expand All @@ -709,17 +819,45 @@ class RoutesProxy extends RegisteringProxy<
(id: string, handler: RouteHandler) => void
> {
override unregister(id: string) {
routesList.delete(id);
super.unregister(id);
const wasRegistered = routesList.delete(id);
routeOwners.delete(id);
try {
super.unregister(id);
} finally {
if (wasRegistered) {
notifyRouteUnregistered(id);
}
}
}

override unregisterModule(mod: string) {
for (const [id, handler] of routesList) {
if (handler.module === mod) {
routesList.delete(id);
}
this.unregisterRoutes(
Array.from(routesList)
.filter(([, handler]) => handler.module === mod)
.map(([id]) => id),
() => super.unregisterModule(mod),
);
}

override unregisterOwner(owner: string) {
this.unregisterRoutes(
Array.from(routeOwners)
.filter(([, routeOwner]) => routeOwner === owner)
.map(([id]) => id),
() => super.unregisterOwner(owner),
);
}

private unregisterRoutes(ids: string[], unregister: () => void) {
ids.forEach((id) => {
routesList.delete(id);
routeOwners.delete(id);
});
try {
unregister();
} finally {
ids.forEach(notifyRouteUnregistered);
}
super.unregisterModule(mod);
}
}

Expand All @@ -729,6 +867,10 @@ class RoutesProxy extends RegisteringProxy<
export const routesProxy: RegisteringProxy<
(id: string, handler: RouteHandler) => void
> = new RoutesProxy();
Object.defineProperty(internal, "routesProxy", {
enumerable: false,
value: routesProxy,
});
let nextId = 0;
/**
* Register a RouteHandler to the API.
Expand All @@ -738,17 +880,17 @@ let nextId = 0;
*/
export function RegisterRoute(handler: RouteHandler) {
const id = nextId++;
// Resolve the owning module here, while the registering controller's frame is
// still on the stack (RegisterRoute runs synchronously during module load).
// Enrich a shallow copy rather than mutating the caller's handler object, so
// onRegister subscribers and getRegisteredRoutes both see `module` without the
// input object gaining an unexpected property.
const enriched: RouteHandler = { ...handler, module: GetResponsibleModule() };
const context = GetModuleContext();
const module = context?.module ?? GetResponsibleModule();
const owner = context?.owner ?? module;
const enriched: RouteHandler = { ...handler, module };
Logging.Debug(
`Registered ${enriched.method.toUpperCase()} ${enriched.location} (${enriched.callback.name || "anonymous"})`,
);
routesProxy.register(id.toString(), enriched);
routesList.set(id.toString(), enriched);
routeOwners.set(id.toString(), owner);
notifyRouteRegistered(id.toString(), enriched);
return id;
}

Expand Down Expand Up @@ -1573,3 +1715,34 @@ export const MultiParameter = MakeParameterAndPropertyDecorator(
});
},
);

type DecoratorFactory = (...args: any[]) => (...args: any[]) => unknown;

function bindDecoratorFactory<T extends DecoratorFactory>(
scope: InterfaceFacadeScope,
factory: T,
): T {
return ((...factoryArgs: Parameters<T>) => {
const decorator = factory(...factoryArgs);
return (...decoratorArgs: Parameters<ReturnType<T>>) =>
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),
};
}
Loading
Loading