Skip to content

elixir-volt/quickbeam

Repository files navigation

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.5.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 and context pools 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},

  # Context pool for high-concurrency use cases
  {QuickBEAM.ContextPool, name: MyApp.JSPool, size: 4},
]

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.

Individual Context processes are typically started dynamically (e.g. from a LiveView mount) and linked to the connection process.

Context Pool

For high-concurrency scenarios (thousands of connections), use ContextPool instead of individual runtimes. Many lightweight JS contexts share a small number of runtime threads:

# Start a pool with N runtime threads (defaults to scheduler count)
{:ok, pool} = QuickBEAM.ContextPool.start_link(name: MyApp.JSPool, size: 4)

# Each context is a GenServer with its own JS global scope
{:ok, ctx} = QuickBEAM.Context.start_link(pool: MyApp.JSPool)
{:ok, 3} = QuickBEAM.Context.eval(ctx, "1 + 2")
{:ok, "HELLO"} = QuickBEAM.Context.eval(ctx, "'hello'.toUpperCase()")
QuickBEAM.Context.stop(ctx)

Contexts support the full API — eval, call, Beam.call/callSync, DOM, messaging, browser/node APIs, handlers, and supervision:

# In a Phoenix LiveView
def mount(_params, _session, socket) do
  {:ok, ctx} = QuickBEAM.Context.start_link(
    pool: MyApp.JSPool,
    handlers: %{"db.query" => &MyApp.query/1}
  )
  {:ok, assign(socket, js: ctx)}
end

The context is linked to the LiveView process — it terminates and cleans up automatically when the connection closes. No explicit terminate callback needed.

Granular API groups

Contexts can load individual API groups instead of the full browser bundle:

QuickBEAM.Context.start_link(pool: pool, apis: [:beam, :fetch])  # 231 KB
QuickBEAM.Context.start_link(pool: pool, apis: [:beam, :url])    # 108 KB
QuickBEAM.Context.start_link(pool: pool, apis: false)            #  58 KB
QuickBEAM.Context.start_link(pool: pool)                         # 429 KB (all browser APIs)

Available groups: :fetch, :websocket, :worker, :channel, :eventsource, :url, :crypto, :compression, :buffer, :dom, :console, :storage, :locks. Dependencies auto-resolve.

Per-context resource limits

{:ok, ctx} = QuickBEAM.Context.start_link(
  pool: pool,
  memory_limit: 512_000,      # per-context allocation limit (bytes)
  max_reductions: 100_000      # opcode budget per eval/call
)

# Track per-context memory
{:ok, %{context_malloc_size: 92_000}} = QuickBEAM.Context.memory_usage(ctx)

Exceeding memory_limit triggers OOM. Exceeding max_reductions interrupts the current eval but keeps the context usable for subsequent calls.

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
process env, cwd(), platform, arch, pid, argv, version, nextTick, hrtime, stdout, stderr
path join, resolve, basename, dirname, extname, parse, format, relative, normalize, isAbsolute, sep, delimiter
fs readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, lstatSync, unlinkSync, renameSync, rmSync, copyFileSync, realpathSync, readFile, writeFile
os platform(), 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 = '<ul><li class="item">One</li><li class="item">Two</li></ul>';
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 = '<h1 class="title">Hello</h1>'])

# 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
Buffer Base, :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
queueMicrotask JS_EnqueueJob

Data conversion

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

JS Elixir
number (integer) integer
number (float) float
string String.t()
boolean boolean
null nil
undefined nil
Array list
Object map (string keys)
Uint8Array binary
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 './utils'\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 'sanitize-html'

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)

Context pool vs individual runtimes at scale:

Runtime (1:1 thread) Context (pooled)
JS heap per instance ~530 KB ~429 KB (full) / ~58 KB (bare)
OS thread stack ~2.5 MB each shared (4 threads total)
OS threads at 10K 10,000 4 (configurable)
Total RAM at 10K ~30 GB ~4.2 GB (full) / ~570 MB (bare)

See bench/ for details.

When to use what

Use case Module Why
One-off eval, scripting QuickBEAM (Runtime) Simple, full isolation
SSR request pool QuickBEAM.Pool Checkout/checkin with reset
Per-connection state (LiveView) QuickBEAM.Context Lightweight, thousands concurrent
Sandboxed user code QuickBEAM or Context with apis: false Memory limits, reduction limits, timeouts

Examples

  • examples/ssr/ — Preact SSR with a pool of runtimes and native DOM. Elixir reads the DOM directly — no renderToString.
  • examples/rule_engine/ — user-defined business rules (pricing, validation, transforms) in sandboxed JS runtimes with apis: false, memory limits, timeouts, and hot reload.
  • examples/live_dashboard/ — Workers (BEAM processes) compute metrics in parallel and broadcast results via BroadcastChannel (:pg). Crash recovery via OTP supervisor.

License

MIT

About

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

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors