This page documents the contract between native bindings packages
(perryts/mysql2-bindings, @perry/iroh, perry-ext-dotenv, …) and
the Perry runtime they execute inside.
New here? Start with Native Bindings — Overview for the architectural picture and the Authoring Guide for the step-by-step. This page is reference-grade detail.
It is intentionally short. The whole point of the contract is minimum surface area — every helper added is a forever commitment, and Perry's internals (string layout, NaN-boxing tags, GC) are free to change underneath as long as this surface holds.
perry-ffi ships its own semver, currently tracking Perry's minor:
perry-ffi = "0.5" for Perry 0.5.x.
v0.5.x consumption (current): until the v0.6.0 type-source-of-truth
refactor lands, perry-ffi re-exports a handful of types from
perry-runtime (StringHeader, ArrayHeader, ObjectHeader,
BigIntHeader, BufferHeader, ClosureHeader, Promise). That
makes it un-publishable to crates.io as-is — perry-runtime is a
private dep and not something we want on crates.io. Wrappers
depend on perry-ffi via the git URL while we're in the v0.5.x
cycle:
[dependencies]
perry-ffi = { git = "https://github.com/PerryTS/perry", branch = "main" }PerryTS/tursodb-bindings and PerryTS/iroh-bindings ship this
shape and cargo build against the live main branch.
v0.6.0 plan (deferred): invert the type ownership so perry-ffi
becomes the source of truth — it defines #[repr(C)] versions of
the ABI types itself, exposes opaque pointers for Promise /
ClosureHeader (which have private state), and perry-runtime
imports the types from perry-ffi. At that point perry-ffi has
zero perry-runtime deps and can publish to crates.io as
perry-ffi = "0.6" — wrappers switch to cargo add perry-ffi.
Tracked under #466 Phase 1 as a v0.6.0 followup; the v0.5.x
git-URL approach is supported and tested end-to-end in the meantime.
A wrapper's package.json declares the ABI it was built against:
{
"perry": {
"nativeLibrary": {
"abiVersion": "0.5",
"...": "..."
}
}
}The Perry compiler refuses to load a wrapper whose declared
abiVersion doesn't satisfy the bundled perry-ffi's semver range
(strict enforcement lands under issue #466 Phase 2). Backwards-
incompatible changes to anything in this document bump perry-ffi's
major version — independent of perry-runtime semver.
The current surface is deliberately minimal — just enough to port
the simplest stdlib wrappers (dotenv, nanoid, uuid, slugify).
It will grow as real wrappers demand it; we'd rather under-design
and add than commit to a helper we later regret.
pub struct JsString(/* opaque */);
pub fn alloc_string(s: &str) -> JsString;
pub fn read_string(handle: JsString) -> Option<&'static str>;
impl JsString {
pub unsafe fn from_raw(ptr: *mut StringHeader) -> Self;
pub fn as_raw(self) -> *mut StringHeader;
pub fn is_null(self) -> bool;
}
pub use perry_runtime::StringHeader; // for `*mut StringHeader` in extern "C" sigsalloc_string allocates a fresh string in the runtime's arena.
The handle is owned by the runtime — Perry's GC reclaims it once
no live references remain, including references held by JS code
your function returned the handle to.
read_string borrows the underlying UTF-8 bytes for the duration
of the FFI call. Returns None on a null handle or invalid UTF-8.
StringHeader is re-exported as the canonical type for extern "C"
return / parameter types — wrappers should write
pub extern "C" fn js_my_module_thing() -> *mut perry_ffi::StringHeader,
not import StringHeader from perry-runtime directly.
These will land as real wrappers force them, tracked under #466 Phase 1's "Open questions":
- Array allocation / read (
alloc_array,read_array). - Object field get / set.
- Closure invocation helpers.
- NaN-boxing constants (undefined / null / true / false).
- Async runtime sharing (
spawn_async,block_on). - BigInt allocation.
If your wrapper needs one of these today, add it to perry-ffi in
the same PR that ports the wrapper. Treat this document as the
review gate: any addition needs a one-line entry above and a
unit test in crates/perry-ffi/src/lib.rs.
The smallest stdlib wrapper Perry ships is the acceptance test for the surface above. Its full FFI surface is two functions:
use perry_ffi::{alloc_string, read_string, JsString, StringHeader};
#[no_mangle]
pub unsafe extern "C" fn js_dotenv_config_path(
path_ptr: *const StringHeader,
) -> f64 {
let handle = JsString::from_raw(path_ptr as *mut _);
let path = read_string(handle).unwrap_or(".env");
// … read file, set env vars, return 1.0 / 0.0 …
}
#[no_mangle]
pub unsafe extern "C" fn js_dotenv_parse(
content_ptr: *const StringHeader,
) -> *mut StringHeader {
let handle = JsString::from_raw(content_ptr as *mut _);
let Some(content) = read_string(handle) else {
return std::ptr::null_mut();
};
let parsed = parse_dotenv_content(content);
let json = serde_json::to_string(&parsed).unwrap_or_else(|_| "{}".into());
alloc_string(&json).as_raw()
}Source: crates/perry-ext-dotenv/src/lib.rs.
It depends only on perry-ffi and serde_json. Zero references to
perry-runtime internals. That's the bar for every wrapper that
moves out of perry-stdlib over the course of #466 Phase 5.
- #466 Phase 2 freezes the
perry.nativeLibrarymanifest spec and enforcesabiVersionat resolve time. - #466 Phase 3 adds
perry native init/validate/prebuildfor scaffolding new wrapper packages. - #466 Phase 4 adds the well-known bindings table so
import 'dotenv'resolves toperry-ext-dotenvautomatically — until it lands,import 'dotenv'continues to bind to theperry-stdlibcopy. - #466 Phase 5 ports the rest of the wrappers in size order
(
uuid,nanoid,slugify,bcrypt,argon2, thenws, then the database batch).