Authoring a native binding
Step-by-step guide to writing and publishing a Rust binding that
Perry programs can import like any npm package.
For the architectural picture this fits into, see Native bindings — overview.
Prerequisites
- A Rust crate you want to expose to TypeScript (e.g.
pdfium-render,image, your own internal library). - Rust toolchain installed.
perryon yourPATH(theperry nativesubcommand ships with the install).- A GitHub account if you want the prebuild release-CI scaffold to Just Work.
1. Scaffold
perry native init my-bindings \
--description "Native bindings for <upstream crate>" \
--upstream-dep '<crate-name> = "<version>"' \
--github-owner <your-handle>
cd my-bindings
This creates:
my-bindings/
├── Cargo.toml # perry-ffi dep + your upstream
├── src/
│ ├── lib.rs # one example #[no_mangle] fn
│ └── index.ts # TS surface user code imports
├── package.json # perry.nativeLibrary block
├── README.md
├── LICENSE # MIT, swap if needed
├── .gitignore
└── .github/workflows/release.yml # multi-target prebuild on tag
2. Add bindings
Each TypeScript-visible function should forward to one extern "C" Rust export.
src/lib.rs
The example template starts with one js_<name>_hello function.
Replace it with your bindings — one #[no_mangle] pub extern "C" fn
per TypeScript-visible call, using only types from perry_ffi:
#![allow(unused)]
fn main() {
use perry_ffi::{alloc_string, read_string, JsString, StringHeader};
/// `pdf.parse(buf) -> string` — extract text from a PDF buffer.
///
/// # Safety
///
/// `buf_ptr` must be null or a Perry-runtime `BufferHeader`.
#[no_mangle]
pub unsafe extern "C" fn js_pdf_parse(buf_ptr: i64) -> *mut StringHeader {
let buf_ptr = (buf_ptr as u64 & 0x0000_FFFF_FFFF_FFFF)
as *const perry_ffi::BufferHeader;
let bytes = perry_ffi::read_buffer_bytes(buf_ptr).unwrap_or(&[]);
match pdfium_render::Pdfium::default().load_pdf_from_byte_slice(bytes, None) {
Ok(doc) => {
let text = doc.pages().iter().map(|p| p.text().unwrap()).collect::<String>();
alloc_string(&text).as_raw()
}
Err(_) => std::ptr::null_mut(),
}
}
}
Key rules:
- Don’t
use perry_runtime::*. perry-runtime’s internals (NaN-box tags, struct layouts) change between Perry releases. perry-ffi is the stable contract. - Use
unsafe extern "C"for any function that takes pointer args.*const StringHeaderetc. require unsafe at the call site. - Document
# Safetyfor unsafe fns — at minimum say “the pointer must be null or a Perry-runtime<Header>”. - Async returns
*mut Promise. Pattern:JsPromise::new()→spawn_blocking(move || { tokio::runtime::Handle::current().block_on(async {...}); promise.resolve(...) })→ returnpromise.as_raw().
src/index.ts
Declare the FFI symbol from your manifest, then export the TypeScript
surface user code imports. Perry’s FFI dispatch keys on the call-site
identifier, so the wrapper body must explicitly call the js_* symbol
listed in perry.nativeLibrary.functions[].
declare function js_pdf_parse(buf: Uint8Array): string;
/**
* Extract text from a PDF buffer.
*/
export function parse(buf: Uint8Array): string {
return js_pdf_parse(buf);
}
package.json
The perry.nativeLibrary block tells Perry’s compiler about every
extern "C" export plus the build config. Schema details in
manifest-v1.md.
{
"name": "my-bindings",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"perry": {
"nativeLibrary": {
"abiVersion": "0.5",
"functions": [
{
"name": "js_pdf_parse",
"params": ["i64"],
"returns": "string"
}
],
"targets": {
"macos": { "cargo_features": [] },
"linux": { "cargo_features": [] },
"windows": { "cargo_features": [] }
}
}
}
}
Every entry in functions[] must:
- have a
namematching exactly the symbol the staticlib exports (Perry’sperry native validateverifies this for you) - declare
paramsandreturnsso codegen knows the calling convention
3. Verify
perry native validate
This runs cargo build --release, locates the resulting .a,
walks nm -gP over its symbols, and diffs against the manifest’s
functions[]. The output flags two failure modes:
- ❌ declared function has NO matching symbol — your manifest
lists a function the staticlib doesn’t export. Either you typo’d
the name, or you forgot
#[no_mangle]. - ⚠
js_*symbol NOT in the manifest — your staticlib exports a function user code can’t reach. Either add it tofunctions[], rename it (drop thejs_prefix), or remove it.
A green run looks like:
perry native validate
======================
package: my-bindings
abiVersion: 0.5
staticlib: ./target/release/libperry_ext_my_bindings.a
declared functions: 1
exported `js_*` symbols: 1
✅ manifest matches the staticlib.
4. Test in a Perry program
In a separate directory:
mkdir test-app && cd test-app
perry init
bun add file:../my-bindings # or any path your tooling supports
Add to your TS:
import { parse } from "my-bindings";
const buf = await Bun.file("input.pdf").bytes();
console.log(parse(buf));
Then perry compile main.ts -o main && ./main.
5. Publish
Tag a release
git tag v0.1.0
git push --tags
The scaffolded .github/workflows/release.yml builds prebuilt
staticlibs for x86_64 + aarch64 macOS/Linux + Windows on tag and
attaches them to the GitHub release. Add or remove targets in the
workflow’s matrix block as needed.
npm publish
npm publish
The scaffolded package.json includes the right files: [...] list
to bundle src/ + Cargo.toml + the README. If you also vendor the
prebuilt artifacts in the npm tarball, add them to the files block.
Two distribution models
There are two ways your users get the staticlib:
| Model | What ships in the npm tarball | Trade-off |
|---|---|---|
| Vendor prebuilts | src/, Cargo.toml, AND prebuilt/<target>/lib<name>.a for every target | Bigger npm tarball; install is fast (no compile); user doesn’t need a Rust toolchain |
| Source-only | src/, Cargo.toml, no prebuilts | Tiny tarball; first perry compile runs cargo build --release (slow); user needs Rust |
Vendoring is the friendlier default for npm consumers. Source-only makes sense if your matrix is too big for one tarball or if you’re publishing a private wrapper to a small audience.
The manifest’s targets.<target>.prebuilt field tells Perry where to
find a prebuilt for the user’s compile target:
{
"perry": {
"nativeLibrary": {
"targets": {
"macos": { "prebuilt": "./prebuilt/macos/libperry_ext_my_bindings.a" },
"linux": { "prebuilt": "./prebuilt/linux/libperry_ext_my_bindings.a" },
"windows": { "prebuilt": "./prebuilt/windows/perry_ext_my_bindings.lib" }
}
}
}
}
If the prebuilt path doesn’t exist on disk at compile time, Perry
falls back to cargo build --release.
6. Update over time
- A new perry-ffi feature lands: bump your
Cargo.toml’sperry-ffiversion, rebuild prebuilts, tag a new release. Usersbun updateto pick it up. Perry’s manifest spec stays at v1 unless the schema changes. - A new Perry minor: same —
perry-ffi’s semver moves with Perry’s minor. The git-URL consumption (v0.5.x) means rebuilding againstmainpicks it up automatically. - Breaking change to the
js_*surface you exported: bump your package’s major version (1.0.0→2.0.0). Users who pin a major aren’t affected.
Common patterns
Async one-shot (HTTP request, DB query)
#![allow(unused)]
fn main() {
use perry_ffi::{alloc_string, spawn_blocking, JsPromise, JsValue, Promise};
#[no_mangle]
pub extern "C" fn js_my_fetch(url_ptr: *const StringHeader) -> *mut Promise {
let promise = JsPromise::new();
let raw = promise.as_raw();
let url = unsafe { read_str(url_ptr) }.unwrap_or_default();
spawn_blocking(move || {
let outcome = tokio::runtime::Handle::current().block_on(async move {
reqwest::get(&url).await.and_then(|r| Ok(r.text())).await
});
match outcome {
Ok(body) => promise.resolve(JsValue::from_string_ptr(alloc_string(&body).as_raw())),
Err(e) => promise.reject_string(&format!("fetch: {}", e)),
}
});
raw
}
}
Sync handle-based class
#![allow(unused)]
fn main() {
use perry_ffi::{get_handle, register_handle, Handle};
pub struct MyThing { val: u64 }
#[no_mangle]
pub extern "C" fn js_my_thing_new() -> Handle {
register_handle(MyThing { val: 0 })
}
#[no_mangle]
pub extern "C" fn js_my_thing_get(h: Handle) -> f64 {
get_handle::<MyThing>(h).map(|t| t.val as f64).unwrap_or(0.0)
}
}
Event listeners (.on(event, cb))
#![allow(unused)]
fn main() {
use perry_ffi::{
gc_register_root_scanner, get_handle_mut, iter_handles_of, register_handle,
Handle, JsClosure, RawClosureHeader, StringHeader,
};
pub struct EventEmitter {
listeners: Vec<i64>, // closure pointers, kept alive by the GC scanner below
}
static SCANNER_REGISTERED: std::sync::Once = std::sync::Once::new();
fn ensure_scanner() {
SCANNER_REGISTERED.call_once(|| {
gc_register_root_scanner(|mark| {
iter_handles_of::<EventEmitter, _>(|emitter| {
for &cb in &emitter.listeners {
if cb != 0 {
let nan_boxed = f64::from_bits(0x7FFD_0000_0000_0000 | (cb as u64 & 0x0000_FFFF_FFFF_FFFF));
mark(nan_boxed);
}
}
});
});
});
}
#[no_mangle]
pub extern "C" fn js_emitter_on(h: Handle, cb: i64) -> Handle {
ensure_scanner();
if let Some(e) = get_handle_mut::<EventEmitter>(h) {
e.listeners.push(cb);
}
h
}
#[no_mangle]
pub extern "C" fn js_emitter_emit(h: Handle, arg: f64) -> bool {
if let Some(e) = get_handle_mut::<EventEmitter>(h) {
for &cb in e.listeners.clone().iter() {
let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) };
let _ = unsafe { closure.call1(arg) };
}
true
} else {
false
}
}
}
The GC scanner is load-bearing: without it, a malloc-triggered
GC between .on(cb) and .emit() will sweep the closure and the
next emit calls freed memory. Always register a scanner if your
handles store closure pointers.
When to extend the perry-ffi surface
If your binding genuinely needs something perry-ffi doesn’t expose,
file an issue against
PerryTS/perry describing:
- the binding you’re writing,
- the perry-runtime function/type you’d otherwise reach into,
- why a higher-level perry-ffi entry would generalize.
The bar for adding to perry-ffi is high — every helper is a forever commitment — but real wrappers driving real needs is exactly the right input. The recent additions (BigInt + Buffer in v0.5.556, JSON-stringify + event-pump in v0.5.567 followups) all came from specific wrappers needing them.
Don’t reach into perry_runtime::* directly to “unblock” your
wrapper today — it’ll break the next time those internals change.
See also
overview.md— the architectural picture.abi.md— perry-ffi reference.manifest-v1.md— the manifest schema in full.PerryTS/tursodb-bindingsandPerryTS/iroh-bindingsfor end-to-end real-world examples.