Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Native bindings — overview

Perry compiles TypeScript to native executables. When user code says import { createConnection } from "mysql2", the call doesn’t bottom out in JavaScript-engine glue — it lands on a Rust function that’s been linked into the binary as extern "C". This page is the map of how that works end-to-end.

The big picture

There are four layers, from most stable to most flexible:

┌─────────────────────────────────────────────────────────────────┐
│  Layer 4: User TypeScript                                        │
│    import { createConnection } from "mysql2";                     │
│    const c = await createConnection({ host, user, password });    │
│    const [rows] = await c.query("SELECT 1");                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ resolved at compile time → maps to
                              │ js_mysql2_* extern "C" symbols
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  Layer 3: Bindings packages                                      │
│    Three sources, queried in this order:                          │
│                                                                   │
│    a. node_modules/<name>/ with perry.nativeLibrary               │
│       → the user installed an external binding via                │
│         `bun add @scope/<name>`. Wins over (b) and (c).           │
│                                                                   │
│    b. node_modules/<name>/ without perry.nativeLibrary            │
│       → fall through to V8/JS interpretation.                     │
│                                                                   │
│    c. well-known table (well_known_bindings.toml)                 │
│       → Perry ships the binding in its install. ~30 names like   │
│         dotenv / mysql2 / axios / ws / lru-cache / commander.     │
│                                                                   │
│    d. nothing matches → resolution error at compile time.         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ all wrapper crates depend on this:
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  Layer 2: perry-ffi crate (the stable ABI)                       │
│    pub fn alloc_string(s: &str) -> JsString                       │
│    pub fn read_string(JsString) -> Option<&'static str>           │
│    pub struct JsValue(u64); JsPromise; JsClosure; ...             │
│                                                                   │
│    9 surface dimensions: strings, async/Promise, handle           │
│    registry, JsValue/objects/arrays, binary bytes, closures,     │
│    GC root scanner, BigInt, Buffer, JSON-stringify, event-pump.  │
│                                                                   │
│    Wrapper authors depend ONLY on perry-ffi. perry-runtime's     │
│    internals (NaN-box tags, struct layouts) can change between    │
│    releases without breaking wrappers.                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ implementation detail of:
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  Layer 1: perry-runtime / perry-stdlib internals                 │
│    StringHeader / ArrayHeader / ObjectHeader layouts, NaN-      │
│    boxing tags, generational GC, arena allocator, async runtime,│
│    the 30+ in-tree native modules (perry/ui, perry/thread, ...).│
│    Free to change between Perry releases — the perry-ffi semver  │
│    is the only stable contract.                                  │
└─────────────────────────────────────────────────────────────────┘

The whole point: anyone can publish a binding. A third-party crate ships an npm package containing a Rust crate, a package.json with a perry.nativeLibrary block, and prebuilt staticlibs. Users bun add it. Perry’s compiler picks it up automatically. No PR to the Perry repo, no central registry approval, no @perryts/ namespace required.

Worked example: import { createConnection } from "mysql2"

Step by step, what happens when you perry compile a program with that import:

1. Module resolution

Perry’s resolver (crates/perry/src/commands/compile/resolve.rs) walks each search path looking for node_modules/mysql2/:

  • If node_modules/mysql2/package.json exists with a perry.nativeLibrary block: parse the manifest, treat the package as a native binding. Skip layers (c) and (d).
  • If node_modules/mysql2/ exists without a perry.nativeLibrary block: this is a JS-only npm package; fall through to the V8 / JS interpretation path (separate compilation flow).
  • If node_modules/mysql2/ doesn’t exist at all: consult the well-known table at crates/perry/well_known_bindings.toml. The table maps mysql2perry-ext-mysql2 (a Rust crate that ships in the Perry install). The user didn’t npm install anything; Perry handles it.
  • If nothing matches: compile error pointing at the import line.

2. ABI version check

If the resolved binding has a perry.nativeLibrary.abiVersion field (required from v0.6.0 onwards; warning-only in v0.5.x), Perry verifies the declared semver range covers the bundled perry-ffi version. A binding declaring "0.5" loads under any 0.5.x Perry; one declaring "^1.0" loads only under 1.x. Mismatches are a hard compile error with a recipe pointing at the offending package.

See manifest-v1.md for the full schema.

3. Symbol mapping

The manifest’s functions[] block lists every extern "C" symbol the staticlib exports plus their TypeScript-visible signature:

{
  "functions": [
    {
      "name": "js_mysql2_create_connection",
      "params": ["jsvalue"],
      "returns": "promise"
    },
    {
      "name": "js_mysql2_connection_query",
      "params": ["i64", "string", "jsvalue"],
      "returns": "promise"
    }
  ]
}

Perry’s codegen translates the user’s TS-side calls (mysql.createConnection(config), c.query(sql, params)) into direct calls to these symbols, with the right argument coercion (JsValue NaN-box ↔ f64 ABI shim, string-pointer extraction, etc.).

4. Linking

The staticlib (libperry_ext_mysql2.a for the well-known case, or a prebuilt artifact in node_modules/mysql2/prebuilt/<target>/ for the external case) joins the link line alongside libperry_runtime.a and libperry_stdlib.a. The js_mysql2_* symbol references in the user’s compiled code resolve at link time.

If the binding ships only Rust source (no prebuilt), Perry runs cargo build --release on the wrapper at compile time. Slow first build, then cached.

5. Runtime

User code runs. Calls into js_mysql2_* happen at native speed — function call overhead is one register-pass for the receiver handle plus one each per param. Promise resolution / closure invocation / async work bridge through perry-ffi’s surface (JsPromise, JsClosure, spawn_blocking + tokio::Handle::current().block_on). The wrapper sees Perry’s NaN-boxed JsValues directly; user TypeScript sees a normal Promise / object / array.

What perry-ffi guarantees

The 9 surface dimensions perry-ffi exposes today are:

SurfaceWhat it doesDocumented at
StringsJsString / alloc_string / read_string / read_bytes / alloc_bytesabi.md
Async / PromiseJsPromise (new / resolve / reject_string), spawn_blockingabi.md
Handlesregister_handle / get_handle / with_handle / take_handle / iter_handles_ofabi.md
JsValue + objects/arraysJsValue, js_array_alloc/push/get/set, js_object_alloc_with_shape, js_object_get_field, js_object_set_field, build_object_shapeabi.md
ClosuresJsClosure::call0..4abi.md
GC root scannergc_register_root_scannerabi.md
BigIntBigIntHeader, alloc_bigint_from_str, read_bigint_limbsabi.md
BufferBufferHeader, alloc_buffer, read_buffer_bytesabi.md
JSON-stringifyjson_stringify(JsValue) -> Option<String>abi.md
Event pumpnotify_main_threadabi.md

A wrapper that uses anything outside this list (e.g. reaches into perry_runtime::* types directly) is off-contract — its build will break the next time those types change. Stay on perry-ffi.

The abi.md page is the source of truth for what’s in each surface. The semver promise: breaking changes to anything documented there bump perry-ffi major, regardless of what perry-runtime does internally.

Code organization

crates/
  perry-ffi/              ← Layer 2: the stable ABI surface
  perry-runtime/          ← Layer 1: NaN-boxing, GC, arena, JS objects
  perry-stdlib/           ← Layer 1: in-tree wrappers (perry/ui, fs,
                            crypto helpers, etc. — anything genuinely
                            coupled to runtime internals)
  perry-ext-<name>/       ← Layer 3, well-known: mysql2, pg, ioredis,
                            cron, decimal, dayjs, axios, ethers,
                            commander, … (~27 today). All depend on
                            perry-ffi only.

External native bindings (Layer 3, third-party — Rust + perry-ffi):
  PerryTS/tursodb-bindings    → bun add @perryts/tursodb
  PerryTS/iroh-bindings       → bun add @perryts/iroh
  <anyone>/whatever-bindings  → user publishes themselves

External pure-TypeScript drivers (compiled via compilePackages):
  PerryTS/postgres            → bun add @perryts/postgres
  PerryTS/mysql               → bun add @perryts/mysql
  PerryTS/mongodb             → bun add @perryts/mongodb
  PerryTS/redis               → bun add @perryts/redis

The split between well-known in-tree wrappers and external is a packaging convention, not a technical distinction. Both depend only on perry-ffi; both ship extern "C" symbols Perry’s codegen calls. The well-known set is the ~30 packages every JS dev expects to import without an npm install step (dotenv, axios, mysql2, …). External wrappers are everything else.

The two existing external native wrappers (tursodb, iroh) cover functionality that doesn’t have an in-tree perry-stdlib equivalent — they’re net-new bindings that originated as third-party packages. That validates the contract: perry-ffi is sufficient to write a real wrapper without forking Perry.

Three paths to a database driver (postgres / mysql / mongodb / redis)

Perry currently ships two parallel database-driver families. Picking one is a packaging trade-off, not a feature trade-off:

PathInstallResolver layerWhat it is
Well-known native bindingnothing (bundled)(c)import 'mysql2' / import 'pg' / import 'mongodb' route to in-tree perry-ext-mysql2 / perry-ext-pg / perry-ext-mongodb. Rust wrappers around sqlx / mongodb crates. Versioned in lockstep with Perry.
@perryts/{postgres,mysql,mongodb,redis}bun add @perryts/postgres(a)Pure-TypeScript wire-protocol drivers — no Rust, no native dep. Use Perry’s compilePackages to compile the TS to native via LLVM. Also run unmodified on Node.js / Bun. Independent semver.
External native bindingbun add @perryts/tursodb(a)Third-party Rust crate using perry-ffi, manifest at package.json::perry.nativeLibrary. Today: @perryts/tursodb, @perryts/iroh.

Resolution precedence (per layer (a) → (b) → (c) above): an installed @perryts/mysql does not override import 'mysql2' because the package names are different. If you bun add @perryts/mysql and also import 'mysql2' in the same program, both drivers ship in the binary — they’re independent. To opt out of the well-known mysql2 shim, just don’t import mysql2.

When to pick which:

  • Well-known native (mysql2 / pg / mongodb) — zero install step, fastest path to “it works”; you accept that the driver’s feature set tracks Perry’s release cadence.
  • @perryts/postgres / @perryts/mysql / @perryts/mongodb / @perryts/redis — you want to read / fork / patch the driver in plain TypeScript; you want the same code running on Node.js or Bun for fallback; you need a feature ahead of Perry’s next release.
  • External native binding — you’re wrapping a Rust crate that doesn’t have a JS-only equivalent (Tursodb’s embedded SQLite- compatible engine, Iroh’s QUIC transport).

Concrete how-tos

If you want to …Read
Use mysql2 / dotenv / etc. in a Perry programNothing! import and go — Perry ships them in the well-known set.
Use a third-party native bindingbun add <package>, then import. Perry’s resolver finds it via node_modules/<pkg>/package.json.
Find which packages ship out-of-the-boxperry native list
Write your own native bindingperry native init my-bindings scaffolds the Cargo crate + package.json + release.yml for prebuilds. Then read abi.md for the perry-ffi surface and manifest-v1.md for the manifest schema.
Verify your binding’s manifest matches its .acd my-bindings && perry native validate (runs cargo build --release, walks nm -gP over the staticlib, diffs against functions[], reports missing or undeclared symbols).
Override a well-known bindingInstall your fork into node_modules/<name>/ with a perry.nativeLibrary block. Resolution layer (a) wins over layer (c).
See what stdlib APIs Perry implementsAuto-generated from the manifest: docs/src/api/reference.md. The perry types command writes a current snapshot to .perry/types/stdlib/index.d.ts for editor squiggles.

Authoring a binding — the 60-second tour

# Scaffold
perry native init my-pdf --description "PDF rendering bindings" \
  --upstream-dep 'pdfium-render = "0.8"'
cd my-pdf

# Edit src/lib.rs — add your `js_*` functions, all using only
# `perry_ffi::*` types
$EDITOR src/lib.rs

# Edit src/index.ts — declare the TS surface user code imports
$EDITOR src/index.ts

# Edit package.json — list every js_* export in the
# perry.nativeLibrary.functions[] block
$EDITOR package.json

# Verify
perry native validate
# ✅ manifest matches the staticlib

# Publish
git tag v0.1.0 && git push --tags  # the scaffolded release.yml
                                    # builds prebuilts for all targets
                                    # and attaches them to the release
npm publish

A user can now bun add my-pdf and import { renderPdf } from "my-pdf" in their Perry program.

Versioning policy

  • perry-ffi semver: tracks Perry’s minor today (perry-ffi = "0.5" for Perry 0.5.x). Backwards-incompatible changes to anything documented in abi.md bump perry-ffi major — independent of perry-runtime. Wrappers depend on perry-ffi = "0.5" and stay buildable across Perry’s 0.5.x releases.
  • Manifest spec v1: locked at abiVersion: "0.5"; missing field is warning-only in v0.5.x, hard error from v0.6.0. Schema changes bump the spec version (v2) and ship alongside a new manifest schema file.
  • Wrappers: each ships independent semver; users bun update a binding without touching Perry.

Consumption today (v0.5.x)

Until the v0.6.0 type-source-of-truth refactor lands, perry-ffi is not yet on crates.io. External wrappers depend on it via git URL:

[dependencies]
perry-ffi = { git = "https://github.com/PerryTS/perry", branch = "main" }

PerryTS/tursodb-bindings and PerryTS/iroh-bindings use this shape and cargo build against live main. The git-URL approach is the supported consumption mechanism for the v0.5.x cycle; the v0.6.0 plan inverts type ownership so perry-ffi becomes the source of truth and can publish to crates.io as perry-ffi = "0.6".

Limits

  • Bindings are build-time linked. Perry doesn’t dlopen plugins at runtime — the staticlib joins the link line, the binary stands on its own.
  • Bindings can’t bring their own JS runtime — they extend Perry’s, not replace it. A binding that wants its own GC / event loop / threading is out of scope.
  • Cross-target prebuilds are the binding author’s responsibility. The scaffolded GitHub Actions workflow handles the common matrix (x86_64+aarch64 macOS/Linux + Windows); other targets need manual additions.

Next pages

  • abi.md — the perry-ffi surface, reference grade.
  • manifest-v1.md — the perry.nativeLibrary schema, every field documented.
  • API reference — auto-generated list of every stdlib symbol Perry implements.