QuickBEAM

JavaScript runtime for the BEAM — Web APIs backed by OTP, native DOM, and a built-in TypeScript toolchain.

JS runtimes are GenServers. They live in supervision trees, send and receive messages, and call into Erlang/OTP libraries — all without leaving the BEAM.

Installation

def deps do
  [{:quickbeam, "~> 0.3.0"}]
end

Requires Zig 0.15+ (installed automatically by Zigler, or use system Zig).

Quick start

{:ok, rt} = QuickBEAM.start()
{:ok, 3} = QuickBEAM.eval(rt, "1 + 2")
{:ok, "HELLO"} = QuickBEAM.eval(rt, "'hello'.toUpperCase()")

# State persists across calls
QuickBEAM.eval(rt, "function greet(name) { return 'hi ' + name }")
{:ok, "hi world"} = QuickBEAM.call(rt, "greet", ["world"])

QuickBEAM.stop(rt)

BEAM integration

JS can call Elixir functions and access OTP libraries:

{:ok, rt} = QuickBEAM.start(handlers: %{
  "db.query" => fn [sql] -> MyRepo.query!(sql).rows end,
  "cache.get" => fn [key] -> Cachex.get!(:app, key) end,
})

{:ok, rows} = QuickBEAM.eval(rt, """
  const rows = await Beam.call("db.query", "SELECT * FROM users LIMIT 5");
  rows.map(r => r.name);
""")

JS can also send messages to any BEAM process:

// Get the runtime's own PID
const self = Beam.self();

// Send to any PID
Beam.send(somePid, {type: "update", data: result});

// Receive BEAM messages
Beam.onMessage((msg) => {
  console.log("got:", msg);
});

// Monitor BEAM processes
const ref = Beam.monitor(pid, (reason) => {
  console.log("process died:", reason);
});
Beam.demonitor(ref);

Supervision

Runtimes are OTP children with crash recovery:

children = [
  {QuickBEAM,
   name: :renderer,
   id: :renderer,
   script: "priv/js/app.js",
   handlers: %{
     "db.query" => fn [sql, params] -> Repo.query!(sql, params).rows end,
   }},
  {QuickBEAM, name: :worker, id: :worker},
]

Supervisor.start_link(children, strategy: :one_for_one)

{:ok, html} = QuickBEAM.call(:renderer, "render", [%{page: "home"}])

The :script option loads a JS file at startup. If the runtime crashes, the supervisor restarts it with a fresh context and re-evaluates the script.

API surfaces

QuickBEAM can load browser APIs, Node.js APIs, or both:

# Browser APIs only (default)
QuickBEAM.start(apis: [:browser])

# Node.js compatibility
QuickBEAM.start(apis: [:node])

# Both
QuickBEAM.start(apis: [:browser, :node])

# Bare QuickJS engine — no polyfills
QuickBEAM.start(apis: false)

Node.js compatibility

Like Bun, QuickBEAM implements core Node.js APIs. BEAM-specific extensions live in the Beam namespace.

{:ok, rt} = QuickBEAM.start(apis: [:node])

QuickBEAM.eval(rt, """
  const data = fs.readFileSync('/etc/hosts', 'utf8');
  const lines = data.split('\\n').length;
  lines
""")
# => {:ok, 12}
Module Coverage
processenv, cwd(), platform, arch, pid, argv, version, nextTick, hrtime, stdout, stderr
pathjoin, resolve, basename, dirname, extname, parse, format, relative, normalize, isAbsolute, sep, delimiter
fsreadFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, lstatSync, unlinkSync, renameSync, rmSync, copyFileSync, realpathSync, readFile, writeFile
osplatform(), arch(), type(), hostname(), homedir(), tmpdir(), cpus(), totalmem(), freemem(), uptime(), EOL, endianness()

process.env is a live Proxy — reads and writes go to System.get_env / System.put_env.

Resource limits

{:ok, rt} = QuickBEAM.start(
  memory_limit: 10 * 1024 * 1024,  # 10 MB heap
  max_stack_size: 512 * 1024        # 512 KB call stack
)

Introspection

# List user-defined globals (excludes builtins)
{:ok, ["myVar", "myFunc"]} = QuickBEAM.globals(rt, user_only: true)

# Get any global's value
{:ok, 42} = QuickBEAM.get_global(rt, "myVar")

# Runtime diagnostics
QuickBEAM.info(rt)
# %{handlers: ["db.query"], memory: %{...}, global_count: 87}

DOM

Every runtime has a live DOM tree backed by lexbor (the C library behind PHP 8.4’s DOM extension and Elixir’s fast_html). JS gets a full document global:

document.body.innerHTML = &#39;<ul><li class="item">One</li><li class="item">Two</li></ul>&#39;;
const items = document.querySelectorAll("li.item");
items[0].textContent; // "One"

Elixir can read the DOM directly — no JS execution, no re-parsing:

{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval(rt, ~s[document.body.innerHTML = &#39;<h1 class="title">Hello</h1>&#39;])

# Returns Floki-compatible {tag, attrs, children} tuples
{:ok, {"h1", [{"class", "title"}], ["Hello"]}} = QuickBEAM.dom_find(rt, "h1")

# Batch queries
{:ok, items} = QuickBEAM.dom_find_all(rt, "li")

# Extract text and attributes
{:ok, "Hello"} = QuickBEAM.dom_text(rt, "h1")
{:ok, "/about"} = QuickBEAM.dom_attr(rt, "a", "href")

# Serialize back to HTML
{:ok, html} = QuickBEAM.dom_html(rt)

Web APIs

Standard browser APIs backed by BEAM primitives, not JS polyfills:

JS API BEAM backend
fetch, Request, Response, Headers:httpc
document, querySelector, createElement lexbor (native C DOM)
URL, URLSearchParams:uri_string
EventSource (SSE) :httpc streaming
WebSocket:gun
Worker BEAM process per worker
BroadcastChannel:pg (distributed)
navigator.locks GenServer + monitors
localStorage ETS
crypto.subtle:crypto
crypto.getRandomValues, randomUUID Zig std.crypto.random
ReadableStream, WritableStream, TransformStream Pure TS with pipeThrough/pipeTo
TextEncoder, TextDecoder Native Zig (UTF-8)
TextEncoderStream, TextDecoderStream Stream + Zig encoding
CompressionStream, DecompressionStream:zlib
BufferBase, :unicode
EventTarget, Event, CustomEvent Pure TS
AbortController, AbortSignal Pure TS
Blob, File Pure TS
DOMException Pure TS
setTimeout, setInterval Timer heap in worker thread
console (log, warn, error, debug, time, group, …) Erlang Logger
atob, btoa Native Zig
performance.now Nanosecond precision
structuredClone QuickJS serialization
queueMicrotaskJS_EnqueueJob

Data conversion

No JSON in the data path. JS values map directly to BEAM terms:

JS Elixir
number (integer) integer
number (float) float
stringString.t()
booleanboolean
nullnil
undefinednil
Arraylist
Objectmap (string keys)
Uint8Arraybinary
Symbol("name"):name (atom)
Infinity / NaN:Infinity / :NaN
PID / Ref / Port Opaque JS object (round-trips)

TypeScript

Type definitions for the BEAM-specific JS API:

// tsconfig.json
{
  "compilerOptions": {
    "types": ["./path/to/quickbeam.d.ts"]
  }
}

The .d.ts file covers the Beam bridge API, opaque BEAM terms (BeamPid, BeamRef, BeamPort), and the compression helper. Standard Web APIs are typed by TypeScript’s lib.dom.d.ts.

TypeScript toolchain

QuickBEAM includes a built-in TypeScript toolchain via OXC Rust NIFs — no Node.js or Bun required:

# Evaluate TypeScript directly
{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval_ts(rt, "const x: number = 40 + 2; x")
# => {:ok, 42}

# Transform, minify, bundle — available as QuickBEAM.JS.*
{:ok, js} = QuickBEAM.JS.transform("const x: number = 1", "file.ts")
{:ok, min} = QuickBEAM.JS.minify("const x = 1 + 2;", "file.js")

# Bundle multiple modules into a single IIFE
files = [
  {"utils.ts", "export function add(a: number, b: number) { return a + b }"},
  {"main.ts", "import { add } from &#39;./utils&#39;\nconsole.log(add(1, 2))"}
]
{:ok, bundle} = QuickBEAM.JS.bundle(files)

npm packages

QuickBEAM ships with a built-in npm client — no Node.js required.

mix npm.install sanitize-html

The :script option auto-resolves imports. Point it at a TypeScript file that imports npm packages, and QuickBEAM bundles everything at startup:

# priv/js/app.ts
import sanitize from &#39;sanitize-html&#39;

Beam.onMessage((html: string) => {
  Beam.callSync("done", sanitize(html))
})
{QuickBEAM, name: :sanitizer, script: "priv/js/app.ts", handlers: %{...}}

No build step, no webpack, no esbuild. TypeScript is stripped, imports are resolved from node_modules/, and everything is bundled into a single script via OXC — all at runtime startup.

You can also bundle from disk programmatically:

{:ok, js} = QuickBEAM.JS.bundle_file("src/main.ts")

Performance

vs QuickJSEx 0.3.1 (Rust/Rustler, JSON serialization):

Benchmark Speedup
Function call — small map 2.5x faster
Function call — large data 4.1x faster
Concurrent JS execution 1.35x faster
Beam.callSync (JS→BEAM) 5 μs overhead (unique to QuickBEAM)
Startup ~600 μs (parity)

See bench/ for details.

Examples

License

MIT