Quickbeam
JavaScript runtime for the BEAM — Web APIs backed by OTP, native DOM, and a built-in TypeScript toolchain.
Install / Use
/learn @elixir-volt/QuickbeamREADME
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.7.1"}]
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);
Beam API reference
| Category | API | Description |
|---|---|---|
| Bridge | Beam.call(name, ...args) | Call an Elixir handler (async) |
| | Beam.callSync(name, ...args) | Call an Elixir handler (sync) |
| | Beam.send(pid, message) | Send a message to a BEAM process |
| | Beam.onMessage(callback) | Receive BEAM messages |
| Process | Beam.self() | PID of the owning GenServer |
| | Beam.spawn(script) | Spawn a new JS runtime as a BEAM process |
| | Beam.register(name) | Register the runtime under a name |
| | Beam.whereis(name) | Look up a registered runtime |
| | Beam.monitor(pid, callback) | Monitor a process for exit |
| | Beam.demonitor(ref) | Cancel a monitor |
| | Beam.link(pid) / Beam.unlink(pid) | Bidirectional crash propagation |
| Distribution | Beam.nodes() | List connected BEAM nodes |
| | Beam.rpc(node, runtime, fn, ...args) | Remote call to another node |
| Utilities | Beam.sleep(ms) / Beam.sleepSync(ms) | Async/sync sleep |
| | Beam.hash(data, range?) | Non-cryptographic hash (:erlang.phash2) |
| | Beam.escapeHTML(str) | Escape & < > " ' |
| | Beam.which(bin) | Find executable on PATH |
| | Beam.peek(promise) / Beam.peek.status(promise) | Read promise result without await |
| | Beam.randomUUIDv7() | Monotonic sortable UUID |
| | Beam.deepEquals(a, b) | Deep structural equality |
| | Beam.nanoseconds() | Monotonic high-res timer |
| | Beam.uniqueInteger() | Monotonically increasing unique integer |
| | Beam.makeRef() | Create a unique BEAM reference |
| | Beam.inspect(value) | Pretty-print any value (including PIDs/refs) |
| Semver | Beam.semver.satisfies(version, range) | Check version against Elixir requirement |
| | Beam.semver.order(a, b) | Compare two semver strings |
| Password | Beam.password.hash(password, opts?) | PBKDF2-SHA256 hash |
| | Beam.password.verify(password, hash) | Constant-time verification |
| Introspection | Beam.version | QuickBEAM version string |
| | Beam.systemInfo() | Schedulers, memory, atoms, OTP release |
| | Beam.processInfo() | Memory, reductions, message queue |
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}
Bytecode disassembly
Disassemble QuickJS bytecode into structured Elixir terms — like
:beam_disasm for the BEAM:
{:ok, bc} = QuickBEAM.disasm(rt, "function fib(n) { if (n <= 1) return n; return fib(n-1) + fib(n-2) }")
fib = hd(bc.cpool)
fib.name # "fib"
fib.args # ["n"]
fib.stack_size # 4
fib.opcodes
# [
# {0, :get_arg0, 0},
# {1, :push_1, 1},
# {2, :lte},
# {3, :if_false8, 7},
# {5, :get_arg0, 0},
# {6, :return},
# {7, :get_var, "fib"},
# {12, :get_arg0, 0},
# {13, :push_1, 1},
# {14, :sub},
# {15, :call1, 1},
# ...
# ]
disasm/1 works on precompiled bytecode binaries without a runtime:
{:ok, bytecode} = QuickBEAM.compile(rt, source)
# later, even on a different node:
{:ok, %QuickBEAM.Bytecode{}} = QuickBEAM.disasm(bytecode)
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
with spec-compliant prototype chains (instanceof HTMLElement works), node identity
(el.parentNode === el.parentNode), and uppercase tagName for HTML elements:
document.body.innerHTML = '<ul><l
