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.

A complete example

Declare the native function in TypeScript. 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
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
i32numberint32_t; inputs use ToInt32yesyes
cstringstringconst char *; callback input only, copied through lossy UTF-8 decodingcallback only (formats 3–4)no
stringstringconst uint8_t *, size_t; UTF-8 bytes, length-delimitedyes; callback input in formats 3–4no
bytesUint8Array or Bufferconst uint8_t *, size_t; raw bytes, length-delimitedyes; callback input in formats 3–4no
voidvoidvoidnoyes

For ordinary outbound parameters, string and byte pointers are borrowed only for the duration of the call. Native code must not mutate, free, or retain them. Strings may contain embedded NUL bytes, so always use the supplied length; an empty span may have a null pointer. The current formats deliberately have no pointer, string, or byte return because those need an explicit ownership and allocator contract.

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

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 process-global slot 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.
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, owned pointer returns, or runtime dlopen/dlsym handles yet.
  • 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.