MusePi

Porting to pi-natives (N-API) — Field Notes

This is a practical guide for moving hot paths into crates/pi-natives and wiring them through the generated native package entrypoint. It exists to avoid the same failures happening twice.

When to port

Port when any of these are true:

Avoid ports that depend on JS-only state or dynamic imports. N-API exports should be data-in/data-out. Long-running work should go through task::blocking (CPU-bound/blocking I/O) or task::future (async I/O) with cancellation where the caller needs timeoutMs or AbortSignal.

Current package shape

@musepi/pi-natives no longer has a packages/natives/src/<module> TypeScript wrapper layer. The package root points at generated native artifacts:

Consumers import directly from @musepi/pi-natives. The generated declarations and explicit ESM exports are produced during bun --cwd=packages/natives run build.

Anatomy of a native export

Rust side:

Package/build side:

Consumer side:

Porting checklist

  1. Add the Rust implementation
  1. Build generated bindings
  1. Update consumers
  1. Add benchmarks
  1. Run focused verification

Pain points and how to avoid them

1) Stale platform/variant artifacts

The loader probes platform-tagged artifacts in deterministic order. For x64, selected variant candidates are tried before the unsuffixed default fallback:

Non-x64 uses pi_natives.<tag>.node.

Compiled binaries also probe <getNativesDir()>/<version>/... and a legacy user-data directory before package/executable locations. Windows node_modules installs stage leaf/core addons into the same versioned directory before probing. If any earlier candidate is stale, a new export may appear missing unless the version sentinel rejects it first.

Fix: remove stale candidate/cache files and rebuild.

rm packages/natives/native/pi_natives.<platform>-<arch>.node
rm packages/natives/native/pi_natives.<platform>-<arch>-modern.node
rm packages/natives/native/pi_natives.<platform>-<arch>-baseline.node
bun --cwd=packages/natives run build

For compiled binaries or Windows staging, delete the versioned addon cache shown in the loader error (normally under ~/.musepi/natives/<version> unless $XDG_DATA_HOME/musepi is used).

2) Generated types do not match loaded binary

This can happen when native/index.d.ts was regenerated but the .node file being loaded is stale, same-version incomplete, or from a different platform/variant. Different-version install/compiled binaries should be rejected by the version sentinel during loading.

Verify the loaded export set from the actual candidate path reported by the loader:

bun -e 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const mod = require(process.argv[2]); console.log(Object.keys(mod).sort())' -- /path/from/loader/error/pi_natives.<tag>[-variant].node

Fix the build/candidate mismatch. Do not paper over it with optional consumer checks if the export is required.

3) Rust signature mismatch

Keep N-API signatures simple and owned. Avoid borrowed references like &str in public exports. If you need structured data, use #[napi(object)] structs. If you need callbacks, use napi-rs ThreadsafeFunction and keep callback error/value behavior explicit.

4) Enum runtime exports and ESM named exports

napi-rs declarations alone are not enough for JS callers that import named symbols or use enum objects at runtime. scripts/gen-enums.ts reads native/index.d.ts, writes explicit export const ... = nativeBindings... entries for public classes/functions, and emits enum objects in native/index.js. If you add or change a native export, verify both native/index.d.ts and the generated export block in native/index.js.

5) Benchmarking mistakes

Benchmark template

const ITERATIONS = 2000;

function bench(name: string, fn: () => void): number {
  const start = Bun.nanoseconds();
  for (let i = 0; i < ITERATIONS; i++) fn();
  const elapsed = (Bun.nanoseconds() - start) / 1e6;
  console.log(
    `${name}: ${elapsed.toFixed(2)}ms total (${(elapsed / ITERATIONS).toFixed(6)}ms/op)`,
  );
  return elapsed;
}

bench("feature/js", () => {
  jsImpl(sample);
});

bench("feature/native", () => {
  nativeImpl(sample);
});

Verification checklist

Rule of thumb