- Home
- Documentation
- natives
- Porting to pi-natives (N-API) — Field Notes
Porting to pi-natives (N-API) — Field Notes
Porting to pi-natives (N-API) — Field Notes
Section titled “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 JS bindings. It exists to avoid the same failures happening twice.
When to port
Section titled “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 (e.g., shell execution).
Avoid ports that depend on JS-only state or dynamic imports. N-API exports should be pure, 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.
Anatomy of a native export
Section titled “Anatomy of a native export”Rust side:
- Implementation lives in
crates/pi-natives/src/<module>.rs. If you add a new module, register it incrates/pi-natives/src/lib.rs. - Export with
#[napi]; snake_case exports are converted to camelCase automatically. Use explicitjs_nameonly for true aliases/non-default names. Use#[napi(object)]for structs. - Use
task::blocking(tag, cancel_token, work)(seecrates/pi-natives/src/task.rs) for CPU-bound or blocking work. Usetask::future(env, tag, work)for async work that needs Tokio (e.g., shell sessions). Pass aCancelTokenwhen you exposetimeoutMsorAbortSignal.
JS side:
packages/natives/src/bindings.tsholds the baseNativeBindingsinterface.packages/natives/src/<module>/types.tsdefines TS types and augmentsNativeBindingsvia declaration merging.packages/natives/src/native.tsimports each<module>/types.tsfile to activate the declarations.packages/natives/src/<module>/index.tswraps thenativebinding frompackages/natives/src/native.ts.packages/natives/src/native.tsloads the addon andvalidateNativeenforces required exports.packages/natives/src/index.tsre-exports the wrapper for callers inpackages/*.
Porting checklist
Section titled “Porting checklist”- Add the Rust implementation
- Put the core logic in a plain Rust function.
- If it’s 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, orEither<JsString, Uint8Array>for large string/byte inputs. - For CPU-bound or blocking work, use
task::blocking; for async work, usetask::future. Pass aCancelTokenand callheartbeat()inside long loops.
- Wire JS bindings
- Add the types and
NativeBindingsaugmentation inpackages/natives/src/<module>/types.ts. - Import
./<module>/typesinpackages/natives/src/native.tsto trigger declaration merging. - Add a wrapper in
packages/natives/src/<module>/index.tsthat callsnative. - Re-export from
packages/natives/src/index.ts.
- Update native validation
- Add
checkFn("newExport")invalidateNative(packages/natives/src/native.ts).
- 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 the benchmark inputs small and realistic (actual data seen in the hot path).
- Build the native binary
bun --cwd=packages/natives run build- Use
bun --cwd=packages/natives run buildand setPI_DEV=1if you want loader diagnostics while testing.
- Run the benchmark
bun run packages/<pkg>/bench/<bench>.ts(orbun --cwd=packages/natives run bench)
- Decide on usage
- If native is slower, keep JS and leave the native export unused.
- If native is faster, switch call sites to the native wrapper.
Pain points and how to avoid them
Section titled “Pain points and how to avoid them”1) Stale pi_natives.node prevents new exports
Section titled “1) Stale pi_natives.node prevents new exports”The loader prefers the platform-tagged binary in packages/natives/native (pi_natives.<platform>-<arch>.node). PI_DEV=1 now only enables loader diagnostics; it no longer switches to a separate dev addon filename. There is also a fallback pi_natives.node. Compiled binaries extract to ~/.xcsh/natives/<version>/pi_natives.<platform>-<arch>.node. If any of these are stale, exports won’t update.
Fix: remove the stale file before rebuilding.
rm packages/natives/native/pi_natives.linux-x64.noderm packages/natives/native/pi_natives.nodebun --cwd=packages/natives run buildIf you’re running a compiled binary, delete the cached addon directory:
rm -rf ~/.xcsh/natives/<version>Then verify the export exists in the binary:
bun -e 'const tag = `${process.platform}-${process.arch}`; const mod = require(`./packages/natives/native/pi_natives.${tag}.node`); console.log(Object.keys(mod).includes("newExport"));'2) “Missing exports” errors from validateNative
Section titled “2) “Missing exports” errors from validateNative”This is good — it prevents silent mismatches. When you see this:
Native addon missing exports ... Missing: visibleWidthit means your binary is stale, the Rust export name (or explicit alias when used) doesn’t match the JS name, or the export never compiled in. Fix the build and the naming mismatch, don’t weaken validation.
3) Rust signature mismatch
Section titled “3) Rust signature mismatch”Keep it simple and owned. String, Vec<String>, and Uint8Array work. Avoid references like &str in public exports. If you need structured data, wrap it in #[napi(object)] structs.
4) Benchmarking mistakes
Section titled “4) Benchmarking mistakes”- Don’t compare different inputs or allocations.
- Keep JS and native using identical input arrays.
- Run both in the same benchmark file to avoid skew.
Benchmark template
Section titled “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
Section titled “Verification checklist”validateNativepasses (no missing exports).NativeBindingsis augmented inpackages/natives/src/<module>/types.tsand the wrapper is re-exported inpackages/natives/src/index.ts.Object.keys(require(...))includes your new export.- Bench numbers recorded in the PR/notes.
- Call site updated only if native is faster or equal.
Rule of thumb
Section titled “Rule of thumb”- If native is slower, do not switch. Keep the export for future work, but the TUI should stay on the faster path.
- If native is faster, switch the call site and keep the benchmark in place to catch regressions.