FFI (ffi global)
ffi opens shared libraries with dlopen and turns declared C symbols
into JS functions. No glue code, no build step, no Node-API — the native
library can be anything with a C ABI: system libz, sqlite3, your own
libfoo.dylib.
Native code runs with the full privileges of the ff process and bypasses
every sandbox guarantee, so the whole global is gated:
Without the flag, ffi.dlopen throws and ffi.allowed is false.
Prebuilt binaries ship without FFI. Release binaries and the GHCR image are built with
-Dffi=false, soffi.dlopenthrowsffi.dlopen: FFI not compiled in (build with -Dffi=true)even with--allow-ffi. FFI works out of the box in source builds (the default).
Quick look
dlopen
path— anything the OS loader accepts: absolute,./-relative, or a system name ("libz.1.dylib","libz.so.1").symbols—{ name: { parameters: [...], result: "<type>" | struct | union, optional?: true, nonblocking?: true } }.- Missing library →
TypeErrorwith the loader's error text. - Missing symbol →
TypeError, unlessoptional: true, in which caselib.symbols.nameisnull. - Max 32 parameters per symbol.
Returns a library object:
A closed library throws TypeError: ffi: library is closed. If the
library object is garbage-collected, the handle is closed for you.
Types
Unknown type names throw at dlopen. void as a parameter throws.
Pointers
FfiPtr is an opaque handle — the address lives inside it.
Memory
Read and write native memory at any pointer, with byte offsets:
ffi.view(p) returns the same pointer object with DataView-style
getters (Deno-compatible names):
Readable/writable types are the scalars plus pointer; cstring strings
are read with ffi.cstring/getCString, buffers with
ffi.copyBytes/copyInto. No bounds checking — C's rules.
Structs
A C struct is a byte image in C layout. Describe it once, get the layout math for free:
Fields are positional; names are yours (they key offsets). Deno-style
type-only lists work too — fields become f0, f1, …:
Layout follows C rules automatically: natural alignment per field,
struct alignment = the largest field's, size padded to it. Need a packed
layout? Insert u8 padding fields yourself. Field types may be any
scalar including pointer/cstring/buffer, or nested struct/union
specs. void fields are rejected.
Nested structs work inline or with named specs:
Nested fields are bytes at the parent's offset — no flattening, no copies. Depth is capped at 8 levels.
Use the spec inline or as a named value in a symbol table:
- Parameters: pass a
Uint8Arrayof at leastBlob.sizebytes, fields written atBlob.offsets. - Results: a fresh
Uint8ArrayofBlob.sizebytes — read it withDataView, orffi.view(ffi.ptr(result)).
Limits: at most 64 fields and 512 bytes per struct.
Structs passed by pointer (the common C pattern — void f(const z_stream *)) don't use struct types at all: lay the fields into a scratch
buffer and pass it as buffer/pointer. Blob.offsets and
ffi.read/ffi.write do the layout work:
Unions
A C union overlays all members at offset 0. Size = the largest member's size, aligned to the largest member's alignment. Passing works exactly like structs — byte images in, byte images out:
Members are positional; names are yours. Inline { union: [...] } works
in symbol specs and as nested struct fields:
Reading a union member: write/read at offset 0 with the member's type:
Limits: at most 64 members, 512 bytes per union, nesting depth 8.
Callbacks
Pass a JS function where C expects a function pointer. Create one with
ffi.callback(spec, fn):
The spec shape matches symbol specs (parameters, result). Use
add.pointer as a function-type argument:
Same-thread calls (C calls back synchronously on the JS thread) are direct — zero overhead. Foreign-thread calls (C calls back from a thread it owns) go through a bridge: the raw argument bytes are copied, enqueued, and the JS function runs on the JS thread. The C caller blocks until the result is ready.
A JS throw inside a callback:
- same-thread: rethrows at the caller (
lib.symbols.apply(...)throws) - foreign-thread with
nonblocking: rejects the enclosing Promise - foreign-thread without
nonblocking: reported as an uncaught error
close() frees the native closure. It refuses (TypeError) while a
call is in flight — call it when C is done with the pointer. The
finalizer is a backstop; freeing under a live native pointer crashes.
cstring results are not supported from callbacks (v1).
Nonblocking calls
Mark a symbol nonblocking: true and it returns a Promise instead of
running on the JS thread. The native call runs on a worker thread; the
event loop stays free:
Semantics:
bufferarguments are copied in at call time and copied back after settle. Mutation is only visible after the Promise resolves.cstringarguments are copied;pointeris passed by value (caller keeps the memory alive).- The result is wrapped at settle time (same as a sync call).
- Up to 32 concurrent calls (job pool).
Combine with callbacks — a JS callback invoked from C during a nonblocking call runs on the JS thread via the bridge, and a throw rejects the Promise:
Ownership
The rules are C's rules — the FFI adds no safety net:
- A
bufferyou pass must stay alive for the duration of the call. Keep theTypedArrayreferenced; don't rely on detached/collected memory. (Fornonblockingcalls, buffers are copied — safe to mutate after settle.) - A
pointer/bufferresult carries no ownership. If the library allocated it, call the library's own free function —ffinever frees native memory. cstringresults and struct/union results are copies — always safe to keep.- Calling past a buffer's bounds, or through a stale pointer, is undefined behavior. Keep the FFI surface you expose small.
Contracts & limits
- Deadlock: never invoke a JS callback from a thread spawned by a
synchronous
fficall. The JS thread is blocked insideffi_calland cannot service the bridge. Callbacks from threads owned by C (or from nonblocking worker threads) are safe. close()while busy:FfiCallback.close()throws while a bridged call is in flight or a nonblocking call holds the callback. Close after C is done.cstringcallback results are not supported (v1).- No Windows (POSIX
dlopenonly), no variadic functions. - Max 32 parameters, 64 fields/members, 512 bytes per struct/union, nesting depth 8.
