3.7 KiB
NativeBindingDetector + assertNoNativeBindings
(no submodules)
Purpose
Hot reload is the heart of the API plugin system, but it has one
non-negotiable boundary: native bindings (.node files compiled to a
specific Node-API version) cannot be reloaded. Bun's ESM cache-bust trick
re-imports a fresh JS module under a new specifier, but the underlying
native dynamic library is loaded into the process's address space exactly
once — re-running dlopen would either be ignored or, worse, corrupt the
existing handle. The loader has to refuse the reload before it tries.
This module owns two pieces: the NativeBindingDetector function type
(host-injected — @suaveplan/api-host ships a Bun-native implementation
that walks the import subgraph), and the assertNoNativeBindings helper
that throws the canonical LOADER.NATIVE_BINDING_HELD error when the
detector reports a non-empty result. Per design doc §8.2: "Native bindings
(.node files) cannot be reloaded; loader walks the dep graph at install
time and refuses reload if any are present."
Features
- Host-injected
NativeBindingDetectorfunction type — runtime-portable across Bun, Node, and a future Deno port. assertNoNativeBindings(bindings)synchronous helper — throwsLOADER.NATIVE_BINDING_HELDwhen the array is non-empty, no-ops otherwise.LoaderErrorsubclass widening the protectedSuaveplanErrorconstructor to public for theLOADER.*namespace.- Defensive copy on the offending paths: mutating the input after the throw
cannot corrupt the error's
context.bindings.
Usage: Basic
import {
type NativeBindingDetector,
assertNoNativeBindings,
} from "@suaveplan/api-loader/native-binding-detector";
declare const detector: NativeBindingDetector;
const bindings = await detector("file:///opt/plugin/index.js");
assertNoNativeBindings(bindings); // safe pass-through when bindings.length === 0
Usage: Advanced
Combine with the resolver to wrap the install-time reload-eligibility check:
import { assertNoNativeBindings } from "@suaveplan/api-loader/native-binding-detector";
import type { ModuleResolver } from "@suaveplan/api-loader/resolver";
async function assertReloadable(
resolver: ModuleResolver,
specifier: string
): Promise<void> {
const bindings = await resolver.walkNativeBindings(specifier);
assertNoNativeBindings(bindings);
}
API Reference
export type NativeBindingDetector = (
specifier: string
) => Promise<readonly string[]>;
export class LoaderError extends SuaveplanError<LoaderCode> {
constructor(
code: LoaderCode,
messageKey: string,
options?: SuaveplanErrorOptions
);
}
export function assertNoNativeBindings(bindings: readonly string[]): void;
Implementation Notes
The thrown LoaderError attaches the offending paths via context.bindings,
copying the input array ([...bindings]) so a caller mutating their array
after the throw cannot retroactively change the error envelope. The
copied form is string[] (mutable) but the type annotation on the input is
readonly string[] — callers are encouraged to pass an immutable array,
but the helper is defensive regardless.
The messageKey ("loader.native_binding_held") is the i18n catalogue key
used by SuaveplanError's resolver; when the host's i18n catalogue is
unwired, the resolver falls back to returning the key verbatim, which is
adequate for the operator-facing logs the §8.7 failure-mode table cites.
assertNoNativeBindings is intentionally synchronous: it operates on the
already-resolved output of walkNativeBindings, so adding an await would
buy nothing. Callers usually wrap it in async code anyway because the
detector itself is async.