Show HN: QuickBEAM – 以受监督的 Erlang/OTP 进程方式运行 JavaScript
Show HN: QuickBEAM – run JavaScript as supervised Erlang/OTP processes

原始链接: https://github.com/elixir-volt/quickbeam

## QuickBEAM: BEAM上的JavaScript QuickBEAM 允许使用 QuickJS 在 Erlang 虚拟机 (BEAM) 中运行 JavaScript,为这两个世界提供强大的桥梁。JS 运行时作为 GenServer 运行,受益于 OTP 的监督、消息传递以及对 Erlang/OTP 库的访问,而无需上下文切换。 主要特性包括:内置 TypeScript 工具链(由 OXC 提供支持)、通过 lexbor 进行 DOM 操作,以及用于通信(`Beam.call`、`Beam.send`)、进程管理和实用函数的全面 API。上下文和上下文池为高并发场景(如 LiveView 应用程序)提供高效的资源管理,共享运行时线程并提供每个上下文的限制。 QuickBEAM 支持浏览器和 Node.js API,能够与现有 JavaScript 代码无缝集成。它还提供对 DOM 的直接访问,允许 Elixir 检查和操作 DOM 而无需 JS 执行。依赖项会自动解析,该系统包含内置的 npm 客户端功能。 基准测试表明,与 QuickJSEx 等替代方案相比,QuickBEAM 具有显著的性能提升。QuickBEAM 非常适合沙盒用户代码执行、SSR、实时应用程序和 AI 代理等场景,为将 JavaScript 集成到 Elixir 项目中提供了一个强大且可扩展的解决方案。

## QuickBEAM:Erlang/OTP 中的 JavaScript QuickBEAM 是一种新的 JavaScript 运行时,直接嵌入在 Erlang/OTP 虚拟机中。它认识到 JavaScript 在全栈应用中的必然存在,旨在在 OTP 强大的监督树中运行该代码,从而提高可靠性和控制力。 每个 JavaScript 运行时都作为一个受监督的进程运行,能够与 Elixir 代码交互、管理进程和访问系统状态。它还提供类似浏览器的 API(fetch、WebSockets 等),这些 API 由 OTP 原语支持。 潜在用途包括服务器端渲染、沙箱化用户代码、管理每连接状态以及具有无缝 Erlang 互操作性的后端 JavaScript——所有这些都不需要 JSON 边界。主要功能包括监督、沙箱化、原生 DOM 访问、TypeScript/npm 支持和原生插件兼容性。 QuickBEAM 是 Elixir Volt 的核心组件,Elixir Volt 是一种全栈工具链,旨在消除对 Node.js 的需求。该项目处于早期阶段,并寻求反馈。
相关文章

原文

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.

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

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

{: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)

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);
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

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.

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.

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.

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)

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.

{:ok, rt} = QuickBEAM.start(
  memory_limit: 10 * 1024 * 1024,  # 10 MB heap
  max_stack_size: 512 * 1024        # 512 KB call stack
)
# 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}

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)

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><li class="item">One</li><li class="item">Two</li></ul>';
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 = '<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)

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

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)

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.

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)

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")

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.

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

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

  • 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.

MIT

联系我们 contact @ memedata.com