diff --git a/docs/2.proxies.md b/docs/2.proxies.md index f0b8fcb..f7529e3 100644 --- a/docs/2.proxies.md +++ b/docs/2.proxies.md @@ -29,9 +29,9 @@ const greeting = await proxy.call("Bob"); // "Hello, Bob!" ### `onCall(callback, manualDetach?)` -Attaches a callback function to the proxy. The proxy automatically tracks the calling module and detaches the callback when that module is unloaded. Pass `manualDetach: true` to disable automatic cleanup. +Attaches a callback function to the proxy. The proxy automatically tracks the calling module and detaches the callback when that module is unloaded. Pass `manualDetach: true` to disable automatic cleanup. Providers loaded by current AntelopeJS Core attach through an equivalent resolver-bound operation with explicit generation ownership. -When attachment occurs inside `RunWithModuleContext`, calls execute in the captured provider context rather than the consumer context. The captured module, owner generation, provider, and provider routes remain available across nested calls and `await`. Calls reject with `ModuleContextInvalidatedError` if the captured owner has been destroyed. +Resolver-bound providers do not need an ambient provider context: their implementation callbacks already close over imports selected for the provider module. ```ts // Automatic cleanup (default) - detaches when the module unloads @@ -129,18 +129,20 @@ routeRegistry.unregister("about"); Attaches the register callback. Any previously registered entries replay through this callback immediately. Automatic module-aware detachment applies unless `manualDetach` is `true`. -Register replay and direct registration execute in the provider context captured when the callback attaches. +Resolver-bound providers receive callbacks unchanged because imports inside those callbacks are already bound to the provider module. ### `onUnregister(callback)` Attaches the unregister callback. This callback is detached at the same time as the register callback. -Unregistration executes in the context captured when this callback attaches. +Resolver-bound ownership applies equally to unregistration callbacks. ### `register(id, ...args)` Registers an entry with the given identifier. If a register callback is attached, it executes immediately. Otherwise, the entry is stored and replayed when a callback is attached. The calling module is tracked for automatic cleanup. +Core selects the registering consumer's interface facade before module evaluation. Functions inside `args` therefore keep using that consumer's selected providers through their normal imports; registering proxies do not wrap those functions at invocation time. + ### `unregister(id)` Removes the entry with the given identifier and calls the unregister callback if one is attached. @@ -149,6 +151,47 @@ Removes the entry with the given identifier and calls the unregister callback if Manually removes both the register and unregister callbacks. Registered entries remain stored. +## Resolver-bound interface facades + +Core resolves an interface package to a facade for each consuming module generation. The facade binds every exported `InterfaceFunction`, including functions nested in namespace objects, to the provider selected by that consumer's `importOverrides`. Application imports and call syntax do not change. + +```ts +// Application code: unchanged, and already bound to this module's Auth provider. +import { ValidateRaw } from "@antelopejs/interface-auth"; + +const user = await ValidateRaw(token); +``` + +Classes, symbols, constants, proxy instances, and metadata objects keep their canonical identity. Only interface function exports receive generation-specific call functions. A stale facade rejects calls after its module generation is destroyed. + +### Derived exports and registration APIs + +Direct `InterfaceFunction` exports require no work from interface authors. An interface needs an internal `BuildInterfaceFacade` export only when another exported function closes over an interface function, or when a decorator/registration API must record generation ownership during module evaluation. + +```ts +import type { InterfaceFacadeScope } from "@antelopejs/interface-core/facades"; + +export const Read = InterfaceFunction<() => string>("example.Read"); +export const Registrations = new RegisteringProxy< + (id: string, handler: Handler) => void +>("example.Registrations"); + +export function BuildInterfaceFacade( + _scope: InterfaceFacadeScope, + facade: Record, +) { + const boundRead = facade.Read as typeof Read; + const boundRegistrations = facade.Registrations as typeof Registrations; + return { + ReadUppercase: async () => (await boundRead()).toUpperCase(), + Register: (handler: Handler) => + boundRegistrations.register(handler.id, handler), + }; +} +``` + +The resolver builds and caches the facade before module evaluation. Interface builders derive helper functions and decorators from the bound values in `facade`; they never restore an ambient context around application callbacks. + ## `ImplementInterface` `ImplementInterface` connects an interface declaration to its implementation. It iterates over the declaration object and wires up each proxy to the corresponding implementation function. @@ -239,7 +282,7 @@ const moduleId = GetResponsibleModule(); ## `RunWithResponsibleModule` -`RunWithResponsibleModule` sets the responsible module explicitly for synchronous and asynchronous work. Proxy registrations made in the callback use this module directly instead of capturing and walking a stack. Nested contexts restore their parent when they complete, including when a callback throws. +`RunWithResponsibleModule` is a compatibility API that sets the responsible module explicitly for synchronous and asynchronous work. Proxy registrations made in the callback use this module directly instead of capturing and walking a stack. Nested contexts restore their parent when they complete, including when a callback throws. ```ts import { RunWithResponsibleModule } from "@antelopejs/interface-core"; @@ -250,9 +293,9 @@ await RunWithResponsibleModule("my-module", async () => { }); ``` -The module loader should wrap known module-owned entry points, including module evaluation and lifecycle hooks. Existing callers need no migration: outside an explicit context, `GetResponsibleModule` retains stack-based resolution as a backward-compatible fallback. Automatic proxy detachment and registration cleanup use the resolved module in both paths. +Current AntelopeJS Core does not wrap module evaluation, lifecycle hooks, or stored callbacks with this API. It resolves lexical interface facades before evaluating each module. Custom loaders and existing direct users can retain this API; outside an explicit context, `GetResponsibleModule` keeps stack-based resolution as a backward-compatible fallback. -Ownership contexts are scoped to a loaded module generation. Loaders that can overlap old and replacement instances should use `RunWithModuleContext` and provide a unique `owner` for every generation. `ModuleDestroyed` invalidates and cleans only the active event context's owner while preserving the existing module ID event contract. Detached asynchronous work from that owner then receives a `ModuleContextInvalidatedError`. +Resolver facades are scoped to a loaded module generation. `ModuleDestroyed` invalidates and cleans the exact owner emitted by Core. Calls retained from a destroyed generation then receive a `ModuleContextInvalidatedError`. ## Next steps diff --git a/docs/5.modules.md b/docs/5.modules.md index b109d36..b735272 100644 --- a/docs/5.modules.md +++ b/docs/5.modules.md @@ -35,25 +35,18 @@ loaded -> constructed -> active -> constructed -> loaded | `active` | Module is fully started and providing services | | `unknown` | Module status cannot be determined | -## Module execution context +## Advanced execution context APIs -`RunWithModuleContext` propagates module ownership and provider routing through synchronous and asynchronous work: +Provider selection is not an execution-context concern. Core resolves module-specific interface facades before module evaluation instead of installing an ambient context around lifecycle hooks or callbacks. -```ts -import { RunWithModuleContext } from "@antelopejs/interface-core/modules"; - -await RunWithModuleContext( - { - module: "search-provider", - owner: "search-provider#42", - provider: "search-provider", - providerRoutes: routes, - }, - () => constructModule(), -); -``` +| Situation | API to use | +| --- | --- | +| Application module lifecycle or ordinary interface call | None; Core binds the module's imports | +| Provider implementation attached with `ImplementInterface` | None; Core binds the provider's imports and ownership | +| Select a provider for an application module | Configure `importOverrides` | +| Inspect explicit ownership established with `RunWithResponsibleModule` | `GetModuleContext` | -`module` remains the stable public module ID. `owner` identifies one lifecycle generation and should be unique when old and replacement instances can overlap. Providers capture this full context when attaching callbacks. `GetModuleContext` returns the active context and throws `ModuleContextInvalidatedError` after its owner is destroyed. +`GetModuleContext` intentionally remains on the `/modules` subpath for ownership diagnostics. It does not select a provider. Application modules normally need neither API. ## Lifecycle events @@ -101,7 +94,7 @@ Events.ModuleDestroyed.register((moduleId: string) => { }); ``` -The event signature remains the module ID. When emitted inside `RunWithModuleContext`, cleanup targets that context's `owner`; without an explicit owner it retains the module-level behavior used by earlier releases. +The public event payload remains the module ID. Core also supplies the destroyed generation owner internally so cleanup cannot remove a replacement generation. An emitter that omits the owner retains the module-level cleanup behavior used by earlier releases. ## Management functions diff --git a/package.json b/package.json index da1cefd..e8e42dc 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,9 @@ "decorators": [ "dist/decorators.d.ts" ], + "facades": [ + "dist/facades.d.ts" + ], "modules": [ "dist/modules.d.ts" ], diff --git a/src/facades.ts b/src/facades.ts new file mode 100644 index 0000000..c3aaa33 --- /dev/null +++ b/src/facades.ts @@ -0,0 +1,357 @@ +import { + type ActiveModuleExecutionContext, + activateModuleContext, + assertActiveModuleContext, + internal, + type ModuleExecutionContext, +} from "./internal"; +import { + type AsyncProxy, + type EventProxy, + GetInterfaceProxyIdentity, + type InterfaceFunctionProxy, + IsInterfaceProxy, + type RegisteringProxy, +} from "./proxies"; + +type Func = (...args: A) => R; +const RESOLVER_FACADE_BINDER = Symbol.for( + "@antelopejs/interface-core/resolver-facade-binder", +); +const activeFacadeContexts = new WeakMap< + ModuleExecutionContext, + ActiveModuleExecutionContext +>(); + +export interface InterfaceFacadeScope { + readonly context: ModuleExecutionContext; + assertActive(): void; + bind>>( + declaration: InterfaceFunctionProxy, + ): InterfaceFunctionProxy; + bindProxy(declaration: T): T; + createFacade>(declaration: T): T; + onDestroy(cleanup: () => void): void; +} + +export type InterfaceFacadeBuilder = ( + scope: InterfaceFacadeScope, + facade: Record, +) => Record; + +export type ResolverFacadeBinder = ( + scope: InterfaceFacadeScope, +) => T; + +interface ResolverBindableFunction extends Func { + [RESOLVER_FACADE_BINDER]?: ResolverFacadeBinder; +} + +interface InterfaceFacadeDeclaration { + BuildInterfaceFacade?: InterfaceFacadeBuilder; +} + +function getSelectedProvider( + declaration: InterfaceFunctionProxy, + context: ModuleExecutionContext, +): string | undefined { + const identity = GetInterfaceProxyIdentity(declaration.proxy); + return identity ? context.providerRoutes?.[identity] : undefined; +} + +function bindInterfaceFunction( + declaration: InterfaceFunctionProxy, + context: ActiveModuleExecutionContext, +): InterfaceFunctionProxy { + const provider = getSelectedProvider(declaration, context); + const bound = (...args: Parameters) => { + try { + assertActiveModuleContext(context); + return declaration.proxy.callProvider(provider, ...args); + } catch (error) { + return Promise.reject(error); + } + }; + bound.proxy = bindInterfaceProxy(declaration.proxy, context); + Object.defineProperty(bound, "name", { + configurable: true, + value: declaration.name, + }); + return bound as InterfaceFunctionProxy; +} + +function bindAsyncProxy( + declaration: AsyncProxy, + facade: Record, + context: ActiveModuleExecutionContext, +): void { + Object.defineProperty(facade, "call", { + configurable: true, + value: (...args: any[]) => declaration.callFor(context, ...args), + }); + Object.defineProperty(facade, "onCall", { + configurable: true, + value: (callback: Func, manualDetach?: boolean) => + declaration.onCallFor(context, callback, manualDetach), + }); +} + +function bindRegisteringProxy( + declaration: RegisteringProxy, + facade: Record, + context: ActiveModuleExecutionContext, +): void { + Object.defineProperty(facade, "register", { + configurable: true, + value: (id: any, ...args: any[]) => + declaration.registerFor(context, id, ...args), + }); + Object.defineProperty(facade, "unregister", { + configurable: true, + value: (id: any) => declaration.unregisterFor(context, id), + }); + Object.defineProperty(facade, "onHandlers", { + configurable: true, + value: (register: Func, unregister: Func, manualDetach?: boolean) => + declaration.onHandlersFor(context, register, unregister, manualDetach), + }); +} + +function bindEventProxy( + declaration: EventProxy, + facade: Record, + context: ActiveModuleExecutionContext, +): void { + Object.defineProperty(facade, "register", { + configurable: true, + value: (callback: Func) => declaration.registerFor(context, callback), + }); + Object.defineProperty(facade, "unregister", { + configurable: true, + value: (callback: Func) => declaration.unregisterFor(context, callback), + }); +} + +function bindInterfaceProxy( + declaration: T, + context: ActiveModuleExecutionContext, +): T { + const facade = Object.create(declaration) as T & Record; + if (IsInterfaceProxy(declaration, "async")) { + bindAsyncProxy(declaration as AsyncProxy, facade, context); + return facade; + } + if (IsInterfaceProxy(declaration, "registering")) { + bindRegisteringProxy(declaration as RegisteringProxy, facade, context); + return facade; + } + if (IsInterfaceProxy(declaration, "event")) { + bindEventProxy(declaration as EventProxy, facade, context); + return facade; + } + return declaration; +} + +function isActiveModuleExecutionContext( + context: ModuleExecutionContext, +): context is ActiveModuleExecutionContext { + return ( + typeof (context as Partial).ownershipToken === + "symbol" + ); +} + +function createFacadeScope( + context: ModuleExecutionContext, +): InterfaceFacadeScope { + let activeContext: ActiveModuleExecutionContext; + if (isActiveModuleExecutionContext(context)) { + activeContext = context; + assertActiveModuleContext(activeContext); + } else { + const existing = activeFacadeContexts.get(context); + if (existing) { + assertActiveModuleContext(existing); + activeContext = existing; + } else { + activeContext = activateModuleContext(context); + activeFacadeContexts.set(context, activeContext); + } + } + return { + context: activeContext, + assertActive: () => assertActiveModuleContext(activeContext), + bind: (declaration) => bindInterfaceFunction(declaration, activeContext), + bindProxy: (declaration) => bindInterfaceProxy(declaration, activeContext), + createFacade: (declaration) => + CreateInterfaceFacade(declaration, activeContext), + onDestroy: (cleanup) => { + assertActiveModuleContext(activeContext); + internal.addOwnerCleanup(activeContext.owner, cleanup); + }, + }; +} + +function isInterfaceFunction( + value: unknown, +): value is InterfaceFunctionProxy { + if (typeof value !== "function" || !("proxy" in value)) { + return false; + } + return IsInterfaceProxy(value.proxy, "async"); +} + +function isNamespaceObject(value: object): boolean { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +interface FacadeVisit { + facade: object; + result?: object; +} + +function bindFacadeProperty( + value: object, + key: PropertyKey, + facade: object, + scope: InterfaceFacadeScope, + seen: WeakMap, +): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor) { + return false; + } + const original: unknown = + "value" in descriptor ? descriptor.value : Reflect.get(value, key); + const bound = bindInterfaceFunctions(original, scope, seen); + Object.defineProperty( + facade, + key, + bound === original + ? descriptor + : { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + value: bound, + writable: "writable" in descriptor ? descriptor.writable : false, + }, + ); + return bound !== original && !(original === value && bound === facade); +} + +function bindNamespaceObject( + value: object, + scope: InterfaceFacadeScope, + seen: WeakMap, +): object { + const existing = seen.get(value); + if (existing) { + return existing.result ?? existing.facade; + } + const facade = Object.create(Object.getPrototypeOf(value)); + const visit: FacadeVisit = { facade }; + seen.set(value, visit); + const changed = Reflect.ownKeys(value) + .map((key) => bindFacadeProperty(value, key, facade, scope, seen)) + .some(Boolean); + const result = changed ? facade : value; + visit.result = result; + return result; +} + +function bindInterfaceFunctions( + value: unknown, + scope: InterfaceFacadeScope, + seen = new WeakMap(), +): unknown { + if (isInterfaceFunction(value)) { + return scope.bind(value); + } + if (typeof value === "function") { + const binder = (value as ResolverBindableFunction)[RESOLVER_FACADE_BINDER]; + return binder ? binder(scope) : value; + } + if (typeof value !== "object" || value === null) { + return value; + } + if (IsInterfaceProxy(value)) { + return bindInterfaceProxy( + value, + scope.context as ActiveModuleExecutionContext, + ); + } + if (!isNamespaceObject(value)) { + return value; + } + return bindNamespaceObject(value, scope, seen); +} + +function applyOverrides>( + facade: T, + overrides: Record, +): T { + const result = Object.create(Object.getPrototypeOf(facade)); + const overrideKeys = new Set(Reflect.ownKeys(overrides)); + for (const key of Reflect.ownKeys(facade)) { + const descriptor = Object.getOwnPropertyDescriptor(facade, key); + if (!descriptor) { + continue; + } + if (!overrideKeys.has(key)) { + Object.defineProperty( + result, + key, + "value" in descriptor && descriptor.value === facade + ? { ...descriptor, value: result } + : descriptor, + ); + continue; + } + Object.defineProperty(result, key, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + value: Reflect.get(overrides, key), + writable: "writable" in descriptor ? descriptor.writable : false, + }); + overrideKeys.delete(key); + } + for (const key of overrideKeys) { + const descriptor = Object.getOwnPropertyDescriptor(overrides, key); + if (descriptor) { + Object.defineProperty(result, key, descriptor); + } + } + return result; +} + +export function CreateInterfaceFacade>( + declaration: T, + context: ModuleExecutionContext, + builder?: InterfaceFacadeBuilder, +): T { + const scope = createFacadeScope(context); + const facade = bindInterfaceFunctions(declaration, scope) as T; + const factory = + builder ?? (declaration as InterfaceFacadeDeclaration).BuildInterfaceFacade; + if (!factory) { + return facade; + } + const overrides = factory(scope, facade); + if (Reflect.ownKeys(overrides).length === 0) { + return facade; + } + return applyOverrides(facade, overrides); +} + +/** @internal Adds a lexical resolver binding to an infrastructure function. */ +export function BindResolverFacade( + declaration: T, + binder: ResolverFacadeBinder, +): void { + Object.defineProperty(declaration, RESOLVER_FACADE_BINDER, { + configurable: false, + enumerable: false, + value: binder, + }); +} diff --git a/src/index.ts b/src/index.ts index e99a8ab..18c4401 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,11 @@ import "reflect-metadata"; import type { Class } from "./decorators"; -import { type InterfaceConnection, internal } from "./internal"; +import { BindResolverFacade } from "./facades"; +import { + type ActiveModuleExecutionContext, + type InterfaceConnection, + internal, +} from "./internal"; import { Logging } from "./logging"; import { type AsyncProxy, @@ -98,20 +103,44 @@ type InterfaceToImpl = T extends infer P interface AsyncProxyProtocol { onCall(callback: Func): unknown; + onCallFor(context: ActiveModuleExecutionContext, callback: Func): unknown; } interface RegisteringProxyProtocol { onHandlers(register: Func, unregister: Func): unknown; + onHandlersFor( + context: ActiveModuleExecutionContext, + register: Func, + unregister: Func, + ): unknown; } interface AttachmentPlan { attach(): void; } +interface InterfaceFunctionDeclaration extends Func { + proxy?: unknown; +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function isCommonJsDeclarationMirror( + key: string, + parent: Record, + child: Record, +): boolean { + if (key !== "default") { + return false; + } + const entries = Object.entries(child); + return ( + entries.length > 0 && entries.every(([key, value]) => parent[key] === value) + ); +} + function assertAcyclic(value: unknown, label: string) { const visited = new WeakSet(); const active = new WeakSet(); @@ -141,19 +170,55 @@ function requireFunction(value: unknown, path: string): Func { return value as Func; } +function createAsyncAttachmentPlan( + proxy: AsyncProxyProtocol, + callback: Func, + context?: ActiveModuleExecutionContext, +): AttachmentPlan { + return { + attach: () => { + if (context) { + proxy.onCallFor(context, callback); + return; + } + proxy.onCall(callback); + }, + }; +} + +function createRegisteringAttachmentPlan( + proxy: RegisteringProxyProtocol, + register: Func, + unregister: Func, + context?: ActiveModuleExecutionContext, +): AttachmentPlan { + return { + attach: () => { + if (context) { + proxy.onHandlersFor(context, register, unregister); + return; + } + proxy.onHandlers(register, unregister); + }, + }; +} + function planProxyAttachment( proxy: unknown, implementation: unknown, path: string, + context?: ActiveModuleExecutionContext, ): AttachmentPlan | undefined { if (IsInterfaceProxy(proxy, "event")) { return; } if (IsInterfaceProxy(proxy, "async")) { const callback = requireFunction(implementation, path); - return { - attach: () => (proxy as AsyncProxyProtocol).onCall(callback), - }; + return createAsyncAttachmentPlan( + proxy as AsyncProxyProtocol, + callback, + context, + ); } if (!IsInterfaceProxy(proxy, "registering")) { return; @@ -166,39 +231,75 @@ function planProxyAttachment( implementation.unregister, `${path}.unregister`, ); - return { - attach: () => - (proxy as RegisteringProxyProtocol).onHandlers(register, unregister), - }; + return createRegisteringAttachmentPlan( + proxy as RegisteringProxyProtocol, + register, + unregister, + context, + ); +} + +function getDeclaredProxy(declared: unknown): unknown { + if (typeof declared === "function" && "proxy" in declared) { + return (declared as InterfaceFunctionDeclaration).proxy; + } + return declared; +} + +function planNestedAttachments( + key: string, + declared: unknown, + implemented: unknown, + declaration: Record, + path: string, + context?: ActiveModuleExecutionContext, +): AttachmentPlan[] { + if ( + !isObject(declared) || + IsInterfaceProxy(declared) || + isCommonJsDeclarationMirror(key, declaration, declared) + ) { + return []; + } + const nestedImplementation = isObject(implemented) ? implemented : {}; + return createAttachmentPlan( + declared, + nestedImplementation, + `${path}.${key}`, + context, + ); } function createAttachmentPlan( declaration: Record, implementation: Record, path = "implementation", + context?: ActiveModuleExecutionContext, ): AttachmentPlan[] { const plans: AttachmentPlan[] = []; for (const [key, declared] of Object.entries(declaration)) { const implemented = implementation[key]; - const proxy = - typeof declared === "function" && "proxy" in declared - ? (declared as Func & { proxy?: unknown }).proxy - : declared; - const proxyPlan = planProxyAttachment(proxy, implemented, `${path}.${key}`); + const proxy = getDeclaredProxy(declared); + const proxyPlan = planProxyAttachment( + proxy, + implemented, + `${path}.${key}`, + context, + ); if (proxyPlan) { plans.push(proxyPlan); continue; } - if (isObject(declared) && !IsInterfaceProxy(declared)) { - const nestedImplementation = isObject(implemented) ? implemented : {}; - plans.push( - ...createAttachmentPlan( - declared, - nestedImplementation, - `${path}.${key}`, - ), - ); - } + plans.push( + ...planNestedAttachments( + key, + declared, + implemented, + declaration, + path, + context, + ), + ); } return plans; } @@ -206,6 +307,7 @@ function createAttachmentPlan( function attachImplementation( declaration: Record, implementation: Record, + context?: ActiveModuleExecutionContext, ) { if (!isObject(declaration) || !isObject(implementation)) { throw new TypeError( @@ -214,7 +316,12 @@ function attachImplementation( } assertAcyclic(declaration, "declaration"); assertAcyclic(implementation, "implementation"); - const plans = createAttachmentPlan(declaration, implementation); + const plans = createAttachmentPlan( + declaration, + implementation, + "implementation", + context, + ); plans.forEach((plan) => { plan.attach(); }); @@ -275,6 +382,38 @@ export function ImplementInterface< return { declaration: decl, implementation: impl as T2 }; } +BindResolverFacade(ImplementInterface, (scope) => { + const context = scope.context as ActiveModuleExecutionContext; + return (( + declaration: Record | PromiseLike>, + implementation: + | Record + | PromiseLike>, + ) => { + if (isThenable(declaration) || isThenable(implementation)) { + return Promise.all([declaration, implementation]).then(([decl, impl]) => { + attachImplementation(decl, impl, context); + return { declaration: decl, implementation: impl }; + }); + } + attachImplementation(declaration, implementation, context); + return { declaration, implementation }; + }) as typeof ImplementInterface; +}); + +BindResolverFacade(GetResponsibleModule, (scope) => { + return ((_startFrame?: number) => + scope.context.module) as typeof GetResponsibleModule; +}); + +function getInterfaceInstancesFor( + module: string | undefined, + interfaceID: string, +): InterfaceConnection[] { + if (!module || !(module in internal.interfaceConnections)) return []; + return internal.interfaceConnections[module][interfaceID] ?? []; +} + /** * Gets all instances of a specific interface across the system. * @@ -286,11 +425,17 @@ export function ImplementInterface< export function GetInterfaceInstances( interfaceID: string, ): InterfaceConnection[] { - const module = GetResponsibleModule(); - if (!module || !(module in internal.interfaceConnections)) return []; - return internal.interfaceConnections[module][interfaceID] ?? []; + return getInterfaceInstancesFor(GetResponsibleModule(), interfaceID); } +BindResolverFacade(GetInterfaceInstances, (scope) => { + return ((interfaceID: string) => + getInterfaceInstancesFor( + scope.context.module, + interfaceID, + )) as typeof GetInterfaceInstances; +}); + /** * Gets a specific instance of an interface by ID. * @@ -304,13 +449,50 @@ export function GetInterfaceInstance( interfaceID: string, connectionID: string, ): InterfaceConnection | undefined { - const module = GetResponsibleModule(); - if (!module || !(module in internal.interfaceConnections)) return; - const connections = internal.interfaceConnections[module]; - return (connections[interfaceID] ?? []).find( + return getInterfaceInstanceFor( + GetResponsibleModule(), + interfaceID, + connectionID, + ); +} + +function getInterfaceInstanceFor( + module: string | undefined, + interfaceID: string, + connectionID: string, +): InterfaceConnection | undefined { + return getInterfaceInstancesFor(module, interfaceID).find( (connection) => connection.id === connectionID, ); } -export * from "./modules"; -export * from "./runtime"; +BindResolverFacade(GetInterfaceInstance, (scope) => { + return ((interfaceID: string, connectionID: string) => + getInterfaceInstanceFor( + scope.context.module, + interfaceID, + connectionID, + )) as typeof GetInterfaceInstance; +}); + +export { + DestroyModule, + Events, + GetModuleInfo, + ListModules, + LoadModule, + type ModuleDefinition, + type ModuleInfo, + ReloadModule, + StartModule, + StopModule, +} from "./modules"; +export { + DEV_REGISTRY_PATH, + type DevServerEndpoint, + type DevServerEntry, + type DevServerRegistry, + GetRuntimeInfo, + RegisterDevServer, + type RuntimeInfo, +} from "./runtime"; diff --git a/src/internal.ts b/src/internal.ts index ff6e6a7..55f3f51 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -28,7 +28,7 @@ export interface ModuleExecutionContext { providerRoutes?: Readonly>; } -interface ActiveModuleExecutionContext extends ModuleExecutionContext { +export interface ActiveModuleExecutionContext extends ModuleExecutionContext { owner: string; ownershipToken: symbol; } @@ -62,10 +62,12 @@ export interface InterfaceRuntime { dir: string; id: string; isImplementor?: boolean; + context?: ActiveModuleExecutionContext; }>; testStubMode: boolean; knownAsync: Map>; knownRegisters: Map>; + ownerCleanups: Map void>>; registeringProxies: Set<{ unregisterModule(module: string): void; unregisterOwner(owner: string): void; @@ -77,6 +79,7 @@ export interface InterfaceRuntime { interfaceConnections: Record>; executionContext: AsyncLocalStorage; activeOwnerTokens: Map; + moduleOwners: Map>; proxyStates: Map; nextProxyIdentity: number; nextLeaseGeneration: number; @@ -92,6 +95,7 @@ export interface InterfaceRuntime { module: string, proxy: RuntimeCleanup | { detach(): void }, ): void; + addOwnerCleanup(owner: string, cleanup: () => void): void; } function addToMapSet(map: Map>, key: string, value: T) { @@ -107,6 +111,7 @@ function createRuntime(): InterfaceRuntime { testStubMode: false, knownAsync: new Map(), knownRegisters: new Map(), + ownerCleanups: new Map(), registeringProxies: new Set(), knownEvents: new Set(), interfaceConnections: Object.create(null) as Record< @@ -115,6 +120,7 @@ function createRuntime(): InterfaceRuntime { >, executionContext: new AsyncLocalStorage(), activeOwnerTokens: new Map(), + moduleOwners: new Map(), proxyStates: new Map(), nextProxyIdentity: 1, nextLeaseGeneration: 1, @@ -125,6 +131,9 @@ function createRuntime(): InterfaceRuntime { addRegisteringProxy(module, proxy) { addToMapSet(runtime.knownRegisters, module, proxy); }, + addOwnerCleanup(owner, cleanup) { + addToMapSet(runtime.ownerCleanups, owner, cleanup); + }, }; return runtime; } @@ -138,6 +147,11 @@ function getRuntime(): InterfaceRuntime { ); } if (existing) { + existing.moduleOwners ??= new Map(); + existing.ownerCleanups ??= new Map(); + existing.addOwnerCleanup ??= (owner, cleanup) => { + addToMapSet(existing.ownerCleanups, owner, cleanup); + }; return existing; } const runtime = createRuntime(); @@ -163,7 +177,13 @@ function getOwnerToken(owner: string): symbol { return token; } -function assertActiveModuleContext(context: ActiveModuleExecutionContext) { +function trackModuleOwner(module: string, owner: string) { + addToMapSet(internal.moduleOwners, module, owner); +} + +export function assertActiveModuleContext( + context: ActiveModuleExecutionContext, +) { if ( internal.activeOwnerTokens.get(context.owner) !== context.ownershipToken ) { @@ -171,23 +191,30 @@ function assertActiveModuleContext(context: ActiveModuleExecutionContext) { } } -export function runWithModuleContext( +export function activateModuleContext( context: ModuleExecutionContext, - callback: () => T, -): T { - const inheritedContext = internal.executionContext.getStore(); - if (inheritedContext) { - assertActiveModuleContext(inheritedContext); - } +): ActiveModuleExecutionContext { if (!context.module) { throw new Error("Module execution context requires a module ID."); } const owner = context.owner ?? context.module; - const activeContext = { + trackModuleOwner(context.module, owner); + return { ...context, owner, ownershipToken: getOwnerToken(owner), }; +} + +export function runWithModuleContext( + context: ModuleExecutionContext, + callback: () => T, +): T { + const inheritedContext = internal.executionContext.getStore(); + if (inheritedContext) { + assertActiveModuleContext(inheritedContext); + } + const activeContext = activateModuleContext(context); return internal.executionContext.run(activeContext, callback); } @@ -197,6 +224,7 @@ export function captureModuleContext(): const context = internal.executionContext.getStore(); if (context) { assertActiveModuleContext(context); + trackModuleOwner(context.module, context.owner); } return context; } @@ -217,6 +245,20 @@ export function getModuleContext(): ModuleExecutionContext | undefined { return captureModuleContext(); } +function removeOwnerFromModules(owner: string) { + for (const [module, owners] of internal.moduleOwners) { + owners.delete(owner); + if (!owners.size) { + internal.moduleOwners.delete(module); + } + } +} + +export function getModuleOwners(module: string): ReadonlySet { + return internal.moduleOwners.get(module) ?? new Set(); +} + export function invalidateModuleContext(owner: string) { internal.activeOwnerTokens.delete(owner); + removeOwnerFromModules(owner); } diff --git a/src/modules.ts b/src/modules.ts index 33c3af1..727e807 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -1,23 +1,20 @@ import { getModuleContext, + getModuleOwners, internal, invalidateModuleContext, type ModuleExecutionContext, peekModuleContext, type RuntimeCleanup, - runWithModuleContext, } from "./internal"; import { EventProxy, InterfaceFunction } from "./proxies"; -/** Runs work with module ownership and an optional provider route across awaits. */ -export function RunWithModuleContext( - context: ModuleExecutionContext, - callback: () => T, -): T { - return runWithModuleContext(context, callback); -} - -/** Returns the active module execution context, if one exists. */ +/** + * Returns the active module execution context, if one exists. + * + * This is intended for framework and interface infrastructure. Application + * modules do not need to inspect their execution context during normal use. + */ export function GetModuleContext(): ModuleExecutionContext | undefined { return getModuleContext(); } @@ -75,9 +72,9 @@ export namespace Events { * * @param module Module ID */ - export const ModuleDestroyed = new EventProxy<(module: string) => void>( - "modules.ModuleDestroyed", - ); + export const ModuleDestroyed = new EventProxy< + (module: string, owner?: string) => void + >("modules.ModuleDestroyed"); } function runCleanup( @@ -96,17 +93,23 @@ function runCleanup( } } -function getDestroyedOwner(module: string): string { +function getDestroyedOwners(module: string, owner?: string): string[] { + if (owner) { + return [owner]; + } const context = peekModuleContext(); - if (context?.module !== module) { - return module; + if (context?.module === module) { + return [context.owner ?? module]; } - return context.owner ?? module; + return [...new Set([module, ...getModuleOwners(module)])]; } -Events.ModuleDestroyed.register((module) => { - const owner = getDestroyedOwner(module); +function cleanupDestroyedOwner(module: string, owner: string) { invalidateModuleContext(owner); + for (const cleanup of internal.ownerCleanups.get(owner) ?? []) { + runCleanup({ cleanup }, owner, "cleanup-owner"); + } + internal.ownerCleanups.delete(owner); for (const cleanup of internal.knownAsync.get(owner) ?? []) { runCleanup(cleanup, owner, "detach-async-provider"); } @@ -135,6 +138,12 @@ Events.ModuleDestroyed.register((module) => { }); } } +} + +Events.ModuleDestroyed.register((module, destroyedOwner) => { + getDestroyedOwners(module, destroyedOwner).forEach((owner) => { + cleanupDestroyedOwner(module, owner); + }); }); /** diff --git a/src/proxies.ts b/src/proxies.ts index 1db7b2f..9b155bb 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -4,10 +4,13 @@ import { ProviderQueueFullError, } from "./errors"; import { + type ActiveModuleExecutionContext, + assertActiveModuleContext, captureModuleContext, getModuleContext, internal, invalidateModuleContext, + type ModuleExecutionContext, type ProxyBrand, RUNTIME_PROTOCOL_VERSION, runWithCapturedModuleContext, @@ -144,18 +147,36 @@ export function GetInterfaceProxyIdentity(value: unknown): string | undefined { return readBrand(value)?.identity; } +interface ExecutionContextResolution { + context?: ModuleExecutionContext; + ambient: boolean; +} + +function resolveExecutionContext(useStack = true): ExecutionContextResolution { + const ambientContext = getModuleContext(); + if (ambientContext) { + return { context: ambientContext, ambient: true }; + } + return { + context: useStack ? getResponsibleModuleContext() : undefined, + ambient: false, + }; +} + function getAttachmentRoute(manualDetach?: boolean) { - const context = getModuleContext(); - const responsible = - manualDetach || context?.module ? undefined : GetResponsibleModule(); - const owner = - context?.owner ?? context?.module ?? responsible ?? DEFAULT_PROVIDER; + const { context } = resolveExecutionContext(!manualDetach); + const owner = context?.owner ?? context?.module ?? DEFAULT_PROVIDER; return { owner, provider: context?.provider ?? owner }; } -function getRequestedProvider(proxyIdentity: string) { - const context = getModuleContext(); - return context?.providerRoutes?.[proxyIdentity] ?? context?.provider; +function getRequestedProvider( + proxyIdentity: string, + resolution = resolveExecutionContext(), +) { + return ( + resolution.context?.providerRoutes?.[proxyIdentity] ?? + (resolution.ambient ? resolution.context?.provider : undefined) + ); } function bindProviderCallback(callback: T): T { @@ -172,13 +193,38 @@ interface ExecutionOwnership { owner?: string; } -function getExecutionOwnership(): ExecutionOwnership { - const context = getModuleContext(); +function getExecutionOwnership( + context = resolveExecutionContext().context, +): ExecutionOwnership { if (context) { return { module: context.module, owner: context.owner ?? context.module }; } - const module = GetResponsibleModule(); - return { module, owner: module }; + return {}; +} + +function getScopedAttachmentRoute( + context: ActiveModuleExecutionContext, +): AttachmentRoute { + assertActiveModuleContext(context); + return { + owner: context.owner, + provider: context.provider ?? context.module, + }; +} + +function getScopedProvider( + proxyIdentity: string, + context: ActiveModuleExecutionContext, +): string | undefined { + assertActiveModuleContext(context); + return context.providerRoutes?.[proxyIdentity]; +} + +function getScopedOwnership( + context: ActiveModuleExecutionContext, +): Required { + assertActiveModuleContext(context); + return { module: context.module, owner: context.owner }; } function selectProvider( @@ -255,10 +301,30 @@ export class AsyncProxy>> { /** Attaches a provider callback and replays compatible queued calls. */ public onCall(callback: T, manualDetach?: boolean): AttachmentLease { const route = getAttachmentRoute(manualDetach); + return this.attachCall(route, bindProviderCallback(callback), manualDetach); + } + + /** @internal Attaches a provider selected lexically by the module resolver. */ + public onCallFor( + context: ActiveModuleExecutionContext, + callback: T, + manualDetach?: boolean, + ): AttachmentLease { + return this.attachCall( + getScopedAttachmentRoute(context), + callback, + manualDetach, + ); + } + + private attachCall( + route: AttachmentRoute, + callback: T, + manualDetach?: boolean, + ): AttachmentLease { const lease = { ...route, generation: internal.nextLeaseGeneration++ }; - const providerCallback = bindProviderCallback(callback); this.state.callbacks.set(route.provider, { - callback: providerCallback, + callback, ...lease, }); if (!manualDetach) { @@ -266,7 +332,7 @@ export class AsyncProxy>> { cleanup: () => this.detach(lease), }); } - this.replayQueue(route.provider, providerCallback); + this.replayQueue(route.provider, callback); return lease; } @@ -302,6 +368,46 @@ export class AsyncProxy>> { if (attachment) { return this.invoke(attachment.callback, args); } + return this.enqueue(args, requested); + } + + /** @internal Calls one explicitly selected provider for a resolver facade. */ + public callProvider( + requested: string | undefined, + ...args: Parameters + ): Promise { + let attachment: Attachment | undefined; + try { + attachment = selectProvider( + this.state.callbacks, + this[PROXY_BRAND].identity, + requested, + ); + } catch (error) { + return Promise.reject(error); + } + if (attachment) { + return this.invoke(attachment.callback, args); + } + return this.enqueue(args, requested); + } + + /** @internal Calls the provider selected lexically by the module resolver. */ + public callFor( + context: ActiveModuleExecutionContext, + ...args: Parameters + ): Promise { + try { + return this.callProvider( + getScopedProvider(this[PROXY_BRAND].identity, context), + ...args, + ); + } catch (error) { + return Promise.reject(error); + } + } + + private enqueue(args: Parameters, requested: string | undefined) { if (internal.testStubMode) { return Promise.reject(new MissingProviderError()); } @@ -339,11 +445,19 @@ export class AsyncProxy>> { } } +export interface InterfaceFunctionProxy< + T extends Func = Func, + R = Awaited>, +> { + (...args: Parameters): Promise; + proxy: AsyncProxy; +} + /** Creates an interface function backed by an asynchronous proxy. */ export function InterfaceFunction< T extends Func = Func, R = Awaited>, ->(identity?: string): (...args: Parameters) => Promise { +>(identity?: string): InterfaceFunctionProxy { const proxy = new AsyncProxy(identity); const func = (...args: Parameters) => proxy.call(...args); func.proxy = proxy; @@ -405,16 +519,43 @@ export class RegisteringProxy { manualDetach?: boolean, ): AttachmentLease { const route = getAttachmentRoute(manualDetach); + return this.attachHandlers( + route, + bindProviderCallback(register), + bindProviderCallback(unregister), + manualDetach, + ); + } + + /** @internal Attaches handlers selected lexically by the module resolver. */ + public onHandlersFor( + context: ActiveModuleExecutionContext, + register: T, + unregister: (id: RID) => void, + manualDetach?: boolean, + ): AttachmentLease { + return this.attachHandlers( + getScopedAttachmentRoute(context), + register, + unregister, + manualDetach, + ); + } + + private attachHandlers( + route: AttachmentRoute, + register: T, + unregister: (id: RID) => void, + manualDetach?: boolean, + ): AttachmentLease { const lease = this.createLease(route); - const boundRegister = bindProviderCallback(register); - const boundUnregister = bindProviderCallback(unregister); this.state.callbacks.set(route.provider, { provider: route.provider, - register: this.createAttachment(boundRegister, lease, manualDetach), - unregister: this.createAttachment(boundUnregister, lease, manualDetach), + register: this.createAttachment(register, lease, manualDetach), + unregister: this.createAttachment(unregister, lease, manualDetach), }); this.trackAttachment(lease, Boolean(manualDetach)); - this.replayRegistrations(route.provider, boundRegister); + this.replayRegistrations(route.provider, register); return lease; } @@ -441,12 +582,51 @@ export class RegisteringProxy { /** Registers an entry with the selected provider or queues it for bootstrap. */ public register(id: RID, ...args: RArgs) { - const requested = getRequestedProvider(this[PROXY_BRAND].identity); + const resolution = resolveExecutionContext(); + const requested = getRequestedProvider( + this[PROXY_BRAND].identity, + resolution, + ); + this.registerWith( + requested, + getExecutionOwnership(resolution.context), + true, + id, + ...args, + ); + } + + /** @internal Registers through a route selected lexically by the resolver. */ + public registerFor( + context: ActiveModuleExecutionContext, + id: RID, + ...args: RArgs + ) { + const requested = getScopedProvider(this[PROXY_BRAND].identity, context); + this.registerWith( + requested, + getScopedOwnership(context), + requested !== undefined, + id, + ...args, + ); + } + + private registerWith( + requested: string | undefined, + ownership: ExecutionOwnership, + queueIfMissing: boolean, + id: RID, + ...args: RArgs + ) { const callback = selectProvider( this.state.callbacks, this[PROXY_BRAND].identity, requested, ); + if (!callback && !queueIfMissing) { + return; + } if (!callback && internal.testStubMode) { throw new MissingProviderError(); } @@ -460,7 +640,6 @@ export class RegisteringProxy { internal.maxPendingOperations, ); } - const ownership = getExecutionOwnership(); this.state.registered.set(id, { ...ownership, provider: requested ?? callback?.provider, @@ -487,6 +666,12 @@ export class RegisteringProxy { } } + /** @internal Unregisters through a live lexical resolver scope. */ + public unregisterFor(context: ActiveModuleExecutionContext, id: RID) { + assertActiveModuleContext(context); + this.unregister(id); + } + /** Unregisters every entry owned by a destroyed module. */ public unregisterModule(module: string) { this.unregisterMatching((entry) => entry.module === module, module); @@ -628,10 +813,20 @@ export class EventProxy { /** Registers a handler once. */ public register(func: T) { + const { context } = resolveExecutionContext(); + this.registerWith(getExecutionOwnership(context), func); + } + + /** @internal Registers a handler owned lexically by the resolver scope. */ + public registerFor(context: ActiveModuleExecutionContext, func: T) { + this.registerWith(getScopedOwnership(context), func); + } + + private registerWith(ownership: ExecutionOwnership, func: T) { if (this.state.registered.some((existing) => existing.func === func)) { return; } - this.state.registered.push({ ...getExecutionOwnership(), func }); + this.state.registered.push({ ...ownership, func }); } /** Unregisters a handler. */ @@ -641,6 +836,12 @@ export class EventProxy { ); } + /** @internal Unregisters through a live lexical resolver scope. */ + public unregisterFor(context: ActiveModuleExecutionContext, fn: T) { + assertActiveModuleContext(context); + this.unregister(fn); + } + /** Unregisters handlers owned by a destroyed module. */ public unregisterModule(module: string) { this.state.registered = this.state.registered.filter( @@ -656,26 +857,47 @@ export class EventProxy { } } -function captureCallStack(startFrame = 0): NodeJS.CallSite[] { +function captureCallStack( + constructorOpt: (...args: any[]) => any, + startFrame = 0, +): NodeJS.CallSite[] { const oldHandler = Error.prepareStackTrace; const oldLimit = Error.stackTraceLimit; Error.stackTraceLimit = Infinity; Error.prepareStackTrace = (_, trace) => trace; const error = {} as { stack: string[] }; - Error.captureStackTrace(error, GetResponsibleModule); + Error.captureStackTrace(error, constructorOpt); const trace = error.stack as unknown as NodeJS.CallSite[]; Error.prepareStackTrace = oldHandler; Error.stackTraceLimit = oldLimit; return trace.slice(startFrame); } +function getResponsibleModuleContext( + startFrame = 0, +): ModuleExecutionContext | undefined { + const trace = captureCallStack(getResponsibleModuleContext, startFrame); + const responsible = findResponsibleFile(trace); + if (responsible.context) { + assertActiveModuleContext(responsible.context); + return responsible.context; + } + if (responsible.module) { + return { module: responsible.module, owner: responsible.module }; + } + internal.asyncContextReporter?.(trace); + return responsible.lastInterface + ? { module: responsible.lastInterface, owner: responsible.lastInterface } + : undefined; +} + /** Gets the responsible module from explicit async context or the call stack. */ export function GetResponsibleModule(startFrame = 0): string | undefined { const contextModule = getModuleContext()?.module; if (contextModule) { return contextModule; } - const trace = captureCallStack(startFrame); + const trace = captureCallStack(GetResponsibleModule, startFrame); const responsible = findResponsibleFile(trace); if (responsible.module) { return responsible.module; diff --git a/src/responsible-module.ts b/src/responsible-module.ts index a205a3e..7a6ad31 100644 --- a/src/responsible-module.ts +++ b/src/responsible-module.ts @@ -1,13 +1,15 @@ -import { internal } from "./internal"; +import { type ActiveModuleExecutionContext, internal } from "./internal"; export interface ModuleFolderEntry { dir: string; id: string; isImplementor?: boolean; + context?: ActiveModuleExecutionContext; } export interface ResponsibleModuleResult { module?: string; + context?: ActiveModuleExecutionContext; lastInterface: string; } @@ -18,10 +20,20 @@ function findMatchingEntry( let best: ModuleFolderEntry | undefined; let bestLen = 0; for (const entry of entries) { - if (fileName.startsWith(entry.dir) && entry.dir.length > bestLen) { - best = entry; - bestLen = entry.dir.length; + if ( + entry.dir.length <= bestLen || + (fileName !== entry.dir && + !fileName.startsWith(`${entry.dir}/`) && + !fileName.startsWith(`${entry.dir}\\`)) + ) { + continue; } + const relativePath = fileName.slice(entry.dir.length + 1); + if (relativePath.split(/[/\\]/).includes("node_modules")) { + continue; + } + best = entry; + bestLen = entry.dir.length; } return best; } @@ -30,7 +42,9 @@ function findMatchingEntry( * Walk the trace to decide which module is responsible for the current call. * * Rules: - * 1. `node_modules` and generic `node:internal/` frames are skipped. + * 1. Generic `node:internal/` frames and dependencies nested below a tracked + * module are skipped. A module whose own root is in `node_modules` is + * still eligible. * 2. `node:internal/modules/...` (the require loader) is a hard boundary: * frames above it belong to the module currently being loaded (owning * the side effect); frames below it belong to whoever triggered the @@ -44,18 +58,14 @@ export function findResponsibleFile( entries: ModuleFolderEntry[] = internal.moduleByFolder, ): ResponsibleModuleResult { const lastInterface = ""; - let implementorMatch: string | undefined; + let implementorMatch: ModuleFolderEntry | undefined; for (const site of trace) { const fileName = site.getFileName(); if (fileName?.startsWith("node:internal/modules/")) { break; } - if ( - !fileName || - fileName.startsWith("node:internal/") || - fileName.match(/[/\\]node_modules[/\\]/) - ) { + if (!fileName || fileName.startsWith("node:internal/")) { continue; } const match = findMatchingEntry(fileName, entries); @@ -63,13 +73,21 @@ export function findResponsibleFile( continue; } if (!match.isImplementor) { - return { module: match.id, lastInterface }; + return { + module: match.id, + context: match.context, + lastInterface, + }; } - implementorMatch = match.id; + implementorMatch = match; } if (implementorMatch) { - return { module: implementorMatch, lastInterface }; + return { + module: implementorMatch.id, + context: implementorMatch.context, + lastInterface, + }; } return { lastInterface }; } diff --git a/src/tests/generation-cleanup.test.ts b/src/tests/generation-cleanup.test.ts index f2569d1..e331cfb 100644 --- a/src/tests/generation-cleanup.test.ts +++ b/src/tests/generation-cleanup.test.ts @@ -5,8 +5,8 @@ import { GetInterfaceProxyIdentity, RegisteringProxy, } from ".."; -import { internal } from "../internal"; -import { Events, RunWithModuleContext } from "../modules"; +import { internal, runWithModuleContext } from "../internal"; +import { Events } from "../modules"; describe("generation-owned cleanup", () => { afterEach(() => { @@ -17,19 +17,19 @@ describe("generation-owned cleanup", () => { const proxy = new AsyncProxy<() => string>("generation.async"); const identity = GetInterfaceProxyIdentity(proxy) as string; let oldLease: ReturnType | undefined; - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "shared#old", provider: "shared" }, () => { oldLease = proxy.onCall(() => "old"); }, ); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "shared#new", provider: "shared" }, () => proxy.onCall(() => "new"), ); proxy.detach(oldLease); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "shared#old", provider: "shared" }, () => { Events.ModuleDestroyed.emit("shared"); @@ -40,7 +40,7 @@ describe("generation-owned cleanup", () => { expect(internal.knownAsync.has("shared#old")).to.equal(false); expect(internal.knownAsync.has("shared#new")).to.equal(true); expect( - await RunWithModuleContext( + await runWithModuleContext( { module: "consumer", owner: "consumer#1", @@ -56,7 +56,7 @@ describe("generation-owned cleanup", () => { "generation.registering", ); const calls: string[] = []; - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "shared-register#old", provider: "shared" }, () => proxy.onHandlers( @@ -64,7 +64,7 @@ describe("generation-owned cleanup", () => { () => undefined, ), ); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "shared-register#new", provider: "shared" }, () => proxy.onHandlers( @@ -72,7 +72,7 @@ describe("generation-owned cleanup", () => { () => undefined, ), ); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "shared-register#old", provider: "shared" }, () => Events.ModuleDestroyed.emit("shared"), ); @@ -89,11 +89,11 @@ describe("generation-owned cleanup", () => { "generation.split-registering", ); const calls: string[] = []; - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "split#old", provider: "shared" }, () => proxy.onRegister((id) => calls.push(`old-register:${id}`)), ); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "split#new", provider: "shared" }, () => proxy.onUnregister((id) => calls.push(`new-unregister:${id}`)), ); @@ -101,7 +101,7 @@ describe("generation-owned cleanup", () => { expect(calls).to.deep.equal(["old-register:item"]); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "split#old", provider: "shared" }, () => { Events.ModuleDestroyed.emit("shared"); @@ -114,7 +114,7 @@ describe("generation-owned cleanup", () => { expect(internal.knownRegisters.has("split#old")).to.equal(false); expect(internal.knownRegisters.has("split#new")).to.equal(true); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "split#new", provider: "shared" }, () => { Events.ModuleDestroyed.emit("shared"); @@ -132,11 +132,11 @@ describe("generation-owned cleanup", () => { "generation.reverse-split-registering", ); const calls: string[] = []; - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "reverse#old", provider: "shared" }, () => proxy.onUnregister((id) => calls.push(`old-unregister:${id}`)), ); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "reverse#new", provider: "shared" }, () => proxy.onRegister((id) => calls.push(`new-register:${id}`)), ); @@ -150,7 +150,7 @@ describe("generation-owned cleanup", () => { "new-register:survivor", ]); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "reverse#old", provider: "shared" }, () => { Events.ModuleDestroyed.emit("shared"); @@ -164,7 +164,7 @@ describe("generation-owned cleanup", () => { expect(internal.knownRegisters.has("reverse#old")).to.equal(false); expect(internal.knownRegisters.has("reverse#new")).to.equal(true); - RunWithModuleContext( + runWithModuleContext( { module: "shared", owner: "reverse#new", provider: "shared" }, () => { Events.ModuleDestroyed.emit("shared"); @@ -188,15 +188,15 @@ describe("generation-owned cleanup", () => { (id) => calls.push(`remove:${id}`), true, ); - RunWithModuleContext({ module: "consumer", owner: "consumer#old" }, () => { + runWithModuleContext({ module: "consumer", owner: "consumer#old" }, () => { registrations.register("old"); event.register(() => calls.push("old-event")); }); - RunWithModuleContext({ module: "consumer", owner: "consumer#new" }, () => { + runWithModuleContext({ module: "consumer", owner: "consumer#new" }, () => { registrations.register("new"); event.register(() => calls.push("new-event")); }); - RunWithModuleContext({ module: "consumer", owner: "consumer#old" }, () => { + runWithModuleContext({ module: "consumer", owner: "consumer#old" }, () => { Events.ModuleDestroyed.emit("consumer"); }); diff --git a/src/tests/implement-interface-validation.test.ts b/src/tests/implement-interface-validation.test.ts index e00e2e0..f0b9fd5 100644 --- a/src/tests/implement-interface-validation.test.ts +++ b/src/tests/implement-interface-validation.test.ts @@ -37,6 +37,17 @@ describe("ImplementInterface validation", () => { ).to.throw("implementation.proxy.unregister"); }); + it("ignores CommonJS namespace mirrors", async () => { + const proxy = new AsyncProxy<() => string>("test.commonjs-mirror"); + const declaration = { proxy }; + + ImplementInterface({ ...declaration, default: declaration }, { + proxy: () => "value", + } as never); + + expect(await proxy.call()).to.equal("value"); + }); + it("rejects cycles in declarations and implementations", () => { const declaration: Record = {}; declaration.self = declaration; diff --git a/src/tests/interface-connections.test.ts b/src/tests/interface-connections.test.ts index b5856a0..a33e7fc 100644 --- a/src/tests/interface-connections.test.ts +++ b/src/tests/interface-connections.test.ts @@ -4,8 +4,7 @@ import { GetInterfaceInstances, type InterfaceConnection, } from ".."; -import { internal } from "../internal"; -import { RunWithModuleContext } from "../modules"; +import { internal, runWithModuleContext } from "../internal"; describe("interface connection metadata", () => { afterEach(() => { @@ -30,7 +29,7 @@ describe("interface connection metadata", () => { "@antelopejs/interface-example": connections, }; - const result = RunWithModuleContext( + const result = runWithModuleContext( { module: "consumer", owner: "consumer#metadata" }, () => ({ all: GetInterfaceInstances("@antelopejs/interface-example"), diff --git a/src/tests/interface-facade.test.ts b/src/tests/interface-facade.test.ts new file mode 100644 index 0000000..54c4406 --- /dev/null +++ b/src/tests/interface-facade.test.ts @@ -0,0 +1,297 @@ +import { expect } from "chai"; +import * as InterfaceCore from ".."; +import { InterfaceFunction } from ".."; +import { ModuleContextInvalidatedError } from "../errors"; +import { CreateInterfaceFacade, type InterfaceFacadeScope } from "../facades"; +import { runWithModuleContext } from "../internal"; +import { + Events, + GetModuleContext, + type ModuleExecutionContext, +} from "../modules"; + +class SharedResult {} + +function providerContext(provider: string): ModuleExecutionContext { + return { + module: provider, + owner: `${provider}#1`, + provider, + }; +} + +function consumerContext( + owner: string, + proxyIdentity: string, + provider: string, +): ModuleExecutionContext { + return { + module: "consumer", + owner, + providerRoutes: { [proxyIdentity]: provider }, + }; +} + +describe("interface facades", () => { + it("does not queue registrations without a selected provider", () => { + const Registrations = new InterfaceCore.RegisteringProxy< + (id: string) => void + >("facade.optional-registrations"); + const facade = CreateInterfaceFacade( + { Registrations }, + { module: "optional-consumer", owner: "optional-consumer#1" }, + ); + const replayed: string[] = []; + + facade.Registrations.register("before-provider"); + const lease = Registrations.onRegister((id) => replayed.push(id), true); + facade.Registrations.register("after-provider"); + + expect(replayed).to.deep.equal(["after-provider"]); + Registrations.detach(lease); + }); + + it("attaches and calls providers without restoring ambient callback context", async () => { + const Read = InterfaceFunction<() => string>("facade.LexicalRead"); + const providerCore = CreateInterfaceFacade( + InterfaceCore, + providerContext("provider"), + ); + const consumer = CreateInterfaceFacade( + { Read }, + consumerContext( + "consumer#lexical", + "async:facade.LexicalRead", + "provider", + ), + ); + let callbackContext: ModuleExecutionContext | undefined; + + providerCore.ImplementInterface( + { Read }, + { + Read: () => { + callbackContext = GetModuleContext(); + return "value"; + }, + }, + ); + + expect(await Promise.resolve().then(() => consumer.Read())).to.equal( + "value", + ); + expect(callbackContext).to.equal(undefined); + }); + + it("automatically binds root and namespace functions to each provider", async () => { + const Call = InterfaceFunction<(value: string) => string>("facade.Call"); + const NestedCall = + InterfaceFunction<(value: string) => string>("facade.NestedCall"); + const proxyIdentity = "async:facade.Call"; + const nestedIdentity = "async:facade.NestedCall"; + const sharedMetadata = {}; + for (const provider of ["provider-a", "provider-b"]) { + runWithModuleContext(providerContext(provider), () => { + Call.proxy.onCall((value) => `${provider}:${value}`, true); + NestedCall.proxy.onCall((value) => `${provider}:nested:${value}`, true); + }); + } + const declaration: Record = { + Call, + internal: { NestedCall }, + metadataA: sharedMetadata, + metadataB: sharedMetadata, + SharedResult, + }; + declaration.default = declaration; + const first = CreateInterfaceFacade(declaration, { + ...consumerContext("consumer#1", proxyIdentity, "provider-a"), + providerRoutes: { + [proxyIdentity]: "provider-a", + [nestedIdentity]: "provider-a", + }, + }); + const second = CreateInterfaceFacade(declaration, { + ...consumerContext("consumer#2", proxyIdentity, "provider-b"), + providerRoutes: { + [proxyIdentity]: "provider-b", + [nestedIdentity]: "provider-b", + }, + }); + + await Promise.resolve(); + + expect(await first.Call("value")).to.equal("provider-a:value"); + expect(await second.Call("value")).to.equal("provider-b:value"); + expect(await first.internal.NestedCall("value")).to.equal( + "provider-a:nested:value", + ); + expect(await second.internal.NestedCall("value")).to.equal( + "provider-b:nested:value", + ); + expect(first.SharedResult).to.equal(SharedResult); + expect(second.SharedResult).to.equal(SharedResult); + expect(first.metadataA).to.equal(sharedMetadata); + expect(first.metadataB).to.equal(sharedMetadata); + expect(first.default).to.equal(first); + expect(second.default).to.equal(second); + }); + + it("lets interface builders derive cold APIs from automatic bindings", async () => { + const Call = InterfaceFunction<() => string>("facade.Derived"); + runWithModuleContext(providerContext("provider"), () => + Call.proxy.onCall(() => "value", true), + ); + const declaration = { + BuildInterfaceFacade: ( + scope: InterfaceFacadeScope, + facade: Record, + ) => { + const boundCall = facade.Call as typeof Call; + const owner = scope.context.owner; + return { Read: () => boundCall(), ReadOwner: () => owner }; + }, + Call, + Read: () => Promise.resolve("unbound"), + ReadOwner: () => undefined as string | undefined, + }; + const facade = CreateInterfaceFacade( + declaration, + consumerContext("consumer#cold", "async:facade.Derived", "provider"), + ); + + expect(await facade.Read()).to.equal("value"); + expect(facade.ReadOwner()).to.equal("consumer#cold"); + }); + + it("lets custom registration APIs clean only the destroyed facade generation", () => { + const entries: Array<{ callback: () => void; owner: string }> = []; + const declaration = { + BuildInterfaceFacade: (scope: InterfaceFacadeScope) => { + const owner = scope.context.owner as string; + scope.onDestroy(() => { + const retained = entries.filter((entry) => entry.owner !== owner); + entries.splice(0, entries.length, ...retained); + }); + return { + Register: (callback: () => void) => { + scope.assertActive(); + entries.push({ callback, owner }); + }, + }; + }, + Register: (_callback: () => void) => undefined, + }; + const staleContext = { + module: "consumer", + owner: "consumer#custom-old", + }; + const currentContext = { + module: "consumer", + owner: "consumer#custom-new", + }; + const stale = CreateInterfaceFacade(declaration, staleContext); + const current = CreateInterfaceFacade(declaration, currentContext); + const calls: string[] = []; + + stale.Register(() => calls.push("old")); + current.Register(() => calls.push("new")); + Events.ModuleDestroyed.emit("consumer", staleContext.owner); + entries.forEach(({ callback }) => { + callback(); + }); + + expect(calls).to.deep.equal(["new"]); + expect(() => stale.Register(() => undefined)).to.throw( + ModuleContextInvalidatedError, + ); + expect(() => current.Register(() => undefined)).not.to.throw(); + + Events.ModuleDestroyed.emit("consumer", currentContext.owner); + }); + + it("returns declarations unchanged when they need no facade", () => { + const declaration = { SharedResult }; + + expect( + CreateInterfaceFacade(declaration, { + module: "consumer", + owner: "consumer#plain", + }), + ).to.equal(declaration); + }); + + it("rejects calls from an invalidated facade generation", async () => { + const Call = InterfaceFunction<() => string>("facade.Stale"); + runWithModuleContext(providerContext("provider"), () => + Call.proxy.onCall(() => "value", true), + ); + const context = consumerContext( + "consumer#stale", + "async:facade.Stale", + "provider", + ); + const facade = CreateInterfaceFacade({ Call }, context); + runWithModuleContext(context, () => + Events.ModuleDestroyed.emit("consumer"), + ); + + const error = await facade.Call().then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).to.be.instanceOf(ModuleContextInvalidatedError); + expect(() => CreateInterfaceFacade({ Call }, context)).to.throw( + ModuleContextInvalidatedError, + ); + }); + + it("rejects unregisters from an invalidated facade generation", () => { + const Registrations = new InterfaceCore.RegisteringProxy< + (id: string) => void + >("facade.StaleRegistration"); + const EventsProxy = new InterfaceCore.EventProxy<() => void>( + "facade.StaleEvent", + ); + const removed: string[] = []; + Registrations.onHandlers( + () => undefined, + (id) => removed.push(id), + true, + ); + const staleContext = { + module: "consumer", + owner: "consumer#stale-unregister", + }; + const currentContext = { + module: "consumer", + owner: "consumer#current-unregister", + }; + const stale = CreateInterfaceFacade( + { EventsProxy, Registrations }, + staleContext, + ); + const current = CreateInterfaceFacade( + { EventsProxy, Registrations }, + currentContext, + ); + const listener = () => undefined; + runWithModuleContext(staleContext, () => + Events.ModuleDestroyed.emit("consumer"), + ); + current.Registrations.register("current"); + current.EventsProxy.register(listener); + + expect(() => stale.Registrations.unregister("current")).to.throw( + ModuleContextInvalidatedError, + ); + expect(() => stale.EventsProxy.unregister(listener)).to.throw( + ModuleContextInvalidatedError, + ); + + current.Registrations.unregister("current"); + current.EventsProxy.unregister(listener); + expect(removed).to.deep.equal(["current"]); + }); +}); diff --git a/src/tests/module-ownership-context.test.ts b/src/tests/module-ownership-context.test.ts index 0651147..e16977c 100644 --- a/src/tests/module-ownership-context.test.ts +++ b/src/tests/module-ownership-context.test.ts @@ -8,8 +8,8 @@ import { RunWithResponsibleModule, } from ".."; import { MissingProviderError } from "../errors"; -import { internal } from "../internal"; -import { Events, RunWithModuleContext } from "../modules"; +import { internal, runWithModuleContext } from "../internal"; +import { Events } from "../modules"; function runDetached(module: string, callback: () => void): Promise { return new Promise((resolve) => { @@ -182,7 +182,7 @@ describe("explicit module ownership", () => { it("invalidates provider-aware module contexts", async () => { const proxy = new AsyncProxy<() => string>(); const staleAttachment = new Promise((resolve) => { - RunWithModuleContext( + runWithModuleContext( { module: "routed-module", provider: "routed-provider" }, () => { setImmediate(() => { diff --git a/src/tests/provider-context.test.ts b/src/tests/provider-context.test.ts index da21c54..04ac407 100644 --- a/src/tests/provider-context.test.ts +++ b/src/tests/provider-context.test.ts @@ -5,7 +5,8 @@ import { ModuleContextInvalidatedError, RegisteringProxy, } from ".."; -import { Events, GetModuleContext, RunWithModuleContext } from "../modules"; +import { runWithModuleContext } from "../internal"; +import { Events, GetModuleContext } from "../modules"; interface ContextObservation { module?: string; @@ -22,7 +23,7 @@ function observeContext(): ContextObservation { }; } -describe("provider callback context", () => { +describe("internal provider callback context", () => { it("restores async provider context across awaits and nested calls", async () => { const nested = new AsyncProxy<() => string>("context.nested"); const outer = new AsyncProxy<() => Promise>( @@ -30,11 +31,11 @@ describe("provider callback context", () => { ); const nestedIdentity = GetInterfaceProxyIdentity(nested) as string; const outerIdentity = GetInterfaceProxyIdentity(outer) as string; - RunWithModuleContext( + runWithModuleContext( { module: "nested-owner", owner: "nested#1", provider: "nested" }, () => nested.onCall(() => `${observeContext().owner}:value`), ); - RunWithModuleContext( + runWithModuleContext( { module: "provider-owner", owner: "provider-owner#1", @@ -51,7 +52,7 @@ describe("provider callback context", () => { }), ); - const observations = await RunWithModuleContext( + const observations = await runWithModuleContext( { module: "consumer", owner: "consumer#1", @@ -78,10 +79,10 @@ describe("provider callback context", () => { operation: string; context: ContextObservation; }> = []; - RunWithModuleContext({ module: "consumer", owner: "consumer#1" }, () => { + runWithModuleContext({ module: "consumer", owner: "consumer#1" }, () => { proxy.register("queued"); }); - RunWithModuleContext( + runWithModuleContext( { module: "provider-owner", owner: "provider#1", provider: "provider" }, () => { proxy.onHandlers( @@ -98,7 +99,7 @@ describe("provider callback context", () => { ); }, ); - RunWithModuleContext( + runWithModuleContext( { module: "consumer", owner: "consumer#1", @@ -131,7 +132,7 @@ describe("provider callback context", () => { "context.registering-throw", ); const failure = new Error("register failed"); - RunWithModuleContext( + runWithModuleContext( { module: "provider", owner: "provider-throw#1", provider: "provider" }, () => proxy.onHandlers( @@ -149,7 +150,7 @@ describe("provider callback context", () => { it("rejects callbacks captured from an invalidated owner", async () => { const proxy = new AsyncProxy<() => string>("context.invalidated"); const identity = GetInterfaceProxyIdentity(proxy) as string; - RunWithModuleContext( + runWithModuleContext( { module: "provider", owner: "provider-old#1", provider: "provider" }, () => { proxy.onCall(() => "stale", true); @@ -157,7 +158,7 @@ describe("provider callback context", () => { }, ); - const error = await RunWithModuleContext( + const error = await runWithModuleContext( { module: "consumer", owner: "consumer#1", @@ -177,7 +178,7 @@ describe("provider callback context", () => { const proxy = new RegisteringProxy<(id: string) => void>( "context.invalidated-registering", ); - RunWithModuleContext( + runWithModuleContext( { module: "provider", owner: "provider-register#old", diff --git a/src/tests/provider-routing.test.ts b/src/tests/provider-routing.test.ts index 8a6b970..be43ee5 100644 --- a/src/tests/provider-routing.test.ts +++ b/src/tests/provider-routing.test.ts @@ -6,26 +6,27 @@ import { MissingProviderError, RegisteringProxy, } from ".."; -import { Events, RunWithModuleContext } from "../modules"; +import { runWithModuleContext } from "../internal"; +import { Events } from "../modules"; describe("provider routing and leases", () => { it("routes providers through async module execution context", async () => { const proxy = new AsyncProxy<() => string>("test.provider-routing"); - RunWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { + runWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { proxy.onCall(() => "a"); }); - RunWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { + runWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { proxy.onCall(() => "b"); }); expect( - await RunWithModuleContext( + await runWithModuleContext( { module: "consumer", provider: "provider-a" }, () => proxy.call(), ), ).to.equal("a"); expect( - await RunWithModuleContext( + await runWithModuleContext( { module: "consumer", provider: "provider-b" }, () => proxy.call(), ), @@ -39,11 +40,11 @@ describe("provider routing and leases", () => { it("rejects an explicit route that has no attached provider", async () => { const proxy = new AsyncProxy<() => string>("test.missing-route"); - RunWithModuleContext({ module: "owner", provider: "available" }, () => { + runWithModuleContext({ module: "owner", provider: "available" }, () => { proxy.onCall(() => "ok"); }); - const result = RunWithModuleContext( + const result = runWithModuleContext( { module: "consumer", provider: "missing" }, () => proxy.call(), ); @@ -57,18 +58,18 @@ describe("provider routing and leases", () => { it("supports per-proxy provider bindings in one module context", async () => { const first = new AsyncProxy<() => string>("test.route-map.first"); const second = new AsyncProxy<() => string>("test.route-map.second"); - RunWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { + runWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { first.onCall(() => "first-a"); second.onCall(() => "second-a"); }); - RunWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { + runWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { first.onCall(() => "first-b"); second.onCall(() => "second-b"); }); const firstIdentity = GetInterfaceProxyIdentity(first) as string; const secondIdentity = GetInterfaceProxyIdentity(second) as string; - const values = await RunWithModuleContext( + const values = await runWithModuleContext( { module: "consumer", providerRoutes: { @@ -87,20 +88,20 @@ describe("provider routing and leases", () => { "test.registering-routes", ); const calls: string[] = []; - RunWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { + runWithModuleContext({ module: "owner-a", provider: "provider-a" }, () => { proxy.onHandlers( (id) => calls.push(`register-a:${id}`), (id) => calls.push(`unregister-a:${id}`), ); }); - RunWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { + runWithModuleContext({ module: "owner-b", provider: "provider-b" }, () => { proxy.onHandlers( (id) => calls.push(`register-b:${id}`), (id) => calls.push(`unregister-b:${id}`), ); }); - RunWithModuleContext({ module: "consumer", provider: "provider-b" }, () => { + runWithModuleContext({ module: "consumer", provider: "provider-b" }, () => { proxy.register("item"); proxy.unregister("item"); }); @@ -110,17 +111,17 @@ describe("provider routing and leases", () => { it("does not let an old owner lease detach a newer provider generation", async () => { const proxy = new AsyncProxy<() => string>("test.provider-lease"); - RunWithModuleContext({ module: "owner-a", provider: "shared" }, () => { + runWithModuleContext({ module: "owner-a", provider: "shared" }, () => { proxy.onCall(() => "old"); }); - RunWithModuleContext({ module: "owner-b", provider: "shared" }, () => { + runWithModuleContext({ module: "owner-b", provider: "shared" }, () => { proxy.onCall(() => "new"); }); Events.ModuleDestroyed.emit("owner-a"); expect( - await RunWithModuleContext( + await runWithModuleContext( { module: "consumer", provider: "shared" }, () => proxy.call(), ), diff --git a/src/tests/queue-and-cleanup.test.ts b/src/tests/queue-and-cleanup.test.ts index 8257f8c..2f06dfe 100644 --- a/src/tests/queue-and-cleanup.test.ts +++ b/src/tests/queue-and-cleanup.test.ts @@ -5,8 +5,12 @@ import { ProviderQueueFullError, RegisteringProxy, } from ".."; -import { internal, type RuntimeErrorDetails } from "../internal"; -import { Events, RunWithModuleContext } from "../modules"; +import { + internal, + type RuntimeErrorDetails, + runWithModuleContext, +} from "../internal"; +import { Events } from "../modules"; describe("bounded queues and resilient cleanup", () => { const originalQueueLimit = internal.maxPendingOperations; @@ -55,7 +59,7 @@ describe("bounded queues and resilient cleanup", () => { internal.runtimeErrorReporter = (error, details) => { errors.push({ error, details }); }; - RunWithModuleContext({ module: "provider" }, () => { + runWithModuleContext({ module: "provider" }, () => { proxy.onHandlers( () => undefined, (id) => { @@ -66,7 +70,7 @@ describe("bounded queues and resilient cleanup", () => { }, ); }); - RunWithModuleContext({ module: "consumer" }, () => { + runWithModuleContext({ module: "consumer" }, () => { proxy.register("first"); proxy.register("second"); }); diff --git a/src/tests/responsible-module.test.ts b/src/tests/responsible-module.test.ts index 33fbe9f..5fad556 100644 --- a/src/tests/responsible-module.test.ts +++ b/src/tests/responsible-module.test.ts @@ -1,4 +1,5 @@ import { expect } from "chai"; +import { activateModuleContext } from "../internal"; import { findResponsibleFile, type ModuleFolderEntry, @@ -29,6 +30,23 @@ describe("findResponsibleFile", () => { expect(findResponsibleFile(trace, entries).module).to.equal("local"); }); + it("accepts a tracked module installed beneath node_modules", () => { + const entries: ModuleFolderEntry[] = [ + { + id: "api", + dir: "/app/node_modules/.pnpm/@antelopejs+api/node_modules/@antelopejs/api", + isImplementor: true, + }, + ]; + const trace = [ + frame( + "/app/node_modules/.pnpm/@antelopejs+api/node_modules/@antelopejs/api/dist/middleware.js", + ), + ]; + + expect(findResponsibleFile(trace, entries).module).to.equal("api"); + }); + it("skips non-loader node:internal frames", () => { const entries: ModuleFolderEntry[] = [{ id: "local", dir: "/app" }]; const trace = [ @@ -41,9 +59,18 @@ describe("findResponsibleFile", () => { }); it("returns the first non-implementor match even when an implementor appears earlier", () => { + const context = activateModuleContext({ + module: "playground", + owner: "playground#1", + providerRoutes: { route: "provider" }, + }); const entries: ModuleFolderEntry[] = [ { id: "cms", dir: "/project/cms", isImplementor: true }, - { id: "playground", dir: "/project/cms/playground" }, + { + id: "playground", + dir: "/project/cms/playground", + context, + }, ]; const trace = [ frame("/project/cms/dist/interfaces/cms/page.js"), @@ -51,7 +78,9 @@ describe("findResponsibleFile", () => { frame("/project/cms/playground/dist/table-view/drawer/page.js"), ]; - expect(findResponsibleFile(trace, entries).module).to.equal("playground"); + const result = findResponsibleFile(trace, entries); + expect(result.module).to.equal("playground"); + expect(result.context).to.equal(context); }); it("falls back to the first implementor match when no consumer frame matches", () => { diff --git a/src/tests/root-declarations.test.ts b/src/tests/root-declarations.test.ts index 5ef9aa7..fac2843 100644 --- a/src/tests/root-declarations.test.ts +++ b/src/tests/root-declarations.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { expect } from "chai"; import * as declarations from ".."; +import * as facades from "../facades"; import * as modules from "../modules"; import * as runtime from "../runtime"; @@ -12,6 +13,7 @@ const path = require("node:path"); const root = process.env.INTERFACE_CORE_ROOT; const entries = { root: path.join(root, "dist"), + facades: path.join(root, "dist", "facades.js"), modules: path.join(root, "dist", "modules.js"), runtime: path.join(root, "dist", "runtime.js"), }; @@ -19,6 +21,7 @@ const loaded = Object.fromEntries( process.env.INTERFACE_CORE_ORDER.split(",").map((entry) => [entry, require(entries[entry])]), ); const core = loaded.root; +const facades = loaded.facades; const modules = loaded.modules; const runtime = loaded.runtime; assert.equal(core.Events, modules.Events); @@ -26,6 +29,12 @@ assert.equal(core.ListModules, modules.ListModules); assert.equal(core.GetModuleInfo, modules.GetModuleInfo); assert.equal(core.GetRuntimeInfo, runtime.GetRuntimeInfo); assert.equal(core.RegisterDevServer, runtime.RegisterDevServer); +assert.equal(core.CreateInterfaceFacade, undefined); +assert.equal(core.GetModuleContext, undefined); +assert.equal(core.RunWithModuleContext, undefined); +assert.equal(typeof facades.CreateInterfaceFacade, "function"); +assert.equal(typeof modules.GetModuleContext, "function"); +assert.equal(modules.RunWithModuleContext, undefined); assert.equal(core.IsInterfaceProxy(core.ListModules.proxy), true); assert.equal(core.IsInterfaceProxy(core.GetRuntimeInfo.proxy), true); assert.equal(core.GetInterfaceProxyIdentity(core.ListModules.proxy), "async:modules.ListModules"); @@ -54,11 +63,20 @@ describe("root interface declarations", () => { expect(declarations.GetModuleInfo).to.equal(modules.GetModuleInfo); }); + it("keeps infrastructure APIs on their dedicated subpaths", () => { + expect("CreateInterfaceFacade" in declarations).to.equal(false); + expect("GetModuleContext" in declarations).to.equal(false); + expect("RunWithModuleContext" in declarations).to.equal(false); + expect(facades.CreateInterfaceFacade).to.be.a("function"); + expect(modules.GetModuleContext).to.be.a("function"); + expect("RunWithModuleContext" in modules).to.equal(false); + }); + it("loads complete canonical declarations when the root loads first", () => { - runFreshProcess("root,runtime,modules"); + runFreshProcess("root,runtime,modules,facades"); }); it("loads complete canonical declarations when subpaths load first", () => { - runFreshProcess("modules,runtime,root"); + runFreshProcess("facades,modules,runtime,root"); }); }); diff --git a/src/tests/runtime-protocol.test.ts b/src/tests/runtime-protocol.test.ts index bb73966..79d3ca7 100644 --- a/src/tests/runtime-protocol.test.ts +++ b/src/tests/runtime-protocol.test.ts @@ -3,7 +3,7 @@ import { cpSync, mkdtempSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { expect } from "chai"; import { AsyncProxy, RunWithResponsibleModule } from ".."; -import { RunWithModuleContext } from "../modules"; +import { runWithModuleContext } from "../internal"; interface ForeignCore { AsyncProxy: new ( @@ -53,7 +53,7 @@ describe("global runtime protocol", () => { foreign.GetResponsibleModule(), ), ).to.equal("shared-owner"); - RunWithModuleContext( + runWithModuleContext( { module: "provider", owner: "provider#copy", provider: "provider" }, () => foreign.ImplementInterface( @@ -65,7 +65,7 @@ describe("global runtime protocol", () => { ); expect( - await RunWithModuleContext( + await runWithModuleContext( { module: "consumer", owner: "consumer#copy", diff --git a/test/package-consumer.mjs b/test/package-consumer.mjs index 00710bd..dc4903a 100644 --- a/test/package-consumer.mjs +++ b/test/package-consumer.mjs @@ -40,7 +40,11 @@ import { ListModules, type InterfaceConnection, } from "@antelopejs/interface-core"; -import type { ModuleExecutionContext } from "@antelopejs/interface-core/modules"; +import { + GetModuleContext, + type ModuleExecutionContext, +} from "@antelopejs/interface-core/modules"; +import { CreateInterfaceFacade } from "@antelopejs/interface-core/facades"; const connection: InterfaceConnection = { path: "example", @@ -53,12 +57,15 @@ const context: ModuleExecutionContext = { }; void connection; void context; +void CreateInterfaceFacade; +void GetModuleContext; void GetRuntimeInfo; void ListModules; `; const rootFirstImports = ` const core = require("@antelopejs/interface-core"); +const facades = require("@antelopejs/interface-core/facades"); const modules = require("@antelopejs/interface-core/modules"); const runtime = require("@antelopejs/interface-core/runtime"); `; @@ -66,6 +73,7 @@ const runtime = require("@antelopejs/interface-core/runtime"); const subpathsFirstImports = ` const modules = require("@antelopejs/interface-core/modules"); const runtime = require("@antelopejs/interface-core/runtime"); +const facades = require("@antelopejs/interface-core/facades"); const core = require("@antelopejs/interface-core"); `; @@ -79,6 +87,12 @@ assert.equal(core.GetRuntimeInfo, runtime.GetRuntimeInfo); assert.equal(core.RegisterDevServer, runtime.RegisterDevServer); assert.equal(core.ListModules, modules.ListModules); assert.equal(core.Events, modules.Events); +assert.equal(core.CreateInterfaceFacade, undefined); +assert.equal(core.GetModuleContext, undefined); +assert.equal(core.RunWithModuleContext, undefined); +assert.equal(typeof facades.CreateInterfaceFacade, "function"); +assert.equal(typeof modules.GetModuleContext, "function"); +assert.equal(modules.RunWithModuleContext, undefined); assert.equal(core.IsInterfaceProxy(core.GetRuntimeInfo.proxy), true); assert.equal(core.IsInterfaceProxy(core.ListModules.proxy), true); assert.equal(core.GetInterfaceProxyIdentity(core.GetRuntimeInfo.proxy), "async:runtime.GetRuntimeInfo"); @@ -92,40 +106,41 @@ const consumerContext = { owner: "consumer#1", providerRoutes: { [identity]: "provider" }, }; +let callbackContext; -modules.RunWithModuleContext(providerContext, () => { - core.ImplementInterface({ GetValue: proxy }, { +const providerCore = facades.CreateInterfaceFacade(core, providerContext); +providerCore.ImplementInterface( + { GetValue: proxy }, + { GetValue: async () => { await Promise.resolve(); - return modules.GetModuleContext(); + callbackContext = modules.GetModuleContext(); + return "provider#old"; }, - }); -}); + }, +); (async () => { - const oldContext = await modules.RunWithModuleContext(consumerContext, () => proxy()); - assert.equal(oldContext.module, "provider"); - assert.equal(oldContext.owner, "provider#old"); - assert.equal(oldContext.provider, "provider"); + const facade = facades.CreateInterfaceFacade({ GetValue: proxy }, consumerContext); + assert.equal(await facade.GetValue(), "provider#old"); + assert.equal(callbackContext, undefined); - modules.RunWithModuleContext( + const replacementProvider = facades.CreateInterfaceFacade( + core, { module: "provider", owner: "provider#new", provider: "provider" }, - () => core.ImplementInterface({ GetValue: proxy }, { - GetValue: () => modules.GetModuleContext().owner, - }), ); - modules.RunWithModuleContext(providerContext, () => { - modules.Events.ModuleDestroyed.emit("provider"); - }); - const replacement = await modules.RunWithModuleContext(consumerContext, () => proxy()); - assert.equal(replacement, "provider#new"); + replacementProvider.ImplementInterface( + { GetValue: proxy }, + { GetValue: () => "provider#new" }, + ); + modules.Events.ModuleDestroyed.emit("provider", providerContext.owner); + assert.equal(await facade.GetValue(), "provider#new"); internal.interfaceConnections.consumer = { example: [{ path: "example", provider: "provider", selected: true }], }; - const metadata = modules.RunWithModuleContext(consumerContext, () => - core.GetInterfaceInstances("example"), - ); + const consumerCore = facades.CreateInterfaceFacade(core, consumerContext); + const metadata = consumerCore.GetInterfaceInstances("example"); assert.deepEqual(metadata, [ { path: "example", provider: "provider", selected: true }, ]);