FFI (Native Libraries)
The tjs:ffi module lets you call functions in native shared libraries directly
from JavaScript — no C compiler, no build step, no bindings to maintain. It is
built on libffi and supports scalar types,
strings, buffers, pointers, structs, and callbacks.
FFI is inherently unsafe: you are calling arbitrary native code with JavaScript-supplied arguments. A wrong type or a stale pointer can crash the process. Treat the signatures you declare as a contract you must get right.
Loading a library
The quickest way in is dlopen: give it a
library path and a description of the symbols you want, and it returns ready-to-call functions.
import { dlopen, Lib } from 'tjs:ffi';
const { symbols, close } = dlopen(Lib.LIBC_NAME, {
getpid: { returns: 'i32' },
abs: { args: ['i32'], returns: 'i32' },
});
console.log('pid:', symbols.getpid());
console.log('abs(-5):', symbols.abs(-5));
close(); // release the library handle when done
Lib.LIBC_NAME and Lib.LIBM_NAME resolve to the platform's C and math
libraries. For your own libraries, build the path with the platform-specific
suffix:
import { suffix } from 'tjs:ffi';
const path = `./libmystuff.${suffix}`; // dylib / so / dll
Types
Each argument and the return value need a type. Types can be given as string
aliases or as objects from the types table.
| Alias | C type |
|---|---|
'i8' / 'u8' … 'i64' / 'u64' | int8_t … uint64_t |
'int', 'long', 'char', 'size_t', … | the matching C type |
'f32', 'f64' | float, double |
'ptr' | void * |
'string' | char * (auto-converted to/from a JS string) |
'buffer' | void * backed by a Uint8Array (argument only) |
'void' | void (return only) |
See TypeAlias for the full list.
returns defaults to 'void' and args to [].
'buffer' can only be used as an argument: a returned void * has no known
length, so use 'ptr' and create a view over it (see
Working with native memory).
Strings and buffers
'string' arguments are transparently converted from a JS string to a
NUL-terminated char *, and 'string' return values are read back into a JS
string. For raw memory, pass a Uint8Array as a 'buffer':
import { dlopen, Lib, bufferToString } from 'tjs:ffi';
const { symbols } = dlopen(Lib.LIBC_NAME, {
// int snprintf(char *str, size_t size, const char *format, ...);
snprintf: { args: ['buffer', 'size_t', 'string'], returns: 'int', fixed: 3 },
});
const out = new Uint8Array(64);
symbols.snprintf(out, out.length, 'hello');
console.log(bufferToString(out)); // "hello"
bufferToString reads a
NUL-terminated char * out of a buffer; its inverse,
stringToBuffer, encodes a JS
string into a Uint8Array you can pass as a 'buffer'.
Variadic functions
For variadic C functions, set fixed to the number of fixed (non-variadic)
arguments. Above, snprintf has three fixed parameters before the ....
Structs
Use the lower-level Lib /
CFunction API together with
StructType to pass or return structs by
value. Field layout (including padding) is computed for you.
import { Lib, CFunction, StructType, types } from 'tjs:ffi';
const lib = new Lib(`./libmystuff.${suffix}`);
// struct point { int x; int y; };
const Point = new StructType([['x', types.sint], ['y', types.sint]], 'point');
// struct point make_point(int x, int y);
const makePoint = new CFunction(lib.symbol('make_point'), Point, [types.sint, types.sint]);
console.log(makePoint.call(3, 4)); // { x: 3, y: 4 }
Passing a struct by value
When a parameter is a struct (passed by value), use the StructType as the
argument type and pass a plain object:
// double point_len(struct point p);
const pointLen = new CFunction(lib.symbol('point_len'), types.double, [Point]);
console.log(pointLen.call({ x: 3, y: 4 })); // 5
Passing a struct by reference
The common C idiom takes a pointer to a struct (struct point *). Wrap the
object with Pointer.createRef to pass a
pointer to its bytes — passing a bare object would be a type error:
import { Pointer } from 'tjs:ffi';
// void scale_point(struct point *p, double f);
const scalePoint = new CFunction(lib.symbol('scale_point'), types.void, [new PointerType(Point), types.double]);
const ref = Pointer.createRef(Point, { x: 3, y: 4 });
scalePoint.call(ref, 2);
console.log(ref.deref()); // { x: 6, y: 8 } — read the mutated value back
createRef keeps the backing buffer alive for as long as the Pointer is
reachable; deref reads the (possibly
mutated) value back.
Arrays and fixed-size strings
For fixed-length array fields, use ArrayType
(new ArrayType(types.sint32, 4, 'int4')), and for char[N] fields that hold a
string use StaticStringType, which
converts to/from a JS string.
Callbacks
Wrap a JS function in a JSCallback to
pass it where C expects a function pointer.
import { Lib, CFunction, JSCallback, types } from 'tjs:ffi';
const lib = new Lib(`./libmystuff.${suffix}`);
// int call_it(int (*fn)(int), int arg);
const callIt = new CFunction(lib.symbol('call_it'), types.sint, [types.jscallback(), types.sint]);
const cb = new JSCallback(types.sint, [types.sint], (n) => n * 2);
console.log(callIt.call(cb, 21)); // 42
Keep the JSCallback alive for as long as C might call it; if it is garbage
collected, the function pointer becomes dangling.
Pointers
Functions that return or accept pointers work with
NativePointer, an opaque handle to
a native address. You can offset it, compare it, and read typed values out of it
with the read helpers:
import { read } from 'tjs:ffi';
const p = symbols.get_thing(); // returns a NativePointer
const first = read.i32(p); // read an int32 at p
const second = read.i32(p, 4); // read an int32 at p + 4 bytes
const inner = read.ptr(p, 8); // read a pointer field
read.u64 / read.i64 return a JS number, which can't represent every 64-bit
value: results above Number.MAX_SAFE_INTEGER (2⁵³−1) lose precision, and a
u64 with its high bit set reads back negative. For exact 64-bit values, read
the raw bytes with toUint8Array instead.
To go the other way, bufferToPointer
gives you a pointer to a Uint8Array's memory.
Typed pointers
Pointer pairs an address with the type it
points at, so you can pass values by reference and read them back without
juggling offsets. Pointer.createRef(type, value)
allocates a buffer holding value and returns a pointer to it (it keeps the
buffer alive while the Pointer is reachable); createRefFromBuf(type, buf)
wraps an existing buffer. Use a PointerType
as the argument/return type to declare a T * parameter:
import { Lib, CFunction, PointerType, Pointer, types } from 'tjs:ffi';
const libc = new Lib(Lib.LIBC_NAME);
// struct tm *localtime(const time_t *timep);
const localtime = new CFunction(libc.symbol('localtime'), new PointerType(Tm), [new PointerType(types.sint64)]);
const tmPtr = localtime.call(Pointer.createRef(types.sint64, 1658319387));
console.log(tmPtr.deref()); // { sec, min, hour, ... } — deref reads the struct
deref() reads one level of indirection; derefAll() follows a multi-level
pointer all the way down.
Working with native memory (zero-copy)
The read helpers copy one value at a time. When a function hands you a pointer
to a block of memory, you can instead get a zero-copy view over it — a
Uint8Array or ArrayBuffer that aliases the native memory directly, with no
copying:
const p = symbols.get_pixels(); // NativePointer to width*height*4 bytes
const pixels = p.toUint8Array(w * h * 4); // a view, not a copy
pixels[0] = 255; // writes straight to the native buffer
toArrayBuffer(byteLength, byteOffset?) returns the buffer instead of a typed
array, and both methods accept a byteOffset to start further into the memory:
const header = p.toArrayBuffer(16); // first 16 bytes
const body = p.toUint8Array(len, 16); // everything after the header
Lifetimes
A zero-copy view aliases memory the runtime does not own and does not track. If that memory is freed, reallocated, or moved while a view still points at it, reading or writing the view is undefined behaviour and can crash the process. You are responsible for keeping the memory alive for at least as long as every view over it.
When the memory is owned by a JavaScript object — for example, a Uint8Array you
got a pointer into — keep a reference to that object alive for as long as any
view over its memory is in use, so the garbage collector can't reclaim it:
import { bufferToPointer } from 'tjs:ffi';
const src = new Uint8Array([1, 2, 3, 4]);
const view = bufferToPointer(src).toUint8Array(src.length);
view[0] = 42;
console.log(src[0]); // 42 — same memory; keep `src` reachable while `view` lives
Freeing native memory
tjs:ffi never frees native memory for you. If a library hands you a buffer that
you must free, you have two options.
For deterministic cleanup, free it yourself when you're done:
const { symbols } = dlopen(Lib.LIBC_NAME, {
free: { args: ['ptr'] },
});
symbols.free(p);
For cleanup tied to the view's lifetime, use a
FinalizationRegistry:
const registry = new FinalizationRegistry((ptr) => symbols.free(ptr));
const view = p.toUint8Array(len);
registry.register(view, p); // free(p) runs after `view` is collected
Cleanup via
FinalizationRegistryis not guaranteed to run promptly (or at all, at shutdown). Prefer explicit freeing when timing matters.
Invalidating a view
The buffer returned by these methods (and the .buffer of a Uint8Array view)
is an ExternalArrayBuffer — a
real ArrayBuffer with one extra method, detach(). After you free the native
memory, call it to neutralize the view so later access reads empty instead of
touching freed memory:
const buf = p.toArrayBuffer(len);
// ... use buf ...
symbols.free(p);
buf.detach(); // buf.byteLength is now 0, buf.detached is true
Unlike ArrayBuffer.prototype.transfer(), detach() does not read or copy the
bytes, so it is safe to call after the memory is gone. For a view returned as a
Uint8Array, call view.buffer.detach().
Declaring symbols from C prototypes
Instead of describing each symbol by hand, you can paste C declarations and let
Lib.parseCProto register the structs, typedefs
and functions for you. Functions then become callable by name with
lib.call(name, ...), and types are retrievable with lib.getType(name):
import { Lib, Pointer } from 'tjs:ffi';
const lib = new Lib(`./libmystuff.${suffix}`);
lib.parseCProto(`
struct point { int x; int y; };
int point_sum(struct point *p);
`);
const Point = lib.getType('struct point');
console.log(lib.call('point_sum', Pointer.createRef(Point, { x: 3, y: 4 }))); // 7
The parser understands scalar types, pointers, fixed-size array members,
structs, typedefs and function pointers (registered as callbacks). lib.call
pairs with lib.getFunc(name) / lib.registerFunction(name, fn) and
lib.getType / lib.registerType(name, type) if you want to inspect or extend
the registry.
Error handling
Many libc-style functions report failure by setting errno. Read it with
errno() and turn a code into a message
with strerror():
import { errno, strerror } from 'tjs:ffi';
if (symbols.some_call() < 0) {
console.log('failed:', strerror(errno()));
}
Closing libraries
Close a Lib (or the handle from dlopen) when you're finished. Lib
implements Symbol.dispose, so using closes it automatically at scope exit:
import { Lib } from 'tjs:ffi';
{
using lib = new Lib(Lib.LIBC_NAME);
// ... use lib ...
} // lib.close() runs here
After a library is closed, symbols obtained from it must not be used.