Files
oh-my-pi/docs/porting-to-natives.md
T
can1357 1c968a7bfc feat(scripts): added native leaf-package publish checks to install smoke tests
- 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.
2026-05-30 19:52:45 +02:00

174 lines
8.5 KiB
Markdown

# 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 `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.js`/`index.d.ts`, and appends enum runtime exports.
- `packages/natives/native/index.js` is the loader that chooses a candidate `.node` file and returns the loaded addon.
- `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.
2. **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` still has generated enum exports appended when enum changes are involved.
3. **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.
4. **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.
5. **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. If any earlier candidate is stale, a new export may appear missing.
**Fix:** remove stale candidate/cache files and rebuild.
```bash
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:
```bash
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
```ts
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.
- The loaded `.node` file's `Object.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.