Proxies
Overview
Proxies are the backbone of cross-module communication in AntelopeJS. They provide module-aware abstractions for function calls, event handling, and registration patterns. Each proxy type automatically tracks the module that attaches callbacks and detaches them when that module is unloaded, preventing memory leaks and dangling references.
AsyncProxy
AsyncProxy wraps asynchronous function calls. It queues invocations when no callback is attached and flushes the queue once a callback becomes available.
Create and use an AsyncProxy
import { AsyncProxy } from "@antelopejs/interface-core";
const proxy = new AsyncProxy<(name: string) => string>();
// Calls made before attachment are queued
const result = proxy.call("Alice"); // returns Promise<string>
// Attach an implementation - queued calls resolve immediately
proxy.onCall((name: string) => {
return `Hello, ${name}!`;
});
// Subsequent calls execute directly
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.
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.
// Automatic cleanup (default) - detaches when the module unloads
proxy.onCall(myHandler);
// Manual cleanup - you are responsible for calling detach()
proxy.onCall(myHandler, true);
call(...args)
Invokes the attached callback. If no callback is attached, the call returns a Promise that resolves once a callback is provided. In test stub mode, unattached calls reject with a MissingProviderError.
detach()
Manually removes the attached callback. After detaching, subsequent calls are queued again.
InterfaceFunction
InterfaceFunction is a convenience wrapper that creates an AsyncProxy and returns a callable function. This is the primary way to declare interface functions.
import { InterfaceFunction } from "@antelopejs/interface-core";
// Declare a typed interface function
const GetUser = InterfaceFunction<(id: string) => { name: string; email: string }>();
// Call it like a regular async function
const user = await GetUser("user-123");
The returned function has a .proxy property that exposes the underlying AsyncProxy for direct access when needed.
EventProxy
EventProxy manages a list of event handlers with module-aware automatic cleanup. Unlike AsyncProxy, it supports multiple registered handlers and does not queue emissions.
Create and use an EventProxy
import { EventProxy } from "@antelopejs/interface-core";
const onUserCreated = new EventProxy<(userId: string, name: string) => void>();
// Register a handler
onUserCreated.register((userId, name) => {
console.log(`User created: ${name} (${userId})`);
});
// Emit the event - all registered handlers are called
onUserCreated.emit("u-1", "Alice");
register(func)
Registers a handler for the event. Duplicate handlers (same function reference) are ignored. The proxy tracks the calling module and removes the handler when that module is unloaded.
emit(...args)
Calls all registered handlers with the provided arguments.
unregister(func)
Removes a specific handler by function reference.
RegisteringProxy
RegisteringProxy manages a register/unregister pair. It tracks registered entries by a unique identifier and supports queuing registrations until the callbacks are attached.
Create and use a RegisteringProxy
import { RegisteringProxy } from "@antelopejs/interface-core";
const routeRegistry = new RegisteringProxy<(id: string, path: string, handler: Function) => void>();
// Register entries - queued if no callback is attached yet
routeRegistry.register("home", "/", homeHandler);
routeRegistry.register("about", "/about", aboutHandler);
// Attach callbacks - queued registrations replay immediately
routeRegistry.onRegister((id, path, handler) => {
router.addRoute(path, handler);
});
routeRegistry.onUnregister((id) => {
router.removeRoute(id);
});
// Unregister by ID
routeRegistry.unregister("about");
onRegister(callback, manualDetach?)
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.
unregister(id)
Removes the entry with the given identifier and calls the unregister callback if one is attached.
detach()
Manually removes both the register and unregister callbacks. Registered entries remain stored.
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.
import { InterfaceFunction, EventProxy, RegisteringProxy, ImplementInterface } from "@antelopejs/interface-core";
// Declaration
const GetItem = InterfaceFunction<(id: string) => { name: string }>();
const OnItemAdded = new EventProxy<(id: string) => void>();
const ItemRegistry = new RegisteringProxy<(id: string, data: any) => void>();
const ItemInterface = { GetItem, OnItemAdded, ItemRegistry };
// Implementation
ImplementInterface(ItemInterface, {
GetItem(id: string) {
return { name: `Item ${id}` };
},
ItemRegistry: {
register(id: string, data: any) {
store.set(id, data);
},
unregister(id: string) {
store.delete(id);
},
},
});
EventProxy entries in the declaration are skipped during implementation wiring, as they are emitted from the declaring side, not implemented.
MissingProviderError
In test stub mode, using a proxy that has no provider attached fails with a MissingProviderError: AsyncProxy.call rejects with it and RegisteringProxy.register throws it.
This error is the supported way to detect the "no provider" condition. The contract is the type and its stable code property ("ERR_NO_PROVIDER", exported as MISSING_PROVIDER_CODE) - never the message text, which may change between versions.
import { isMissingProviderError, MissingProviderError } from "@antelopejs/interface-core";
try {
registry.register("my-entry", data);
} catch (error) {
if (!isMissingProviderError(error)) throw error;
// Tolerate the missing provider
}
Prefer the isMissingProviderError guard over instanceof: it checks the code property, so detection keeps working across realm boundaries and duplicated copies of the package where instanceof fails.
GetInterfaceInstances and GetInterfaceInstance
These functions retrieve information about active interface connections for the current module.
import { GetInterfaceInstances, GetInterfaceInstance } from "@antelopejs/interface-core";
// Get all connections for an interface
const connections = GetInterfaceInstances("database");
// Get a specific connection by ID
const primary = GetInterfaceInstance("database", "primary");
Each InterfaceConnection includes the provider module ID and whether that provider is selected for unqualified calls:
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.
import { GetResponsibleModule } from "@antelopejs/interface-core";
const moduleId = GetResponsibleModule();
Warning: Calling
GetResponsibleModulefrom within an async context (such assetTimeoutorsetInterval) without explicit ownership breaks hot reloading. The system logs an error when this is detected.
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.
import { RunWithResponsibleModule } from "@antelopejs/interface-core";
await RunWithResponsibleModule("my-module", async () => {
proxy.onCall(myHandler);
await initializeModule();
});
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. 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
- Decorators - Build type-safe decorator factories
- Metadata - Reflection-based metadata retrieval