From 7896665a26df8c776ab3b39cb64bffba7dc6ef7e Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 16:32:08 +0000 Subject: [PATCH 1/8] fix(runtime): export core interface declarations --- src/index.ts | 3 +++ src/tests/root-declarations.test.ts | 17 +++++++++++++++++ test/package-consumer.mjs | 13 ++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/tests/root-declarations.test.ts diff --git a/src/index.ts b/src/index.ts index b0dc34a..20c229e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -328,3 +328,6 @@ export function GetInterfaceInstance( (connection) => connection.id === connectionID, ); } + +export * from "./modules"; +export * from "./runtime"; diff --git a/src/tests/root-declarations.test.ts b/src/tests/root-declarations.test.ts new file mode 100644 index 0000000..70c3fa5 --- /dev/null +++ b/src/tests/root-declarations.test.ts @@ -0,0 +1,17 @@ +import { expect } from "chai"; +import * as declarations from ".."; +import * as modules from "../modules"; +import * as runtime from "../runtime"; + +describe("root interface declarations", () => { + it("exports the canonical runtime proxies", () => { + expect(declarations.GetRuntimeInfo).to.equal(runtime.GetRuntimeInfo); + expect(declarations.RegisterDevServer).to.equal(runtime.RegisterDevServer); + }); + + it("exports the canonical module proxies", () => { + expect(declarations.Events).to.equal(modules.Events); + expect(declarations.ListModules).to.equal(modules.ListModules); + expect(declarations.GetModuleInfo).to.equal(modules.GetModuleInfo); + }); +}); diff --git a/test/package-consumer.mjs b/test/package-consumer.mjs index f7668dc..92e10c4 100644 --- a/test/package-consumer.mjs +++ b/test/package-consumer.mjs @@ -28,7 +28,11 @@ function createConsumer(tarball) { } const typeConsumerSource = ` -import type { InterfaceConnection } from "@antelopejs/interface-core"; +import { + GetRuntimeInfo, + ListModules, + type InterfaceConnection, +} from "@antelopejs/interface-core"; import type { ModuleExecutionContext } from "@antelopejs/interface-core/modules"; const connection: InterfaceConnection = { @@ -42,6 +46,8 @@ const context: ModuleExecutionContext = { }; void connection; void context; +void GetRuntimeInfo; +void ListModules; `; const consumerSource = ` @@ -49,6 +55,11 @@ const assert = require("node:assert/strict"); const core = require("@antelopejs/interface-core"); const { internal } = require("@antelopejs/interface-core/internal"); const modules = require("@antelopejs/interface-core/modules"); +const runtime = require("@antelopejs/interface-core/runtime"); + +assert.equal(core.GetRuntimeInfo, runtime.GetRuntimeInfo); +assert.equal(core.RegisterDevServer, runtime.RegisterDevServer); +assert.equal(core.ListModules, modules.ListModules); const proxy = core.InterfaceFunction("package-consumer.context"); const identity = core.GetInterfaceProxyIdentity(proxy.proxy); From 065c73b58029d003de159e7cd72e12ea56be2ca6 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 16:39:42 +0000 Subject: [PATCH 2/8] fix(runtime): remove declaration import cycle --- src/index.ts | 21 ++----------- src/modules.ts | 2 +- src/proxies.ts | 11 +++++++ src/runtime.ts | 2 +- src/tests/root-declarations.test.ts | 47 +++++++++++++++++++++++++++++ test/package-consumer.mjs | 35 ++++++++++++++++++--- 6 files changed, 92 insertions(+), 26 deletions(-) diff --git a/src/index.ts b/src/index.ts index 20c229e..e99a8ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import type { Class } from "./decorators"; import { type InterfaceConnection, internal } from "./internal"; import { Logging } from "./logging"; import { - AsyncProxy, + type AsyncProxy, type EventProxy, GetResponsibleModule, IsInterfaceProxy, @@ -17,6 +17,7 @@ export { EventProxy, GetInterfaceProxyIdentity, GetResponsibleModule, + InterfaceFunction, IsInterfaceProxy, RegisteringProxy, RunWithResponsibleModule, @@ -75,24 +76,6 @@ export function GetMetadata< type Func = (...args: A) => R; -/** - * Creates an interface function proxy. - * - * Returns a function that routes calls through an AsyncProxy, allowing for module-aware - * asynchronous function calls that can be implemented by other modules. - * - * @returns A function that proxies calls to the implementation when available - */ -export function InterfaceFunction< - T extends Func = Func, - R = Awaited>, ->(identity?: string): (...args: Parameters) => Promise { - const proxy = new AsyncProxy(identity); - const func = (...args: Parameters) => proxy.call(...args); - func.proxy = proxy; - return func; -} - type RID = T extends (id: infer P, ...args: any[]) => void ? P : never; type InterfaceImplType = T extends RegisteringProxy diff --git a/src/modules.ts b/src/modules.ts index a708a05..33c3af1 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -1,4 +1,3 @@ -import { EventProxy, InterfaceFunction } from "."; import { getModuleContext, internal, @@ -8,6 +7,7 @@ import { 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( diff --git a/src/proxies.ts b/src/proxies.ts index 212aa6c..1db7b2f 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -339,6 +339,17 @@ export class AsyncProxy>> { } } +/** Creates an interface function backed by an asynchronous proxy. */ +export function InterfaceFunction< + T extends Func = Func, + R = Awaited>, +>(identity?: string): (...args: Parameters) => Promise { + const proxy = new AsyncProxy(identity); + const func = (...args: Parameters) => proxy.call(...args); + func.proxy = proxy; + return func; +} + /** Proxy for provider-aware register and unregister handlers. */ export class RegisteringProxy { public readonly [PROXY_BRAND]: ProxyBrand; diff --git a/src/runtime.ts b/src/runtime.ts index 0de93aa..50da403 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,4 +1,4 @@ -import { InterfaceFunction } from "."; +import { InterfaceFunction } from "./proxies"; /** * Information about the runtime environment of the running Antelope project. diff --git a/src/tests/root-declarations.test.ts b/src/tests/root-declarations.test.ts index 70c3fa5..5ef9aa7 100644 --- a/src/tests/root-declarations.test.ts +++ b/src/tests/root-declarations.test.ts @@ -1,8 +1,47 @@ +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; import { expect } from "chai"; import * as declarations from ".."; import * as modules from "../modules"; import * as runtime from "../runtime"; +const PACKAGE_ROOT = resolve(__dirname, "..", ".."); +const FRESH_PROCESS_CONTRACT = ` +const assert = require("node:assert/strict"); +const path = require("node:path"); +const root = process.env.INTERFACE_CORE_ROOT; +const entries = { + root: path.join(root, "dist"), + modules: path.join(root, "dist", "modules.js"), + runtime: path.join(root, "dist", "runtime.js"), +}; +const loaded = Object.fromEntries( + process.env.INTERFACE_CORE_ORDER.split(",").map((entry) => [entry, require(entries[entry])]), +); +const core = loaded.root; +const modules = loaded.modules; +const runtime = loaded.runtime; +assert.equal(core.Events, modules.Events); +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.IsInterfaceProxy(core.ListModules.proxy), true); +assert.equal(core.IsInterfaceProxy(core.GetRuntimeInfo.proxy), true); +assert.equal(core.GetInterfaceProxyIdentity(core.ListModules.proxy), "async:modules.ListModules"); +assert.equal(core.GetInterfaceProxyIdentity(core.GetRuntimeInfo.proxy), "async:runtime.GetRuntimeInfo"); +`; + +function runFreshProcess(order: string): void { + execFileSync(process.execPath, ["-e", FRESH_PROCESS_CONTRACT], { + env: { + ...process.env, + INTERFACE_CORE_ORDER: order, + INTERFACE_CORE_ROOT: PACKAGE_ROOT, + }, + }); +} + describe("root interface declarations", () => { it("exports the canonical runtime proxies", () => { expect(declarations.GetRuntimeInfo).to.equal(runtime.GetRuntimeInfo); @@ -14,4 +53,12 @@ describe("root interface declarations", () => { expect(declarations.ListModules).to.equal(modules.ListModules); expect(declarations.GetModuleInfo).to.equal(modules.GetModuleInfo); }); + + it("loads complete canonical declarations when the root loads first", () => { + runFreshProcess("root,runtime,modules"); + }); + + it("loads complete canonical declarations when subpaths load first", () => { + runFreshProcess("modules,runtime,root"); + }); }); diff --git a/test/package-consumer.mjs b/test/package-consumer.mjs index 92e10c4..00710bd 100644 --- a/test/package-consumer.mjs +++ b/test/package-consumer.mjs @@ -23,7 +23,14 @@ function createConsumer(tarball) { }, }), ); - writeFileSync(join(temporary, "contract.cjs"), consumerSource); + writeFileSync( + join(temporary, "contract-root-first.cjs"), + createConsumerSource(rootFirstImports), + ); + writeFileSync( + join(temporary, "contract-subpaths-first.cjs"), + createConsumerSource(subpathsFirstImports), + ); writeFileSync(join(temporary, "contract.ts"), typeConsumerSource); } @@ -50,16 +57,32 @@ void GetRuntimeInfo; void ListModules; `; -const consumerSource = ` -const assert = require("node:assert/strict"); +const rootFirstImports = ` const core = require("@antelopejs/interface-core"); -const { internal } = require("@antelopejs/interface-core/internal"); const modules = require("@antelopejs/interface-core/modules"); const runtime = require("@antelopejs/interface-core/runtime"); +`; + +const subpathsFirstImports = ` +const modules = require("@antelopejs/interface-core/modules"); +const runtime = require("@antelopejs/interface-core/runtime"); +const core = require("@antelopejs/interface-core"); +`; + +function createConsumerSource(imports) { + return ` +const assert = require("node:assert/strict"); +${imports} +const { internal } = require("@antelopejs/interface-core/internal"); 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.IsInterfaceProxy(core.GetRuntimeInfo.proxy), true); +assert.equal(core.IsInterfaceProxy(core.ListModules.proxy), true); +assert.equal(core.GetInterfaceProxyIdentity(core.GetRuntimeInfo.proxy), "async:runtime.GetRuntimeInfo"); +assert.equal(core.GetInterfaceProxyIdentity(core.ListModules.proxy), "async:modules.ListModules"); const proxy = core.InterfaceFunction("package-consumer.context"); const identity = core.GetInterfaceProxyIdentity(proxy.proxy); @@ -111,6 +134,7 @@ modules.RunWithModuleContext(providerContext, () => { process.exitCode = 1; }); `; +} try { run("corepack", ["pnpm", "run", "build"], repository); @@ -146,7 +170,8 @@ try { ], temporary, ); - run(process.execPath, ["contract.cjs"], temporary); + run(process.execPath, ["contract-root-first.cjs"], temporary); + run(process.execPath, ["contract-subpaths-first.cjs"], temporary); } finally { rmSync(temporary, { force: true, recursive: true }); } From 391087ec63f94eb2b88c158d62c80bf8bad386f3 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 17:06:31 +0000 Subject: [PATCH 3/8] feat(runtime): bind callbacks to module context --- src/modules.ts | 27 +++++++++++++ src/tests/provider-context.test.ts | 59 ++++++++++++++++++++++++++++- src/tests/root-declarations.test.ts | 4 ++ test/package-consumer.mjs | 3 ++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/modules.ts b/src/modules.ts index 33c3af1..c93b35b 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -1,14 +1,18 @@ import { + captureModuleContext, getModuleContext, internal, invalidateModuleContext, type ModuleExecutionContext, peekModuleContext, type RuntimeCleanup, + runWithCapturedModuleContext, runWithModuleContext, } from "./internal"; import { EventProxy, InterfaceFunction } from "./proxies"; +type ModuleCallback = (...args: A) => R; + /** Runs work with module ownership and an optional provider route across awaits. */ export function RunWithModuleContext( context: ModuleExecutionContext, @@ -22,6 +26,29 @@ export function GetModuleContext(): ModuleExecutionContext | undefined { return getModuleContext(); } +/** Binds a callback to the active module generation and provider routes. */ +export function BindToCurrentModuleContext( + callback: T, +): T { + const context = captureModuleContext(); + if (!context) { + return callback; + } + const bound = function ( + this: ThisParameterType, + ...args: Parameters + ): ReturnType { + return runWithCapturedModuleContext(context, () => + callback.apply(this, args), + ); + }; + Object.defineProperty(bound, "name", { + configurable: true, + value: callback.name, + }); + return bound as T; +} + export type { ModuleExecutionContext } from "./internal"; /** diff --git a/src/tests/provider-context.test.ts b/src/tests/provider-context.test.ts index da21c54..5dc6a54 100644 --- a/src/tests/provider-context.test.ts +++ b/src/tests/provider-context.test.ts @@ -5,7 +5,12 @@ import { ModuleContextInvalidatedError, RegisteringProxy, } from ".."; -import { Events, GetModuleContext, RunWithModuleContext } from "../modules"; +import { + BindToCurrentModuleContext, + Events, + GetModuleContext, + RunWithModuleContext, +} from "../modules"; interface ContextObservation { module?: string; @@ -13,6 +18,10 @@ interface ContextObservation { provider?: string; } +interface CallbackReceiver { + prefix: string; +} + function observeContext(): ContextObservation { const context = GetModuleContext(); return { @@ -23,6 +32,54 @@ function observeContext(): ContextObservation { } describe("provider callback context", () => { + it("binds callbacks to consumer routes without changing their contract", () => { + function readContext(this: CallbackReceiver, suffix: string) { + return { + context: observeContext(), + value: `${this.prefix}:${suffix}`, + }; + } + const bound = RunWithModuleContext( + { + module: "consumer", + owner: "consumer#bound", + providerRoutes: { "async:auth.Verify": "auth" }, + }, + () => BindToCurrentModuleContext(readContext), + ); + + const result = RunWithModuleContext( + { module: "api", owner: "api#1", provider: "api" }, + () => bound.call({ prefix: "route" }, "handler"), + ); + + expect(bound.name).to.equal(readContext.name); + expect(result).to.deep.equal({ + context: { + module: "consumer", + owner: "consumer#bound", + provider: undefined, + }, + value: "route:handler", + }); + }); + + it("returns the original callback outside a module context", () => { + const callback = () => "value"; + + expect(BindToCurrentModuleContext(callback)).to.equal(callback); + }); + + it("rejects bound callbacks after their module generation is destroyed", () => { + const bound = RunWithModuleContext( + { module: "consumer", owner: "consumer#stale" }, + () => BindToCurrentModuleContext(() => "value"), + ); + Events.ModuleDestroyed.emit("consumer"); + + expect(() => bound()).to.throw(ModuleContextInvalidatedError); + }); + it("restores async provider context across awaits and nested calls", async () => { const nested = new AsyncProxy<() => string>("context.nested"); const outer = new AsyncProxy<() => Promise>( diff --git a/src/tests/root-declarations.test.ts b/src/tests/root-declarations.test.ts index 5ef9aa7..c432dd9 100644 --- a/src/tests/root-declarations.test.ts +++ b/src/tests/root-declarations.test.ts @@ -22,6 +22,7 @@ const core = loaded.root; const modules = loaded.modules; const runtime = loaded.runtime; assert.equal(core.Events, modules.Events); +assert.equal(core.BindToCurrentModuleContext, modules.BindToCurrentModuleContext); assert.equal(core.ListModules, modules.ListModules); assert.equal(core.GetModuleInfo, modules.GetModuleInfo); assert.equal(core.GetRuntimeInfo, runtime.GetRuntimeInfo); @@ -49,6 +50,9 @@ describe("root interface declarations", () => { }); it("exports the canonical module proxies", () => { + expect(declarations.BindToCurrentModuleContext).to.equal( + modules.BindToCurrentModuleContext, + ); expect(declarations.Events).to.equal(modules.Events); expect(declarations.ListModules).to.equal(modules.ListModules); expect(declarations.GetModuleInfo).to.equal(modules.GetModuleInfo); diff --git a/test/package-consumer.mjs b/test/package-consumer.mjs index 00710bd..3c9bb25 100644 --- a/test/package-consumer.mjs +++ b/test/package-consumer.mjs @@ -36,6 +36,7 @@ function createConsumer(tarball) { const typeConsumerSource = ` import { + BindToCurrentModuleContext, GetRuntimeInfo, ListModules, type InterfaceConnection, @@ -53,6 +54,7 @@ const context: ModuleExecutionContext = { }; void connection; void context; +void BindToCurrentModuleContext; void GetRuntimeInfo; void ListModules; `; @@ -77,6 +79,7 @@ const { internal } = require("@antelopejs/interface-core/internal"); assert.equal(core.GetRuntimeInfo, runtime.GetRuntimeInfo); assert.equal(core.RegisterDevServer, runtime.RegisterDevServer); +assert.equal(core.BindToCurrentModuleContext, modules.BindToCurrentModuleContext); assert.equal(core.ListModules, modules.ListModules); assert.equal(core.Events, modules.Events); assert.equal(core.IsInterfaceProxy(core.GetRuntimeInfo.proxy), true); From 30ee1c345f84cee6f7d2d44e4b668ec8b54019c1 Mon Sep 17 00:00:00 2001 From: Upd4ting Date: Fri, 21 Aug 2026 17:32:28 +0000 Subject: [PATCH 4/8] fix(runtime): invalidate bound module generations --- src/internal.ts | 23 ++++++++++++++++++ src/modules.ts | 18 +++++++++----- src/tests/provider-context.test.ts | 38 ++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/internal.ts b/src/internal.ts index ff6e6a7..23eb263 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -77,6 +77,7 @@ export interface InterfaceRuntime { interfaceConnections: Record>; executionContext: AsyncLocalStorage; activeOwnerTokens: Map; + moduleOwners: Map>; proxyStates: Map; nextProxyIdentity: number; nextLeaseGeneration: number; @@ -115,6 +116,7 @@ function createRuntime(): InterfaceRuntime { >, executionContext: new AsyncLocalStorage(), activeOwnerTokens: new Map(), + moduleOwners: new Map(), proxyStates: new Map(), nextProxyIdentity: 1, nextLeaseGeneration: 1, @@ -138,6 +140,7 @@ function getRuntime(): InterfaceRuntime { ); } if (existing) { + existing.moduleOwners ??= new Map(); return existing; } const runtime = createRuntime(); @@ -163,6 +166,10 @@ function getOwnerToken(owner: string): symbol { return token; } +function trackModuleOwner(module: string, owner: string) { + addToMapSet(internal.moduleOwners, module, owner); +} + function assertActiveModuleContext(context: ActiveModuleExecutionContext) { if ( internal.activeOwnerTokens.get(context.owner) !== context.ownershipToken @@ -183,6 +190,7 @@ export function runWithModuleContext( throw new Error("Module execution context requires a module ID."); } const owner = context.owner ?? context.module; + trackModuleOwner(context.module, owner); const activeContext = { ...context, owner, @@ -197,6 +205,7 @@ export function captureModuleContext(): const context = internal.executionContext.getStore(); if (context) { assertActiveModuleContext(context); + trackModuleOwner(context.module, context.owner); } return context; } @@ -217,6 +226,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 c93b35b..30d8e70 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -1,6 +1,7 @@ import { captureModuleContext, getModuleContext, + getModuleOwners, internal, invalidateModuleContext, type ModuleExecutionContext, @@ -123,16 +124,15 @@ function runCleanup( } } -function getDestroyedOwner(module: string): string { +function getDestroyedOwners(module: string): string[] { 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.knownAsync.get(owner) ?? []) { runCleanup(cleanup, owner, "detach-async-provider"); @@ -162,6 +162,12 @@ Events.ModuleDestroyed.register((module) => { }); } } +} + +Events.ModuleDestroyed.register((module) => { + getDestroyedOwners(module).forEach((owner) => { + cleanupDestroyedOwner(module, owner); + }); }); /** diff --git a/src/tests/provider-context.test.ts b/src/tests/provider-context.test.ts index 5dc6a54..ea0e222 100644 --- a/src/tests/provider-context.test.ts +++ b/src/tests/provider-context.test.ts @@ -80,6 +80,44 @@ describe("provider callback context", () => { expect(() => bound()).to.throw(ModuleContextInvalidatedError); }); + it("keeps a replacement bound after destroying the previous generation", () => { + const stale = RunWithModuleContext( + { module: "reloaded-consumer", owner: "consumer#old" }, + () => BindToCurrentModuleContext(() => "old"), + ); + Events.ModuleDestroyed.emit("reloaded-consumer"); + const replacement = RunWithModuleContext( + { module: "reloaded-consumer", owner: "consumer#new" }, + () => BindToCurrentModuleContext(() => "new"), + ); + + expect(() => stale()).to.throw(ModuleContextInvalidatedError); + expect(replacement()).to.equal("new"); + }); + + it("invalidates only the generation destroyed in its own context", () => { + const staleContext = { + module: "concurrent-consumer", + owner: "concurrent-consumer#old", + }; + const stale = RunWithModuleContext(staleContext, () => + BindToCurrentModuleContext(() => "old"), + ); + const replacement = RunWithModuleContext( + { + module: "concurrent-consumer", + owner: "concurrent-consumer#new", + }, + () => BindToCurrentModuleContext(() => "new"), + ); + RunWithModuleContext(staleContext, () => + Events.ModuleDestroyed.emit("concurrent-consumer"), + ); + + expect(() => stale()).to.throw(ModuleContextInvalidatedError); + expect(replacement()).to.equal("new"); + }); + it("restores async provider context across awaits and nested calls", async () => { const nested = new AsyncProxy<() => string>("context.nested"); const outer = new AsyncProxy<() => Promise>( From 3fa26675fa7eadd7c743fbbc7a610bd4268a71f1 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 25 Aug 2026 16:58:18 +0000 Subject: [PATCH 5/8] feat(runtime): bind interface facades to selected providers Co-authored-by: Upd4ting --- docs/2.proxies.md | 40 +++- docs/5.modules.md | 26 --- package.json | 3 + src/facades.ts | 206 ++++++++++++++++++ src/index.ts | 20 +- src/internal.ts | 28 ++- src/modules.ts | 34 --- src/proxies.ts | 35 ++- .../implement-interface-validation.test.ts | 11 + src/tests/interface-facade.test.ts | 151 +++++++++++++ src/tests/provider-context.test.ts | 97 +-------- src/tests/root-declarations.test.ts | 17 +- test/package-consumer.mjs | 14 +- 13 files changed, 502 insertions(+), 180 deletions(-) create mode 100644 src/facades.ts create mode 100644 src/tests/interface-facade.test.ts diff --git a/docs/2.proxies.md b/docs/2.proxies.md index 3c512e7..87c787d 100644 --- a/docs/2.proxies.md +++ b/docs/2.proxies.md @@ -141,7 +141,7 @@ Unregistration executes in the context captured when this callback attaches. 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. -Provider routing applies to the register operation, not automatically to function values inside `args`. If an interface accepts a consumer callback that its provider invokes, the interface author must bind that callback with `BindToCurrentModuleContext`. See [Advanced execution context APIs](./5.modules.md#advanced-execution-context-apis). +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)` @@ -151,6 +151,44 @@ 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"); + +// Derived export: use the automatically bound Read function. +export function BuildInterfaceFacade( + scope: InterfaceFacadeScope, + facade: Record, +) { + const boundRead = facade.Read as typeof Read; + return { + ReadUppercase: async () => (await boundRead()).toUpperCase(), + Register: (handler: Handler) => + scope.run(() => Register(handler)), + }; +} +``` + +`scope.run` is for cold registration/decorator work, not request handling. The resolver builds and caches the facade before module evaluation; providers receive the application's original callbacks without a per-invocation context wrapper. + ## `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. diff --git a/docs/5.modules.md b/docs/5.modules.md index dfc988e..8e45695 100644 --- a/docs/5.modules.md +++ b/docs/5.modules.md @@ -45,7 +45,6 @@ Execution context APIs are exported from `@antelopejs/interface-core/modules`. T | Provider implementation attached with `ImplementInterface` | None; proxies restore its context | | Select a provider for an application module | Configure `importOverrides` | | Core or a custom module loader executes module-owned work | `RunWithModuleContext` | -| An interface accepts a consumer callback that a provider invokes | `BindToCurrentModuleContext` | | Framework diagnostics need to inspect the active context | `GetModuleContext` | These APIs intentionally remain on the `/modules` subpath rather than the package root so application code does not mistake them for provider-selection helpers. @@ -72,31 +71,6 @@ await RunWithModuleContext( Only Core and custom module loaders should create module execution contexts. Application modules must use `importOverrides` instead of calling `RunWithModuleContext` to force provider selection. -### `BindToCurrentModuleContext` - -An interface must bind a callback when it accepts that callback from a consumer and a provider will invoke it from the provider's execution context. Invocation may happen immediately or later; the important boundary is that control moved from the consumer to another module. - -```ts -import { - BindToCurrentModuleContext, -} from "@antelopejs/interface-core/modules"; - -const scheduledJobs = new Map Promise>(); - -export function RegisterJob( - id: string, - callback: () => Promise, -): void { - scheduledJobs.set(id, BindToCurrentModuleContext(callback)); -} -``` - -The bound callback preserves its arguments, `this`, return value, and function name. Each invocation restores the consumer's module generation and provider routes. It throws `ModuleContextInvalidatedError` instead of running after that generation is destroyed. - -Binding is not required for ordinary lifecycle callbacks, implementation functions passed to `ImplementInterface`, or work that never leaves the current module context. Built-in proxies preserve their own provider callbacks, but they cannot infer that an arbitrary function argument is a callback that another module will invoke. - -HTTP routers, schedulers, job queues, and plugin registries are common callback boundaries. Their interface packages should apply binding during registration so application consumers never call this API themselves. - ## Lifecycle events The `Events` namespace exposes four `EventProxy` instances that fire during module lifecycle transitions. 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..987f897 --- /dev/null +++ b/src/facades.ts @@ -0,0 +1,206 @@ +import { + type ActiveModuleExecutionContext, + activateModuleContext, + assertActiveModuleContext, + type ModuleExecutionContext, + runWithCapturedModuleContext, +} from "./internal"; +import { + GetInterfaceProxyIdentity, + type InterfaceFunctionProxy, + IsInterfaceProxy, +} from "./proxies"; + +type Func = (...args: A) => R; +const activeFacadeContexts = new WeakMap< + ModuleExecutionContext, + ActiveModuleExecutionContext +>(); + +export interface InterfaceFacadeScope { + readonly context: ModuleExecutionContext; + bind(declaration: InterfaceFunctionProxy): T; + run(callback: () => T): T; +} + +export type InterfaceFacadeBuilder = ( + scope: InterfaceFacadeScope, + facade: Record, +) => Record; + +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, +): T { + 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 = declaration.proxy; + Object.defineProperty(bound, "name", { + configurable: true, + value: declaration.name, + }); + return bound as unknown as T; +} + +function createFacadeScope( + context: ModuleExecutionContext, +): InterfaceFacadeScope { + let activeContext = activeFacadeContexts.get(context); + if (activeContext) { + assertActiveModuleContext(activeContext); + } else { + activeContext = activateModuleContext(context); + activeFacadeContexts.set(context, activeContext); + } + return { + context: activeContext, + bind: (declaration) => bindInterfaceFunction(declaration, activeContext), + run: (callback) => runWithCapturedModuleContext(activeContext, callback), + }; +} + +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 bindInterfaceFunctions( + value: unknown, + scope: InterfaceFacadeScope, + seen = new WeakMap(), +): unknown { + if (isInterfaceFunction(value)) { + return scope.bind(value); + } + if (typeof value !== "object" || value === null) { + return value; + } + if (!isNamespaceObject(value)) { + return value; + } + 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); + let changed = false; + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor) { + continue; + } + const original: unknown = + "value" in descriptor ? descriptor.value : Reflect.get(value, key); + const bound = bindInterfaceFunctions(original, scope, seen); + const isSelfReference = original === value && bound === facade; + if (bound !== original && !isSelfReference) { + changed = true; + } + Object.defineProperty( + facade, + key, + bound === original + ? descriptor + : { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + value: bound, + writable: "writable" in descriptor ? descriptor.writable : false, + }, + ); + } + visit.result = changed ? facade : value; + return visit.result; +} + +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, +): T { + const scope = createFacadeScope(context); + const facade = bindInterfaceFunctions(declaration, scope) as T; + const factory = (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); +} diff --git a/src/index.ts b/src/index.ts index 8a7fce5..75568c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,6 +112,20 @@ 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(); @@ -189,7 +203,11 @@ function createAttachmentPlan( plans.push(proxyPlan); continue; } - if (isObject(declared) && !IsInterfaceProxy(declared)) { + if ( + isObject(declared) && + !IsInterfaceProxy(declared) && + !isCommonJsDeclarationMirror(key, declaration, declared) + ) { const nestedImplementation = isObject(implemented) ? implemented : {}; plans.push( ...createAttachmentPlan( diff --git a/src/internal.ts b/src/internal.ts index 23eb263..6062d25 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; } @@ -170,7 +170,9 @@ function trackModuleOwner(module: string, owner: string) { addToMapSet(internal.moduleOwners, module, owner); } -function assertActiveModuleContext(context: ActiveModuleExecutionContext) { +export function assertActiveModuleContext( + context: ActiveModuleExecutionContext, +) { if ( internal.activeOwnerTokens.get(context.owner) !== context.ownershipToken ) { @@ -178,24 +180,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; trackModuleOwner(context.module, owner); - const activeContext = { + 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); } diff --git a/src/modules.ts b/src/modules.ts index c04da28..8139bfc 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -1,5 +1,4 @@ import { - captureModuleContext, getModuleContext, getModuleOwners, internal, @@ -7,13 +6,10 @@ import { type ModuleExecutionContext, peekModuleContext, type RuntimeCleanup, - runWithCapturedModuleContext, runWithModuleContext, } from "./internal"; import { EventProxy, InterfaceFunction } from "./proxies"; -type ModuleCallback = (...args: A) => R; - /** * Runs work with module ownership and provider routing across asynchronous work. * @@ -38,36 +34,6 @@ export function GetModuleContext(): ModuleExecutionContext | undefined { return getModuleContext(); } -/** - * Binds a callback to the active module generation and provider routes. - * - * Interface authors should bind callbacks received from a consumer when a - * provider will invoke them from its own execution context, either immediately - * or later. Ordinary module lifecycle and interface calls are already managed - * by Core and the proxy runtime and do not need explicit binding. - */ -export function BindToCurrentModuleContext( - callback: T, -): T { - const context = captureModuleContext(); - if (!context) { - return callback; - } - const bound = function ( - this: ThisParameterType, - ...args: Parameters - ): ReturnType { - return runWithCapturedModuleContext(context, () => - callback.apply(this, args), - ); - }; - Object.defineProperty(bound, "name", { - configurable: true, - value: callback.name, - }); - return bound as T; -} - export type { ModuleExecutionContext } from "./internal"; /** diff --git a/src/proxies.ts b/src/proxies.ts index 1db7b2f..56f59c9 100644 --- a/src/proxies.ts +++ b/src/proxies.ts @@ -302,6 +302,31 @@ 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); + } + + private enqueue(args: Parameters, requested: string | undefined) { if (internal.testStubMode) { return Promise.reject(new MissingProviderError()); } @@ -339,11 +364,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; 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-facade.test.ts b/src/tests/interface-facade.test.ts new file mode 100644 index 0000000..fb84d31 --- /dev/null +++ b/src/tests/interface-facade.test.ts @@ -0,0 +1,151 @@ +import { expect } from "chai"; +import { InterfaceFunction } from ".."; +import { ModuleContextInvalidatedError } from "../errors"; +import { CreateInterfaceFacade, type InterfaceFacadeScope } from "../facades"; +import { + Events, + type ModuleExecutionContext, + RunWithModuleContext, +} 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("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.run(() => 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("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, + ); + }); +}); diff --git a/src/tests/provider-context.test.ts b/src/tests/provider-context.test.ts index ea0e222..da21c54 100644 --- a/src/tests/provider-context.test.ts +++ b/src/tests/provider-context.test.ts @@ -5,12 +5,7 @@ import { ModuleContextInvalidatedError, RegisteringProxy, } from ".."; -import { - BindToCurrentModuleContext, - Events, - GetModuleContext, - RunWithModuleContext, -} from "../modules"; +import { Events, GetModuleContext, RunWithModuleContext } from "../modules"; interface ContextObservation { module?: string; @@ -18,10 +13,6 @@ interface ContextObservation { provider?: string; } -interface CallbackReceiver { - prefix: string; -} - function observeContext(): ContextObservation { const context = GetModuleContext(); return { @@ -32,92 +23,6 @@ function observeContext(): ContextObservation { } describe("provider callback context", () => { - it("binds callbacks to consumer routes without changing their contract", () => { - function readContext(this: CallbackReceiver, suffix: string) { - return { - context: observeContext(), - value: `${this.prefix}:${suffix}`, - }; - } - const bound = RunWithModuleContext( - { - module: "consumer", - owner: "consumer#bound", - providerRoutes: { "async:auth.Verify": "auth" }, - }, - () => BindToCurrentModuleContext(readContext), - ); - - const result = RunWithModuleContext( - { module: "api", owner: "api#1", provider: "api" }, - () => bound.call({ prefix: "route" }, "handler"), - ); - - expect(bound.name).to.equal(readContext.name); - expect(result).to.deep.equal({ - context: { - module: "consumer", - owner: "consumer#bound", - provider: undefined, - }, - value: "route:handler", - }); - }); - - it("returns the original callback outside a module context", () => { - const callback = () => "value"; - - expect(BindToCurrentModuleContext(callback)).to.equal(callback); - }); - - it("rejects bound callbacks after their module generation is destroyed", () => { - const bound = RunWithModuleContext( - { module: "consumer", owner: "consumer#stale" }, - () => BindToCurrentModuleContext(() => "value"), - ); - Events.ModuleDestroyed.emit("consumer"); - - expect(() => bound()).to.throw(ModuleContextInvalidatedError); - }); - - it("keeps a replacement bound after destroying the previous generation", () => { - const stale = RunWithModuleContext( - { module: "reloaded-consumer", owner: "consumer#old" }, - () => BindToCurrentModuleContext(() => "old"), - ); - Events.ModuleDestroyed.emit("reloaded-consumer"); - const replacement = RunWithModuleContext( - { module: "reloaded-consumer", owner: "consumer#new" }, - () => BindToCurrentModuleContext(() => "new"), - ); - - expect(() => stale()).to.throw(ModuleContextInvalidatedError); - expect(replacement()).to.equal("new"); - }); - - it("invalidates only the generation destroyed in its own context", () => { - const staleContext = { - module: "concurrent-consumer", - owner: "concurrent-consumer#old", - }; - const stale = RunWithModuleContext(staleContext, () => - BindToCurrentModuleContext(() => "old"), - ); - const replacement = RunWithModuleContext( - { - module: "concurrent-consumer", - owner: "concurrent-consumer#new", - }, - () => BindToCurrentModuleContext(() => "new"), - ); - RunWithModuleContext(staleContext, () => - Events.ModuleDestroyed.emit("concurrent-consumer"), - ); - - expect(() => stale()).to.throw(ModuleContextInvalidatedError); - expect(replacement()).to.equal("new"); - }); - it("restores async provider context across awaits and nested calls", async () => { const nested = new AsyncProxy<() => string>("context.nested"); const outer = new AsyncProxy<() => Promise>( diff --git a/src/tests/root-declarations.test.ts b/src/tests/root-declarations.test.ts index 33a13d6..2c758f5 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,10 +29,10 @@ 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.BindToCurrentModuleContext, undefined); +assert.equal(core.CreateInterfaceFacade, undefined); assert.equal(core.GetModuleContext, undefined); assert.equal(core.RunWithModuleContext, undefined); -assert.equal(typeof modules.BindToCurrentModuleContext, "function"); +assert.equal(typeof facades.CreateInterfaceFacade, "function"); assert.equal(typeof modules.GetModuleContext, "function"); assert.equal(typeof modules.RunWithModuleContext, "function"); assert.equal(core.IsInterfaceProxy(core.ListModules.proxy), true); @@ -60,20 +63,20 @@ describe("root interface declarations", () => { expect(declarations.GetModuleInfo).to.equal(modules.GetModuleInfo); }); - it("keeps execution context APIs on the modules subpath", () => { - expect("BindToCurrentModuleContext" in declarations).to.equal(false); + 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(modules.BindToCurrentModuleContext).to.be.a("function"); + expect(facades.CreateInterfaceFacade).to.be.a("function"); expect(modules.GetModuleContext).to.be.a("function"); expect(modules.RunWithModuleContext).to.be.a("function"); }); 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/test/package-consumer.mjs b/test/package-consumer.mjs index d01b4ca..c34a19a 100644 --- a/test/package-consumer.mjs +++ b/test/package-consumer.mjs @@ -41,11 +41,11 @@ import { type InterfaceConnection, } from "@antelopejs/interface-core"; import { - BindToCurrentModuleContext, GetModuleContext, type ModuleExecutionContext, RunWithModuleContext, } from "@antelopejs/interface-core/modules"; +import { CreateInterfaceFacade } from "@antelopejs/interface-core/facades"; const connection: InterfaceConnection = { path: "example", @@ -58,7 +58,7 @@ const context: ModuleExecutionContext = { }; void connection; void context; -void BindToCurrentModuleContext; +void CreateInterfaceFacade; void GetModuleContext; void GetRuntimeInfo; void ListModules; @@ -67,6 +67,7 @@ void RunWithModuleContext; 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"); `; @@ -74,6 +75,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"); `; @@ -87,10 +89,10 @@ 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.BindToCurrentModuleContext, undefined); +assert.equal(core.CreateInterfaceFacade, undefined); assert.equal(core.GetModuleContext, undefined); assert.equal(core.RunWithModuleContext, undefined); -assert.equal(typeof modules.BindToCurrentModuleContext, "function"); +assert.equal(typeof facades.CreateInterfaceFacade, "function"); assert.equal(typeof modules.GetModuleContext, "function"); assert.equal(typeof modules.RunWithModuleContext, "function"); assert.equal(core.IsInterfaceProxy(core.GetRuntimeInfo.proxy), true); @@ -118,9 +120,12 @@ modules.RunWithModuleContext(providerContext, () => { (async () => { const oldContext = await modules.RunWithModuleContext(consumerContext, () => proxy()); + const facade = facades.CreateInterfaceFacade({ GetValue: proxy }, consumerContext); + const facadeContext = await facade.GetValue(); assert.equal(oldContext.module, "provider"); assert.equal(oldContext.owner, "provider#old"); assert.equal(oldContext.provider, "provider"); + assert.deepEqual(facadeContext, oldContext); modules.RunWithModuleContext( { module: "provider", owner: "provider#new", provider: "provider" }, @@ -133,6 +138,7 @@ modules.RunWithModuleContext(providerContext, () => { }); const replacement = await modules.RunWithModuleContext(consumerContext, () => proxy()); assert.equal(replacement, "provider#new"); + assert.equal(await facade.GetValue(), "provider#new"); internal.interfaceConnections.consumer = { example: [{ path: "example", provider: "provider", selected: true }], From 6cbb1c23fe2ad3a362e9f77cdbd8321d5943b482 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 25 Aug 2026 23:28:23 +0000 Subject: [PATCH 6/8] fix(runtime): bind interface facades lexically Route interface calls and registrations through resolver-selected providers without wrapping stored callbacks. Preserve provider ownership and invalidate only the destroyed module generation. Co-authored-by: Upd4ting --- src/facades.ts | 257 +++++++++++++++++++++------ src/index.ts | 215 ++++++++++++++++++---- src/internal.ts | 11 ++ src/modules.ts | 23 ++- src/proxies.ts | 241 ++++++++++++++++++++++--- src/responsible-module.ts | 46 +++-- src/tests/interface-facade.test.ts | 148 ++++++++++++++- src/tests/responsible-module.test.ts | 33 +++- 8 files changed, 835 insertions(+), 139 deletions(-) diff --git a/src/facades.ts b/src/facades.ts index 987f897..c3aaa33 100644 --- a/src/facades.ts +++ b/src/facades.ts @@ -2,16 +2,22 @@ import { type ActiveModuleExecutionContext, activateModuleContext, assertActiveModuleContext, + internal, type ModuleExecutionContext, - runWithCapturedModuleContext, } 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 @@ -19,8 +25,13 @@ const activeFacadeContexts = new WeakMap< export interface InterfaceFacadeScope { readonly context: ModuleExecutionContext; - bind(declaration: InterfaceFunctionProxy): T; - run(callback: () => T): T; + assertActive(): void; + bind>>( + declaration: InterfaceFunctionProxy, + ): InterfaceFunctionProxy; + bindProxy(declaration: T): T; + createFacade>(declaration: T): T; + onDestroy(cleanup: () => void): void; } export type InterfaceFacadeBuilder = ( @@ -28,22 +39,30 @@ export type InterfaceFacadeBuilder = ( 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, +function getSelectedProvider( + declaration: InterfaceFunctionProxy, context: ModuleExecutionContext, ): string | undefined { const identity = GetInterfaceProxyIdentity(declaration.proxy); return identity ? context.providerRoutes?.[identity] : undefined; } -function bindInterfaceFunction( - declaration: InterfaceFunctionProxy, +function bindInterfaceFunction( + declaration: InterfaceFunctionProxy, context: ActiveModuleExecutionContext, -): T { +): InterfaceFunctionProxy { const provider = getSelectedProvider(declaration, context); const bound = (...args: Parameters) => { try { @@ -53,28 +72,123 @@ function bindInterfaceFunction( return Promise.reject(error); } }; - bound.proxy = declaration.proxy; + bound.proxy = bindInterfaceProxy(declaration.proxy, context); Object.defineProperty(bound, "name", { configurable: true, value: declaration.name, }); - return bound as unknown as T; + 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 = activeFacadeContexts.get(context); - if (activeContext) { + let activeContext: ActiveModuleExecutionContext; + if (isActiveModuleExecutionContext(context)) { + activeContext = context; assertActiveModuleContext(activeContext); } else { - activeContext = activateModuleContext(context); - activeFacadeContexts.set(context, activeContext); + 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), - run: (callback) => runWithCapturedModuleContext(activeContext, callback), + bindProxy: (declaration) => bindInterfaceProxy(declaration, activeContext), + createFacade: (declaration) => + CreateInterfaceFacade(declaration, activeContext), + onDestroy: (cleanup) => { + assertActiveModuleContext(activeContext); + internal.addOwnerCleanup(activeContext.owner, cleanup); + }, }; } @@ -97,6 +211,55 @@ interface FacadeVisit { 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, @@ -105,48 +268,23 @@ function bindInterfaceFunctions( 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; } - 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); - let changed = false; - for (const key of Reflect.ownKeys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor) { - continue; - } - const original: unknown = - "value" in descriptor ? descriptor.value : Reflect.get(value, key); - const bound = bindInterfaceFunctions(original, scope, seen); - const isSelfReference = original === value && bound === facade; - if (bound !== original && !isSelfReference) { - changed = true; - } - Object.defineProperty( - facade, - key, - bound === original - ? descriptor - : { - configurable: descriptor.configurable, - enumerable: descriptor.enumerable, - value: bound, - writable: "writable" in descriptor ? descriptor.writable : false, - }, - ); - } - visit.result = changed ? facade : value; - return visit.result; + return bindNamespaceObject(value, scope, seen); } function applyOverrides>( @@ -190,11 +328,12 @@ function applyOverrides>( export function CreateInterfaceFacade>( declaration: T, context: ModuleExecutionContext, + builder?: InterfaceFacadeBuilder, ): T { const scope = createFacadeScope(context); const facade = bindInterfaceFunctions(declaration, scope) as T; - const factory = (declaration as InterfaceFacadeDeclaration) - .BuildInterfaceFacade; + const factory = + builder ?? (declaration as InterfaceFacadeDeclaration).BuildInterfaceFacade; if (!factory) { return facade; } @@ -204,3 +343,15 @@ export function CreateInterfaceFacade>( } 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 75568c1..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,16 +103,26 @@ 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; } @@ -155,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; @@ -180,43 +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) && - !isCommonJsDeclarationMirror(key, declaration, declared) - ) { - const nestedImplementation = isObject(implemented) ? implemented : {}; - plans.push( - ...createAttachmentPlan( - declared, - nestedImplementation, - `${path}.${key}`, - ), - ); - } + plans.push( + ...planNestedAttachments( + key, + declared, + implemented, + declaration, + path, + context, + ), + ); } return plans; } @@ -224,6 +307,7 @@ function createAttachmentPlan( function attachImplementation( declaration: Record, implementation: Record, + context?: ActiveModuleExecutionContext, ) { if (!isObject(declaration) || !isObject(implementation)) { throw new TypeError( @@ -232,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(); }); @@ -293,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. * @@ -304,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. * @@ -322,14 +449,32 @@ 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, ); } +BindResolverFacade(GetInterfaceInstance, (scope) => { + return ((interfaceID: string, connectionID: string) => + getInterfaceInstanceFor( + scope.context.module, + interfaceID, + connectionID, + )) as typeof GetInterfaceInstance; +}); + export { DestroyModule, Events, diff --git a/src/internal.ts b/src/internal.ts index 6062d25..55f3f51 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -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; @@ -93,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) { @@ -108,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< @@ -127,6 +131,9 @@ function createRuntime(): InterfaceRuntime { addRegisteringProxy(module, proxy) { addToMapSet(runtime.knownRegisters, module, proxy); }, + addOwnerCleanup(owner, cleanup) { + addToMapSet(runtime.ownerCleanups, owner, cleanup); + }, }; return runtime; } @@ -141,6 +148,10 @@ 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(); diff --git a/src/modules.ts b/src/modules.ts index 8139bfc..4170767 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -14,8 +14,8 @@ import { EventProxy, InterfaceFunction } from "./proxies"; * Runs work with module ownership and provider routing across asynchronous work. * * This is an infrastructure API for AntelopeJS Core and custom module loaders. - * Application modules should rely on the context installed by Core and use - * `importOverrides` to select providers instead of calling this function. + * Application modules should rely on resolver-selected interface imports and + * use `importOverrides` instead of calling this function. */ export function RunWithModuleContext( context: ModuleExecutionContext, @@ -87,9 +87,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( @@ -108,7 +108,10 @@ function runCleanup( } } -function getDestroyedOwners(module: string): string[] { +function getDestroyedOwners(module: string, owner?: string): string[] { + if (owner) { + return [owner]; + } const context = peekModuleContext(); if (context?.module === module) { return [context.owner ?? module]; @@ -118,6 +121,10 @@ function getDestroyedOwners(module: string): string[] { 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"); } @@ -148,8 +155,8 @@ function cleanupDestroyedOwner(module: string, owner: string) { } } -Events.ModuleDestroyed.register((module) => { - getDestroyedOwners(module).forEach((owner) => { +Events.ModuleDestroyed.register((module, destroyedOwner) => { + getDestroyedOwners(module, destroyedOwner).forEach((owner) => { cleanupDestroyedOwner(module, owner); }); }); diff --git a/src/proxies.ts b/src/proxies.ts index 56f59c9..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; } @@ -326,6 +392,21 @@ export class AsyncProxy>> { 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()); @@ -438,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; } @@ -474,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(); } @@ -493,7 +640,6 @@ export class RegisteringProxy { internal.maxPendingOperations, ); } - const ownership = getExecutionOwnership(); this.state.registered.set(id, { ...ownership, provider: requested ?? callback?.provider, @@ -520,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); @@ -661,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. */ @@ -674,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( @@ -689,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/interface-facade.test.ts b/src/tests/interface-facade.test.ts index fb84d31..6bec15b 100644 --- a/src/tests/interface-facade.test.ts +++ b/src/tests/interface-facade.test.ts @@ -1,9 +1,11 @@ import { expect } from "chai"; +import * as InterfaceCore from ".."; import { InterfaceFunction } from ".."; import { ModuleContextInvalidatedError } from "../errors"; import { CreateInterfaceFacade, type InterfaceFacadeScope } from "../facades"; import { Events, + GetModuleContext, type ModuleExecutionContext, RunWithModuleContext, } from "../modules"; @@ -31,6 +33,56 @@ function consumerContext( } 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 = @@ -96,7 +148,7 @@ describe("interface facades", () => { facade: Record, ) => { const boundCall = facade.Call as typeof Call; - const owner = scope.run(() => scope.context.owner); + const owner = scope.context.owner; return { Read: () => boundCall(), ReadOwner: () => owner }; }, Call, @@ -112,6 +164,52 @@ describe("interface facades", () => { 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 }; @@ -148,4 +246,52 @@ describe("interface facades", () => { 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/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", () => { From b7ee1049b6ba476893055c78b9798851f312fe60 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 25 Aug 2026 23:28:30 +0000 Subject: [PATCH 7/8] docs(runtime): explain resolver-bound facade usage Co-authored-by: Upd4ting --- docs/2.proxies.md | 25 ++++++++++++++----------- docs/5.modules.md | 18 +++++++++--------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/2.proxies.md b/docs/2.proxies.md index 87c787d..fb2cfaa 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. +The legacy `RunWithModuleContext` path still captures context for direct proxy users. Resolver-bound providers do not need that context: their implementation callbacks already close over imports selected for the provider module. ```ts // Automatic cleanup (default) - detaches when the module unloads @@ -129,13 +129,13 @@ 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. +The legacy `RunWithModuleContext` path restores captured provider context. 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. +The same legacy compatibility behavior applies to unregistration callbacks. ### `register(id, ...args)` @@ -172,22 +172,25 @@ Direct `InterfaceFunction` exports require no work from interface authors. An in 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"); -// Derived export: use the automatically bound Read function. export function BuildInterfaceFacade( - scope: InterfaceFacadeScope, + _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) => - scope.run(() => Register(handler)), + boundRegistrations.register(handler.id, handler), }; } ``` -`scope.run` is for cold registration/decorator work, not request handling. The resolver builds and caches the facade before module evaluation; providers receive the application's original callbacks without a per-invocation context wrapper. +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` @@ -279,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"; @@ -290,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 8e45695..cbc701b 100644 --- a/docs/5.modules.md +++ b/docs/5.modules.md @@ -37,21 +37,21 @@ loaded -> constructed -> active -> constructed -> loaded ## Advanced execution context APIs -Execution context APIs are exported from `@antelopejs/interface-core/modules`. They are framework primitives, not APIs that ordinary AntelopeJS modules need during normal use. Core installs the correct context before module evaluation and lifecycle hooks, and interface proxies restore provider contexts automatically. +Execution context APIs are exported from `@antelopejs/interface-core/modules`. They are compatibility and framework primitives, not APIs that ordinary AntelopeJS modules need during normal use. Current Core resolves module-specific interface facades before module evaluation instead of installing an ambient context around lifecycle hooks or callbacks. | Situation | API to use | | --- | --- | -| Application module lifecycle or ordinary interface call | None; Core handles the context | -| Provider implementation attached with `ImplementInterface` | None; proxies restore its context | +| 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` | -| Core or a custom module loader executes module-owned work | `RunWithModuleContext` | -| Framework diagnostics need to inspect the active context | `GetModuleContext` | +| Existing custom loader that still uses ambient routing | `RunWithModuleContext` | +| Diagnostics for that compatibility path | `GetModuleContext` | These APIs intentionally remain on the `/modules` subpath rather than the package root so application code does not mistake them for provider-selection helpers. ### `RunWithModuleContext` -`RunWithModuleContext` propagates module ownership and provider routing through synchronous and asynchronous work: +`RunWithModuleContext` propagates module ownership and provider routing through synchronous and asynchronous work for legacy direct proxy operations: ```ts import { RunWithModuleContext } from "@antelopejs/interface-core/modules"; @@ -67,9 +67,9 @@ await RunWithModuleContext( ); ``` -`module` remains the stable public module ID. `owner` identifies one lifecycle generation and must 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. +`module` remains the stable public module ID. `owner` identifies one lifecycle generation and must be unique when old and replacement instances can overlap. Direct proxy attachments made inside the callback capture this context. `GetModuleContext` returns the active compatibility context and throws `ModuleContextInvalidatedError` after its owner is destroyed. -Only Core and custom module loaders should create module execution contexts. Application modules must use `importOverrides` instead of calling `RunWithModuleContext` to force provider selection. +Application modules must use `importOverrides` instead of calling `RunWithModuleContext` to force provider selection. Current Core's resolver path does not call this API per request, per callback, or per lifecycle hook. ## Lifecycle events @@ -117,7 +117,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 From 951c82fb8cc068b8ed32ff82457973071b82c1e8 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 26 Aug 2026 12:38:38 +0000 Subject: [PATCH 8/8] refactor(runtime): remove ambient provider context API Co-authored-by: Upd4ting --- docs/2.proxies.md | 6 +-- docs/5.modules.md | 29 ++----------- src/modules.ts | 15 ------- src/tests/generation-cleanup.test.ts | 40 +++++++++--------- src/tests/interface-connections.test.ts | 5 +-- src/tests/interface-facade.test.ts | 12 +++--- src/tests/module-ownership-context.test.ts | 6 +-- src/tests/provider-context.test.ts | 25 ++++++------ src/tests/provider-routing.test.ts | 33 +++++++-------- src/tests/queue-and-cleanup.test.ts | 12 ++++-- src/tests/root-declarations.test.ts | 4 +- src/tests/runtime-protocol.test.ts | 6 +-- test/package-consumer.mjs | 47 ++++++++++------------ 13 files changed, 101 insertions(+), 139 deletions(-) diff --git a/docs/2.proxies.md b/docs/2.proxies.md index fb2cfaa..f7529e3 100644 --- a/docs/2.proxies.md +++ b/docs/2.proxies.md @@ -31,7 +31,7 @@ const greeting = await proxy.call("Bob"); // "Hello, Bob!" 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. -The legacy `RunWithModuleContext` path still captures context for direct proxy users. Resolver-bound providers do not need that context: their implementation callbacks already close over imports selected for the provider module. +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,13 +129,13 @@ 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`. -The legacy `RunWithModuleContext` path restores captured provider context. Resolver-bound providers receive callbacks unchanged because imports inside those callbacks are already bound to the provider module. +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. -The same legacy compatibility behavior applies to unregistration callbacks. +Resolver-bound ownership applies equally to unregistration callbacks. ### `register(id, ...args)` diff --git a/docs/5.modules.md b/docs/5.modules.md index cbc701b..b735272 100644 --- a/docs/5.modules.md +++ b/docs/5.modules.md @@ -37,39 +37,16 @@ loaded -> constructed -> active -> constructed -> loaded ## Advanced execution context APIs -Execution context APIs are exported from `@antelopejs/interface-core/modules`. They are compatibility and framework primitives, not APIs that ordinary AntelopeJS modules need during normal use. Current Core resolves module-specific interface facades before module evaluation instead of installing an ambient context around lifecycle hooks or callbacks. +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. | 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` | -| Existing custom loader that still uses ambient routing | `RunWithModuleContext` | -| Diagnostics for that compatibility path | `GetModuleContext` | +| Inspect explicit ownership established with `RunWithResponsibleModule` | `GetModuleContext` | -These APIs intentionally remain on the `/modules` subpath rather than the package root so application code does not mistake them for provider-selection helpers. - -### `RunWithModuleContext` - -`RunWithModuleContext` propagates module ownership and provider routing through synchronous and asynchronous work for legacy direct proxy operations: - -```ts -import { RunWithModuleContext } from "@antelopejs/interface-core/modules"; - -await RunWithModuleContext( - { - module: "search-provider", - owner: "search-provider#42", - provider: "search-provider", - providerRoutes: routes, - }, - () => constructModule(), -); -``` - -`module` remains the stable public module ID. `owner` identifies one lifecycle generation and must be unique when old and replacement instances can overlap. Direct proxy attachments made inside the callback capture this context. `GetModuleContext` returns the active compatibility context and throws `ModuleContextInvalidatedError` after its owner is destroyed. - -Application modules must use `importOverrides` instead of calling `RunWithModuleContext` to force provider selection. Current Core's resolver path does not call this API per request, per callback, or per lifecycle hook. +`GetModuleContext` intentionally remains on the `/modules` subpath for ownership diagnostics. It does not select a provider. Application modules normally need neither API. ## Lifecycle events diff --git a/src/modules.ts b/src/modules.ts index 4170767..727e807 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -6,24 +6,9 @@ import { type ModuleExecutionContext, peekModuleContext, type RuntimeCleanup, - runWithModuleContext, } from "./internal"; import { EventProxy, InterfaceFunction } from "./proxies"; -/** - * Runs work with module ownership and provider routing across asynchronous work. - * - * This is an infrastructure API for AntelopeJS Core and custom module loaders. - * Application modules should rely on resolver-selected interface imports and - * use `importOverrides` instead of calling this function. - */ -export function RunWithModuleContext( - context: ModuleExecutionContext, - callback: () => T, -): T { - return runWithModuleContext(context, callback); -} - /** * Returns the active module execution context, if one exists. * 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/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 index 6bec15b..54c4406 100644 --- a/src/tests/interface-facade.test.ts +++ b/src/tests/interface-facade.test.ts @@ -3,11 +3,11 @@ 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, - RunWithModuleContext, } from "../modules"; class SharedResult {} @@ -91,7 +91,7 @@ describe("interface facades", () => { const nestedIdentity = "async:facade.NestedCall"; const sharedMetadata = {}; for (const provider of ["provider-a", "provider-b"]) { - RunWithModuleContext(providerContext(provider), () => { + runWithModuleContext(providerContext(provider), () => { Call.proxy.onCall((value) => `${provider}:${value}`, true); NestedCall.proxy.onCall((value) => `${provider}:nested:${value}`, true); }); @@ -139,7 +139,7 @@ describe("interface facades", () => { it("lets interface builders derive cold APIs from automatic bindings", async () => { const Call = InterfaceFunction<() => string>("facade.Derived"); - RunWithModuleContext(providerContext("provider"), () => + runWithModuleContext(providerContext("provider"), () => Call.proxy.onCall(() => "value", true), ); const declaration = { @@ -223,7 +223,7 @@ describe("interface facades", () => { it("rejects calls from an invalidated facade generation", async () => { const Call = InterfaceFunction<() => string>("facade.Stale"); - RunWithModuleContext(providerContext("provider"), () => + runWithModuleContext(providerContext("provider"), () => Call.proxy.onCall(() => "value", true), ); const context = consumerContext( @@ -232,7 +232,7 @@ describe("interface facades", () => { "provider", ); const facade = CreateInterfaceFacade({ Call }, context); - RunWithModuleContext(context, () => + runWithModuleContext(context, () => Events.ModuleDestroyed.emit("consumer"), ); @@ -277,7 +277,7 @@ describe("interface facades", () => { currentContext, ); const listener = () => undefined; - RunWithModuleContext(staleContext, () => + runWithModuleContext(staleContext, () => Events.ModuleDestroyed.emit("consumer"), ); current.Registrations.register("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/root-declarations.test.ts b/src/tests/root-declarations.test.ts index 2c758f5..fac2843 100644 --- a/src/tests/root-declarations.test.ts +++ b/src/tests/root-declarations.test.ts @@ -34,7 +34,7 @@ assert.equal(core.GetModuleContext, undefined); assert.equal(core.RunWithModuleContext, undefined); assert.equal(typeof facades.CreateInterfaceFacade, "function"); assert.equal(typeof modules.GetModuleContext, "function"); -assert.equal(typeof modules.RunWithModuleContext, "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"); @@ -69,7 +69,7 @@ describe("root interface declarations", () => { expect("RunWithModuleContext" in declarations).to.equal(false); expect(facades.CreateInterfaceFacade).to.be.a("function"); expect(modules.GetModuleContext).to.be.a("function"); - expect(modules.RunWithModuleContext).to.be.a("function"); + expect("RunWithModuleContext" in modules).to.equal(false); }); it("loads complete canonical declarations when the root loads first", () => { 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 c34a19a..dc4903a 100644 --- a/test/package-consumer.mjs +++ b/test/package-consumer.mjs @@ -43,7 +43,6 @@ import { import { GetModuleContext, type ModuleExecutionContext, - RunWithModuleContext, } from "@antelopejs/interface-core/modules"; import { CreateInterfaceFacade } from "@antelopejs/interface-core/facades"; @@ -62,7 +61,6 @@ void CreateInterfaceFacade; void GetModuleContext; void GetRuntimeInfo; void ListModules; -void RunWithModuleContext; `; const rootFirstImports = ` @@ -94,7 +92,7 @@ assert.equal(core.GetModuleContext, undefined); assert.equal(core.RunWithModuleContext, undefined); assert.equal(typeof facades.CreateInterfaceFacade, "function"); assert.equal(typeof modules.GetModuleContext, "function"); -assert.equal(typeof modules.RunWithModuleContext, "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"); @@ -108,44 +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()); const facade = facades.CreateInterfaceFacade({ GetValue: proxy }, consumerContext); - const facadeContext = await facade.GetValue(); - assert.equal(oldContext.module, "provider"); - assert.equal(oldContext.owner, "provider#old"); - assert.equal(oldContext.provider, "provider"); - assert.deepEqual(facadeContext, oldContext); + 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 }, ]);