Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion docs/2.proxies.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ 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.

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.

```ts
// Automatic cleanup (default) - detaches when the module unloads
proxy.onCall(myHandler);
Expand Down Expand Up @@ -127,10 +129,14 @@ 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.

### `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.

### `register(id, ...args)`

Registers an entry with the given identifier. If a register callback is attached, it executes immediately. Otherwise, the entry is stored and replayed when a callback is attached. The calling module is tracked for automatic cleanup.
Expand Down Expand Up @@ -208,6 +214,17 @@ const connections = GetInterfaceInstances("database");
const primary = GetInterfaceInstance("database", "primary");
```

Each `InterfaceConnection` includes the provider module ID and whether that provider is selected for unqualified calls:

```ts
interface InterfaceConnection {
id?: string;
path: string;
provider: string;
selected: boolean;
}
```

## `GetResponsibleModule`

`GetResponsibleModule` inspects the call stack to determine which module is responsible for the current execution. The proxy classes use this internally for automatic cleanup tracking.
Expand Down Expand Up @@ -235,7 +252,7 @@ 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.

Ownership contexts are scoped to a loaded module generation. `ModuleDestroyed` invalidates that generation before cleanup, so detached asynchronous work cannot add stale providers or handlers afterward. Such work receives a `ModuleContextInvalidatedError`. A later invocation for the same module ID creates a fresh generation without reactivating older contexts.
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`.

## Next steps

Expand Down
22 changes: 22 additions & 0 deletions docs/5.modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ loaded -> constructed -> active -> constructed -> loaded
| `active` | Module is fully started and providing services |
| `unknown` | Module status cannot be determined |

## Module execution context

`RunWithModuleContext` propagates module ownership and provider routing through synchronous and asynchronous work:

```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 should be unique when old and replacement instances can overlap. Providers capture this full context when attaching callbacks. `GetModuleContext` returns the active context and throws `ModuleContextInvalidatedError` after its owner is destroyed.

## Lifecycle events

The `Events` namespace exposes four `EventProxy` instances that fire during module lifecycle transitions.
Expand Down Expand Up @@ -81,6 +101,8 @@ 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.

## Management functions

These functions are declared as `InterfaceFunction` proxies. They are available once the core runtime provides their implementation.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
"lint": "biome check .",
"prepack": "pnpm run build",
"release": "pnpm run lint && pnpm run prepack && release-it",
"test": "pnpm run build && ajs module test ."
"test": "pnpm run build && ajs module test .",
"test:package": "node test/package-consumer.mjs"
},
"antelopeJs": {
"test": "src/antelope.test.ts",
Expand Down
8 changes: 2 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "reflect-metadata";
import type { Class } from "./decorators";
import { internal } from "./internal";
import { type InterfaceConnection, internal } from "./internal";
import { Logging } from "./logging";
import {
AsyncProxy,
Expand All @@ -11,6 +11,7 @@ import {
} from "./proxies";

export * from "./errors";
export type { InterfaceConnection } from "./internal";
export {
AsyncProxy,
EventProxy,
Expand Down Expand Up @@ -291,11 +292,6 @@ export function ImplementInterface<
return { declaration: decl, implementation: impl as T2 };
}

interface InterfaceConnection {
id?: string;
path: string;
}

/**
* Gets all instances of a specific interface across the system.
*
Expand Down
68 changes: 54 additions & 14 deletions src/internal.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { ModuleContextInvalidatedError } from "./errors";

export const RUNTIME_PROTOCOL_VERSION = 2;
export const RUNTIME_PROTOCOL_VERSION = 3;
export const RUNTIME_SYMBOL = Symbol.for("@antelopejs/interface-core/runtime");

/** Provider connection metadata visible to an interface consumer. */
export interface InterfaceConnection {
/** Optional connection alias. */
id?: string;
/** Resolved interface package path. */
path: string;
/** Module ID of the provider represented by this connection. */
provider: string;
/** Whether this provider is selected for unqualified interface calls. */
selected: boolean;
}

/** Module identity and provider routes propagated through asynchronous work. */
export interface ModuleExecutionContext {
/** Stable module ID used by existing lifecycle and attribution APIs. */
module: string;
/** Unique lifecycle generation ID. Defaults to the module ID when omitted. */
owner?: string;
/** Provider ID used when attaching implementations. */
provider?: string;
/** Provider selections keyed by stable interface proxy identity. */
providerRoutes?: Readonly<Record<string, string>>;
}

interface ActiveModuleExecutionContext extends ModuleExecutionContext {
owner: string;
ownershipToken: symbol;
}

Expand Down Expand Up @@ -52,11 +66,17 @@ export interface InterfaceRuntime {
testStubMode: boolean;
knownAsync: Map<string, Set<RuntimeCleanup | { detach(): void }>>;
knownRegisters: Map<string, Set<RuntimeCleanup | { detach(): void }>>;
registeringProxies: Set<{ unregisterModule(module: string): void }>;
knownEvents: Set<{ unregisterModule(module: string): void }>;
registeringProxies: Set<{
unregisterModule(module: string): void;
unregisterOwner(owner: string): void;
}>;
knownEvents: Set<{
unregisterModule(module: string): void;
unregisterOwner(owner: string): void;
}>;
interfaceConnections: Record<string, Record<string, InterfaceConnection[]>>;
executionContext: AsyncLocalStorage<ActiveModuleExecutionContext>;
activeModuleTokens: Map<string, symbol>;
activeOwnerTokens: Map<string, symbol>;
proxyStates: Map<string, RuntimeProxyState>;
nextProxyIdentity: number;
nextLeaseGeneration: number;
Expand Down Expand Up @@ -94,7 +114,7 @@ function createRuntime(): InterfaceRuntime {
Record<string, InterfaceConnection[]>
>,
executionContext: new AsyncLocalStorage<ActiveModuleExecutionContext>(),
activeModuleTokens: new Map(),
activeOwnerTokens: new Map(),
proxyStates: new Map(),
nextProxyIdentity: 1,
nextLeaseGeneration: 1,
Expand Down Expand Up @@ -133,19 +153,19 @@ function getRuntime(): InterfaceRuntime {
/** @internal */
export const internal = getRuntime();

function getModuleToken(module: string): symbol {
const activeToken = internal.activeModuleTokens.get(module);
function getOwnerToken(owner: string): symbol {
const activeToken = internal.activeOwnerTokens.get(owner);
if (activeToken) {
return activeToken;
}
const token = Symbol(module);
internal.activeModuleTokens.set(module, token);
const token = Symbol(owner);
internal.activeOwnerTokens.set(owner, token);
return token;
}

function assertActiveModuleContext(context: ActiveModuleExecutionContext) {
if (
internal.activeModuleTokens.get(context.module) !== context.ownershipToken
internal.activeOwnerTokens.get(context.owner) !== context.ownershipToken
) {
throw new ModuleContextInvalidatedError(context.module);
}
Expand All @@ -162,21 +182,41 @@ export function runWithModuleContext<T>(
if (!context.module) {
throw new Error("Module execution context requires a module ID.");
}
const owner = context.owner ?? context.module;
const activeContext = {
...context,
ownershipToken: getModuleToken(context.module),
owner,
ownershipToken: getOwnerToken(owner),
};
return internal.executionContext.run(activeContext, callback);
}

export function getModuleContext(): ModuleExecutionContext | undefined {
export function captureModuleContext():
| ActiveModuleExecutionContext
| undefined {
const context = internal.executionContext.getStore();
if (context) {
assertActiveModuleContext(context);
}
return context;
}

export function invalidateModuleContext(module: string) {
internal.activeModuleTokens.delete(module);
export function runWithCapturedModuleContext<T>(
context: ActiveModuleExecutionContext,
callback: () => T,
): T {
assertActiveModuleContext(context);
return internal.executionContext.run(context, callback);
}

export function peekModuleContext(): ModuleExecutionContext | undefined {
return internal.executionContext.getStore();
}

export function getModuleContext(): ModuleExecutionContext | undefined {
return captureModuleContext();
}

export function invalidateModuleContext(owner: string) {
internal.activeOwnerTokens.delete(owner);
}
28 changes: 19 additions & 9 deletions src/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
internal,
invalidateModuleContext,
type ModuleExecutionContext,
peekModuleContext,
type RuntimeCleanup,
runWithModuleContext,
} from "./internal";
Expand Down Expand Up @@ -95,19 +96,28 @@ function runCleanup(
}
}

function getDestroyedOwner(module: string): string {
const context = peekModuleContext();
if (context?.module !== module) {
return module;
}
return context.owner ?? module;
}

Events.ModuleDestroyed.register((module) => {
invalidateModuleContext(module);
for (const cleanup of internal.knownAsync.get(module) ?? []) {
runCleanup(cleanup, module, "detach-async-provider");
const owner = getDestroyedOwner(module);
invalidateModuleContext(owner);
for (const cleanup of internal.knownAsync.get(owner) ?? []) {
runCleanup(cleanup, owner, "detach-async-provider");
}
internal.knownAsync.delete(module);
for (const cleanup of internal.knownRegisters.get(module) ?? []) {
runCleanup(cleanup, module, "detach-registering-provider");
internal.knownAsync.delete(owner);
for (const cleanup of internal.knownRegisters.get(owner) ?? []) {
runCleanup(cleanup, owner, "detach-registering-provider");
}
internal.knownRegisters.delete(module);
internal.knownRegisters.delete(owner);
for (const proxy of internal.registeringProxies) {
try {
proxy.unregisterModule(module);
proxy.unregisterOwner(owner);
} catch (error) {
internal.runtimeErrorReporter?.(error, {
operation: "unregister-module",
Expand All @@ -117,7 +127,7 @@ Events.ModuleDestroyed.register((module) => {
}
for (const proxy of internal.knownEvents) {
try {
proxy.unregisterModule(module);
proxy.unregisterOwner(owner);
} catch (error) {
internal.runtimeErrorReporter?.(error, {
operation: "unregister-event-module",
Expand Down
Loading
Loading