Show HN: QuickBEAM – run JavaScript as supervised Erlang/OTP processes

2026/03/29 6:03

Show HN: QuickBEAM – run JavaScript as supervised Erlang/OTP processes

RSS: https://news.ycombinator.com/rss

要約

本文

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 = '

  • One
  • Two
'; const items = document.querySelectorAll("li.item"); items[0].textContent; // "One" items[0] instanceof HTMLElement; // true Elixir can read the DOM directly — no JS execution, no re-parsing: {:ok, rt} = QuickBEAM.start() QuickBEAM.eval(rt, ~s[document.body.innerHTML = '

Hello

'])

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:

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

Development

Runs the full project quality gate: formatting, Credo, Dialyzer, Zig lint, TypeScript lint, duplicate-code checks, and tests. Examples

examples/chat_room/ — real-time chat with Phoenix Channels. Each room is a supervised QuickBEAM runtime; JS broadcasts become PubSub pushes to WebSocket clients. examples/ai_agent/ — conversational AI agent with streaming, tool use, and pluggable LLM backends. JS orchestrates the conversation loop; Elixir provides the I/O. examples/counter_live/ — LiveView counter where each session gets a ~58 KB JS context from a shared pool. The simplest QuickBEAM + Phoenix integration. 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

同じ日のほかのニュース

一覧に戻る →