Boam is an Elixir wrapper around the Boa JavaScript engine, implemented as a Rustler NIF.

The library starts a dedicated JavaScript runtime per Elixir process and lets JavaScript call back into the BEAM through an explicit dispatch bridge.

Features

  • Boa-backed JavaScript evaluation from Elixir
  • Dedicated runtime thread per engine, matching Boa's thread-safety model
  • beam.call(name, ...args) bridge from JavaScript into Elixir
  • JSON-compatible value round-tripping between JS and Elixir
  • Configurable dispatcher process for custom routing and supervision setups

Installation

Add boam to your list of dependencies in mix.exs:

def deps do
  [
    {:boam, "~> 0.1.0"}
  ]
end

Quick Start

{:ok, _dispatcher} =
  MyApp.JsDispatcher.start_link(name: MyApp.JsDispatcher)

{:ok, runtime} =
  Boam.start_link(
    dispatcher: MyApp.JsDispatcher,
    js_expose: %{math: %{sum: true, greet: {:dispatch, "app.greet"}}}
  )

Boam.eval(runtime, "1 + 2")
#=> {:ok, 3}

Boam.eval(runtime, "math.sum(2, 3)")
#=> {:ok, 5}

Boam.eval(runtime, "math.greet('Ada')")
#=> {:ok, "hello Ada"}

In most applications, managing your own dispatcher is the intended setup. dispatcher: accepts any GenServer.server/0, so a named sibling child works cleanly under supervision.

Startup Prelude

Run JavaScript during runtime startup with prelude::

{:ok, runtime} =
  Boam.start_link(
    prelude: [
      "globalThis.appName = 'boam';",
      "globalThis.version = 1;"
    ]
  )

Those snippets run before your first call to Boam.eval/2.

Managed Dispatcher

If you want to own the dispatch process yourself, pass it directly:

children = [
  {MyApp.JsDispatcher, name: MyApp.JsDispatcher},
  {Boam.Runtime,
   dispatcher: MyApp.JsDispatcher,
   js_expose: %{console: %{log: true}}}
]

If the dispatcher is a sibling child, prefer a supervisor strategy like :rest_for_one so the runtime restarts after the dispatcher restarts. Boam resolves the dispatcher to a pid during startup.

Convenience Dispatcher

If you want JavaScript functions like console.log(...) without writing a dispatcher process, Boam can start a small convenience dispatcher from :exports and :js_expose:

{:ok, runtime} =
  Boam.start_link(
    exports: %{
      "console.log" => fn [message] -> "logged: #{message}" end,
      "logger.warn" => fn [message] -> "warn: #{message}" end
    },
    js_expose: %{
      console: %{
        log: true,
        warn: {:dispatch, "logger.warn"}
      }
    }
  )

Boam.eval(runtime, "console.log('hello')")
#=> {:ok, "logged: hello"}

Leaf dispatch names default to the dot-joined path, so console.log dispatches to "console.log" unless you override it with {:dispatch, "custom.name"}.

expose: still exists as legacy sugar when you want to configure both the JS shim and the Elixir handler in one tree.

Manual Shim Generation

If you want to keep dispatch setup separate from runtime startup, generate the shim code yourself:

prelude =
  Boam.JS.export_prelude(%{
    console: %{
      log: true,
      error: {:dispatch, "logger.error"}
    }
  })

{:ok, runtime} =
  Boam.start_link(
    prelude: prelude,
    fallback: fn name, args -> {name, args} end
  )

Runtime JS Exposure

You can add JS functions after startup with either a synchronous call or an asynchronous message:

:ok = Boam.expose_js(runtime, %{console: %{log: true}})

send(runtime, {:boam_expose_js, %{console: %{debug: true}}})

The message form is the one to use from a beam.call(...) handler, because the runtime only applies it after the current evaluation unwinds.

Value Model

Boam intentionally restricts the bridge to JSON-compatible values:

  • JavaScript null maps to Elixir nil
  • booleans, numbers, strings, arrays, and objects round-trip normally
  • top-level JavaScript undefined becomes {:ok, :undefined}
  • undefined is rejected when passed through beam.call(...)
  • JavaScript object keys come back as strings

For predictable round-tripping, return Elixir maps with string keys from dispatch handlers.

Dispatching Into Elixir

JavaScript code can call:

beam.call("name", arg1, arg2)

That request is delivered to a Boam.Dispatcher process on the BEAM side. A handler can return:

  • any JSON-compatible value
  • {:ok, value}
  • {:error, reason}

If a handler crashes, the JavaScript caller receives an error instead of hanging forever.

Architecture

  • Boam is the small public entrypoint
  • Boam.Runtime owns the NIF resource and runtime lifecycle
  • Boam.Dispatcher resolves and executes beam.call(...) handlers
  • Boam.JS generates JavaScript shim preludes from nested export trees
  • the internal NIF bridge module is intentionally hidden from the generated docs

Generating Docs

Generate the docs locally with:

mix docs

The generated site will be written to doc/.