- Added repeatable `--tag` argument parsing in `gen-npm-packages.ts` and threaded parsed tags into `generateNpmPackages` for targeted leaf publishing. - Exported `prepareNativeCorePackage` and expanded package manifest typing to support scripted manifest rewrites used by release workflows. - Reworked install-test smoke logic to pack the host leaf package, pack the rewritten natives core, and assert the platform leaf package resolves from the core optional dependency.
8.5 KiB
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:
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 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.js/index.d.ts, and appends enum runtime exports.packages/natives/native/index.jsis the loader that chooses a candidate.nodefile and returns the loaded addon.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.jsstill has generated enum exports appended 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. If any earlier candidate is stale, a new export may appear missing.
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, 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 or from a different platform/variant.
Verify the loaded export set from the actual candidate path:
bun -e 'const tag = `${process.platform}-${process.arch}`; const mod = require(`./packages/natives/native/pi_natives.${tag}.node`); console.log(Object.keys(mod).sort())'
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
napi-rs declarations alone are not enough for JS callers that use enum objects at runtime. scripts/gen-enums.ts appends enum objects to native/index.js. If you add or change a native enum, verify both native/index.d.ts and the generated enum 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. - The loaded
.nodefile'sObject.keys(require(candidate))includes the new export. - Runtime enum objects are present when the change adds/changes enums.
- 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.