Two things in this runtime call into machine code, and they sit on opposite sides of a line that matters for where ArcSX is going.
Sxn.ffi |
.node addons |
|
|---|---|---|
| Lives in | src/ffi.c |
src/napi.c |
| Installed by | the runtime's own Sxn surface (src/network.c) |
the node: layer (src/node.c) |
| Direction | JavaScript calls out to a C function | a C library calls back into the host |
| Backed by | libffi + dlopen
|
Node-API implemented on QuickJS |
| Goes to Rayact | yes | no |
The question that decided this is what happens when ArcSX is folded into Rayact. Rayact embeds quickjs-ng 0.15.0 — the same base this fork started from, with about 350 lines of its own on top — so the swap is a small delta rather than a re-port, and whatever these two features are attached to comes along with it.
Rayact already loads native code in its engine core. native/core/ rayact_module_abi.h (ABI version 8) defines a plugin as a shared library
that the loader dlopens to call rayact_module_register, and
native/desktop/plugin_loader.cpp is compiled verbatim into the Android
.so as well as the desktop binary. native/desktop/ is a misnomer: it is
the shared core, and mobile is a first-class consumer of it. There is no
"desktop-only capability" tier anywhere in that tree. The gating that exists
is per-platform implementation — and where a platform cannot dlopen at
all, which is iOS, the answer is not "this capability is unavailable" but
linkage: static with weak-symbol registration
(native/ios/ios_plugins_register.cpp) behind the same entry point.
So calling native code is not, in this family of projects, a desktop luxury.
It is core, and it is gated by how a library is linked rather than by
whether the capability exists. Sxn.ffi belongs there.
Rayact has no Node layer to put anything in. Its JavaScript environment
is browser-like: window, self, a stub navigator, and no process, no
Buffer, no require. Its bundler actively steers away from Node builds
(packages/rayact-dev-server/src/bundler.ts: "resolution picks the Node
build of dual-build packages … which crash in QuickJS"). Putting FFI behind
a Node-compatibility shell would mean building that shell in Rayact for no
other reason, and Node-API in particular would be dead weight next to a
module ABI Rayact already has, with autolinking, manifest platform gating and
SHA-256 artifact verification.
Hence the split. Sxn.ffi is an engine capability that travels; the .node
loader is Node emulation that does not. A build that wants the runtime
without the Node surface drops src/napi.c and the vendored headers and
loses nothing else.
Worth saying plainly, because it is the reason the mobile runtime is treated
as QuickJS rather than as Node: on iOS you cannot dlopen code that arrived
after the app was signed. An npm-installed addon is out there no matter how
complete Node-API becomes. A library bundled into the app is not.
Returns a callable. The libffi call interface is prepared once, here, so the returned function is the only thing on the hot path.
const pow = Sxn.ffi("libSystem.B.dylib", "pow", ["f64", "f64"], "f64");
pow(2, 10); // 1024An empty library name means this executable, which is how a program reaches libc and its own symbols without naming a platform-specific file. Handles are opened once and never closed: a wrapper that outlived its library would call into unmapped memory, and nothing tracks that lifetime yet.
Types: void bool i8 u8 i16 u16 i32 u32 i64 u64 f32 f64
pointer cstring. The C spellings int, unsigned, long, float,
double, char, size_t, ptr and string are accepted too, so a
declaration can be copied out of a header. void alone as the argument list
means the function takes nothing.
- 64-bit integers cross as BigInt in both directions. A double cannot carry one exactly, and silently losing the low bits of a handle or a size is worse than making the caller be explicit.
-
pointeraccepts a typed array or an ArrayBuffer and passes the address of its bytes — a view passes its own offset — so out-parameters work.nullandundefinedpass NULL. A BigInt passes as a raw address. A returned pointer comes back as a BigInt, ornull. -
cstringconverts a JS string to a temporary UTF-8 buffer that lives exactly as long as the call. A returnedchar*is copied into a JS string and not freed — a function that returns owned memory needs its own free call, declared separately.
Not supported, and rejected rather than half-done: structs by value,
callbacks into JS, and variadics. Each needs ownership rules this runtime has
not written down (spec/ABI.md).
The unsafe extern declaration in spec/LANGUAGE.md lowers to exactly this
call:
unsafe extern pow(f64, f64): f64 from "libSystem.B.dylib";
// const pow = Sxn.ffi("libSystem.B.dylib", "pow", "f64, f64", "f64");require("./thing.node") works, and so does process.dlopen(module, path),
which is what require calls.
The shape of this problem is the reverse of FFI, and it is worth stating
because "we have FFI, so we can load addons" does not follow. An addon
exports one symbol, napi_register_module_v1, and imports around seventy
napi_* functions that the host must provide — next-swc imports 67, sharp
53. Loading one is not a matter of calling into a library; it is a matter of
being the library it calls into. So src/napi.c is ordinary exported C, and
the executable is linked with ENABLE_EXPORTS so dlopen can resolve those
imports back to it.
third_party/node-api/ holds Node's own four headers, copied verbatim, so an
addon sees exactly the declarations it was compiled against.
A napi_value is a JSValue owned by the innermost handle scope. QuickJS is
refcounted rather than tracing, so a scope only has to release its values on
close — simpler than the same thing on V8, and it means an addon that leaks
handles leaks memory rather than corrupting anything.
Two details are worth writing down, because both looked like addon bugs.
A napi_value is a pointer to the slot holding its JSValue, so slots must
never move: a growable array would relocate every handle the addon still held
the moment it needed one more. Scope storage is therefore fixed blocks,
allocated and never resized. Small addons never notice; a large one fails
immediately and confusingly.
A handle used after its scope closes is the other half of the same hazard,
and it is the addon that is wrong rather than the runtime. Release cannot
afford to check every read. The assertions build can, so there a closed scope
keeps its blocks and stamps every slot, and the next read of one aborts with
a native addon used a napi_value after its handle scope closed instead of
returning whatever now lives at that address. That is what shipping two
builds is for: the checked one finds it, the fast one costs nothing.
A class constructor cannot go through the same shape as a plain function.
QuickJS's data-carrying C functions are never told they were called with
new, so napi_get_new_target always answered "no" and every addon that
guards its constructor threw on new Foo() — which is every class written
with node-addon-api. Constructors use the shape that is told, and because
that shape carries only an integer, the callback is looked up by index in a
table built once at module init.
Thread-safe functions are implemented on libuv. Each one owns a queue and a
uv_async_t; a worker thread appends under a mutex and calls uv_async_send,
which is the one libuv call that is safe from another thread, and the loop
thread -- the only one allowed to touch the context -- drains it. A tsfn is
unreffed at creation so it does not hold the process open on its own.
Implemented: values and coercions, properties and elements, functions,
callbacks with their own scope, classes, constructors, errors and the
pending-exception protocol, handle and escapable scopes, references,
napi_wrap/unwrap, externals, external buffers, ArrayBuffers, typed
arrays, Buffers, promises, async work on libuv's thread pool, and thread-safe
functions. 120 entry points, which covers every symbol next-swc and sharp
import.
That is enough for next-swc -- the 130 MB Rust binary Next.js compiles with
-- to load and compile JSX:
$ sxn -e 'require("./next-swc.darwin-arm64.node").transformSync(...)'
export default function A() {
return /*#__PURE__*/ React.createElement("b", null, "hi");
}
Not implemented:
-
Weak references.
napi_create_referencewith a count of zero is kept strong, because QuickJS has no weak handle that can be resurrected. That leaks rather than dangles. -
The old V8
NODE_MODULEinterface. An addon built against it is refused by name; there is no path to it that does not embed V8.
This page is generated from
spec/NATIVE.md.
Machine-readable copies of the whole set:
llms.txt,
llms-full.txt.