Native FFI
Native FFI
How a compiled chttp app calls C code, and why this lives in docs instead of a package.
Why docs, not a package
Outbound FFI in scriptc is build-time plumbing, not a runtime API: a declare function in your source plus a JSON manifest passed to scriptc build. There is nothing a @chttp/* package could wrap that the toolchain does not already do, so chttp documents the pattern and ships a worked example (examples/ws-echo) rather than an abstraction.
N-API is unavailable in ELFs
Under Node, native code usually arrives as a Node-API addon. That path is closed in a compiled binary — the scriptc docs put it plainly: “Compiled programs can’t load Node-API (.node) addons, because those depend on Node.js itself.” Renaming a .node file or linking an archive that still calls napi_* does not remove the dependency; move the operation into a plain C function and call it through FFI.
Two runtimes, two FFI styles
| Node.js | Native ELF (scriptc build) |
|
|---|---|---|
| Mechanism | koffi, node:ffi (Node 22+), napi-rs addons |
scriptc build --ffi <manifest> |
| Declaration | Runtime FFI object | declare function at module scope |
| Linking | Shared library loaded at runtime | Object/archive linked into the binary |
Same C function, different plumbing. A project targeting both keeps the C code shared and swaps the binding layer.
Manifest and declaration
The manifest describes the C signature; the declaration gives TypeScript its types. TypeScript only has number, so the manifest decides the actual C width:
declare function nativeScale(value: number, factor: number): number;
{
"ffi_format": 1,
"functions": [
{
"name": "nativeScale",
"symbol": "native_scale",
"params": ["f64", "f64"],
"returns": "f64"
}
],
"libraries": ["./libnative.a"]
}
scriptc build main.ts --ffi ffi.json -o app
Pass the same manifest to scriptc coverage so the native calls count as compiled.
ABI classes
ffi_format selects the available classes:
- Formats 1–5:
f64,bool,u8,u32,i32(allnumber),cstring/string/bytes(callback input only),void. - Format 6 adds
f32,i8/u16/i16, andmutable-bytes— a writable borrow of aUint8Array/Bufferview; in-place writes are visible through the original array. - Format 7 adds
i64,u64,pointer(bigintin TypeScript).
string/bytes pointers are borrowed for the call duration; native code must not mutate, free, or retain them. String and byte returns are unsupported — they need an ownership contract. Format 7 can return an opaque pointer as a bigint; scriptc does not infer its lifetime.
Callbacks and threads
C APIs that take a function pointer map to a callback descriptor ("callback") in the manifest, with an optional void * context entry the compiler supplies:
lifetime: "call"(format 2) — call-scoped; valid only for the duration of the FFI call. No userdata parameter is needed for plain callbacks — scriptc installs the closure in a binding-specific thread-local slot.lifetime: "retained"(format 4) — same-thread native APIs that store the callback and invoke it later; pair registration with a release binding.invoke: "foreign"(format 5) — a native thread invokes the closure; delivery is queued onto the JavaScript event loop, so the closure always runs on the main thread.
Foreign-thread delivery is the same trick the router uses internally: settleChain in packages/router/src/index.ts settles a chain result by awaiting it inside a scheduled task rather than on the caller’s stack. FFI format 5 gives you the same guarantee for C-side threads — the callback fires on the event loop, never re-entrantly inside native code.
Bridging Node and ELF
Format 7 emits node:ffi static bindings: the same manifest and declare function shape runs against Node’s built-in FFI, so one C library can serve both the Node entrypoint and the compiled ELF without a second binding layer.
Constraints
- Outbound FFI is an executable feature. Quoting the scriptc docs: “Outbound FFI is currently available for executable builds, not
scriptc build --lib.” - Build the object/archive for the same target as the scriptc binary.
cc -cis the portable baseline (ar rcsfor an archive);zig ccworks when you need cross-compilation, but adds a toolchain dependency. - Compiled ELFs can still reach system libraries via
system_librariesin the manifest.
Example
examples/ws-echo computes FNV-1a in C: GET /ffi/{text} passes each byte to native code through a call-scoped next() callback (ffi.json, ffi_native.c), and the build compiles the object before scriptc build --ffi. tests/ws-echo-ffi-smoke/run.sh builds the ELF and asserts the route end to end.