probe

JavaScript to native. Write JS, ship a binary.

What it does

probe compiles JavaScript to statically linked native binaries. You give it a .js file. It gives you an executable. No V8, no JIT, no garbage collector at runtime. The binary starts in under 5ms and runs at the speed of compiled C.

It supports the JavaScript you actually write — closures, classes, async/await, destructuring, iterators, template literals. It does not support eval, with, or runtime code generation. If your code does not need to generate code at runtime, it can probably compile.

Install

$ npm install -g axial-probe

Requires Node 18+ on the host machine for compilation. The output binary has no dependency on Node or any runtime.

Quick start

Compile and run

// hello.js
const name = process.argv[2] || 'world';
console.log(`hello, ${name}`);
$ probe build hello.js
  compile  hello.js → hello.ir        12ms
  link     hello.ir → hello            38ms
  output   hello  (41kb, linux-x64)

$ ./hello probe
hello, probe

$ time ./hello
hello, world
real    0m0.003s

41 kilobytes. 3 milliseconds. No runtime.

Using Node APIs

probe implements a substantial subset of the Node.js standard library. Import them the way you normally would:

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';

const file = join(process.cwd(), 'data.bin');
const buf = readFileSync(file);
const hash = createHash('sha256').update(buf).digest('hex');

console.log(hash);
$ probe build hash.js -o hash
$ ./hash
a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a

How it works

probe does not interpret JavaScript. It performs whole-program type inference on your source, lowers it to a typed intermediate representation, and compiles that IR to machine code via LLVM. The process:

  1. Parse the source into an AST
  2. Run global type inference — every variable, every property, every return value gets a concrete type or a tagged union
  3. Lower the typed AST to probe IR, a simple SSA form
  4. Emit LLVM IR from probe IR
  5. Link against a minimal libc and the probe runtime stubs (syscalls, allocator, I/O)

Memory is managed by region-based allocation. Each function call gets a region. When the call returns, the region is freed in one operation. No tracing collector, no reference counting. This works because probe statically proves that most allocations do not escape their call frame. For those that do, it falls back to reference counting with cycle detection at compile time.

probe does not compile arbitrary dynamic JavaScript. It compiles the static subset — which is most of the JavaScript people actually write. The trade-off is real: you cannot use eval, Proxy, or Reflect. What you get in return is a 40kb binary that starts before your terminal prompt finishes drawing.

Node.js compatibility

The following Node built-in modules are fully supported:

  • node:fs — sync and async file operations, including watch v0.6
  • node:path — all methods
  • node:crypto — hashing, HMAC, randomBytes (backed by libsodium)
  • node:child_processexecSync, spawn
  • node:os — platform, arch, cpus, homedir, tmpdir
  • node:events — EventEmitter
  • node:stream — Readable, Writable, Transform, pipeline
  • node:buffer — Buffer (all encodings)
  • node:net — TCP client and server
  • node:http — HTTP/1.1 client and server

Partially supported: node:url, node:querystring, node:util. Not supported: node:vm, node:worker_threads, node:cluster.

Async support

async/await compiles to a state machine, not to coroutines or fibers. Each await point becomes a state transition. The event loop is a simple epoll/kqueue loop compiled into the binary.

import { readFile } from 'node:fs/promises';

async function main() {
  const files = process.argv.slice(2);
  const results = await Promise.all(
    files.map(async (f) => {
      const data = await readFile(f, 'utf-8');
      return { file: f, lines: data.split('\n').length };
    })
  );
  console.table(results);
}

main();
$ probe build count-lines.js
$ ./count-lines src/*.js
┌──────────────┬───────┐
│ file         │ lines │
├──────────────┼───────┤
│ src/parse.js │   312 │
│ src/lower.js │   847 │
│ src/emit.js  │   203 │
└──────────────┴───────┘

CLI reference

probe build <entry>

$ probe build <entry> [--out <name>] [--target <triple>] [--release] [--strip]
  • --out, -o — output binary name. Defaults to the entry filename without extension.
  • --target — cross-compilation target triple (e.g. aarch64-linux-musl).
  • --release — enable optimizations. Slower compilation, faster binary.
  • --strip — strip debug symbols from the output.

probe check <entry>

Run type inference and report errors without compiling. Useful in CI.

probe inspect <entry>

Print the inferred types for every binding in the program. Useful when the compiler rejects code and you want to understand why.

$ probe inspect server.js
  server.js:4   port      : number (literal 3000)
  server.js:5   handler   : (req: HttpRequest, res: HttpResponse) → void
  server.js:12  app       : HttpServer
  server.js:14  middleware : Array<(req, res, next) → void>