Files
2026-05-27 23:29:53 +01:00

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 NativeBindingDetector function type — runtime-portable across Bun, Node, and a future Deno port.
  • assertNoNativeBindings(bindings) synchronous helper — throws LOADER.NATIVE_BINDING_HELD when the array is non-empty, no-ops otherwise.
  • LoaderError subclass widening the protected SuaveplanError constructor to public for the LOADER.* 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.