Problem
Requests for native bindings to Rust libraries (most recently #424 tursodb, #425 iroh; before that mongodb, lettre, etc.) keep landing in this repo. Each one is a judgement call: bake it into perry-stdlib (bloat the default build, slow every user's compile, couple another upstream's release cadence to ours) or decline (which loses a real use case Perry is well-suited for).
The mechanism to do this externally already exists — perry.nativeLibrary in a package.json (see crates/perry/src/commands/compile/resolve.rs:75-231 and link.rs:1547-1709). Bloom Engine ships ~230 FFI functions (wgpu renderer + Jolt physics + per-platform AppKit/UIKit/DirectX/Vulkan/WebGPU code) entirely outside this repo using exactly that mechanism.
What's missing is the contract + tooling that makes writing one of these cheap, sustainable, and not coupled to perry's internals. Today a wrapper has to know the layout of StringHeader, the meaning of NaN-boxing tags, etc. — internals that change. We need to extract a stable ABI and tooling around it, then dogfood by moving the wrapper-style code currently in perry-stdlib out.
Goals
- Zero developer-facing change.
import { createConnection } from 'mysql2' keeps working byte-identically. So do ioredis, ws, ethers, dotenv, pg, mongodb, fastify, etc. The user never installs anything new to get what works today. They never see @perry/mysql2 if they don't want to. Naming is unenforced — packages can be @perry/tursodb, tursodb-perry, whatever-they-want.
- Anyone can publish a wrapper for any Rust crate without a PR to this repo, and it Just Works after
npm install.
- Big maintained wrappers live in the
perryts GitHub org as their own repos (perryts/tursodb, perryts/iroh, eventually perryts/mysql2-bindings, etc.) with their own release cadence.
- Wrapper authors don't depend on
perry-runtime internals. They depend on a small, versioned perry-ffi crate. Breaking changes to perry's runtime layout don't break the ecosystem.
- First-compile UX stays fast. Pulling in iroh shouldn't put the user in front of a 4-minute cargo build; prebuilt artifacts should be supported.
Non-Goals
- Replacing the existing
perry-stdlib-as-staticlib link model. Binaries still statically link only the bindings they use.
- A package registry / hosting infrastructure. We use npm + GitHub.
- Forcing renames or migrations on existing user code.
- Dynamic loading / plugin dlopen — these are still build-time linked.
Architecture
Three layers, from most stable to most flexible:
┌─────────────────────────────────────────────────────────────────┐
│ Layer 3: Bindings packages (anyone can publish) │
│ perryts/tursodb @perry/iroh community/foo │
│ ────────────── ─────────── ───────────── │
│ package.json with perry.nativeLibrary manifest │
│ src/index.ts (TS surface) │
│ native/<plat>/ Rust crate (wraps upstream) │
└─────────────────────────────────────────────────────────────────┘
│
│ depends on
▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 2: perry-ffi crate (published to crates.io, versioned) │
│ read_string, alloc_string, read_array, alloc_array, │
│ nanbox_pointer, etc. ~15 helpers — the only stable surface │
└─────────────────────────────────────────────────────────────────┘
│
│ implementation detail of
▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: perry-runtime internals (free to change) │
│ StringHeader layout, NaN-boxing tags, GC, arena, ... │
└─────────────────────────────────────────────────────────────────┘
Resolution flow (preserves bare names):
import X from 'mysql2'
│
▼
┌────────────────────────────────────────────────────┐
│ 1. node_modules/mysql2/ exists with a │
│ perry.nativeLibrary manifest? → use it │
│ 2. node_modules/mysql2/ exists without manifest? │
│ → fall through (V8 / error as today) │
│ 3. \`well_known_bindings.toml\` (shipped in perry │
│ install) maps 'mysql2' → bundled wrapper │
│ crate path + prebuilt .a → use it │
│ 4. None match → error │
└────────────────────────────────────────────────────┘
The well-known table is what makes `import 'mysql2'` keep working with zero `npm install`. The bundled wrappers ship inside the perry install (as crates in this repo, prebuilt during release). Step 1 means a user can override any well-known by dropping their own wrapper in node_modules.
Phase plan
Five phases, each independently shippable. The dogfood migration in Phase 5 is the load-bearing one — if the contract from Phases 1-3 can't sustain the wrappers we already maintain, it can't sustain third-party ones.
Phase 1 — Extract `perry-ffi` crate (the ABI contract)
Why: Single biggest risk. Today wrappers reach into `perry-runtime` internals (StringHeader layout, NaN-boxing tag values, etc.) that change between releases. Without a stable surface, every refactor breaks every external package silently.
Scope:
- New `crates/perry-ffi/` workspace member, published to crates.io as `perry-ffi = "0.5"`.
- Audit every `#[no_mangle] extern "C"` callsite in `perry-stdlib` and Bloom (`../../bloom/engine`) for what they need from the runtime: string read/write, array read/write, pointer NaN-boxing, JSValue tag extraction.
- Distill into a minimal helper set (~15 functions). Initial sketch:
pub fn read_string(handle: i64) -> &'static str
pub fn alloc_string(s: &str) -> i64
pub fn read_array(value: f64) -> Vec<JsValue>
pub fn alloc_array(items: &[JsValue]) -> f64
pub fn read_object_field(obj: f64, key: &str) -> JsValue
pub fn write_object_field(obj: f64, key: &str, value: JsValue)
pub fn nanbox_pointer(ptr: *mut c_void) -> f64
pub fn nanbox_string(s: i64) -> f64
pub fn unbox_pointer(value: f64) -> *mut c_void
pub fn js_undefined() -> f64
pub fn js_null() -> f64
pub fn js_true() -> f64
pub fn js_false() -> f64
pub fn is_truthy(value: f64) -> bool
// + JsValue enum for typed inspection
- `perry-ffi` is a thin re-export layer around `perry-runtime` (same process, same arena, same GC). It exists for API stability, not isolation.
- Set semver: `0.5.x` matches Perry's current minor; bump major when the surface breaks.
Deliverables:
Validation: A throwaway external crate (`/tmp/perry-ffi-smoke/`) that uses only `perry-ffi` builds, links, and round-trips a string + array + object through a Perry binary.
Risks:
- Choosing the wrong surface granularity. Mitigation: do the Phase 5 dogfood port of `dotenv` (smallest binding) before shipping perry-ffi 0.5.0 publicly. If dotenv needs a helper not in perry-ffi, add it before publishing.
- Bloom may need helpers we didn't anticipate (it has its own audit). Loop them in before freezing.
Phase 2 — Freeze manifest spec + add `abiVersion`
Why: Today the manifest schema is implicit (whatever `resolve.rs:109-231` parses). External authors have no spec, no version field, and no way to declare compatibility. When perry's ABI changes, external packages segfault instead of failing loudly.
Scope:
- Write `docs/native-libraries/manifest-v1.md` documenting every field, every `returns` type (`string`/`ptr`/`i64`/`i64_str`/`f64`/`void`), every `params` type, every per-target field (`crate`, `lib`, `frameworks`, `libs`, `pkgConfig`, `swift_sources`, `metal_sources`).
- Add JSON schema at `docs/native-libraries/manifest.schema.json` for editor validation.
- Add required field: `perry.nativeLibrary.abiVersion: "0.5"` (semver range).
- `resolve.rs` rejects manifests whose `abiVersion` doesn't satisfy the perry binary's range, with an actionable error pointing at the package.json line.
- Backwards-compat: missing `abiVersion` is allowed for the v0.5.x cycle (warning), required from v0.6.0.
Deliverables:
Validation: `cargo run -- foo.ts` against a wrapper declaring `abiVersion: "99.0"` errors with a clear message naming the package.
Phase 3 — `perry native` CLI subcommand (scaffold + validate + prebuild)
Why: Writing a wrapper today means reading Bloom's source. Tooling drops the barrier from "hours of archaeology" to "minutes".
Scope:
- `perry native init ` — scaffolds a new package:
<name>/
package.json (with perry.nativeLibrary template, abiVersion filled)
src/index.ts (TS declarations matching FFI surface)
native/macos/
Cargo.toml (perry-ffi dep, the upstream crate dep, staticlib)
src/lib.rs (one example #[no_mangle] fn)
.github/workflows/
release.yml (prebuild artifacts for all targets, attach to GH releases)
README.md
Asks the user for: package name, upstream crate name, target list (defaults: macos, ios, linux, windows, android, web).
- `perry native validate` — run from the wrapper's root:
- Parses the manifest.
- Runs `cargo build --release` on the native crate.
- Runs `nm` on the resulting `.a` and diffs symbols against the `functions[]` array.
- Type-checks `src/index.ts`'s declarations against the manifest signatures.
- Reports drift (missing symbols, extra symbols, signature mismatches) with file:line.
- `perry native prebuild --target ` — builds the staticlib + writes it to `prebuilt//lib.a`. Manifest gains optional `targets..prebuilt: "./prebuilt//lib.a"` field; `link.rs` prefers prebuilt over cargo-build when present.
Deliverables:
Validation: `perry native init tursodb-bindings && cd tursodb-bindings && perry native validate` produces a green build that another perry project can import via `file:../tursodb-bindings`.
Phase 4 — Well-known bindings registry (preserves bare-name imports)
Why: The whole point — `import 'mysql2'` must keep working. With Phase 5 about to move bindings out of `perry-stdlib`, we need a resolution layer that still maps the bare name to the bindings code.
Scope:
- New file in this repo: `crates/perry/well_known_bindings.toml`:
[\"mysql2\"]
repo = \"perryts/mysql2-bindings\"
bundled_crate = \"perry-ext-mysql2\" # crate path inside this workspace
[\"ioredis\"]
repo = \"perryts/ioredis-bindings\"
bundled_crate = \"perry-ext-ioredis\"
# ... etc for ws, ethers, dotenv, pg, mongodb, fastify, axios, etc.
- Bundled wrapper crates (`crates/perry-ext-*`) are built during `cargo build --release` like any other workspace member, and their `.a` files are bundled with releases (Homebrew bottle, install tarball).
- `resolve.rs` resolution order:
- `node_modules//` with a `perry.nativeLibrary` manifest → use it (user override wins).
- `node_modules//` without a manifest → fall through to V8 (existing behavior).
- `well_known_bindings.toml` has `` → use the bundled crate's prebuilt `.a`.
- None match → error.
- The hardcoded `PERRY_NATIVE_EXTENSION_PACKAGES` list in `resolve.rs:236` is deleted — replaced entirely by the well-known table.
Deliverables:
Validation: Build perry, build a TS file with `import { createConnection } from 'mysql2'`, confirm output is byte-identical to today's behavior.
Phase 5 — Dogfood: migrate `perry-stdlib` wrappers out
Why: This is the test of whether Phases 1-4 actually work. Real wrapper authors will hit every gap in the contract; if our own wrappers can't survive the move, theirs definitely can't.
Scope (in migration order, smallest first):
- `dotenv` (~80 LOC, no deps) — proves Phase 1 (perry-ffi surface) and Phase 2 (manifest spec). New crate `crates/perry-ext-dotenv/`, registered in well-known table. Code physically moves; user TS imports unchanged.
- `uuid`, `nanoid`, `slugify`, `bcrypt`, `argon2` (small, single-crate wrappers) — batched proof.
- `ws` — proves async-runtime sharing works between bindings and core.
- `mysql2`, `pg`, `sqlite`, `ioredis`, `mongodb` (database batch) — biggest wrappers, most likely to surface contract gaps. Each gets its own crate.
- `reqwest`-backed wrappers (`fetch`, `axios`) — proves shared transitive deps are tolerable (each crate pulls reqwest; cargo dedupes at workspace level).
- `fastify`, `hyper` server pieces, `tokio-tungstenite` — biggest dep tree, last to move.
What stays in `perry-stdlib`: anything genuinely coupled to runtime internals — timers, threads, GC-aware bits, the framework hooks that aren't "wrapper around a Rust crate". Target end state: `perry-stdlib` shrinks to maybe 10 files, all touching arena/GC/promise machinery directly.
Two-phase migration per wrapper to keep main green:
- Add new `crates/perry-ext-/` crate, register in well-known table, leave old code in `perry-stdlib` (gated off via feature). CI builds both, well-known resolution prefers the new crate. Validates the move.
- Once a release ships and no regressions surface, delete the old code from `perry-stdlib`.
Deliverables:
Validation: Full parity sweep (`./run_parity_tests.sh`) and full `cargo test --workspace` stay green at every step. End state: any test that worked before works after, byte-identical output.
Migration strategy / risk surface
For users: Zero action required. `npm install` of an existing project continues to work. Compiled binaries continue to work. The only visible change is faster default builds (because perry-stdlib is smaller).
For maintainer (Ralph): Each Phase 5 batch is one commit/PR cycle. Migration order is risk-ordered (smallest first); if dotenv reveals a perry-ffi surface gap, we fix Phase 1 before continuing.
For external contributors: After Phase 3 ships, "add support for X" issues get a one-line answer: "Run `perry native init x-bindings`, fill in the FFI, publish to npm. Happy to link from the awesome list once it works." Issues #424 and #425 become tractable for anyone, not just Ralph.
Rollback: Each phase is independently revertable. The dogfood migration's two-step approach (add new crate, then later delete old code) means we can always point well-known resolution back at the old in-stdlib code if something breaks in production.
Open questions
-
Should perry-ffi re-export the entire JSValue type or just opaque accessors? Re-export is more ergonomic for wrappers; opaque accessors give us more refactor freedom. I lean re-export with #[non_exhaustive] to thread the needle.
-
Prebuilt artifact distribution — once a wrapper repo's release CI publishes prebuilt `.a`s as GitHub release assets, how does `npm install` get them onto the user's disk? Options: (a) the npm package's `postinstall` downloads them; (b) the package vendors prebuilt artifacts directly in the npm tarball (simpler but bigger). Lean (b) for now; revisit if package sizes get painful.
-
Cross-target prebuilds — the wrapper author may not have iOS/tvOS/Windows machines. CI matrix on GitHub Actions covers it but adds release-time complexity. The `perry native init` template should ship a working multi-target release.yml so authors don't have to figure this out.
-
Do we want a CLI command to list well-known bindings? `perry native list` printing what's available out-of-the-box would help discovery. Cheap addition; punt to Phase 4 implementation.
-
Async runtime sharing — most wrappers need tokio. perry-stdlib currently exposes a shared runtime via internal helpers. perry-ffi needs an equivalent so external wrappers don't each spawn their own runtime (would deadlock spectacularly). Phase 1 must include a `perry_ffi::spawn_async` / `block_on` surface.
What I'd start with
Phase 1 (perry-ffi extraction) blocks everything else and is the highest-risk piece (getting the surface right). Realistic first deliverable: extract perry-ffi + port `dotenv` (Phase 5 step 1) as a single PR, treating dotenv as the perry-ffi acceptance test. If perry-ffi 0.5.0 can't ship dotenv, it's not done.
Problem
Requests for native bindings to Rust libraries (most recently #424 tursodb, #425 iroh; before that mongodb, lettre, etc.) keep landing in this repo. Each one is a judgement call: bake it into
perry-stdlib(bloat the default build, slow every user's compile, couple another upstream's release cadence to ours) or decline (which loses a real use case Perry is well-suited for).The mechanism to do this externally already exists —
perry.nativeLibraryin a package.json (seecrates/perry/src/commands/compile/resolve.rs:75-231andlink.rs:1547-1709). Bloom Engine ships ~230 FFI functions (wgpu renderer + Jolt physics + per-platform AppKit/UIKit/DirectX/Vulkan/WebGPU code) entirely outside this repo using exactly that mechanism.What's missing is the contract + tooling that makes writing one of these cheap, sustainable, and not coupled to perry's internals. Today a wrapper has to know the layout of
StringHeader, the meaning of NaN-boxing tags, etc. — internals that change. We need to extract a stable ABI and tooling around it, then dogfood by moving the wrapper-style code currently inperry-stdlibout.Goals
import { createConnection } from 'mysql2'keeps working byte-identically. So doioredis,ws,ethers,dotenv,pg,mongodb,fastify, etc. The user never installs anything new to get what works today. They never see@perry/mysql2if they don't want to. Naming is unenforced — packages can be@perry/tursodb,tursodb-perry,whatever-they-want.npm install.perrytsGitHub org as their own repos (perryts/tursodb,perryts/iroh, eventuallyperryts/mysql2-bindings, etc.) with their own release cadence.perry-runtimeinternals. They depend on a small, versionedperry-fficrate. Breaking changes to perry's runtime layout don't break the ecosystem.Non-Goals
perry-stdlib-as-staticlib link model. Binaries still statically link only the bindings they use.Architecture
Three layers, from most stable to most flexible:
Resolution flow (preserves bare names):
The well-known table is what makes `import 'mysql2'` keep working with zero `npm install`. The bundled wrappers ship inside the perry install (as crates in this repo, prebuilt during release). Step 1 means a user can override any well-known by dropping their own wrapper in node_modules.
Phase plan
Five phases, each independently shippable. The dogfood migration in Phase 5 is the load-bearing one — if the contract from Phases 1-3 can't sustain the wrappers we already maintain, it can't sustain third-party ones.
Phase 1 — Extract `perry-ffi` crate (the ABI contract)
Why: Single biggest risk. Today wrappers reach into `perry-runtime` internals (StringHeader layout, NaN-boxing tag values, etc.) that change between releases. Without a stable surface, every refactor breaks every external package silently.
Scope:
Deliverables:
Validation: A throwaway external crate (`/tmp/perry-ffi-smoke/`) that uses only `perry-ffi` builds, links, and round-trips a string + array + object through a Perry binary.
Risks:
Phase 2 — Freeze manifest spec + add `abiVersion`
Why: Today the manifest schema is implicit (whatever `resolve.rs:109-231` parses). External authors have no spec, no version field, and no way to declare compatibility. When perry's ABI changes, external packages segfault instead of failing loudly.
Scope:
Deliverables:
Validation: `cargo run -- foo.ts` against a wrapper declaring `abiVersion: "99.0"` errors with a clear message naming the package.
Phase 3 — `perry native` CLI subcommand (scaffold + validate + prebuild)
Why: Writing a wrapper today means reading Bloom's source. Tooling drops the barrier from "hours of archaeology" to "minutes".
Scope:
Deliverables:
Validation: `perry native init tursodb-bindings && cd tursodb-bindings && perry native validate` produces a green build that another perry project can import via `file:../tursodb-bindings`.
Phase 4 — Well-known bindings registry (preserves bare-name imports)
Why: The whole point — `import 'mysql2'` must keep working. With Phase 5 about to move bindings out of `perry-stdlib`, we need a resolution layer that still maps the bare name to the bindings code.
Scope:
Deliverables:
Validation: Build perry, build a TS file with `import { createConnection } from 'mysql2'`, confirm output is byte-identical to today's behavior.
Phase 5 — Dogfood: migrate `perry-stdlib` wrappers out
Why: This is the test of whether Phases 1-4 actually work. Real wrapper authors will hit every gap in the contract; if our own wrappers can't survive the move, theirs definitely can't.
Scope (in migration order, smallest first):
What stays in `perry-stdlib`: anything genuinely coupled to runtime internals — timers, threads, GC-aware bits, the framework hooks that aren't "wrapper around a Rust crate". Target end state: `perry-stdlib` shrinks to maybe 10 files, all touching arena/GC/promise machinery directly.
Two-phase migration per wrapper to keep main green:
Deliverables:
Validation: Full parity sweep (`./run_parity_tests.sh`) and full `cargo test --workspace` stay green at every step. End state: any test that worked before works after, byte-identical output.
Migration strategy / risk surface
For users: Zero action required. `npm install` of an existing project continues to work. Compiled binaries continue to work. The only visible change is faster default builds (because perry-stdlib is smaller).
For maintainer (Ralph): Each Phase 5 batch is one commit/PR cycle. Migration order is risk-ordered (smallest first); if dotenv reveals a perry-ffi surface gap, we fix Phase 1 before continuing.
For external contributors: After Phase 3 ships, "add support for X" issues get a one-line answer: "Run `perry native init x-bindings`, fill in the FFI, publish to npm. Happy to link from the awesome list once it works." Issues #424 and #425 become tractable for anyone, not just Ralph.
Rollback: Each phase is independently revertable. The dogfood migration's two-step approach (add new crate, then later delete old code) means we can always point well-known resolution back at the old in-stdlib code if something breaks in production.
Open questions
Should
perry-ffire-export the entireJSValuetype or just opaque accessors? Re-export is more ergonomic for wrappers; opaque accessors give us more refactor freedom. I lean re-export with#[non_exhaustive]to thread the needle.Prebuilt artifact distribution — once a wrapper repo's release CI publishes prebuilt `.a`s as GitHub release assets, how does `npm install` get them onto the user's disk? Options: (a) the npm package's `postinstall` downloads them; (b) the package vendors prebuilt artifacts directly in the npm tarball (simpler but bigger). Lean (b) for now; revisit if package sizes get painful.
Cross-target prebuilds — the wrapper author may not have iOS/tvOS/Windows machines. CI matrix on GitHub Actions covers it but adds release-time complexity. The `perry native init` template should ship a working multi-target release.yml so authors don't have to figure this out.
Do we want a CLI command to list well-known bindings? `perry native list` printing what's available out-of-the-box would help discovery. Cheap addition; punt to Phase 4 implementation.
Async runtime sharing — most wrappers need tokio. perry-stdlib currently exposes a shared runtime via internal helpers. perry-ffi needs an equivalent so external wrappers don't each spawn their own runtime (would deadlock spectacularly). Phase 1 must include a `perry_ffi::spawn_async` / `block_on` surface.
What I'd start with
Phase 1 (perry-ffi extraction) blocks everything else and is the highest-risk piece (getting the surface right). Realistic first deliverable: extract perry-ffi + port `dotenv` (Phase 5 step 1) as a single PR, treating dotenv as the perry-ffi acceptance test. If perry-ffi 0.5.0 can't ship dotenv, it's not done.