1dba122c创建于 1 天前历史提交

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 CancelToken when the API exposes timeoutMs or AbortSignal, and call heartbeat() inside long loops.

Package/build side:

  • packages/natives/scripts/build-native.ts runs napi-rs, installs the .node artifact, copies generated index.d.ts, and regenerates explicit ESM class/function exports plus enum runtime exports in the checked-in native/index.js.
  • packages/natives/native/index.js is the ESM entrypoint that calls the loader, exposes named exports, and rejects install/compiled .node files that do not expose the package-version sentinel.
  • packages/natives/package.json exposes 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 .node is 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 still import from @oh-my-pi/pi-natives.

Consumer side:

  • Update direct imports/callsites in packages/coding-agent or packages/tui when the new export replaces a JS implementation.
  • Keep higher-level policy in consumers unless it belongs in the native primitive itself.

Porting checklist

  1. 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, use task::future.
  • If exposing cancellation, include timeout_ms: Option<u32> and signal: Option<Unknown<'env>> in options, create CancelToken::new(...), and heartbeat in long loops.
  1. Build generated bindings
  • Run bun --cwd=packages/natives run build.
  • Confirm the generated packages/natives/native/index.d.ts includes the new export with the intended JS name/signature.
  • Confirm packages/natives/native/index.js has generated explicit ESM exports for the new class/function and enum objects when enum changes are involved.
  1. 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.
  1. Add benchmarks
  • Put benchmarks next to the owning package (packages/tui/bench, packages/natives/bench, or packages/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.
  1. 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, then pi_natives.<tag>.node.
  • baseline: pi_natives.<tag>-baseline.node, then pi_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.ts includes the new export and intended TS signature.
  • native/index.js includes the generated named export; enum objects are present when the change adds/changes enums.
  • The loaded .node file's Object.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.