1c968a7bfc
- 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.
174 lines
8.5 KiB
Markdown
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.
|