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:
- The hot path runs in render loops, tight UI updates, or large batches.
- JS allocations dominate (string churn, regex backtracking, large arrays).
- You already have a JS baseline and can benchmark both versions side by side.
- The work is CPU-bound or blocking I/O that can run on the libuv thread pool.
- The work is async I/O that can run on Tokio's runtime (for example shell execution).
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
@oh-my-pi/pi-natives no longer has a packages/natives/src/<module> TypeScript wrapper layer. The package root points at generated native artifacts:
- runtime entry/export wrapper:
packages/natives/native/index.js - types entry:
packages/natives/native/index.d.ts - loader helpers:
packages/natives/native/loader-state.js - embedded manifest:
packages/natives/native/embedded-addon.js
Consumers import directly from @oh-my-pi/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:
- Implementation lives in
crates/pi-natives/src/<module>.rs. - If you add a new module, register it in
crates/pi-natives/src/lib.rs. - Export with
#[napi]; snake_case exports are converted to camelCase automatically. Use explicit JS names only for true aliases/non-default names. Use#[napi(object)]for object-shaped structs. - For CPU-bound or blocking work, use
task::blocking(tag, cancel_token, work). - For async work that needs Tokio, use
task::future(env, tag, work). - Pass a
CancelTokenwhen the API exposestimeoutMsorAbortSignal, and callheartbeat()inside long loops.
Package/build side:
packages/natives/scripts/build-native.tsruns napi-rs, installs the.nodeartifact, copies generatedindex.d.ts, and regenerates explicit ESM class/function exports plus enum runtime exports in the checked-innative/index.js.packages/natives/native/index.jsis the ESM entrypoint that calls the loader, exposes named exports, and rejects install/compiled.nodefiles that do not expose the package-version sentinel.packages/natives/package.jsonexposes only the package root (@oh-my-pi/pi-natives) as the import surface. At publish time the binaries are split out: the core ships the loader only (no.node), and each platform's.nodeis published as an optional-dependency leaf package@oh-my-pi/pi-natives-<tag>(scripts/ci-release-publish.ts+packages/natives/scripts/gen-npm-packages.ts). This is transparent to importers — you stillimportfrom@oh-my-pi/pi-natives.
Consumer side:
- Update direct imports/callsites in
packages/coding-agentorpackages/tuiwhen the new export replaces a JS implementation. - Keep higher-level policy in consumers unless it belongs in the native primitive itself.
Porting checklist
- Add the Rust implementation
- Put the core logic in a plain Rust function.
- If it is a new module, add it to
crates/pi-natives/src/lib.rs. - Expose it with
#[napi]so the default snake_case -> camelCase mapping stays consistent. - Keep signatures owned and simple:
String,Vec<String>,Uint8Array,Either<JsString, Uint8Array>, or#[napi(object)]structs. - For CPU-bound or blocking work, use
task::blocking; for async work, usetask::future. - If exposing cancellation, include
timeout_ms: Option<u32>andsignal: Option<Unknown<'env>>in options, createCancelToken::new(...), and heartbeat in long loops.
- Build generated bindings
- Run
bun --cwd=packages/natives run build. - Confirm the generated
packages/natives/native/index.d.tsincludes the new export with the intended JS name/signature. - Confirm
packages/natives/native/index.jshas generated explicit ESM exports for the new class/function and enum objects when enum changes are involved.
- Update consumers
- Import the new export directly from
@oh-my-pi/pi-natives. - Replace only callsites where the native implementation is faster/equivalent and preserves behavior.
- Remove obsolete JS implementation code in the same change when the native path becomes canonical.
- Add benchmarks
- Put benchmarks next to the owning package (
packages/tui/bench,packages/natives/bench, orpackages/coding-agent/bench). - Include a JS baseline and native version in the same run.
- Use
Bun.nanoseconds()and a fixed iteration count. - Keep benchmark inputs realistic for the hot path.
- Run focused verification
- Build the native package.
- Run the benchmark.
- Run the narrow tests or scenario covering the changed export/callsites.
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:
modern:pi_natives.<tag>-modern.node, then...-baseline.node, thenpi_natives.<tag>.node.baseline:pi_natives.<tag>-baseline.node, thenpi_natives.<tag>.node.
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 ~/.omp/natives/<version> unless $XDG_DATA_HOME/omp 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
- Do not compare different inputs or allocations.
- Keep JS and native using identical input arrays.
- Run both in the same benchmark file to avoid skew.
- Include enough iterations to smooth startup noise, but keep inputs realistic.
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
- Generated
native/index.d.tsincludes the new export and intended TS signature. native/index.jsincludes the generated named export; enum objects are present when the change adds/changes enums.- The loaded
.nodefile'sObject.keys(require(candidate))includes the new export and the package-version sentinel. - Bench numbers are recorded in the PR/notes.
- Call sites are updated only if native is faster/equal and behavior-compatible.
- Obsolete JS code is removed when the native implementation becomes canonical.
Rule of thumb
- If native is slower, do not switch callsites. Keep or remove the export based on whether it has a near-term owner.
- If native is faster and behavior-compatible, switch callsites and keep a benchmark to catch regressions.