TL;DR: Relaxnative is a Node.js library that lets you write a function in C, C++, Rust, Zig or Go, point
loadNative()at the file, and call it from JavaScript like any other function. Nonode-gyp, nobinding.gyp, no hand-written N-API glue. It compiles on first import, caches by content hash, can ship prebuilt so production needs no compiler, and lets you choose how much a native crash is allowed to hurt.npm i relaxnative
Table of Contents
- Why I built this
- Hello, native
- Five languages, one call
- What
loadNative()actually does - Reading signatures: tree-sitter and friends
- The types contract
- A cache keyed by everything
- Ahead-of-time builds: no compiler in production
- Three isolation modes
- Annotations:
@sync,@async,@cost - Benchmarks: where native wins (and where it loses)
- RelaxRegistry and supply-chain trust
- Developer experience
- What I learned
- What's next
Why I built this
JavaScript is fast, often surprisingly fast. But every Node developer eventually hits a wall where it isn't fast enough:
-
64-bit integer math. JS numbers are doubles, so real
u64work meansBigInt, andBigIntis slow. - Raw byte crunching, like image pixels, checksums and histograms, where you want tight loops over memory with no bounds checks.
- Code that already exists in C or Rust that you'd rather reuse than rewrite.
The traditional answer is a native addon: node-gyp, a binding.gyp, N-API boilerplate, a C++ wrapper per function, and a build that breaks on every other Windows machine. It works, but it's a lot of ceremony for "I want to call add(a, b) written in C".
I wanted something closer to Python's ctypes or Bun's bun:ffi, but for plain Node, for several languages, and with real answers for safety. What happens when native code segfaults? What happens when you install someone else's native code? That's Relaxnative.
Hello, native
Create native/add.c:
// @sync
int add(int a, int b) {
return a + b;
}
Call it from JavaScript:
import { loadNative } from 'relaxnative';
const mod = await loadNative('native/add.c', { isolation: 'worker' });
console.log(mod.add(1, 2)); // 3
That's the whole integration. The first loadNative() finds a compiler, builds a shared library, reads the function signatures out of your source and binds them. Every later run hits the cache.
Not sure which toolchains you have? Ask:
npx relaxnative doctor
β C compiler detected (clang 22.1.8)
β C++ compiler detected (clang 22.1.8)
β Rust compiler detected (rustc 1.97.1)
β Zig compiler missing (install zig)
β Go toolchain detected (go-wsl: go1.22.2 linux/amd64)
β Worker threads supported
β Cache directory OK
β ESM loader supported (Node >= 18)
Five languages, one call
Each language compiles to a C-ABI shared library (.dll / .so / .dylib), so from JavaScript they all look the same:
| Language | Extension | How you export | Compiler |
|---|---|---|---|
| C | .c |
plain top-level functions | clang / gcc / MSVC cl / zig cc
|
| C++ |
.cpp .cc .cxx
|
extern "C" functions |
clang++ / g++ / cl / zig c++
|
| Rust | .rs |
#[no_mangle] pub extern "C" fn |
rustc --crate-type cdylib |
| Zig | .zig |
export fn |
zig build-lib -dynamic |
| Go | .go |
//export Name + import "C"
|
go build -buildmode=c-shared |
// add.rs
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }
// add.zig
export fn add(a: i32, b: i32) i32 { return a + b; }
// add.go
package main
import "C"
//export add
func add(a, b C.int) C.int { return a + b }
func main() {}
const mod = await loadNative('native/add.zig', { isolation: 'in-process' });
console.log(mod.add(20, 22)); // 42
One Zig install is a cheat code here: zig cc and zig c++ are clang-based, so a single download covers C, C++ and Zig.
Go on Windows was the fun one. To build a real Windows DLL, Relaxnative cross-compiles inside WSL with GOOS=windows CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -buildmode=c-shared, so you only need go and mingw-w64 inside your distro.
Multi-file builds work too. Extra C/C++ sources get linked into the same library, Rust files are mod-declared, Zig files are @imported, and Go files form one main package:
const mod = await loadNative('native/main.c', {
isolation: 'in-process',
build: {
sources: ['native/helper.c'],
includePaths: ['native/include'],
libraries: ['m'],
flags: ['-O3'],
},
});
What loadNative() actually does
The whole library is built around one pipeline in src/loader.ts:
-
Prebuilt? If
.relaxnative/prebuilt/has an artifact for this platform and this exact source content, use it and skip compiling entirely. -
Detect the toolchain for the file's language (clang, gcc,
cl, zig, rustc, go). - Hash the compile inputs. On a cache hit, reuse the library.
-
Compile to a shared library on a miss. C/C++ get
-O2by default, because building at-O0makes native code lose badly to V8's JIT and that's an embarrassing benchmark. - Parse the signatures from your source to learn names, parameter types and return types.
- Bind them with koffi, a fast FFI library for Node.
- Wrap each function according to the isolation mode you picked.
The result is a plain object of JavaScript functions.
Reading signatures: tree-sitter and friends
To bind a function, Relaxnative has to know its signature without you writing it twice. So it reads your source:
- C, C++ and Rust are parsed with tree-sitter grammars, giving a real syntax tree instead of regex guesses.
-
Zig and Go use small lexical scanners that look for
export fnand//exportand map types likei32,[*]u8orC.intto the shared type model.
Real C is messier than it looks, and the parser has the scars to prove it. My favourite: for a pointer-returning function like char *greet(int), tree-sitter nests the function declarator inside a pointer_declarator and hangs the * there, not on the return type. The first version read the name as greet(int), found no parameters, decided the return type was unknown, and silently dropped the function. The fix unwraps every pointer_declarator layer and folds the stars back into the return type.
Other details it handles:
-
foo(void)means "no parameters", so the lonevoidis dropped. -
Variadic functions (
printf-style) are detected and rejected instead of being called with a garbage stack. - Pointer stars can live in the declarator (
uint8_t *buf), so type and declarator text are combined with a space. Otherwiseunsigned long long xbecomesunsigned long longx.
And there's a rule: if a type isn't recognized, fail fast. A wrong FFI signature doesn't throw a nice error. It corrupts memory.
The types contract
| Concept | C / C++ | Rust | Zig | Go (cgo) |
|---|---|---|---|---|
| 32-bit int |
int / int32_t
|
i32 |
i32 |
C.int |
| 64-bit int | int64_t |
i64 |
i64 |
int64 |
| float / double |
float / double
|
f32 / f64
|
f32 / f64
|
float32 / float64
|
| bool | bool |
bool |
bool |
bool |
| byte buffer | uint8_t* |
*mut u8 |
[*]u8 |
*byte |
| C string | const char* |
*const i8 |
[*c]const u8 |
*C.char |
On the JavaScript side:
-
Scalars become
number. 64-bit integers can come back asbigintand accept either. -
Pointers accept TypedArrays: pass a
Uint8Arrayforuint8_t*or aUint32Arrayforuint32_t*. Native code writes straight into your array. -
const char*parameters and returns are plain JS strings. - For manual memory there's
native.alloc(size), which returns aNativeBufferwith an.address.
// @async
void histogram_u8(const uint8_t* data, int n, uint32_t* out256) {
for (int i = 0; i < 256; i++) out256[i] = 0;
for (int i = 0; i < n; i++) out256[data[i]]++;
}
const { histogram_u8 } = await loadNative('native/histogram.c', { isolation: 'worker' });
const data = new Uint8Array(1024 * 1024);
const out = new Uint32Array(256);
await histogram_u8(data, data.length, out); // out is filled in place
Tip: prefer fixed-width types (uint32_t* over unsigned*). They make the parser's job unambiguous, and they're better C anyway.
A cache keyed by everything
Compiling on every import would be unbearable, so builds are cached under ~/.relaxnative/cache. The key is a SHA-256 over everything that changes the output binary:
// src/cache/hash.ts (simplified)
for (const p of [sourcePath, ...sources]) hash.update(readFileSync(p)); // CONTENTS, not paths
hash.update(compiler.path + compiler.version);
hash.update(flags.join(' '));
hash.update(includePaths.join(' '));
hash.update(libraryPaths.join(' '));
hash.update(libraries.join(' '));
hash.update(platform); // e.g. win32-x64
Two bugs taught me this list. Originally only the entry file was hashed, so editing a helper file in a multi-file build gave a false cache hit: you changed code and nothing happened. Now every source's contents are hashed. And a failed compile used to leave a half-populated cache directory behind. Now a cache entry only counts once its meta.json exists, and failed builds clean up after themselves.
npx relaxnative cache status
npx relaxnative cache clean
Ahead-of-time builds: no compiler in production
Compile-on-import is lovely in development and a liability in a slim Docker image. So there's an AOT mode:
npx relaxnative build native/add.c native/kernel.zig native/svc.go
Or declare targets once in relaxnative.build.json:
{
"targets": [
{ "source": "native/add.c" },
{
"source": "native/mathkernel/main.c",
"sources": ["native/mathkernel/helper.c"],
"libraries": ["m"],
"flags": ["-O3"]
}
]
}
This writes the compiled libraries plus the parsed bindings to .relaxnative/prebuilt/. Ship that folder, and at runtime loadNative() uses a prebuilt artifact when it matches the current platform and the source's current content hash. No compiler, no parser work.
If you edit the source after building, the hash no longer matches and it quietly falls back to compiling, which is exactly what you want in development. RELAXNATIVE_NO_PREBUILT=1 turns the fast path off.
Three isolation modes
Here's the uncomfortable truth about native code: a segfault doesn't throw, it kills the process. No try/catch saves you. So Relaxnative makes the blast radius a per-call choice:
await loadNative('native/kernel.c', { isolation: 'in-process' }); // fastest
await loadNative('native/kernel.c', { isolation: 'worker' }); // default
await loadNative('native/kernel.c', { isolation: 'process' }); // safest
in-process calls the function directly on the JS thread. It has the lowest overhead and no safety net. Use it for trusted, hot code.
worker (the default) sends async work to a worker-thread pool so heavy calls don't block the event loop. To keep tiny calls cheap, sync functions still run in-process. That means worker mode is not crash-safe for sync functions, and the code comments say so plainly.
process forks a helper process and talks to it over IPC. It's the heavyweight option, and it buys you a lot:
-
Crash containment. If the native code segfaults, the helper dies, your app gets a
ProcessIsolationErrorwith codeISOLATED_PROCESS_CRASH(and the JS callsite that made the call), and the helper restarts. - Per-call timeouts. An infinite loop gets the helper killed instead of hanging forever.
- A best-effort memory cap. The helper watches its own RSS and exits if it goes over.
-
Permission guards. Inside the helper, imports of
fs,net/http/dns/tls, andchild_processare denied unless granted.worker_threads,vm,v8andinspectorare always blocked because each one is an isolation escape hatch. Blocking is by module family, sonode:fs/promisescan't slip past a check forfs.
All calls in process mode are async, since there's an IPC boundary in the way.
I'm careful about wording here, and so is the threat model: this is defense in depth, not a sandbox. Native code can make raw syscalls that no Node-level guard can see. Kernel-level sandboxing (seccomp, AppContainer, sandbox-exec) is explicitly a non-goal for now.
Annotations: @sync, @async, @cost
How does the wrapper know whether a function should return a value or a Promise? You tell it, in a comment up to 3 lines above the function:
// @async
// @cost high
int heavy(int n) {
long x = 0;
for (int i = 0; i < n * 10000000; i++) x += i;
return (int)x;
}
| Annotation | Effect |
|---|---|
@sync |
Returns a plain value. In worker mode it may run on the main thread for speed. |
@async |
Returns a Promise and always goes through the worker in worker mode. |
@cost high |
Treated like @async, so heavy work stays off the event loop. |
You can also override per call: loadNative(path, { config: { functionMode: { add: 'sync' }, defaultMode: 'async' } }).
Benchmarks: where native wins (and where it loses)
I didn't want to write a "native is 1000Γ faster!!" post, because it isn't, at least not always. Every FFI call has a fixed cost of a few microseconds. Native only wins when each call does enough work to pay that back.
The numbers above come from the in-repo suites (benchmark.report.test.ts and benchmark.realworld.test.ts), using in-process isolation. They tell a clear story:
-
64-bit integers: 53Γ. JS has to use
BigInt, while native uses rawu64. I re-ran a xorshift kernel in Rust on a different Windows machine while writing this post and got ~74Γ (2000 Γ 4000 iterations: 6.7 s in JS, 91 ms native), with results matching JS bit for bit. - Raw buffers: 14Γ for summing 1 MiB, and 6.7Γ for RGBAβgrayscale on a 2.1 MP image.
-
Float-heavy loops: about 1.1β2Γ. V8's JIT is genuinely excellent at
numbermath, so Mandelbrot and dot products barely move. -
A trivial
a + b: 0.01Γ, roughly 80Γ slower. The FFI crossing costs far more than the addition, which V8 would simply inline.
The same u64 kernel across languages is a nice sanity check that it's the workload, not the language, that matters:
| Language | Calls/s | vs JS |
|---|---|---|
| JS (V8, BigInt) | 1,945 | 1.0Γ |
| Rust | 28,503 | 14.7Γ |
| Go | 40,560 | 20.9Γ |
C (zig cc) |
42,242 | 21.7Γ |
| Zig | 45,304 | 23.3Γ |
The rule of thumb: cross the boundary rarely, and do a lot of work each time. Batch your data into TypedArrays and make one big call, not a million small ones.
You can benchmark your own kernels against a plain-JS baseline:
npx relaxnative bench examples/matmul.c matmul_f32 --traditional --iterations 5 --warmup 1
RelaxRegistry and supply-chain trust
Once loading native code is this easy, the next question is scary: what about native code from someone else? An npm package can at least be sandboxed by your judgement. A native package can do anything your user account can.
So Relaxnative ships a small package system, RelaxRegistry, built with trust in mind:
npx relaxnative add file:examples/registry/fast-matrix
npx relaxnative list
npx relaxnative remove fast-matrix
Each package has a relax.json declaring its exports, the permissions it wants, and a trust level:
-
local: your own code. No prompts. -
community: third-party code. You get a warning and must consent. Elevated permissions (fs, network, spawn) are refused, and isolation is forced toprocess. -
verified: elevated trust, but only if a real cryptographic signature checks out. Otherwise the package is demoted tocommunity.
Before any of that, a static scan flags risky APIs such as system, popen, exec*, fork, CreateProcessW and raw sockets. It strips comments, splices backslash line-continuations and blanks out string literals first, so sys\<newline>tem or "sy" "stem" can't dodge it. It's still only a heuristic, and the code says so.
Signing, done properly
My first design had a digest field and treated a matching digest as "verified". That's wrong, because anyone can recompute a hash. Integrity isn't authenticity. The final design has two separate layers:
{
"trust": "verified",
"registrySignature": {
"alg": "sha256",
"digest": "<sha256 over canonical manifest + per-source hashes>",
"sources": { "add.c": "<sha256>" },
"keyId": "relaxnative-registry-2026",
"signature": "<base64 Ed25519 over the digest>"
}
}
-
Integrity: the
digestcovers the manifest and every source file's content. It's computed over canonical JSON (keys sorted recursively, no whitespace), so reformatting the file can't change it. Any tampering fails the install. -
Authenticity: an Ed25519 signature over that digest, verified against a pinned public key. Only this grants
verified. Keys are bundled or provided viaRELAXNATIVE_TRUSTED_KEYS, andsignPackage()produces signatures.
Consent is content-pinned too. Your "yes" is stored with the package's content digest in native/registry/.trust.json, so if someone republishes the same name@version with different bytes, you're asked again. And the low-level raw installer is deliberately not exported. Every install goes through the trust-enforcing path, so there's no shortcut that skips the gate.
Developer experience
A few things that make day-to-day use pleasant:
Tracing. When a crash happens behind a worker or process boundary, the real error is easy to lose. RELAXNATIVE_TRACE=1 prints lifecycle events (loadNative.begin, build.done, dispatch, isolation.process.callβ¦), and RELAXNATIVE_TRACE_LEVEL=debug also prints every parsed signature.
Hot reload. With RELAXNATIVE_DEV=1, loadNative() returns a stable proxy that watches the source file, rebuilds on save and swaps the implementation in place. It fingerprints the ABI (names, argument types, return types), so it knows when a change is more than a body edit.
An ESM loader. Run Node with --loader relaxnative/loader and you can import native files directly:
// node --loader relaxnative/loader app.js
import math from './native/add.c';
It currently handles C, C++ and Rust sources, plus relaxnative/<package> registry specifiers, with package names validated so relaxnative/../../etc/passwd goes nowhere.
A native test harness. Write tests in C:
int test_add() { return add(2, 2) == 4 ? 0 : 1; } // 0 = pass
const char* test_msg() { return NULL; } // NULL/"" = pass
npx relaxnative test native/examples --isolation process
Running tests in process isolation means a segfaulting test fails that test instead of taking down the runner.
Express in five lines:
import express from 'express';
import { loadNative } from 'relaxnative';
const app = express();
const native = await loadNative('native/loop.c', { isolation: 'worker' });
app.get('/sum', (req, res) => res.json({ v: native.loop_sum(Number(req.query.n ?? 1e6)) }));
app.listen(3000);
What I learned
-
FFI cost is the whole game. Native isn't faster per se. It's faster per unit of work, and only once that work dwarfs the few-microsecond crossing. The 0.01Γ
addbenchmark taught me more than the 53Γ one. -
V8 is a formidable opponent. For plain float loops the JIT is within a whisker of
-O2C. Real wins come where JS is structurally weak: 64-bit ints and raw memory. -
Parsing C is humbling. Pointer declarators,
(void), variadics and multi-word types: every edge case is a silent-corruption bug waiting to happen. "Fail fast on unknown types" became a core principle. - Hashes aren't signatures. Separating integrity (the digest) from authenticity (Ed25519 with a pinned key) was the most important security fix in the project.
- Be honest about safety. It's tempting to call process isolation a "sandbox". It isn't, and the docs and threat model say exactly what is and isn't protected.
What's next
- Fix clang on Windows MSVC targets. It currently rejects
-fPIC, which is why my demo above used Rust.zig ccorclwork today. - Cache toolchain detection so that warm loads skip probing compilers (and WSL) on every call.
- Bring Zig and Go to the ESM loader.
- Richer types: structs and callbacks.
- An optional hardened runtime: seccomp-bpf on Linux, AppContainer on Windows, sandbox-exec on macOS.
Try it
mkdir my-native-app && cd my-native-app
npm init -y && npm pkg set type=module
npm i relaxnative
npx relaxnative doctor
β Star it on GitHub, install it from npm, and if you find a signature it can't parse or a kernel where native loses, open an issue. Those are my favourite bug reports.
Happy hacking! β‘
Top comments (0)