DEV Community

Cover image for Relaxnative: Import C, C++, Rust, Zig and Go Straight into Node.js
Ravi Kishan
Ravi Kishan

Posted on Originally published at ravikishan.me

Relaxnative: Import C, C++, Rust, Zig and Go Straight into Node.js

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. No node-gyp, no binding.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

πŸ”— GitHub Β· πŸ“¦ npm Β· πŸ› Issues


Table of Contents

  1. Why I built this
  2. Hello, native
  3. Five languages, one call
  4. What loadNative() actually does
  5. Reading signatures: tree-sitter and friends
  6. The types contract
  7. A cache keyed by everything
  8. Ahead-of-time builds: no compiler in production
  9. Three isolation modes
  10. Annotations: @sync, @async, @cost
  11. Benchmarks: where native wins (and where it loses)
  12. RelaxRegistry and supply-chain trust
  13. Developer experience
  14. What I learned
  15. 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 u64 work means BigInt, and BigInt is 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;
}
Enter fullscreen mode Exit fullscreen mode

Call it from JavaScript:

import { loadNative } from 'relaxnative';

const mod = await loadNative('native/add.c', { isolation: 'worker' });
console.log(mod.add(1, 2)); // 3
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
βœ“ 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)
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode
// add.zig
export fn add(a: i32, b: i32) i32 { return a + b; }
Enter fullscreen mode Exit fullscreen mode
// add.go
package main
import "C"
//export add
func add(a, b C.int) C.int { return a + b }
func main() {}
Enter fullscreen mode Exit fullscreen mode
const mod = await loadNative('native/add.zig', { isolation: 'in-process' });
console.log(mod.add(20, 22)); // 42
Enter fullscreen mode Exit fullscreen mode

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'],
  },
});
Enter fullscreen mode Exit fullscreen mode

What loadNative() actually does

The whole library is built around one pipeline in src/loader.ts:

  1. Prebuilt? If .relaxnative/prebuilt/ has an artifact for this platform and this exact source content, use it and skip compiling entirely.
  2. Detect the toolchain for the file's language (clang, gcc, cl, zig, rustc, go).
  3. Hash the compile inputs. On a cache hit, reuse the library.
  4. Compile to a shared library on a miss. C/C++ get -O2 by default, because building at -O0 makes native code lose badly to V8's JIT and that's an embarrassing benchmark.
  5. Parse the signatures from your source to learn names, parameter types and return types.
  6. Bind them with koffi, a fast FFI library for Node.
  7. 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 fn and //export and map types like i32, [*]u8 or C.int to 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 lone void is 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. Otherwise unsigned long long x becomes unsigned 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 as bigint and accept either.
  • Pointers accept TypedArrays: pass a Uint8Array for uint8_t* or a Uint32Array for uint32_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 a NativeBuffer with 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]]++;
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ProcessIsolationError with code ISOLATED_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, and child_process are denied unless granted. worker_threads, vm, v8 and inspector are always blocked because each one is an isolation escape hatch. Blocking is by module family, so node:fs/promises can't slip past a check for fs.

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;
}
Enter fullscreen mode Exit fullscreen mode
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 raw u64. 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 number math, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 to process.
  • verified: elevated trust, but only if a real cryptographic signature checks out. Otherwise the package is demoted to community.

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>"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Integrity: the digest covers 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 via RELAXNATIVE_TRUSTED_KEYS, and signPackage() 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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
npx relaxnative test native/examples --isolation process
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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Γ— add benchmark taught me more than the 53Γ— one.
  • V8 is a formidable opponent. For plain float loops the JIT is within a whisker of -O2 C. 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 cc or cl work 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
Enter fullscreen mode Exit fullscreen mode

⭐ 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)