DevTools overview
Part of why the BEAM feels the way it does is that you can see the
system: observer connects to a live node and shows you the process
tree, the mailboxes, the load. DevTools is that idea for actor-ts —
an embeddable web UI you attach to a running ActorSystem.
import { ActorSystem } from 'actor-ts';import { DevTools, DevToolsOptions } from 'actor-ts/devtools';
const system = ActorSystem.create('orders');
const devtoolsOptions = DevToolsOptions.create().withPort(9333);const devtools = await DevTools.attach(system, devtoolsOptions);console.log(devtools.url); // http://127.0.0.1:9333Open that URL and you land on the overview: what this system is, what it is doing, and how that is trending. The nav rail on the left is the way into every other tool.
The panels
Section titled “The panels”DevTools is one product assembled from several tools. The nav rail always shows the full roster: a tool this system cannot offer stays greyed out with the reason, so nothing is hidden behind a click that fails.
| Panel | Shows |
|---|---|
| Overview | System identity, uptime, live figures, trends |
| Actors | Live actor tree, cell states, mailbox depths, busiest actors |
| Cluster | Node topology, shard distribution, membership history |
| Tracing | Flame graph and waterfall over recorded message spans |
| Explain plan | The last messages one actor handled, with timings |
| Time travel | Browse a journal, reconstruct state at any point |
| Profiler | Where the actor system spends its time |
| Dead letters | Messages the system could not deliver, and why |
| Event stream | Live tail of the event bus, and the cluster topics |
| Configuration | Every resolved HOCON key, and which layer set it |
| Send message | Send JSON to an actor — off unless acknowledged |
All of them speak one tap protocol over a single WebSocket, so they share a connection rather than each opening their own.
The overview itself is three sections, in the order you actually ask the questions:
- Common — actor system, actor-ts version, uptime, runtime, cluster status. Uptime is the system’s, read from the server, so it survives a page reload and a dropped socket. The version is the running framework’s, straight from the handshake — hover it for the tap protocol version.
- Numbers — actors, messages per second, processed messages, spawn and stop rates, restarts, mailbox backlog, stashed messages, suspended actors, dead letters, mailbox drops, and handler p99.
- Charts — the same figures over the last few minutes, split across three plots so a level and a rate never share a y-axis, plus the busiest mailboxes right now.
Pausing time
Section titled “Pausing time”Every panel here is a live view, which is exactly wrong at the moment it becomes interesting: the row you want to read is gone before you have read it. Pause in the header — or the P key, anywhere outside a text field — stops the whole view at once. Resume starts it again.
Two things stop, and the second one is the point:
- The data. No panel folds anything new in while time is stopped.
- The clock the panels are read against. Every “how long ago” reading in the UI holds still with the view it describes — uptime, a departed node’s last seen, and the stopped 12s ago badge on a terminated actor. This is what makes the pause useful rather than merely quiet: the actors panel keeps a stopped actor for 30 seconds and then sweeps it away, and 30 seconds is well inside the time it takes to read a supervision tree. Paused, it stays.
Nothing that happened during the pause is lost, but the two kinds of stream get there differently:
| Streams | While paused | On resume |
|---|---|---|
| Event stream, tracing spans, profiler | held, oldest dropped past a cap | delivered in order |
| Overview figures, actors, cluster, mailboxes | discarded | fresh snapshot — the view jumps to now |
The split is not a compromise, it is what each kind of data is. A tail is its frames and the server keeps no past to recover them from, so holding them is the only way not to lose them. A tree or a membership list only ever answers “what is true now”, and a fresh snapshot answers that exactly — and more cheaply than replaying every delta. The header says how many frames are being held, and names anything the cap had to throw away.
The charts do not end up with a hole. The server records its figures continuously whether or not anyone is watching, so resuming re-reads the window and the paused stretch fills in.
Two things deliberately do not stop:
- The connection check. A node that dies while you are paused still raises the No node reachable dialog. A paused screen and a dead one look identical, so the one you did not ask for has to say so.
- The server. The taps keep running; pausing is a property of your view, not of the system being watched. It is also why a pause costs the actor system nothing extra and can be held as long as you like.
Attaching
Section titled “Attaching”DevTools.attach binds a server of its own — usually what you want,
since the DevTools port should be firewalled separately from your
application’s.
-
Attach, ideally behind a flag so production never runs it:
if (process.env.DEVTOOLS === '1') {await DevTools.attach(system); // http://127.0.0.1:9333} -
Open the URL the returned binding reports. With
port: 0the operating system picks a free port and the binding tells you which. -
Detach when you are done. This also happens automatically during
CoordinatedShutdown, so aSIGTERMreleases the port:await devtools.detach();
Mounting into an existing server
Section titled “Mounting into an existing server”To put DevTools next to your management endpoints instead of on a port of its own, take the routes and bind them yourself:
import { BearerTokenAuth, concat, path } from 'actor-ts/http';import { DevTools, DevToolsOptions } from 'actor-ts/devtools';
const devtoolsOptions = DevToolsOptions.create() .withAuth(BearerTokenAuth({ tokens: [process.env.DEVTOOLS_TOKEN!] }));
const routes = concat( managementRoutes(system, cluster), path('devtools', DevTools.mount(system, devtoolsOptions)),);await system.http(8558, { host: '127.0.0.1' }).bind(routes);Whatever middleware wraps that subtree applies to DevTools too.
mount asks for the gate up front, and this is why: unlike attach it
never learns where its routes end up. host is not read on this path —
the caller binds — so DevTools cannot tell a private port from a public
one, and it will not guess. Passing auth or ipAllowlist is the
direct answer and the sturdier one: both wrap the tree that is returned,
so the gate travels with it wherever it is mounted. When the surrounding
server already gates the mount point, say so instead:
const devtoolsOptions = DevToolsOptions.create().withAllowUngatedMount();DevTools then logs one line recording that it is running without a gate of its own, and the rest is yours to get right.
Options
Section titled “Options”Builder-first, as everywhere else in the framework; a plain object works just as well.
const devtoolsOptions = DevToolsOptions.create() .withPort(9333) .withHost('127.0.0.1') .withPanels({ timeTravel: false });| Option | Default | Meaning |
|---|---|---|
host | '127.0.0.1' | Interface to bind. Anything routable needs a gate — see below. |
port | 9333 | Port to bind; 0 picks a free one. |
auth | — | Middleware wrapping the whole tree (e.g. BearerTokenAuth). |
ipAllowlist | — | Middleware wrapping the whole tree. |
allowRemote | false | Acknowledge an ungated, routable bind (attach). |
allowUngatedMount | false | Acknowledge a mounted tree DevTools does not gate (mount). |
backend | framework default | HTTP backend for the DevTools server. |
serveUi | true | false leaves the tap without the UI. |
allowedOrigins | same-origin | Origins allowed to open the WebSocket. |
panels | all enabled | Per-panel switches — see below. |
uiDevelopmentRoot | — | Serve the UI from disk; panel development only. |
Switching panels off
Section titled “Switching panels off”Each panel can be disabled individually, and a disabled panel is disabled for good — its data never leaves the process, whatever a client asks for:
const devtoolsOptions = DevToolsOptions.create() .withPanels({ timeTravel: false, deadLetters: false });Time travel is the one to think about first: it is the panel that surfaces raw persisted events — closely followed by dead letters and the event stream, which both show message payloads.
Security
Section titled “Security”DevTools is a debugger, and the defaults treat it as one.
-
Loopback by default.
hostis127.0.0.1, so nothing outside the machine can reach it. -
A routable bind must be deliberate. On
attach, settinghostto anything that is not loopback throws unless you also passauth,ipAllowlist, orallowRemote: true. The error names all three, so a typo in a host string cannot quietly publish your actor state. -
A mount must be deliberate too.
mounthands its routes to a server DevTools never sees, so it cannot check the host the wayattachdoes —hostis not even read on that path. It therefore asks up front:auth,ipAllowlist, orallowUngatedMount: true, which acknowledges that whatever gates this tree is outside DevTools (and gets a line in the log saying so). The loopback default does not count as a gate here, because it is not what the caller will bind. -
Gates cover everything.
authandipAllowlistwrap the UI, the JSON endpoints and the WebSocket upgrade — a gate on only the JSON would leave the actual data channel open. -
Same-origin sockets. The tap accepts a WebSocket upgrade whose
Originnames the tap itself, and rejects any other — so a page you visit cannot dial your local DevTools. This is on by default and needs no configuration: the UI is served by the tap, so its origin always matches.allowedOriginswidens the rule for a UI served from somewhere else; it does not replace it. A request with noOriginat all is allowed, because CSWSH needs a browser and a browser always sends one.Binding to loopback is not a substitute for this. A WebSocket handshake is not subject to the same-origin policy, so any page in the developer’s browser can reach
127.0.0.1. -
A connected client cannot flood the tap. Requests are answered off the hub’s mailbox, so one slow journal read cannot stall the other tabs — which also means the mailbox is not what bounds the work. The hub counts it instead: 32 requests in flight per connection and 256 across the tap. A request past either is refused with an
unavailableerror rather than queued. The sockets are capped too, at 32 concurrent connections; an upgrade past that is closed with 1013 before it is wired up.A panel never comes near those numbers. A client asking for thousands of concurrent journal replays does, and the tap runs in the same process as the actors you are debugging — so what it could starve is your application, not just the debugger. See the protocol reference for what a client should do when it is refused.
-
The cluster side goes by the connection. When you pass a
Cluster, every node answers “how is your node doing?” over the cluster transport and the serving node collects the answers. Both ends trust the connection rather than the message: a node replies down the connection the question arrived on, never to an address the question named, and the collector files a reading under the address the transport supplied, never the one the reading claims for itself. Only a node the cluster currently holds as a member gets a row, and both the number of rows and the size of a reported actor tree are capped. A peer that lies can only lie about its own row.This bounds what one compromised or buggy member can do to the dashboard. It is not authentication of the cluster port — that is mTLS, and it stays the thing to configure when the port is reachable by anything you do not run.
If you do expose it, put it behind the same auth as your management endpoints and treat the credentials as production secrets:
import { BearerTokenAuth } from 'actor-ts/http';
const devtoolsOptions = DevToolsOptions.create() .withHost('0.0.0.0') .withAuth(BearerTokenAuth({ tokens: [process.env.DEVTOOLS_TOKEN!] }));Try it on the examples
Section titled “Try it on the examples”The examples wired for DevTools are the ones that run until you stop them — the HTTP and cache services, the sharding demo, the chat and voice backends — and none of them pay for it unless you ask:
bun run examples/http/rest-service.ts --devtools--devtools works in every shell. DEVTOOLS=1 bun run … does the same
on a POSIX shell, but VAR=value command is a parser error in
PowerShell — there, set it separately:
$env:DEVTOOLS = '1'; bun run examples/http/rest-service.tsThat dividing line is the whole rule: an example that finishes on its own
carries no reference to DevTools at all, so you can copy one into a
project and run it unchanged. It took two passes to get there, which is
worth knowing if you remember the older wording. The sweep that removed
the wiring (#552) keyed on which examples had explicitly parked
themselves before shutdown so DevTools could be opened at all —
hello-world, the patterns/ and persistence/ walkthroughs, and the
rest of the short scripts. That left seven behind that had never parked
themselves and only ever attached: cluster/singleton-hello.ts,
cluster/singleton-cron.ts,
cluster/sharded-daemon-hello.ts,
cluster/sharded-daemon-fixed-workers.ts,
discovery/service-locator-cluster.ts, pubsub/event-bus-across-nodes.ts
and management/opentelemetry-tracing.ts. They bound a port and logged a
URL that was dead before a browser could open it — singleton-hello
printed three such URLs and exited after about 1.3 s. They are unwired
now too.
Teaching those seven to stay up was the other way to close that gap, and it is not what happened: each one ends by leaving the cluster and terminating its systems, so parking afterwards would hand the browser a system that is already gone — and parking instead of the teardown would delete the demonstration, which in three of them is exactly that teardown. For something to actually look at, reach for one of the services above or for a cluster node below.
Each attachment takes its own port, counting up from --devtools-port /
DEVTOOLS_PORT (default 9333). That counter is per process, so a
cluster built from separate terminals — where every node would start at
9333 — takes the first free port in the range instead, the same way the
cluster transport scans for its own:
bun run examples/cluster/counter-node.ts --devtools --port 9001bun run examples/cluster/counter-node.ts --devtools --port 9002 --seeds 127.0.0.1:9001bun run examples/cluster/counter-node.ts --devtools --port 9003 --seeds 127.0.0.1:9001Three terminals, one three-node cluster, and DevTools on 9333, 9334
and 9335 without a single DevTools port flag — --port there is the
node’s cluster port. If none is free — or anything else about DevTools
fails — the example logs a warning and starts anyway. A debugger that
cannot bind is not a reason for the program under debug to die.
The bind interface is 127.0.0.1. --devtools-host / DEVTOOLS_HOST
changes it — the flag for when the browser is not on the machine running
the example: a container, a VM, a WSL or remote dev box.
bun run examples/http/rest-service.ts --devtools --devtools-host 0.0.0.0Typing a non-loopback host there is the deliberate act
Security asks for, so the example pairs it with
allowRemote and DevTools binds — and warns that it is reachable
without auth — instead of refusing and leaving you to guess why. That
trade is fine for an example and for nothing else: anything with real
state in it wants auth or an ipAllowlist in front of the port.
The wiring lives in examples/devtools.ts if you want to copy the
pattern into your own app.
When nothing answers
Section titled “When nothing answers”Every panel keeps showing the last thing it was told — the final reading before a node died is usually the one worth having — so a lost connection would otherwise look exactly like a healthy system. After a couple of seconds without an answer a dialog opens saying No node reachable, counting how long that has been true, and it closes itself the moment something answers. Dismiss it to read the last figures anyway — the panel stays dimmed while the connection is down, so they cannot be mistaken for live ones, and the uptime counter freezes at its last reading rather than counting up past the death of the system it measures.
Each node serves its own DevTools, so another node’s port may still answer while the one you have open does not.
Unused, none. Creating the extension does nothing: no port, no
taps, no instrumentation. Everything starts at attach(), and each
panel’s data collection only runs while a browser is actually subscribed
to it — an open overview does not make the system record spans.
While attached, DevTools switches the
metrics registry on if you had
not, because
message throughput, mailbox drops and handler latency are counted by the
framework itself and read 0 against the default noop registry. That
makes every framework metric live, cluster counters included, for as
long as DevTools is attached; detach() puts the noop back. A registry
you enabled yourself is left alone in both directions — DevTools reads
it and never disables it.
Working on the UI
Section titled “Working on the UI”The UI is an Angular application whose charts are drawn by Apache ECharts,
built by its own toolchain and embedded into the published package as a
gzipped bundle. Both are build-time dependencies only: neither appears in
actor-ts’s dependencies or peerDependencies, nothing a consumer installs
carries them, and the served page loads nothing over the network. So the UI
still needs no framework at runtime and no network access — what changed is
how the bundle is produced, not what ships.
ECharts is loaded lazily, in a chunk of its own, so opening a panel that draws nothing costs none of it.
That toolchain is a separate install, deliberately not a workspace, which is why a fresh clone can typecheck, test and smoke without it — the built bundle is committed. Install it once before working on the UI:
bun run ui:installTo work on a panel:
bun run build:ui -- --dev --watchbun run dev:devtoolsThe first rebuilds on save into devtools-ui/.dev; the second boots a
demo system serving that directory via uiDevelopmentRoot. The loop is
save → refresh, with no TypeScript build and no server restart.
