Current section
Files
Jump to
Current section
Files
lib/quickbeam.ex
defmodule QuickBEAM do
import QuickBEAM.VM.Heap.Keys
alias QuickBEAM.Bytecode
alias QuickBEAM.JS.Error, as: JSError
alias QuickBEAM.Native
alias QuickBEAM.Runtime
alias QuickBEAM.VM.BytecodeParser, as: BeamBytecodeParser
alias QuickBEAM.VM.Compiler, as: BeamCompiler
alias QuickBEAM.VM.Heap
alias QuickBEAM.VM.Interpreter
alias QuickBEAM.VM.PromiseState, as: Promise
alias QuickBEAM.VM.Runtime, as: BeamRuntime
@moduledoc """
QuickJS-NG JavaScript engine embedded in the BEAM.
Each runtime is a GenServer holding a persistent JS context.
State, functions, and variables survive across `eval/2` and `call/3` calls.
iex> {:ok, rt} = QuickBEAM.start()
iex> {:ok, 3} = QuickBEAM.eval(rt, "1 + 2")
iex> QuickBEAM.stop(rt)
:ok
## Handlers
JS code can call Elixir functions via `Beam.call` and `Beam.callSync`:
iex> {:ok, rt} = QuickBEAM.start(handlers: %{
...> "greet" => fn [name] -> "Hello, \#{name}!" end
...> })
iex> QuickBEAM.eval(rt, ~s[Beam.callSync("greet", "world")])
{:ok, "Hello, world!"}
iex> QuickBEAM.stop(rt)
:ok
## Supervision
Runtimes work as OTP children:
children = [
{QuickBEAM, name: :app, script: "priv/js/app.js", handlers: %{...}},
]
Supervisor.start_link(children, strategy: :one_for_one)
## Options
* `:name` — GenServer name registration
* `:id` — child spec ID (defaults to `:name`, then module)
* `:handlers` — map of handler name → function for `Beam.call`/`Beam.callSync`
* `:script` — path to a JS/TS file evaluated on startup. TypeScript files
are automatically transformed. Files with `import` statements are
automatically bundled — imports are resolved from the filesystem and
`node_modules/`, then compiled into a single script via OXC.
* `:memory_limit` — maximum JS heap in bytes (default: 256 MB)
* `:max_stack_size` — maximum JS call stack in bytes (default: 4 MB)
* `:max_convert_depth` — maximum nesting depth for JS→BEAM value conversion (default: 32)
* `:max_convert_nodes` — maximum total nodes for JS→BEAM value conversion (default: 10,000)
## DOM
Each runtime has a live DOM tree backed by lexbor. JS gets `document`,
`querySelector`, `createElement`, etc. Elixir can read the DOM directly
via `dom_find/2`, `dom_find_all/2`, `dom_text/2`, `dom_attr/3`, and
`dom_html/1` — returning Floki-compatible `{tag, attrs, children}` tuples.
"""
@type runtime :: GenServer.server()
@type js_result :: {:ok, term()} | {:error, QuickBEAM.JS.Error.t()}
@doc false
def child_spec(opts) do
Runtime.child_spec(opts)
end
@doc """
Start a new JavaScript runtime.
Returns `{:ok, pid}` on success.
## Options
* `:name` — register the GenServer under this name
* `:handlers` — `%{String.t() => function}` map for `Beam.call`/`Beam.callSync`
* `:script` — path to a JS/TS file to evaluate on startup (auto-bundles imports)
* `:apis` — which API surfaces to load (default: `[:browser]`)
* `:browser` — Web APIs (fetch, DOM, WebSocket, crypto, streams, …)
* `:node` — Node.js compat (process, path, fs, os)
* `[:browser, :node]` — both
* `false` — bare QuickJS engine, no polyfills
* `:define` — `%{String.t() => term()}` of globals to inject before the script runs.
Values are JSON-encoded. Useful for passing config without `Beam.callSync`.
QuickBEAM.start(script: "build.ts", define: %{"outputDir" => "/tmp/site"})
* `:memory_limit` — maximum JS heap in bytes (default: 256 MB)
* `:max_stack_size` — maximum JS call stack in bytes (default: 8 MB)
* `:max_convert_depth` — maximum nesting depth for JS→BEAM value conversion (default: 32)
* `:max_convert_nodes` — maximum total nodes for JS→BEAM value conversion (default: 10,000)
"""
@spec start(keyword()) :: GenServer.on_start()
def start(opts \\ []) do
opts =
if Keyword.has_key?(opts, :mode) do
opts
else
case System.get_env("QUICKBEAM_MODE") do
"beam" -> Keyword.put(opts, :mode, :beam)
"auto" -> Keyword.put(opts, :mode, :auto)
"beam_compiler" -> Keyword.put(opts, :mode, :beam_compiler)
_ -> opts
end
end
Runtime.start_link(opts)
end
@doc """
Evaluate JavaScript code and return the result.
Top-level `await` is supported.
iex> {:ok, rt} = QuickBEAM.start()
iex> QuickBEAM.eval(rt, "40 + 2")
{:ok, 42}
iex> QuickBEAM.eval(rt, "await Promise.all([1, 2].map(x => Promise.resolve(x)))")
{:ok, [1, 2]}
iex> QuickBEAM.stop(rt)
:ok
## Options
* `:timeout` — maximum execution time in milliseconds (default: no limit).
If exceeded, the JS execution is interrupted and an error is returned.
The runtime remains usable after a timeout.
QuickBEAM.eval(rt, "while(true) {}", timeout: 1000)
# => {:error, %QuickBEAM.JS.Error{message: "interrupted", ...}}
* `:vars` — a map of variable names to values, available in the code as
globals. Values are converted using the standard BEAM→JS conversion.
Variables are automatically cleaned up after evaluation, even if the
code throws an error.
QuickBEAM.eval(rt, "name.toUpperCase()", vars: %{"name" => "quickbeam"})
# => {:ok, "QUICKBEAM"}
QuickBEAM.eval(rt, "items.map(i => i.price * i.qty).reduce((a, b) => a + b, 0)",
vars: %{"items" => [%{"price" => 10, "qty" => 3}, %{"price" => 5, "qty" => 2}]})
# => {:ok, 40}
"""
@spec eval(runtime(), String.t(), keyword()) :: js_result()
def eval(runtime, code, opts \\ []) do
case resolve_mode(runtime, opts) do
mode when mode in [:beam, :auto, :beam_compiler] -> eval_beam(runtime, code, opts, mode)
_ -> Runtime.eval(runtime, code, opts)
end
end
defp resolve_mode(runtime, opts) do
case Keyword.get(opts, :mode) do
nil ->
case Heap.get_runtime_mode(runtime) do
nil ->
mode =
try do
GenServer.call(runtime, :get_mode, 1000)
catch
:exit, _ -> :nif
end
Heap.put_runtime_mode(runtime, mode)
mode
cached ->
cached
end
mode ->
mode
end
end
defp eval_beam(runtime, code, opts, mode) do
# Deliver any pending BEAM messages before running JS
deliver_pending_beam_messages(runtime)
handler_globals =
case Heap.get_handler_globals() do
nil ->
handlers =
try do
GenServer.call(runtime, :get_handlers, 1000)
catch
:exit, _ -> %{}
end
globals =
for {name, handler} <- handlers, into: %{} do
{name,
{:builtin, name,
fn args ->
case handler do
{:with_caller, fun} -> fun.(args, self())
fun when is_function(fun, 1) -> fun.(args)
_ -> :undefined
end
end}}
end
Heap.put_handler_globals(globals)
globals
cached ->
cached
end
compile_code = maybe_wrap_async(code)
case Runtime.compile(runtime, compile_code, Keyword.get(opts, :filename, "")) do
{:ok, bc} ->
case BeamBytecodeParser.decode(bc) do
{:ok, parsed} ->
result = eval_beam_bytecode(parsed, runtime, handler_globals, mode)
Promise.drain_microtasks()
converted = convert_beam_result(result)
Heap.gc(beam_gc_roots(result) ++ global_gc_roots())
converted
{:error, _} = err ->
err
end
{:error, _} = err ->
err
end
end
defp eval_beam_bytecode(parsed, runtime, handler_globals, mode)
when mode in [:auto, :beam_compiler] do
opts = %{gas: 1_000_000_000, runtime_pid: runtime, globals: handler_globals}
ctx = QuickBEAM.VM.Interpreter.Setup.build_eval_context(opts, parsed.atoms, opts.gas)
Heap.put_atoms(parsed.atoms)
QuickBEAM.VM.Interpreter.Setup.store_function_atoms(parsed.value, parsed.atoms)
Heap.put_ctx(QuickBEAM.VM.Interpreter.Context.mark_synced(ctx))
case BeamCompiler.invoke(parsed.value, [], ctx) do
{:ok, _} = ok -> ok
:error when mode == :auto -> Interpreter.eval(parsed.value, [], opts, parsed.atoms)
:error -> {:error, {:beam_compiler_unsupported, :top_level}}
{:error, {:js_throw, _}} = error -> error
end
end
defp eval_beam_bytecode(parsed, runtime, handler_globals, _mode) do
Interpreter.eval(
parsed.value,
[],
%{gas: 1_000_000_000, runtime_pid: runtime, globals: handler_globals},
parsed.atoms
)
end
defp maybe_wrap_async(code) do
if String.contains?(code, "await") do
wrap_as_async(String.trim(code))
else
code
end
end
# Wraps top-level code containing `await` in an async IIFE.
# Finds the last top-level statement and prepends `return`.
defp wrap_as_async(code) do
stmts = split_top_level_statements(code)
case stmts do
[] ->
code
_ ->
last = List.last(stmts)
rest = Enum.drop(stmts, -1)
{rest2, last2} = maybe_split_block_tail(rest, last)
last_with_return =
if needs_return?(last2) do
stripped = last2 |> String.trim() |> String.trim_trailing(";")
"return #{stripped}"
else
maybe_wrap_block_return(last2)
end
body = (rest2 ++ [last_with_return]) |> Enum.join("\n")
"(async () => {\n#{body}\n})()"
end
end
# Heuristic: returns true if this statement should be prefixed with `return`.
# We return if it's an expression statement (not a declaration/control-flow).
@no_return_prefixes ~w[const let var function class return if for while do switch try throw import export async]
defp maybe_wrap_block_return(stmt) do
trimmed = String.trim(stmt)
if String.starts_with?(trimmed, "try ") or String.starts_with?(trimmed, "try{") do
inject_try_catch_returns(trimmed)
else
stmt
end
end
defp inject_try_catch_returns(try_stmt) do
# Find catch block and inject return before its last expression
case Regex.run(~r/^(try\s*\{.*\})\s*(catch\s*\([^)]*\)\s*\{)(.*)\}\s*$/s, try_stmt) do
[_, try_part, catch_header, catch_body] ->
last_catch_expr = catch_body |> String.trim() |> String.trim_trailing(";")
if last_catch_expr != "" and not String.starts_with?(last_catch_expr, "return ") do
"#{try_part} #{catch_header} return #{last_catch_expr}; }"
else
try_stmt
end
_ ->
try_stmt
end
end
defp maybe_split_block_tail(rest, last) do
trimmed = String.trim_leading(last)
starts_with_block =
Enum.any?(
~w[while for if switch do try],
&(String.starts_with?(trimmed, &1 <> " ") or String.starts_with?(trimmed, &1 <> "("))
)
if starts_with_block do
case split_block_and_tail(last) do
{block, tail} when tail != "" ->
{rest ++ [block], tail}
_ ->
{rest, last}
end
else
{rest, last}
end
end
defp split_block_and_tail(stmt) do
chars = String.to_charlist(stmt)
find_last_block_end(chars, 0, 0, 0, 0, [], 0, -1)
end
defp find_last_block_end([], _p, _b, _br, _s, acc, _pos, last_depth0_close) do
if last_depth0_close > 0 do
all = IO.iodata_to_binary(Enum.reverse(acc))
block = String.slice(all, 0, last_depth0_close)
tail = all |> String.slice(last_depth0_close, String.length(all)) |> String.trim()
{block, tail}
else
{"", ""}
end
end
defp find_last_block_end([c | rest], p, b, br, in_str, acc, pos, last_depth0_close) do
{p2, b2, br2, is2, new_last} =
case {c, in_str} do
{?', 0} ->
{p, b, br, 1, last_depth0_close}
{?', 1} ->
{p, b, br, 0, last_depth0_close}
{?", 0} ->
{p, b, br, 2, last_depth0_close}
{?", 2} ->
{p, b, br, 0, last_depth0_close}
{?`, 0} ->
{p, b, br, 3, last_depth0_close}
{?`, 3} ->
{p, b, br, 0, last_depth0_close}
{?(, 0} ->
{p + 1, b, br, 0, last_depth0_close}
{?), 0} ->
{max(p - 1, 0), b, br, 0, last_depth0_close}
{?[, 0} ->
{p, b + 1, br, 0, last_depth0_close}
{?], 0} ->
{p, max(b - 1, 0), br, 0, last_depth0_close}
{?{, 0} ->
{p, b, br + 1, 0, last_depth0_close}
{?}, 0} ->
new_br = max(br - 1, 0)
new_last = if new_br == 0 and p == 0 and b == 0, do: pos + 1, else: last_depth0_close
{p, b, new_br, 0, new_last}
_ ->
{p, b, br, in_str, last_depth0_close}
end
find_last_block_end(rest, p2, b2, br2, is2, [c | acc], pos + 1, new_last)
end
defp needs_return?(stmt) do
trimmed = String.trim_leading(stmt)
not Enum.any?(@no_return_prefixes, &String.starts_with?(trimmed, &1 <> " ")) and
not String.starts_with?(trimmed, "//") and
not String.starts_with?(trimmed, "/*") and
trimmed != ""
end
# Splits code into top-level statements by tracking brace/paren/bracket depth.
# Each statement is separated by `;` at depth 0, or by newlines in some cases.
defp split_top_level_statements(code) do
code
|> String.trim()
|> find_top_level_statement_ends()
|> Enum.filter(&(String.trim(&1) != ""))
end
defp find_top_level_statement_ends(code) do
find_stmts(code, 0, 0, 0, 0, [], [])
end
defp find_stmts(<<>>, _parens, _brackets, _braces, _in_str, current, acc) do
stmt = IO.iodata_to_binary(Enum.reverse(current))
if String.trim(stmt) != "", do: Enum.reverse([stmt | acc]), else: Enum.reverse(acc)
end
defp find_stmts(<<?;, rest::binary>>, 0, 0, 0, 0, current, acc) do
stmt = IO.iodata_to_binary(Enum.reverse([?; | current]))
find_stmts(rest, 0, 0, 0, 0, [], [stmt | acc])
end
defp find_stmts(<<c, rest::binary>>, parens, brackets, braces, in_str, current, acc) do
{p2, b2, br2, is2} =
case {c, in_str} do
{?', 0} -> {parens, brackets, braces, 1}
{?', 1} -> {parens, brackets, braces, 0}
{?", 0} -> {parens, brackets, braces, 2}
{?", 2} -> {parens, brackets, braces, 0}
{?`, 0} -> {parens, brackets, braces, 3}
{?`, 3} -> {parens, brackets, braces, 0}
{?(, 0} -> {parens + 1, brackets, braces, 0}
{?), 0} -> {max(parens - 1, 0), brackets, braces, 0}
{?[, 0} -> {parens, brackets + 1, braces, 0}
{?], 0} -> {parens, max(brackets - 1, 0), braces, 0}
{?{, 0} -> {parens, brackets, braces + 1, 0}
{?}, 0} -> {parens, brackets, max(braces - 1, 0), 0}
_ -> {parens, brackets, braces, in_str}
end
find_stmts(rest, p2, b2, br2, is2, [c | current], acc)
end
defp convert_beam_result({:error, {:js_throw, {:obj, _ref} = obj}}) do
val = convert_beam_value(obj)
{:error, wrap_js_error(val)}
end
defp convert_beam_result({:error, {:js_throw, val}}) do
{:error, wrap_js_error(convert_beam_value(val))}
end
defp convert_beam_result({:ok, {:obj, ref}}) do
case Heap.get_obj(ref) do
%{"__promise_state__" => :rejected, "__promise_value__" => val} ->
{:error, wrap_js_error(convert_beam_value(val))}
%{"__promise_state__" => :resolved, "__promise_value__" => val} ->
{:ok, convert_beam_value(val)}
_ ->
{:ok, convert_beam_value({:obj, ref})}
end
end
defp convert_beam_result({:ok, val}), do: {:ok, convert_beam_value(val)}
defp convert_beam_result({:error, _} = err), do: err
defp wrap_js_error(val), do: JSError.from_js_value(val)
defp beam_gc_roots({:ok, value}), do: [value]
defp beam_gc_roots({:error, {:js_throw, value}}), do: [value]
defp beam_gc_roots(_), do: []
defp global_gc_roots do
cache = Heap.get_global_cache() || %{}
channel_roots = broadcast_channel_gc_roots()
Map.values(cache) ++ channel_roots
end
defp broadcast_channel_gc_roots do
case Process.get(:qb_broadcast_channels) do
nil ->
[]
channels when is_map(channels) ->
channels
|> Map.values()
|> List.flatten()
|> Enum.flat_map(fn
{_id, ref} when is_reference(ref) ->
case Process.get(ref) do
nil -> []
v -> [v]
end
_ ->
[]
end)
_ ->
[]
end
end
defp elixir_to_js(val) when is_map(val) do
ref = make_ref()
obj = Map.new(val, fn {k, v} -> {to_string(k), elixir_to_js(v)} end)
Heap.put_obj(ref, obj)
{:obj, ref}
end
defp elixir_to_js(val) when is_list(val) do
ref = make_ref()
Heap.put_obj(ref, Enum.map(val, &elixir_to_js/1))
{:obj, ref}
end
defp elixir_to_js(val), do: val
defp convert_beam_value(:undefined), do: nil
defp convert_beam_value({:obj, ref}) do
case Heap.get_obj(ref) do
nil ->
nil
{:qb_arr, arr} ->
:array.to_list(arr) |> Enum.map(&convert_beam_value/1)
list when is_list(list) ->
Enum.map(list, &convert_beam_value/1)
map when is_map(map) ->
if Map.get(map, "__is_buffer__") == true do
extract_buffer_bytes(map)
else
map
|> Map.drop([key_order()])
|> Map.new(fn {k, v} -> {convert_beam_key(k), convert_beam_value(v)} end)
|> Map.reject(fn {k, _} ->
is_binary(k) and String.starts_with?(k, "__") and String.ends_with?(k, "__")
end)
end
end
end
defp convert_beam_value(list) when is_list(list), do: Enum.map(list, &convert_beam_value/1)
defp convert_beam_value(v), do: v
defp deliver_pending_beam_messages(runtime) do
# First, deliver messages queued via send_message (which may register monitors)
try do
msgs = GenServer.call(runtime, :take_pending_messages, 1000)
if msgs != [] do
alias QuickBEAM.VM.Runtime.Web.BeamAPI
Enum.each(msgs, fn msg ->
elixir_msg = convert_msg_to_js(msg)
BeamAPI.deliver_beam_message(elixir_msg)
end)
end
catch
:exit, _ -> :ok
end
# Then, drain DOWN messages (after monitors may have been registered)
drain_down_messages()
end
defp drain_down_messages do
alias QuickBEAM.VM.Runtime.Web.BeamAPI
monitors_key = :qb_beam_monitors
receive do
{:DOWN, ref, :process, _pid, reason} ->
monitors = Process.get(monitors_key, %{})
case Map.get(monitors, ref) do
nil ->
:ok
callback ->
reason_str =
case reason do
:normal -> "normal"
:killed -> "killed"
a when is_atom(a) -> Atom.to_string(a)
_ -> inspect(reason)
end
try do
QuickBEAM.VM.Invocation.invoke_with_receiver(callback, [reason_str], :undefined)
rescue
_ -> :ok
catch
_, _ -> :ok
end
Process.put(monitors_key, Map.delete(monitors, ref))
end
drain_down_messages()
after
0 -> :ok
end
end
defp convert_msg_to_js(msg) when is_map(msg) do
Heap.wrap(Map.new(msg, fn {k, v} -> {to_string(k), convert_msg_to_js(v)} end))
end
defp convert_msg_to_js(msg) when is_list(msg) do
Heap.wrap(Enum.map(msg, &convert_msg_to_js/1))
end
defp convert_msg_to_js(nil), do: nil
defp convert_msg_to_js(true), do: true
defp convert_msg_to_js(false), do: false
defp convert_msg_to_js(n) when is_number(n), do: n
defp convert_msg_to_js(s) when is_binary(s), do: s
defp convert_msg_to_js(a) when is_atom(a), do: Atom.to_string(a)
defp convert_msg_to_js(pid) when is_pid(pid), do: pid
defp convert_msg_to_js(ref) when is_reference(ref), do: ref
defp convert_msg_to_js(_), do: nil
defp extract_buffer_bytes(map) do
case Map.get(map, "buffer") do
{:obj, buf_ref} ->
case Heap.get_obj(buf_ref) do
bm when is_map(bm) ->
ab = Map.get(bm, "__buffer__", <<>>)
offset = Map.get(map, "byteOffset", 0)
byte_len = Map.get(map, "byteLength", byte_size(ab))
if byte_size(ab) >= offset + byte_len and byte_len > 0 do
binary_part(ab, offset, byte_len)
else
ab
end
_ ->
<<>>
end
_ ->
# Fallback: try reading from the typed array's direct buffer
Map.get(map, "__buffer__", <<>>)
end
end
defp convert_beam_key(k) when is_binary(k), do: k
defp convert_beam_key(k) when is_integer(k), do: Integer.to_string(k)
defp convert_beam_key(k), do: inspect(k)
defp load_module_beam(runtime, name, code) do
wrapper =
"(function() { var module = {exports: {}}; var exports = module.exports; " <>
code <> "; return module.exports })()"
case Runtime.compile(runtime, wrapper) do
{:ok, bc} ->
case BeamBytecodeParser.decode(bc) do
{:ok, parsed} ->
case Interpreter.eval(
parsed.value,
[],
%{gas: 1_000_000_000, runtime_pid: runtime},
parsed.atoms
) do
{:ok, mod_exports} ->
Heap.register_module(name, mod_exports)
:ok
{:error, {:js_throw, _}} = error ->
convert_beam_result(error)
error ->
error
end
error ->
error
end
error ->
error
end
end
@doc """
Call a global JavaScript function by name.
Arguments are converted to JS values; the return value is converted back.
Promise-returning functions are automatically awaited.
iex> {:ok, rt} = QuickBEAM.start()
iex> QuickBEAM.eval(rt, "function add(a, b) { return a + b }")
iex> QuickBEAM.call(rt, "add", [2, 3])
{:ok, 5}
iex> QuickBEAM.stop(rt)
:ok
## Options
* `:timeout` — maximum execution time in milliseconds (default: no limit)
"""
@spec call(runtime(), String.t(), list(), keyword()) :: js_result()
def call(runtime, fn_name, args \\ [], opts \\ []) do
if resolve_mode(runtime, opts) == :beam do
call_beam(runtime, fn_name, args)
else
Runtime.call(runtime, fn_name, args, opts)
end
end
defp call_beam(_runtime, fn_name, args) do
handler_globals = Heap.get_handler_globals() || %{}
globals =
BeamRuntime.global_bindings()
|> Map.merge(handler_globals)
|> Map.merge(Heap.get_persistent_globals())
case Map.get(globals, fn_name) do
nil ->
{:error,
JSError.from_js_value(%{
"message" => "#{fn_name} is not defined",
"name" => "ReferenceError"
})}
fun ->
try do
result = Interpreter.invoke(fun, args, 1_000_000_000)
convert_beam_result({:ok, result})
catch
{:js_throw, val} -> convert_beam_result({:error, {:js_throw, val}})
end
end
end
@doc """
Disassemble precompiled bytecode into a `%QuickBEAM.Bytecode{}` struct.
Does not require a running runtime — creates a temporary QuickJS context
internally to parse the binary format.
{:ok, bytecode} = QuickBEAM.compile(rt, "function add(a, b) { return a + b }")
{:ok, %QuickBEAM.Bytecode{}} = QuickBEAM.disasm(bytecode)
Also accepts JavaScript source code and a runtime, compiling it first:
{:ok, %QuickBEAM.Bytecode{}} = QuickBEAM.disasm(rt, "function add(a, b) { return a + b }")
"""
@spec disasm(binary()) :: {:ok, QuickBEAM.Bytecode.t()} | {:error, String.t()}
def disasm(bytecode) when is_binary(bytecode) do
case Native.disasm_bytecode(bytecode) do
{:ok, map} -> {:ok, Bytecode.from_map(map)}
{:error, _} = error -> error
end
end
@doc """
Compile JavaScript source and disassemble it.
In the default NIF mode this returns `%QuickBEAM.Bytecode{}`. In `:beam`
mode it returns the raw `:beam_disasm.file/1` result.
{:ok, %QuickBEAM.Bytecode{cpool: [%QuickBEAM.Bytecode{name: "add"}]}} =
QuickBEAM.disasm(rt, "function add(a, b) { return a + b }")
{:ok, rt} = QuickBEAM.start(mode: :beam, apis: false)
{:ok, {:beam_file, _, _, _, _, _}} =
QuickBEAM.disasm(rt, "function fib(n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2) }")
"""
@spec disasm(runtime(), String.t(), keyword()) ::
{:ok, QuickBEAM.Bytecode.t() | tuple()} | {:error, term()}
def disasm(runtime, code, opts \\ []) when is_binary(code) do
if resolve_mode(runtime, opts) == :beam do
disasm_beam(runtime, code, opts)
else
with {:ok, bytecode} <- Runtime.compile(runtime, code, Keyword.get(opts, :filename, "")) do
disasm(bytecode)
end
end
end
defp disasm_beam(runtime, code, opts) do
with {:ok, bytecode} <- Runtime.compile(runtime, code, Keyword.get(opts, :filename, "")),
{:ok, parsed} <- BeamBytecodeParser.decode(bytecode) do
BeamCompiler.disasm(parsed.value)
end
end
@doc """
Compile JavaScript source to bytecode without executing it.
Returns `{:ok, bytecode}` where `bytecode` is a binary that can be loaded
into any runtime with `load_bytecode/2`. Useful for precompilation, caching,
and transferring compiled code between runtimes or nodes.
"""
@spec compile(runtime(), String.t()) :: {:ok, binary()} | {:error, QuickBEAM.JS.Error.t()}
def compile(runtime, code) do
Runtime.compile(runtime, code)
end
@doc """
Execute precompiled bytecode from `compile/2`.
The bytecode runs in the current runtime's context, with access to all
globals, handlers, and builtins.
"""
@spec load_bytecode(runtime(), binary()) :: js_result()
def load_bytecode(runtime, bytecode) do
Runtime.load_bytecode(runtime, bytecode)
end
@doc """
Load an ES module into the runtime.
iex> {:ok, rt} = QuickBEAM.start()
iex> code = "export function add(a, b) { return a + b; }"
iex> QuickBEAM.load_module(rt, "math", code)
:ok
iex> QuickBEAM.stop(rt)
:ok
"""
@spec load_module(runtime(), String.t(), String.t(), keyword()) :: :ok | {:error, String.t()}
def load_module(runtime, name, code, opts \\ []) do
if resolve_mode(runtime, opts) == :beam do
load_module_beam(runtime, name, code)
else
Runtime.load_module(runtime, name, code)
end
end
@doc """
Load a native addon (.node file) via N-API.
The addon is loaded with `dlopen` and its `napi_register_module_v1` (or
`napi_module_register`) entry point is called. Returns the addon's exports
as an Elixir term.
## Options
* `:as` - set the addon's exports as a global JS variable with this name,
making the functions callable from `eval/3` and `call/3`
## Examples
QuickBEAM.load_addon(rt, "/path/to/addon.node")
QuickBEAM.load_addon(rt, "/path/to/crc32.node", as: "crc32")
QuickBEAM.eval(rt, "crc32.crc32('hello')")
"""
@spec load_addon(runtime(), String.t(), keyword()) :: {:ok, term()} | {:error, term()}
def load_addon(runtime, path, opts \\ []) do
Runtime.load_addon(runtime, path, opts)
end
@doc """
Reset the runtime to a fresh JS context. Clears all state and functions.
iex> {:ok, rt} = QuickBEAM.start()
iex> QuickBEAM.eval(rt, "globalThis.x = 42")
iex> QuickBEAM.reset(rt)
:ok
iex> QuickBEAM.eval(rt, "typeof x")
{:ok, "undefined"}
iex> QuickBEAM.stop(rt)
:ok
"""
@spec reset(runtime()) :: :ok | {:error, String.t()}
def reset(runtime) do
Runtime.reset(runtime)
end
@doc "Stop a runtime and free its resources."
@spec stop(runtime()) :: :ok
def stop(runtime) do
Runtime.stop(runtime)
end
@doc """
Get current JS coverage data for a runtime.
Returns `{:ok, %{filename => %{line => hit_count}}}`.
Coverage must be enabled via `QuickBEAM.Cover`.
"""
@spec coverage(runtime()) :: {:ok, map()} | {:error, term()}
def coverage(runtime) do
GenServer.call(runtime, :get_coverage, :infinity)
end
@doc """
Evaluate TypeScript code by transforming it to JavaScript first.
Equivalent to `OXC.transform!/2` followed by `eval/3`, but in a single call.
iex> {:ok, rt} = QuickBEAM.start()
iex> QuickBEAM.eval_ts(rt, "const x: number = 40 + 2; x")
{:ok, 42}
iex> QuickBEAM.stop(rt)
:ok
## Options
Accepts the same options as `eval/3` (e.g., `:timeout`).
"""
@spec eval_ts(runtime(), String.t(), keyword()) :: js_result()
def eval_ts(runtime, ts_code, opts \\ []) do
js = OXC.transform!(ts_code, "eval.ts")
eval(runtime, js, opts)
end
@doc "Return QuickJS memory usage statistics."
@spec memory_usage(runtime()) :: map()
def memory_usage(runtime) do
Runtime.memory_usage(runtime)
end
@doc """
Send a message to the runtime's JS handler.
The message is delivered to the callback registered via `Beam.onMessage`
in JS. If no handler is registered, the message is silently discarded.
"""
@spec send_message(runtime(), term()) :: :ok
def send_message(runtime, message) do
# In BEAM mode, try to deliver the message synchronously to the current process's JS state
# (if it has BEAM mode active). Fall back to GenServer cast if not.
case Heap.get_global_cache() do
nil ->
# No BEAM state in this process - use GenServer
Runtime.send_message(runtime, message)
_globals ->
# This process has BEAM state - deliver directly
js_msg = convert_msg_to_js(message)
alias QuickBEAM.VM.Runtime.Web.BeamAPI
BeamAPI.deliver_beam_message(js_msg)
# Also drain any pending DOWN messages that may have been registered
drain_down_messages()
end
end
@doc """
List global names defined in the JS context.
By default returns all `globalThis` property names. Pass `user_only: true`
to exclude JS builtins and QuickBEAM internals — only names defined by
your scripts.
## Examples
{:ok, all} = QuickBEAM.globals(rt)
# ["Array", "Boolean", "Buffer", "Object", "console", "myVar", ...]
{:ok, mine} = QuickBEAM.globals(rt, user_only: true)
# ["myVar", "myFunc"]
"""
@spec globals(runtime(), keyword()) :: {:ok, [String.t()]} | {:error, QuickBEAM.JS.Error.t()}
def globals(runtime, opts \\ []) do
user_only = Keyword.get(opts, :user_only, false)
with {:ok, names} <- GenServer.call(runtime, {:list_globals, user_only}, :infinity) do
{:ok, Enum.sort(names)}
end
end
@doc """
Get the value of a JS global. Works like `eval(rt, "name")` but safer —
the name is accessed as a property, not evaluated as code.
Returns the value converted to Elixir terms. For objects, returns a map
of enumerable own properties. For functions, returns a map with metadata.
## Examples
QuickBEAM.get_global(rt, "myVar")
{:ok, 42}
QuickBEAM.get_global(rt, "myObj")
{:ok, %{"x" => 1, "y" => 2}}
QuickBEAM.get_global(rt, "nonexistent")
{:ok, nil}
"""
@spec get_global(runtime(), String.t()) :: js_result()
def get_global(runtime, name, opts \\ []) when is_binary(name) do
if resolve_mode(runtime, opts) == :beam do
persistent = Heap.get_persistent_globals()
raw = Map.get(persistent, name, :undefined)
{:ok, convert_beam_value(raw)}
else
GenServer.call(runtime, {:get_global, name}, :infinity)
end
end
@doc """
Set a JS global variable from Elixir.
The value is converted using the standard BEAM→JS conversion (no JSON).
## Examples
QuickBEAM.set_global(rt, "config", %{"theme" => "dark", "limit" => 100})
{:ok, "dark"} = QuickBEAM.eval(rt, "config.theme")
QuickBEAM.set_global(rt, "items", [1, 2, 3])
{:ok, 3} = QuickBEAM.eval(rt, "items.length")
"""
@spec set_global(runtime(), String.t(), term()) :: :ok
def set_global(runtime, name, value, opts \\ []) when is_binary(name) do
if resolve_mode(runtime, opts) == :beam do
persistent = Heap.get_persistent_globals()
js_val = elixir_to_js(value)
Heap.put_persistent_globals(Map.put(persistent, name, js_val))
:ok
else
GenServer.call(runtime, {:set_global, name, value}, :infinity)
end
end
@doc """
Return runtime diagnostics: registered handlers, memory stats, and JS global count.
"""
@spec info(runtime()) :: map()
def info(runtime) do
handlers = GenServer.call(runtime, :info, :infinity)
mem = memory_usage(runtime)
{:ok, global_count} = eval(runtime, "Object.getOwnPropertyNames(globalThis).length")
%{
handlers: handlers,
memory: mem,
global_count: global_count
}
end
@doc """
Find the first element matching a CSS selector in the runtime's DOM.
Returns the element as a Floki-compatible `{tag, attrs, children}` tuple,
or `nil` if no match is found. This reads the live DOM tree directly from
the native layer — no JS execution or HTML re-parsing.
{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval(rt, "document.body.innerHTML = '<p class=\"intro\">Hello</p>'")
{:ok, {"p", [{"class", "intro"}], ["Hello"]}} = QuickBEAM.dom_find(rt, "p.intro")
"""
@spec dom_find(runtime(), String.t()) :: {:ok, tuple() | nil}
def dom_find(runtime, selector) do
Runtime.dom_find(runtime, selector)
end
@doc """
Find all elements matching a CSS selector in the runtime's DOM.
Returns a list of Floki-compatible `{tag, attrs, children}` tuples.
{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval(rt, ~s[document.body.innerHTML = '<ul><li>a</li><li>b</li></ul>'])
{:ok, items} = QuickBEAM.dom_find_all(rt, "li")
length(items) # => 2
"""
@spec dom_find_all(runtime(), String.t()) :: {:ok, list()}
def dom_find_all(runtime, selector) do
Runtime.dom_find_all(runtime, selector)
end
@doc """
Extract text content from the first element matching a CSS selector.
{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval(rt, "document.body.innerHTML = '<h1>Title</h1>'")
{:ok, "Title"} = QuickBEAM.dom_text(rt, "h1")
"""
@spec dom_text(runtime(), String.t()) :: {:ok, String.t()}
def dom_text(runtime, selector) do
Runtime.dom_text(runtime, selector)
end
@doc """
Get an attribute value from the first element matching a CSS selector.
Returns `nil` if the element or attribute is not found.
{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval(rt, ~s[document.body.innerHTML = '<a href="/page">link</a>'])
{:ok, "/page"} = QuickBEAM.dom_attr(rt, "a", "href")
"""
@spec dom_attr(runtime(), String.t(), String.t()) :: {:ok, String.t() | nil}
def dom_attr(runtime, selector, attr_name) do
Runtime.dom_attr(runtime, selector, attr_name)
end
@doc """
Serialize the entire DOM tree to an HTML string.
{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval(rt, "document.body.innerHTML = '<p>Hello</p>'")
{:ok, html} = QuickBEAM.dom_html(rt)
"""
@spec dom_html(runtime()) :: {:ok, String.t()}
def dom_html(runtime) do
Runtime.dom_html(runtime)
end
end