3.9 KiB
ModuleResolver
(no submodules)
Purpose
ModuleResolver is the API plugin host's abstraction over import(). It
imports a plugin entry under a cache-busted ESM specifier, returns an opaque
ModuleHandle, and releases the host's strong reference when dispose is
called so the previous module becomes eligible for GC. The contract lives
here in @suaveplan/api-loader; concrete implementations (Bun-native ESM,
ZIP-backed plugins, sandboxed worker proxy) land in @suaveplan/api-host
Stage 2 and conform to the runLoaderConformance harness exported from
@suaveplan/api-loader/testing. Design intent: design doc §8.1 and §8.2.
The contract is types-only. No runtime ships from this module beyond the
type re-export shim — the type definitions exist so the lifecycle code in
api-host and the spec for §8 have a single home that downstream tests can
expectTypeOf against.
Features
resolve({ specifier, version })— imports the plugin entry underbustSpecifier(specifier, version)and returns aModuleHandle<M>.dispose(handle)— releases the host's strong ref to the handle so the previous module can be collected.walkNativeBindings(specifier)— returns paths of.nodefiles reachable from the entry's import subgraph; the host uses this at install to gate hot-reload eligibility.- Generic over the resolved module type
Mso consumers can pin a strict plugin-entry shape (e.g.{ default: PluginFactory }).
Usage: Basic
import type { ModuleResolver } from "@suaveplan/api-loader/resolver";
declare const resolver: ModuleResolver<{ default: () => void }>;
const handle = await resolver.resolve({
specifier: "file:///opt/plugin/index.js",
version: 1,
});
handle.module.default();
await resolver.dispose(handle);
Usage: Advanced
Combining walkNativeBindings with assertNoNativeBindings at install to
gate hot-reload eligibility:
import type { ModuleResolver } from "@suaveplan/api-loader/resolver";
import { assertNoNativeBindings } from "@suaveplan/api-loader/native-binding-detector";
declare const resolver: ModuleResolver;
const bindings = await resolver.walkNativeBindings(
"file:///opt/plugin/index.js"
);
assertNoNativeBindings(bindings); // throws LOADER.NATIVE_BINDING_HELD if non-empty
API Reference
export interface ResolveOptions {
readonly specifier: string;
readonly version: number;
}
export interface ModuleHandle<M = unknown> {
readonly specifier: string;
readonly version: number;
readonly module: M;
readonly resolvedAt: string;
}
export interface ModuleResolver<M = unknown> {
resolve(options: ResolveOptions): Promise<ModuleHandle<M>>;
dispose(handle: ModuleHandle<M>): Promise<void>;
walkNativeBindings(specifier: string): Promise<readonly string[]>;
}
export type ModuleResolverFactory<M = unknown> = () => ModuleResolver<M>;
Implementation Notes
resolve returns the evaluated namespace, not a thenable wrapping it —
the host's lifecycle FSM treats a successful return as a synchronous
"module loaded" signal even though the call itself is async. Concrete
implementations are free to memoise nothing (the cache-bust strategy in
§8.2 makes memoisation incorrect) and to allocate a fresh ModuleHandle on
every call.
dispose is Promise<void> rather than void because the in-host
implementation may need to await a gc() round-trip in test environments;
the contract surface does not assume synchronous release.
walkNativeBindings is async because the canonical implementation reads
the dependency graph from a manifest or via a static traversal of import
statements — both can involve disk I/O. The return type is
readonly string[] (paths of .node files) rather than a single boolean so
the operator-facing error envelope can show the exact bindings that blocked
the reload.