A JS/TS node for BEAM. It connects to an Erlang or Elixir node over the native distribution protocol.
Zero dependencies: the term codec and the protocol are written from scratch. Tested with Bun against OTP 23 to 29; the built package also runs on Node 22+ and Deno 2.
Start an Elixir node (the example below assumes it has a process registered
as :echo and a GenServer registered as Counter, like the one in
test/support/node.ts):
iex --sname app@localhost --cookie secretTalk to it:
import { atom, start, tuple } from "erlnode";
const APP = "app@localhost";
const node = start({ name: "js@localhost", cookie: "secret" });
// Optional: connections open on first use, connect() just checks early.
await node.connect(APP);
// send({:echo, :"app@localhost"}, msg)
await node.send(["echo", APP], ["hello", 42]);
// GenServer.call({Counter, :"app@localhost"}, :inc).
// Names are written as in Elixir: "Counter" is the module Counter
// (:"Elixir.Counter"), lowercase names like "lists" are Erlang modules.
await node.call(["Counter", APP], atom("inc"));
// :erpc.call: errors in the remote function throw ErpcError.
await node.erpc(APP, "erlang", "node"); // atom("app@localhost")
// :rpc.call: like in Erlang, errors come back as a {:badrpc, reason} value.
await node.rpc(APP, "erlang", "node");
// A process: a pid, a mailbox and a body. It exits when the body returns.
node.spawn(async (self) => {
await self.send(["echo", APP], tuple(self.pid, "ping"));
const reply = await self.receive(undefined, 5000);
// A {:DOWN, ref, :process, object, reason} message arrives when :echo exits.
const ref = await self.monitor(["echo", APP]);
self.demonitor(ref);
});
await node.close();The API follows Elixir:
| Elixir | erlnode |
|---|---|
Node.start/1 |
start({ name, cookie }) |
Node.connect/1, Node.disconnect/1, Node.list/0 |
node.connect(n), node.disconnect(n), node.list() |
send(pid, msg) |
node.send(pid, msg) |
send({name, node}, msg) |
node.send([name, node], msg) |
send(name, msg) for a local name |
node.send(name, msg) |
GenServer.call(dest, req, timeout) |
node.call(dest, req, timeout) |
:rpc.call/5, :erpc.call/5 |
node.rpc(...), node.erpc(...) |
spawn/1 |
node.spawn(async (self) => ...) |
Node.spawn/4 with :link, :monitor |
self.spawnOn(node, m, f, args, opts) |
receive |
self.receive(match, timeout) |
Process.monitor/1, Process.link/1 |
self.monitor(dest), self.link(pid) |
Process.register/2, Process.whereis/1 |
self.register(name), node.whereis(name) |
Process.flag(:trap_exit, true) |
self.trapExit = true |
Phoenix.PubSub.subscribe/2 |
self.subscribe(node, pubsub, topic) |
use GenServer, GenServer.start_link/3 |
defineGenServer({...}), .start(node, opts) |
Phoenix.PubSub.broadcast/3 |
node.broadcast(node, pubsub, topic, msg) |
Node.monitor/2 |
node.monitor(node, handler, { reconnect }) |
exit/1, Process.exit/2 |
self.exit(reason), node.exit(pid, reason) |
One node keeps any number of connections, outgoing and incoming.
Module and process names follow Elixir: a name that starts with an
uppercase letter is an Elixir alias, so "MyApp.Users" means
:"Elixir.MyApp.Users"; lowercase names ("lists", "erlang") are Erlang
modules. This applies wherever erlnode expects a module or a registered
name. atom("...") is always taken literally, so atoms inside arguments
need the full name: atom("Elixir.MyApp.Registry").
Shortcuts for common jobs, built on the API above. Each one is a process with a loop you could write yourself; the sections below show how.
// Listen to a Phoenix.PubSub topic. Subscribes again if the connection or
// the subscription is lost; returns a function that unsubscribes.
const off = node.subscribe(APP, "MyApp.PubSub", "orders", (event) => {
console.log(event);
});
// Answer GenServer.call / GenServer.cast to {:prices, :"js@localhost"}.
let price = 0;
node.serve("prices", {
call: (req) => price, // the return value is the reply
cast: (req) => {
const [op, value] = req as Tuple; // GenServer.cast(pid, {:set, 42})
if (op === atom("set")) price = value as number;
},
info: (msg) => console.log(msg), // any other message
});
// Call functions of a remote module through :erpc. The interface is
// optional and only gives types; nothing checks it at run time.
interface Users {
get(id: number): Promise<Term>;
list(opts: Term): Promise<Term>;
}
const users = node.module<Users>(APP, "MyApp.Users");
await users.get(1); // :erpc.call(APP, MyApp.Users, :get, [1])
// Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", msg) from JS.
await node.broadcast(APP, "MyApp.PubSub", "orders", tuple(atom("created"), 42));
// Know when the Elixir node goes away and comes back.
node.monitor(APP, (event, reason) => console.log(event, reason), { reconnect: 1000 });
// Start a process again after a crash, like a :transient supervisor child.
node.spawn(async (self) => { /* ... */ }, { restart: { delay: 1000 } });For a server with setup, cleanup or state worth naming, describe it once the way Elixir does and start it as many times as needed:
import { atom, defineGenServer, toJS, type Tuple } from "erlnode";
const Rates = defineGenServer({
init: async (self) => ({
rates: await loadRates(),
timer: setInterval(() => node.send(self.pid, atom("refresh")), 60_000),
}),
// GenServer.call(Rates, {:get, "EUR"})
handleCall: (req, s) => {
const [op, cur] = req as Tuple;
if (op === atom("get")) return { reply: s.rates[toJS<string>(cur!)] ?? null, state: s };
},
// GenServer.cast(Rates, {:set, "EUR", 1.1})
handleCast: (req, s) => {
const [op, cur, rate] = req as Tuple;
if (op === atom("set")) s.rates[toJS<string>(cur!)] = rate as number;
return s;
},
// send(Rates, :refresh)
handleInfo: async (msg, s) => (msg === atom("refresh") ? { ...s, rates: await loadRates() } : s),
terminate: (_reason, s) => clearInterval(s.timer),
});
await Rates.start(node, { name: "Rates", restart: true });startresolves onceinitis done and rejects if it throws; a failed first start is not restarted.handleCallreturns{ reply, state }; returning nothing means the request has no clause, and the server crashes so the caller gets an error instead of waiting for a timeout.handleCastandhandleInforeturn the new state.terminateruns whenever the process ends, killed or not, so it is the place for cleanup.- State goes in and out of the callbacks, so they can be tested as plain functions, without a node.
serve is the short form for servers that need none of this.
servehandles one message at a time. A handler that throws crashes the process and the pending call fails on the Elixir side;serve(name, handlers, { restart })brings it back.modulechecks nothing up front: a missing function fails when called. Names with?or!need brackets:users["valid?"](x). Instead of an interface, a union of names works too:node.module<"get" | "list">(...).monitorcalls the handler with"down"right away when there is no connection, then on every change. Withoutreconnectnothing tries to connect again, as in Erlang; the nextcallorsenddoes.restartdoes not restart after:normal,:shutdownor{:shutdown, _}. Each run gets a new pid; register a name for a stable address.
| Erlang | JS (decode) | JS (encode) |
|---|---|---|
| integer | number, bigint outside the safe range |
number (safe integer), bigint |
| float | number |
number (non-integer), float(x) |
| atom | Atom (atom("ok")), true/false as booleans |
Atom, boolean |
| binary | Uint8Array (binaryToString for text) |
Uint8Array, string |
| list | array | array |
| charlist | array of numbers | array of numbers |
| tuple | Tuple (t.at(i), const [a, b] = t) |
Tuple (tuple(...)) |
| map | Map |
Map, plain object (keys stay strings) |
| improper list | ImproperList |
ImproperList |
| bitstring | BitBinary (bytes and bit count of the last byte) |
BitBinary |
&m.f/a |
Export |
Export |
| fun | Fun (opaque, can be sent back) |
Fun |
| pid, reference, port | Pid, Ref, Port |
Pid, Ref, Port |
Atoms are interned, so atom("ok") === atom("ok") and they work as Map keys.
null is encoded as nil.
Nothing from JS becomes an atom unless you ask for it: object keys stay
strings ({ name: "Bob" } → %{"name" => "Bob"}), strings stay binaries.
Atoms are never garbage collected and a node has a limit on them, so
turning outside data into atoms can bring the node down. When the Elixir
side wants atoms, say so with these helpers:
import { amap, kw, struct } from "erlnode";
kw({ timeout: 5000, force: true }); // [timeout: 5000, force: true]
amap({ name: "Bob", age: 30 }); // %{name: "Bob", age: 30}
struct("MyApp.User", { name: "Bob" }); // %MyApp.User{name: "Bob"} (no defaults filled in)toJS(term) turns a decoded term into plain JS data: atoms become strings
(nil becomes null), UTF-8 binaries become strings, tuples become arrays,
maps become objects. It loses information, so it is only applied when you
call it:
import { toJS } from "erlnode";
type User = { name: string; roles: string[]; manager: string | null };
const user = toJS<User>(await node.erpc(APP, "MyApp.Users", "get", [1]));
// %{name: "Bob", roles: [:admin], manager: nil}
// → { name: "Bob", roles: ["admin"], manager: null }
// The type parameter only saves a cast; nothing checks the data against it.- EPMD lookup, handshake version 6 (OTP 23+), ticks.
sendto a pid, a local name or[name, node].- Processes:
node.spawn(fn)runsfn(self)as a process with its own pid and mailbox. It exits with:normalwhenfnreturns and with{:js_error, message}when it throws (the error is logged too). Messages between JS processes are copied as if they went over the wire: the receiver gets the same terms a remote process would, and nothing is shared with the sender. call(the gen_server protocol) andrpc(throughrex). Output that the remote function writes is discarded.callmonitors the target, so a missing or crashed process fails the call withCallError.erpc(:erpc.call): runs the function in a new process on the remote node; exceptions come back asErpcErrorwith the Erlang class and reason. Prefer it (ornode.module) torpc: a failedrpcdoes not throw, it returns{:badrpc, reason}, which is easy to miss.self.subscribe(node, pubsub, topic)subscribes to Phoenix.PubSub;self.relay(node, m, f, args)does the same forRegistry,:pgor anything else that registers the caller. Nothing is deployed on the Elixir side.self.spawnOn(node, m, f, args, { link, monitor })starts a process on the remote node (spawn_request). Spawn requests from BEAM to us are answered withnotsup: the JS node cannot run Erlang code.- Links and monitors in both directions, with Erlang's exit rules: a linked
process exiting with a non-normal reason takes ours down, unless
trapExitis set and the signal arrives as{:EXIT, from, reason};:killcannot be trapped. Monitors deliver{:DOWN, ref, :process, object, reason}. When a connection drops, links and monitors over it fire with:noconnection. - Incoming connections after
node.listen(): the node registers in the local EPMD and accepts handshakes. Processes can be registered under a name (self.register("inbox")), so Erlang code can usesend({:inbox, :"js@host"}, msg), monitor{:inbox, :"js@host"}orGenServer.callit (answer withnode.reply(from, value)).
The node is hidden, so it shows up in Node.list(:hidden) and not in
Node.list(). Without listen() it only makes outgoing connections.
listen() binds all interfaces unless host is given, like erl does.
Also:
- Fragmented messages (
DFLAG_FRAGMENTS): BEAM sends large messages in 64 KB pieces, possibly interleaved between senders; they are put back together per sender. Our own messages go out whole.
Not supported yet: TLS distribution, the atom cache.
A JS process reads its mailbox with for await. Elixir can reach it in two ways.
By a name registered on the JS node:
import { atom, start, tuple } from "erlnode";
const node = start({ name: "js@localhost", cookie: "secret" });
await node.connect("app@localhost");
node.spawn(async (self) => {
self.register("updates");
for await (const msg of self) {
console.log("from elixir:", msg); // send({:updates, :"js@localhost"}, {:price, 42})
}
});By pid: the JS process sends its pid first, Elixir keeps it and sends to it
later. For Elixir it is an ordinary remote pid: send, Process.monitor and
Process.link work on it, and :DOWN arrives when the JS process exits or
the connection drops.
node.spawn(async (self) => {
await self.send(["Prices", "app@localhost"], tuple(atom("subscribe"), self.pid));
for await (const msg of self) console.log(msg);
});Messages travel over the connection the JS node opened. If Elixir has to
connect first (we never connected, or the connection dropped), call
node.listen() so the JS node accepts connections.
A JS process can subscribe to a Phoenix.PubSub topic of a running Elixir app, with no code added to the app:
node.spawn(async (self) => {
await self.subscribe("app@localhost", "MyApp.PubSub", "orders");
for await (const event of self) {
console.log(event); // Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", event)
}
});subscribe is built on relay, which works for anything that registers
the calling process. relay(node, m, f, args) starts a process on node
that calls m.f(...args) and then forwards every message it gets to the JS
process. The atom $self in args stands for the relay's pid:
// Registry.register(MyApp.Registry, "key", nil)
// (inside arguments, atom() needs the full Elixir name)
await self.relay(APP, "Registry", "register", [atom("Elixir.MyApp.Registry"), "key", atom("nil")]);
// :pg.join(:my_scope, "workers", relay_pid)
await self.relay(APP, "pg", "join", [atom("my_scope"), "workers", atom("$self")]);The relay runs through erl_eval, so nothing has to be deployed on the
Elixir side. It is linked to the JS process and monitors it: when the JS
process exits or the connection drops, the relay exits and the subscription
is gone. If the relay dies, the JS process gets the exit signal. Both
relay and subscribe return stop() to unsubscribe and keep the process.
When the relay dies or the connection drops, the JS process gets an exit
signal. To keep listening, trap it and subscribe again. This is what
node.subscribe does:
node.spawn(async (self) => {
self.trapExit = true;
for (;;) {
try {
await self.subscribe(APP, "MyApp.PubSub", "orders");
for await (const msg of self) {
if (msg instanceof Tuple && msg.at(0) === atom("EXIT")) break; // subscription lost
console.log(msg);
}
} catch {
// node unreachable: wait and try again
}
await new Promise((r) => setTimeout(r, 1000));
}
});defineGenServer and node.serve do this for you. By hand, a JS process can stand in for a GenServer: GenServer.call arrives as
{:"$gen_call", from, request} and is answered with node.reply(from, value),
GenServer.cast arrives as {:"$gen_cast", request}. State is a plain variable.
import { atom, start, type Tuple } from "erlnode";
const node = start({ name: "js@localhost", cookie: "secret" });
await node.listen();
// GenServer.cast({:counter, :"js@localhost"}, {:add, 5})
// GenServer.call({:counter, :"js@localhost"}, :inc) #=> 6
node.spawn(async (self) => {
self.register("counter");
let count = 0;
for await (const msg of self) {
const [tag, a, b] = msg as Tuple;
if (tag === atom("$gen_call") && b === atom("inc")) {
await node.reply(a!, ++count);
} else if (tag === atom("$gen_cast")) {
const [op, n] = a as Tuple;
if (op === atom("add")) count += n as number;
}
}
});Set ERLNODE_DEBUG=1 to log connections and every control message to
stderr, in Erlang notation:
erlnode: connected to app@localhost
erlnode: → app@localhost MONITOR_P {19,#PID<js@localhost.2.0>,'Elixir.Counter',#Reference<js@localhost.1.2665438959.0>}
erlnode: → app@localhost REG_SEND {6,#PID<js@localhost.2.0>,'','Elixir.Counter'} {'$gen_call',{#PID<js@localhost.2.0>,#Reference<js@localhost.1.2665438959.0>},inc}
erlnode: app@localhost → SEND_SENDER {22,#PID<app@localhost.118.0>,#PID<js@localhost.2.0>} {#Reference<js@localhost.1.2665438959.0>,1}
erlnode: → app@localhost DEMONITOR_P {20,#PID<js@localhost.2.0>,'Elixir.Counter',#Reference<js@localhost.1.2665438959.0>}
erlnode: connection to app@localhost closed: connection closed by us
That is node.call(["Counter", "app@localhost"], atom("inc")): a monitor on
the target, the $gen_call message, the reply {ref, 1}, the demonitor.
Payloads longer than 500 characters are cut. Without the variable nothing is
logged. The log shows message contents (never the cookie), so keep it off
where messages carry private data.
The full test suite passes against live nodes of each of these releases (last run on 2026-10-25 with Bun 1.4.2):
| OTP | Elixir |
|---|---|
| 23.3 | 1.14 |
| 24.3 | 1.14 |
| 25.3 | 1.14 |
| 26.2 | 1.15 |
| 27.3 | 1.18 |
| 28.5 | 1.20 |
| 29.1 | 1.18 |
To repeat it: scripts/test-otp.sh (needs nix).
One known gap: ports are always encoded as V4_PORT_EXT, which OTP 23 does
not read, so a port cannot be sent to an OTP 23 node.
The cookie gives full access to the remote node: whoever has it can run any code there. Keep it out of logs and source control, and do not expose the distribution port to untrusted networks.
bun testTests start real Elixir nodes, so elixir and epmd must be on the PATH.
The Phoenix.PubSub test installs phoenix_pubsub with Mix.install, so its
first run needs access to hex.pm.
ERLNODE_LONG=1 bun test also runs a 3 minute keepalive test.
bun run build builds dist/ with tsdown: one ESM file and its type
declarations. node scripts/smoke.mjs (or deno run -A scripts/smoke.mjs)
runs the built package against a live Elixir node outside Bun. TypeScript
projects on Node need @types/node, since the public types mention
node:net sockets.
scripts/test-otp.sh runs the suite against OTP 23 to 29 with nix
(scripts/test-otp.sh 25 26 for some of them). CI runs OTP 24 to 29;
GitHub's runners no longer support OTP 23.