Native FFI

Outbound FFI lets statically compiled TypeScript call C ABI symbols directly. A strict JSON manifest connects a signature-only TypeScript declaration to a native symbol and supplies the archives or objects that resolve it at link time. There is no runtime symbol lookup and no JavaScript engine at the boundary.

Replacing a Node-API addon

A Node-API addon exposes JavaScript functions through callbacks that take napi_env and napi_callback_info. To use its native operation through FFI, extract that operation into a plain C ABI function. Build the function into an object file or static archive, and bind its C signature in the manifest. Keep the Node-API registration and JavaScript value conversion in a separate wrapper if you also publish a Node addon. Renaming a .node file or linking an archive that still calls napi_* does not remove its dependency on Node. See native addon limitations.

For example, an addon client might call a small numeric helper like this:

addon-client.ts
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const native = require("./native.node") as {
  scale(value: number): number;
};

console.log(native.scale(21));

Replace the addon import and method call with a signature-only TypeScript declaration and a direct call. The declaration gives the type checker its ordinary source-level signature; it emits no JavaScript body.

main.ts
declare function nativeScale(value: number): number;

console.log(nativeScale(21));

Implement a C ABI symbol with the matching native signature:

native.c
double native_scale(double value) {
  return value * 2.0;
}

Bind the two names in an FFI manifest. Library paths are resolved relative to this file.

ffi.json
{
  "ffi_format": 1,
  "functions": [
    {
      "name": "nativeScale",
      "symbol": "native_scale",
      "params": ["f64"],
      "returns": "f64"
    }
  ],
  "libraries": ["./libnative.a"],
  "system_libraries": []
}

Build the native archive, then pass the manifest to scriptc:

$ clang -c native.c -o native.o
$ ar rcs libnative.a native.o
$ scriptc build main.ts --ffi ffi.json -o app
$ ./app
42

The FFI binding applies only to a direct call of that exact declaration. A function with a body, an overload, a generic declaration, an alias such as const f = nativeScale, or a shadowing local does not silently become a native call.

ABI classes

The manifest is the native ABI authority. TypeScript has only number, so its declaration cannot distinguish a double from an integer-width parameter.

Manifest classTypeScript typeC ABI type and behaviorParameterReturn
f64numberdoubleyesyes
f32 (format 6)numberfloat; rounds to IEEE-754 single precision on the native boundary, widens back to a JavaScript number on returnyesyes
boolbooleanuint8_t; inputs are 0 or 1, any nonzero return becomes trueyesyes
u8numberuint8_t; inputs use JavaScript's modulo conversionyesyes
u32numberuint32_t; inputs use ToUint32yesyes
i8, u16, i16 (format 6)numberint8_t, uint16_t, int16_t; modulo conversion like the corresponding JavaScript typed arrayyesyes
i32numberint32_t; inputs use ToInt32yesyes
cstringstringconst char *; callback input only, copied through lossy UTF-8 decodingcallback only (formats 3–7)no
stringstringconst uint8_t *, size_t; UTF-8 bytes, length-delimitedyes; callback input in formats 3–7no
bytesUint8Array or Bufferconst uint8_t *, size_t; raw bytes, length-delimitedyes; callback input in formats 3–7no
voidvoidvoidnoyes
mutable-bytes (format 6)Uint8Array or Bufferuint8_t *, size_t; a writable borrow of the exact view, including its byte offset and lengthoutbound calls onlyno

For ordinary outbound parameters, string and byte pointers are borrowed only for the duration of the call. Native code must not mutate string or bytes spans. Format 6's mutable-bytes permits in-place writes within the supplied length; mutations are visible through the original array and its aliases. Native code must not free or retain any borrowed pointer. Strings may contain embedded NUL bytes, so always use the supplied length; an empty span may have a null pointer. String and byte returns remain unsupported because they need an explicit ownership and allocator contract. Format 7 can return an opaque pointer as a bigint; scriptc does not infer its lifetime, allocation size, or deallocator.

Set ffi_format to 6 to use the additional scalar widths or writable spans. The new scalar classes also work in call-scoped, retained, and foreign-thread callback parameters, and in same-thread callback returns. Foreign-thread delivery widens their values before queueing them without losing precision. Formats 1–5 retain their original class sets.

Set ffi_format to 7 for i64, u64, and pointer. These classes use TypeScript bigint and the C types int64_t, uint64_t, and void *. Integer inputs use the low 64 bits; pointer inputs use the low pointer-width bits. Results preserve every bit without conversion through a JavaScript number. They work in direct calls and same-thread callbacks, including retained callbacks. Foreign-thread callback parameters using these classes remain refused.

For C++, export the symbol with extern "C" so it keeps the manifest's unmangled C name.

For operating-system helpers, wrap the system call in a fixed-signature C function too. For example, Linux's variadic prctl needs a wrapper that supplies its operation and trailing arguments. A wrapper returning C int32_t uses the manifest's i32 return class and a TypeScript number; check its error result in TypeScript before using the value. Build the object or archive for the same target as the scriptc binary.

Static bindings for node:ffi

JavaScript packages that load node:ffi through createRequire can use a format 7 catalog. Add a library string to each function to expose it through ffi.dlopen. The string must match the library argument used by the package; the native implementation comes from the manifest's linked objects or archives. No JavaScript engine, runtime library loader, or executable-memory allocation is involved.

ffi.json
{
  "ffi_format": 7,
  "libraries": ["./native.o"],
  "functions": [
    {
      "name": "nativeU64",
      "symbol": "native_u64",
      "library": "native",
      "params": ["u64"],
      "returns": "u64"
    }
  ]
}
native.c
#include <stdint.h>
uint64_t native_u64(uint64_t value) { return value; }
app.mjs
import { createRequire } from "node:module";
const ffi = createRequire(import.meta.url)("node:ffi");
const { lib, functions } = ffi.dlopen("native", {
  nativeU64: { arguments: ["u64"], return: "u64" }
});
console.log(functions.nativeU64(9007199254740993n));
lib.close();

Catalog entries support scalar and pointer signatures. Each requested symbol must have the compiled argument and return types. Calls check arity and integer ranges, and 64-bit arguments must be bigints. In this Node API, bool is an alias for u8, so its arguments and results are numbers. The direct manifest API retains its boolean convention.

Pointer arguments accept a non-negative bigint address, an ArrayBuffer or view, a NUL-free UTF-8 string, or nullish values for a null pointer. Storage stays alive during the call. getRawPointer returns an ArrayBuffer or view's address, including a view's byte offset. toArrayBuffer(pointer, length) copies memory; passing false as its third argument creates a borrowed view. The caller must keep foreign memory valid for the entire lifetime of a borrowed view and must supply a readable range.

For packages that call lib.registerCallback, declare each callback signature in the root callbacks array:

"callbacks": [
  { "library": "native", "params": ["u64"], "returns": "u64", "capacity": 16 }
]

The compiler emits a fixed pool of native function pointers for each signature. Capacity defaults to 16 and may be 1–256. Registrations occupy independent slots; unregistering a callback or closing its library releases its slots. Exhausting the compiled capacity throws. These callbacks run on the registering script thread; calling one from a foreign thread is unsupported. Native code must stop using a callback pointer before unregistering it or closing its library. Closing a library also makes its saved function wrappers reject further calls.

Callback returns undergo the same scalar range checks; pointer returns must be nullish or a non-negative bigint. Callback exceptions follow scriptc's deferred exception contract: the native call receives a zero result, and the exception reaches the calling script frame when that native call returns.

On Darwin targets, a format 7 manifest may name system frameworks in frameworks, such as ["Foundation"]. Objects and archives still belong in libraries, and ordinary system libraries belong in system_libraries.

Callbacks and context pointers

Format 2 adds call-scoped C function-pointer parameters. It describes the function pointer and opaque context as independent ABI entries, in their actual positions—matching the model used by C, Rust's extern "C" fn plus *mut c_void, and Zig's *const fn (...) callconv(.c) plus *anyopaque.

For example, this C function takes a callback first, a value second, and its context last. The callback itself receives the context last:

native.c
typedef double (*map_callback)(double value, void *context);

double native_map(map_callback callback, double value, void *context) {
  return callback(value, context);
}

The TypeScript declaration contains only source values. Context entries are supplied by the compiler, so there is no context parameter in TypeScript:

main.ts
declare function nativeMap(
  callback: (value: number) => number,
  value: number,
): number;

const offset = 7;
console.log(nativeMap((value) => value + offset, 5));

The callback id connects the two independently positioned context entries. Both positions are explicit; no adjacency or conventional argument order is assumed.

ffi.json
{
  "ffi_format": 2,
  "functions": [
    {
      "name": "nativeMap",
      "symbol": "native_map",
      "params": [
        {
          "callback": {
            "id": "map",
            "params": ["f64", { "context": "map" }],
            "returns": "f64",
            "lifetime": "call"
          }
        },
        "f64",
        { "context": "map" }
      ],
      "returns": "f64"
    }
  ],
  "libraries": ["./libnative.a"]
}

A callback descriptor consumes one TypeScript function parameter and one native function-pointer slot. A context entry consumes no TypeScript parameter and one native void * slot. Formats 2 through 5 accept f64, bool, u8, u32, and i32 callback parameters plus at most one context entry. Formats 3 through 5 additionally accept cstring, string, and bytes; callback returns remain scalar or void.

Format 3 string-bearing callback parameters copy native data before the closure runs. cstring reads one non-null, NUL-terminated const char *. string and bytes each consume a const uint8_t *, size_t pair; a null pointer is valid only when its length is zero. Text is decoded as UTF-8 with malformed sequences replaced by U+FFFD, matching Buffer.toString("utf8"). The resulting string or Uint8Array is freshly owned scriptc storage, so the closure may retain it without depending on the native buffer's lifetime. An unexpected null cstring, or a null non-empty span, traps at the boundary instead of being treated as empty.

For a raw call-scoped C callback type with no userdata, omit the context entry from both parameter lists. scriptc installs that closure in a binding-specific thread-local slot around the native call, so captures and nested calls still work.

Format 4 adds lifetime: "retained" for same-thread native APIs that store a callback and invoke it from a later FFI call. Registration pins the closure and its captures. A paired release binding passes the same trampoline and closure context back to native code, then unpins one matching registration after the native call returns:

main.ts
declare function timerAdd(interval: number, tick: () => void): void;
declare function timerRemove(tick: () => void): void;

const tick = () => console.log("tick");
timerAdd(100, tick);
// A later native pump call may invoke tick here.
timerRemove(tick);
ffi.json
{
  "ffi_format": 4,
  "functions": [
    {
      "name": "timerAdd",
      "symbol": "timer_add",
      "params": [
        "u32",
        {
          "callback": {
            "id": "tick",
            "params": [{ "context": "tick" }],
            "returns": "void",
            "lifetime": "retained"
          }
        },
        { "context": "tick" }
      ],
      "returns": "void"
    },
    {
      "name": "timerRemove",
      "symbol": "timer_remove",
      "params": [
        { "callback": { "release": "timerAdd:tick" } },
        { "context": "timerAdd:tick" }
      ],
      "returns": "void"
    }
  ]
}

The release argument must be the same function value used for registration. Registrations to a context-bearing descriptor are counted: registering the same closure twice requires two releases. A raw descriptor's slot has replace semantics instead — every set call supersedes the previous registration, including one that passes the already-registered closure, so exactly one release is ever pending for that descriptor. Releasing an unregistered value traps because native code may still hold the original pointer — the trap fires before the native release call runs, so native code never observes the invalid release. The callback's function type must be exact at retained call sites; an implicit wrapper would create a different pointer and make release identity unsound. An inline function literal as a release argument is rejected for the same reason: it creates a fresh closure at every evaluation, a pointer no registration holds — pass the same named value used to register. Registering an inline literal remains legal; such a registration is simply permanent and is dropped by the exit teardown. A single binding cannot both register and release the same descriptor — the manifest loader rejects a release targeting a retained callback declared in the same function's parameter list, because the register-then-release ordering within one call would defeat the pre-call release validation.

Context-bearing descriptors support multiple concurrent closures. A raw retained descriptor has no context pointer, so it has one slot on the script thread with replace semantics: the previous registration stays live and dispatching until the replacing set call returns (a native setter that flushes the outgoing callback mid-replace still reaches the old closure), then it is released and the slot commits to the new closure. Script-thread retained registrations do not keep the event loop alive; format 5 foreign registrations do. At process exit, process 'exit' listeners run first — they may still release registrations or pump script-thread callbacks on every exit path. Foreign posting is disarmed when the loop stops, so a straggling native post is silently dropped. On exits that run atexit handlers, the runtime then drops the remaining registrations and disarms raw slots; process.exit() terminates immediately after its listeners and skips that sweep, leaving remaining registrations to the operating system. A raw-slot invocation after teardown traps instead of reaching a freed closure. A context-bearing registration has no slot to disarm — its trampoline and context pointer dangle once teardown frees the closure — so native code must not invoke one after exit; a library that can fire on its own exit path should have its registrations released from a process 'exit' listener.

Retained identity is scoped to the declaring binding. Every retained callback parameter is its own descriptor: the <binding>:<callback-id> pair names one registration ledger, one generated trampoline, and (for a raw descriptor) one slot, and a release binding validates and unpins only registrations made through the binding its release reference targets. Two bindings that store into the same native state — a plain setter and a flush-on-replace setter for one native slot, say — are therefore independent descriptors that pass native code two different function pointers. Registering the same function value through both and then releasing it through one is unsound: the release unpins in its own descriptor's ledger, but native code compares stored pointers against the other descriptor's trampoline, so the surviving registration stays armed and keeps dispatching — nothing traps, and the callback keeps firing after the program believes it released it. Keep a function value registered with one such native registration point through exactly one binding at a time, and release it through that binding's paired release.

Format 5 adds invoke: "foreign" to a retained, context-bearing callback descriptor for libraries that invoke the callback from their own threads:

ffi.json
{
  "ffi_format": 5,
  "functions": [
    {
      "name": "timerAdd",
      "symbol": "timer_add",
      "params": [
        "u32",
        {
          "callback": {
            "id": "tick",
            "params": ["cstring", { "context": "tick" }],
            "returns": "void",
            "lifetime": "retained",
            "invoke": "foreign"
          }
        },
        { "context": "tick" }
      ],
      "returns": "void"
    }
  ]
}

The native trampoline never runs script code. It copies scalar values and native string/byte memory into plain staging storage, posts to the process event loop, and returns immediately—even if native code happened to invoke it on the script thread. The loop delivers one invocation per turn, FIFO by enqueue order, with microtasks and timers interleaved. Live foreign registrations are ref'd: they keep the loop alive until their paired release binding runs. A callback may release itself; already-enqueued deliveries remain valid and drain before its closure pin is dropped. Throws follow ordinary timer-callback behavior and are uncaught unless the surrounding loop-dispatch semantics catch them.

Foreign delivery is deliberately fire-and-forget. It requires lifetime: "retained", returns: "void", and a context entry. Value-returning foreign callbacks would have to block the library thread on the script loop and are refused as deadlock-prone. Delivery takes at least one loop turn and is not suitable for real-time work such as audio DSP. Direct execution of script closures on native threads remains permanently unsupported because reference counting, exception cells, and fibers are thread-confined. Dereferenceable struct callback parameters are also unsupported; use an opaque native handle with accessor functions when the API permits it.

If a script-thread callback throws, the adapter returns zero (or void) to native code and suppresses further script callback execution while the exception is pending. When the outer native function returns, the original exception resumes through scriptc's ordinary catchable unwind path. Native work performed between the callback's return and the outer function's return is not rolled back. A foreign callback's native trampoline has already returned before its closure runs; a throw therefore follows event-loop callback semantics instead.

Manifest fields

ffi_format
Required. Format 1 supports value parameters; format 2 preserves them and adds callback/context entries; format 3 adds copy-in cstring, string-span, and byte-span callback parameters; format 4 adds retained registrations and release references; format 5 adds retained foreign-thread callbacks marshalled to the event loop; format 6 adds f32, i8, u16, i16, and outbound mutable-bytes spans.
functions
Required array. Every entry has exactly name, symbol, params, and returns. Binding names and symbols must be unique. Callback ids must be unique within a function and every context must match exactly one callback or release. A release references a retained <binding>:<callback-id> in the same manifest and inherits its callback ABI.
libraries
Optional array of archive or object paths. Relative paths are resolved from the manifest directory and appended after the generated program at link time.
system_libraries
Optional array of linker-neutral library names. For example, ["m"] is emitted as -lm.

Unknown fields, invalid ABI classes, duplicate names, and signature mismatches fail the build with an SC5xxx diagnostic. The same manifest can be passed to scriptc coverage so native call sites count as statically compiled.

Boundary rules and current limits

  • Native calls are synchronous and must return normally. Do not unwind C++ exceptions or longjmp across the boundary.
  • Native code is outside scriptc's exception, reference-counting, and sanitizer contracts. A bad pointer or mismatched C signature can still corrupt the process.
  • invoke defaults to "script-thread". Format 5 foreign callbacks are asynchronous, void-returning, context-bearing, explicitly released, and not real-time capable; direct foreign-thread script execution is unsupported.
  • There are no variadic calls, struct-by-value arguments, or owned pointer returns. The node:ffi catalog resolves only bindings compiled into the binary; runtime dlopen/dlsym loading remains unsupported.
  • The archive or object must match the build target. Cross-compilation does not translate native inputs.
  • Outbound FFI is currently available for executable builds, not scriptc build --lib.