Aller au contenu
Français

DevTools tap protocol

Ce contenu n’est pas encore disponible dans votre langue.

Every DevTools panel talks to the actor system through one tap: a single multiplexed WebSocket plus a couple of JSON endpoints. One socket rather than one per panel means a browser tab holds a single connection, switching panels costs no reconnect, and an idle tab costs the system nothing.

This page is the contract. It is stable enough to write your own client against — the types are exported from actor-ts/devtools.

EndpointPurpose
GET /The UI shell (omit with serveUi: false)
GET /assets/*UI bundle
GET /api/infoHandshake data as plain JSON — curl-able, no socket needed
WS /api/wsThe tap

The protocol carries a single integer version, exported as DEVTOOLS_PROTOCOL_VERSION.

  • Additive changes keep the version. New frame kinds, new streams, new request methods and new optional payload fields may appear at any time. Clients must ignore frame kinds, streams and fields they do not recognise — that is what lets an older UI keep working against a newer server.
  • Anything else bumps it. Removing or renaming a field, or changing the meaning or type of an existing one.

The version is exchanged in the handshake and a mismatch closes the socket with code 4400. Refusing beats negotiating here: a UI bundle from a different release rendering a half-understood actor tree is worse than a banner telling you to rebuild.

The client sends hello first; nothing else is accepted before it.

// client → server
{ "kind": "hello", "protocolVersion": 1, "client": "devtools-ui" }
// server → client
{
"kind": "welcome",
"protocolVersion": 1,
"serverVersion": "0.11.0",
"systemName": "orders",
"startedAtMs": 1730000000000,
"streams": ["stats", "actors"],
"panels": [
{ "id": "dashboard", "status": "active" },
{ "id": "time-travel", "status": "unavailable", "reason": "no journal configured" }
]
}

streams and panels are the capability advertisement. A panel is active, disabled (the operator switched it off), or unavailable with a reason — which the UI renders on the greyed-out nav entry, so a capability this system lacks is explained rather than silently missing.

KindFieldsMeaning
helloprotocolVersion, client?Open the session
subscribestream, parameters?Start receiving a stream
unsubscribestreamStop receiving it
requestrequestId, method, parameters?Invoke a pull operation
KindFieldsMeaning
welcomesee aboveHandshake accepted
eventstream, sequenceNumber, payloadOne stream event
responserequestId, resultAnswer to a request
errorcode, message, requestId?Rejection

Error codes: version-mismatch, malformed-frame, unknown-stream, unknown-method, unavailable, bad-parameters, internal. An error carrying a requestId rejects that request; one without it is connection-level.

Subscribing yields a snapshot first, then deltas — the server generates the snapshot on the same mailbox that publishes the deltas, so it can never be built from a half-updated view.

StreamPayload kinds
statsstats-sample
actorsactor-tree-snapshot, actor-started, actor-changed, actor-stopped, actor-restarted
clustercluster-snapshot, cluster-event, shard-map-changed
mailboxesmailbox-sample
spansspan-batch
explainexplain-entries
profilerprofiler-progress, profiler-completed

Every event frame carries a per-stream sequenceNumber starting at 1. A gap means frames were dropped, so the client’s incremental state is now a guess: the bundled UI responds by re-subscribing for a fresh snapshot rather than rendering a tree that quietly disagrees with reality. Any client should do the same.

Counters in stream payloads are cumulative since attach, never deltas — a client computes rates from consecutive samples, so a reconnect or a missed tick cannot corrupt the figures.

Pull operations, namespaced by the panel that owns them:

MethodPurpose
explain.enable / explain.disable / explain.fetchPer-actor explain plan
journal.ids / journal.readBrowse a persistence journal
replay.capabilities / replay.state / replay.diffReconstruct past state
profiler.capabilitiesWhich profiling modes this host can run
profiler.start / profiler.stopControl a profiling session
stats.historyThe overview’s charted series for a chosen timespan
tracing.bufferHow many recent spans the server retains

A method with no handler on this system answers error with code unavailable — the same signal the panel descriptor already gave, so a client that ignored the handshake still fails safely.

Requests are answered off the tap’s mailbox, so a slow journal read cannot stall other connected tabs.

The types and helpers are exported from the DevTools entry point:

import { match } from 'ts-pattern';
import {
DEVTOOLS_PROTOCOL_VERSION,
decodeClientFrame,
helloFrame,
type DevToolsServerFrame,
} from 'actor-ts/devtools';
const socket = new WebSocket('ws://127.0.0.1:9333/api/ws');
socket.addEventListener('open', () => socket.send(JSON.stringify(helloFrame('my-client'))));
socket.addEventListener('message', (event) => {
const frame = JSON.parse(String(event.data)) as DevToolsServerFrame;
match(frame)
.with({ kind: 'welcome' }, () => socket.send(JSON.stringify({ kind: 'subscribe', stream: 'stats' })))
.otherwise(() => {});
});

Inbound frames on the server side go through decodeClientFrame, which rejects anything that is not a well-formed frame — including kinds it does not know. The “ignore what you don’t recognise” half of the compatibility rule applies to clients reading server frames, not to the server: it has no reason to accept frames from a client newer than itself.