Files
skills/docs/api-stack/api/api-loader/src/resolver/resolver.md
2026-05-29 08:20:56 +01:00

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 under bustSpecifier(specifier, version) and returns a ModuleHandle<M>.
  • dispose(handle) — releases the host's strong ref to the handle so the previous module can be collected.
  • walkNativeBindings(specifier) — returns paths of .node files reachable from the entry's import subgraph; the host uses this at install to gate hot-reload eligibility.
  • Generic over the resolved module type M so 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.