Actor-Web provides actor runtimes, topology descriptors, browser/client sources, gateway projections, and runtime-to-runtime transports. The public API favors typed messages, topology-owned actors, and hexagonal boundaries.
Read this guide in layers:
- Define actor behavior with
defineBehavior(...). - Declare runtime ownership with
defineActorWebTopology(...). - Start actor-owning locations with
serveNode(...)orstartActorWebNode(...). - Connect thin UIs and dashboards with
createActorWebReadModelClient(...)orcreateActorWebReadModelSource(...). - Add application ingress with
serveActorWebHttp(...).
Current guarantees:
- Actor behavior handlers return
{ context, reply, emit }. - Runtime-to-runtime transport uses the
MessageTransportseam. - Gateway is a client projection/control channel, not cluster transport.
- Built-in runtime transport is direct-peer and at-most-once.
- Runtime peers and gateway clients can be authenticated before stream/peer admission.
- Topology runners expose normalized runtime transport status with peer freshness and heartbeat-derived stale detection.
- Gateway streams detect sequence gaps and resync from a bounded replay tail, using in-memory storage by default and optionally restoring that tail from an app-provided durable replay provider.
- Topology runners can use a runtime peer discovery provider instead of only
static
peersmaps. - Runtime transport telemetry, exporter plumbing, and the Node JSONL sink are available for transport observability.
Actor-Web treats a distributed application as a topology of logical nodes. A node can run in a backend process, browser worker, service worker, CLI process, or another machine. Actors belong to nodes, and clients consume actor projections through gateway sources.
Use these APIs by location:
| Location | Owns actors? | API |
|---|---|---|
| Shared contract package | No runtime | defineActorWebTopology(...) |
| Backend/server process | Yes | serveNode(...) |
| Backend REST/application ingress | Uses served actors | serveActorWebHttp(runtime) |
| Browser UI / Ignite host | No ActorSystem by default | createActorWebReadModelClient(...) |
| Browser/WebWorker runtime location | Yes | startActorWebNode(...) |
| Browser-local worker or service-worker edge | Optional | createMessagePortTransport(...) passed to startActorWebNode(...) |
| Separate frontend/backend repos | Usually client-only | createActorWebReadModelSource({ address, contractVersion, gateway }) |
There are two different network channels:
- Gateway URL: client projection/control channel for UI, Ignite Element, live command clients, and dashboards.
- Transport URL: runtime-to-runtime channel used by Actor-Web nodes that own actors and exchange actor messages.
Use a gateway source when a page or client wants to observe/control an actor. Use
startActorWebNode(...) or serveNode(...) when that process is an
Actor-Web runtime location that owns actors.
Recommended imports:
// Shared topology and actor behavior.
import { defineBehavior, defineFSM } from '@actor-web/runtime';
import { actor, defineActorWebTopology, node, supervisor, tool } from '@actor-web/runtime/topology';
// Browser/client projection and browser worker runtime hosting.
import {
createActorWebCommandSource,
createActorWebReadModelClient,
createActorWebReadModelSource,
startRuntime,
startActorWebNode,
} from '@actor-web/runtime/browser';
// Node/server runtime hosting and HTTP ingress.
import { serveActorWebHttp, serveNode } from '@actor-web/runtime/node';Use defineBehavior to define actor behavior. The normal handler context exposes
only the public capabilities needed for application code:
const shipmentBehavior = defineBehavior()
.withContext({
shipmentId: null,
shipmentCount: 0,
status: 'idle',
destination: '',
carrier: 'pending',
eta: 'pending',
routeNotes: 'pending route plan',
timeline: [],
})
.onMessage(({ message, context }) => {
if (message.type === 'GET_SHIPMENT_COUNT') {
return { reply: context.shipmentCount };
}
})
.build();.build() is optional everywhere it appears in this document — topology
declarations and system.spawn(...) accept an un-built builder and build it
for you. Examples here call it explicitly only to show the complete chain.
Handler context:
message: incoming actor message.context: current actor context when.withContext(...)or.withMachine(...)provides one.tools: actor tool ports assigned by topology and implemented by the node runner.actor: advanced state/machine inspection when needed.
Handler result:
return {
context: {
...context,
status: 'route-assigned',
},
reply: { ok: true },
emit: [{ type: 'ROUTE_ASSIGNED', shipmentId }],
};context: replaces this actor's state.reply: responds toask(...).emit: publishes facts/events to subscribers and gateway streams.
Use withFSM(...) for a small synchronous constraint map. Side effects, tools,
emits, replies, and context updates stay in onTransition(...).
withFSM(...) does not provide actor context by itself. If the actor needs
domain state, pair it with withContext(...) as shown below.
const shipmentFSM = defineFSM({
initial: 'idle',
states: {
idle: {
on: {
CREATE_SHIPMENT: 'route-requested',
},
},
'route-requested': {
on: {
ASSIGN_ROUTE: 'route-assigned',
},
},
'route-assigned': {
on: {
MARK_DELIVERED: 'delivered',
},
},
delivered: {},
},
});
const behavior = defineBehavior()
.withContext({
shipmentId: null,
shipmentCount: 0,
status: 'idle',
destination: '',
carrier: 'pending',
eta: 'pending',
routeNotes: 'pending route plan',
timeline: [],
})
.withFSM(shipmentFSM)
.onTransition({
CREATE_SHIPMENT: ({ context }) => ({
context: {
...context,
status: 'route-requested',
},
emit: [{ type: 'SHIPMENT_CREATED' }],
}),
})
.build();Invalid transitions are returned as error values for ask(...) instead of being
thrown by default.
The FSM is a constraint map, not an effect engine. Keep I/O, tool calls, actor-to-actor messaging, emitted events, replies, and context updates in actor handlers.
Use withMachine(...) when you need XState features. Do not combine
withMachine(...) and withFSM(...) on the same actor.
import { createMachine } from 'xstate';
const workflowMachine = createMachine({
context: {
approved: false,
},
initial: 'pending',
states: {
pending: {
on: {
APPROVE: 'approved',
},
},
approved: {},
},
});
const behavior = defineBehavior()
.withMachine(workflowMachine)
.onTransition({
APPROVE: ({ context }) => ({
context: {
...context,
approved: true,
},
emit: [{ type: 'WORKFLOW_APPROVED' }],
}),
})
.build();Topology is the shared source of truth for nodes, actors, supervision metadata, gateway exposure, and tool requirements.
import { actor, defineActorWebTopology, node, supervisor, tool } from '@actor-web/runtime/topology';
const shipmentBehavior = defineBehavior()
.withContext({
shipmentId: null,
shipmentCount: 0,
status: 'idle',
})
.onMessage(({ message, context }) => {
if (message.type === 'CREATE_SHIPMENT') {
return {
context: {
...context,
shipmentId: String(message.shipmentId),
shipmentCount: context.shipmentCount + 1,
status: 'route-requested',
},
emit: [{ type: 'SHIPMENT_CREATED' }],
};
}
})
.build();
const routingBehavior = defineBehavior()
.withContext({
carrier: 'pending',
eta: 'pending',
routeNotes: 'pending worker plan',
})
.onMessage(({ message, context }) => {
if (message.type === 'PLAN_ROUTE') {
return {
context: {
...context,
carrier: 'Northline Express',
eta: '24h',
routeNotes: `Route through ${String(message.destination)}`,
},
reply: {
carrier: 'Northline Express',
eta: '24h',
routeNotes: `Route through ${String(message.destination)}`,
},
emit: [{ type: 'ROUTE_ASSIGNED' }],
};
}
})
.build();
export const logistics = defineActorWebTopology({
contractVersion: '1.0.0',
nodes: {
browser: node('logistics-browser-host'),
server: node('logistics-server-runtime'),
worker: node('logistics-worker-runtime'),
},
tools: [tool('routing.plan')],
actors: {
shipment: actor({
id: 'logistics-shipment',
node: 'server',
behavior: shipmentBehavior,
gateway: true,
supervision: {
strategy: 'restart',
maxRestarts: 3,
withinMs: 60_000,
},
}),
routing: actor({
id: 'logistics-routing',
node: 'worker',
behavior: routingBehavior,
tools: ['routing.plan'],
gateway: true,
}),
},
supervisors: {
serverLogistics: supervisor({
node: 'server',
strategy: 'one-for-one',
children: ['shipment'],
}),
},
});Actor descriptors include an inferred address:
logistics.actors.shipment.address;
// actor://logistics-server-runtime/logistics-shipmentThe node field declares ownership, not import location. For example,
shipment can be defined in a shared package, served by a backend process, and
projected into a browser UI without the browser owning the actor runtime.
Topology supervision has two levels:
actor(... { supervision })declares the intended failure policy for one actor.supervisor(...)declares a process group on one node.
supervisors: {
serverLogistics: supervisor({
node: 'server',
strategy: 'one-for-one',
children: ['shipment', 'routing'],
}),
}The topology builder validates supervisor node keys and child actor keys. Current
runtime enforcement is intentionally basic: actor failures are restarted through
the runtime's per-actor restart guardrails. Topology-specific group strategies
such as one-for-all and rest-for-one are metadata-only until the supervision
enforcement slice is completed; today they fall back to independent per-actor
restart behavior rather than restarting or stopping sibling actors as a group.
Tools are topology ports. Declare them once, assign them to actors, and implement them in the node runner that hosts those actors.
Tool access has three layers:
- Root
tools: the topology-wide catalog and contract. - Actor
tools: the least-privilege allowlist for one actor. - Runner
tools: concrete implementations supplied by the hosting process.
Root tools do not automatically become available to every actor. Actor behavior only receives tools explicitly assigned to that actor.
import type { ActorToolExecutor } from '@actor-web/runtime';
type ScanInput = { label: string; destination: string };
type ScanResult = { accepted: boolean; label: string };
type RouteResult = { carrier: string; eta: string; routeNotes: string };
type LogisticsTools = {
'provider.scan.verify': ActorToolExecutor<ScanInput, ScanResult>;
'route.plan': ActorToolExecutor<ScanInput, RouteResult>;
};
const scanShipmentBehavior = defineBehavior()
.withContext({ status: 'idle', latestScan: null })
.withTools<LogisticsTools>()
.onMessage(async ({ message, context, tools }) => {
if (message.type === 'SCAN_LABEL') {
const scan = await tools.execute('provider.scan.verify', message);
return {
context: {
...context,
latestScan: scan,
},
emit: [{ type: 'PROVIDER_SIGNAL_RECORDED' }],
};
}
})
.build();
const logistics = defineActorWebTopology({
tools: [tool('provider.scan.verify'), tool('route.plan')],
nodes: {
server: node('logistics-server-runtime'),
},
actors: {
shipment: actor({
id: 'logistics-shipment',
node: 'server',
behavior: scanShipmentBehavior,
tools: ['provider.scan.verify', 'route.plan'],
}),
},
});
const server = await serveNode(logistics, {
node: 'server',
tools: {
'provider.scan.verify': async (input) => ({
accepted: true,
label: String(input.label),
}),
'route.plan': async (input) => ({
carrier: 'Northline Express',
eta: '24h',
routeNotes: `Route through ${String(input.destination)}`,
}),
},
});Actor behavior uses assigned tools from the handler context. The node runner
implements those tool ports at the process boundary. withTools<TRegistry>()
is type-only; it makes tools.execute(...) infer tool payload and result
shapes, while topology actor tools still controls runtime least-privilege
access. A runner can register additional tools, but an actor cannot execute a
tool that is not assigned to that actor.
This is the recommended agent pattern: the actor owns state, routing, emitted facts, and transition constraints; tools are explicit ports for outside capabilities such as model calls, scanners, payment providers, search, or workflow engines.
Use this when the server owns the actor lifecycle and the browser only needs live state and commands.
// server.ts
const server = await serveNode(logistics, {
node: 'server',
gateway: {
auth: {
verifyToken: ({ token }) => token === process.env.ACTOR_WEB_GATEWAY_TOKEN,
},
},
});
// browser.ts
const client = createActorWebReadModelClient(logistics, {
gateway: {
url: 'ws://logistics.example.com/gateway',
auth: {
token: () => sessionStorage.getItem('actor-web-gateway-token') ?? undefined,
},
},
});
const shipmentSource = client.actors.shipment;Use this when different runtime locations own different actors and communicate through Actor-Web transport.
// server.ts
const server = await serveNode(logistics, {
node: 'server',
gateway: true,
transport: {
listen: true,
auth: {
token: () => process.env.ACTOR_WEB_NODE_TOKEN,
verifyToken: ({ token }) => token === process.env.ACTOR_WEB_NODE_TOKEN,
},
},
});
// worker.ts
const worker = await startActorWebNode(logistics, {
node: 'worker',
peers: {
server: 'ws://logistics.example.com/runtime-transport',
},
transport: {
auth: {
token: () => workerRuntimeToken,
},
},
});ws://logistics.example.com/runtime-transport is the server node's transport
URL, not the gateway URL. Browser UI sources still use the gateway URL.
For deployments where peer URLs are not known at compile time, use a discovery provider:
const discovery = createStaticRuntimePeerDiscoveryProvider([
{
nodeAddress: 'logistics-server-runtime',
url: 'ws://logistics.example.com/runtime-transport',
},
]);
await startActorWebNode(logistics, {
node: 'worker',
discovery,
});Use createMessagePortTransport(...) when the host page and worker already
share a MessagePort. This is useful for browser-local demos, embedded worker
edges, or service worker proofs.
const channel = new MessageChannel();
const transport = createMessagePortTransport({
nodeAddress: 'browser-host',
peerAddress: 'service-worker-runtime',
port: channel.port1,
});
await startActorWebNode(logistics, {
node: 'serviceWorker',
transport,
});Use createActorWebReadModelClient(...) as the default UI/client entrypoint
when a shared topology is available. It binds gateway configuration once and
exposes each actor as a projection-only source.
const client = createActorWebReadModelClient(logistics, {
gateway: { url: 'ws://127.0.0.1:4100' },
clientVersion: 'logistics-ui',
});
const shipmentSource = client.actors.shipment;Actor-Web read-model sources provide:
snapshot()subscribe(listener)subscribeEvent(listener, options?)transportStatus()subscribeTransportStatus(listener)close()
Topology actors expose the same source factory names everywhere:
| Factory | Use when | Ignite Element mapping |
|---|---|---|
readModel(opts) |
the host only projects snapshots/events | pass as igniteCore({ source }) |
source(opts) |
the host projects state and intentionally sends commands | pass as igniteCore({ source }) |
commands(opts) |
the host owns command/control without projection replay | pass as igniteCore({ source }) |
session(opts) |
the host intentionally separates read and command handles with one lifecycle hook | pass session.commands when Ignite commands; pass session.readModel for read-only projection |
opts is ActorWebSourceOptions: { gateway: { url, scope?, auth? }, streamId?, createSocket?, clientVersion? }. It is gateway/transport
configuration, not actor identity. The actor is selected by the topology path
(logistics.actors.shipment); gateway.scope.params carries product filters
such as tenant, document, shipment, or vehicle ids.
For Ignite Element, the target API keeps this read-model source as the default
source for ignite-element/actor-web. Components that only render live
projection state should not need a command-capable source:
const shipmentCard = igniteCore({
source: () =>
logistics.actors.shipment.readModel({
gateway: { url: 'ws://127.0.0.1:4100' },
}),
states: ({ context, transport }) => ({
shipmentId: context.shipmentId,
status: context.status,
syncState: transport.state,
}),
});When an Ignite host needs both live projection state and command/control for
the same actor, pass the command-capable Actor-Web source as Ignite's single
source. commands(...) is an Actor-Web source factory, not a second Ignite
config key. The split remains visible in the source factory choice, without
manual generics:
const shipmentCard = igniteCore({
source: () =>
logistics.actors.shipment.source({
gateway: { url: 'ws://127.0.0.1:4100' },
}),
commands: ({ actor }) => ({
cancel: (shipmentId: string) =>
actor.send({ type: 'CANCEL_SHIPMENT', shipmentId }),
}),
});Generic browser clients that do not use Ignite can still compose a read-model client and command-capable source explicitly when that is the product shape.
ignite-element/actor-web should treat close() as the Actor-Web cleanup hook,
equivalent to stop() for handles, so product code does not need to adapt
{ source, stop: () => source.close() } by hand.
For single-process proofs, use startRuntime(...) instead of
hand-starting every topology node and manually adapting ActorRefs. It starts the
selected topology nodes over an in-memory transport network and exposes each
actor at the top level with the Ignite-friendly source shape:
const runtime = await startRuntime(logistics, {
tools: {
'routing.plan': async (input) => ({
carrier: 'Northline Express',
eta: '24h',
routeNotes: `Route through ${String(input.destination)}`,
}),
},
});
const dashboard = igniteCore({
source: ({ host }) => runtime.dashboard.readModel({ host }),
});readModel({ host }) accepts Ignite's host-context object so product code can
pass the source factory arguments straight through. Use commands({ host }) as
the Ignite source when a host intentionally owns command/control but does not
need projection replay. Use source({ host }) when the same component needs
both projection and command capability.
source.close() closes that source's subscriptions; runtime.stop() closes
every source the runtime opened and then stops all started Actor-Web nodes.
App-owned runtimes should call runtime.stop() during teardown. Ignite-owned
isolated sources can rely on close() or an AbortSignal passed to
readModel(...)/commands(...).
Outside Ignite, use an explicit command helper when the host owns command/control without a projection stream:
const shipmentCommands = logistics.actors.shipment.commands({
gateway: { url: 'ws://127.0.0.1:4100' },
});
await shipmentCommands.send({
type: 'CREATE_SHIPMENT',
shipmentId: 'shipment-1001',
destination: 'Chicago warehouse',
});Generic command-capable clients can still use createActorWebClient(...):
const legacyClient = createActorWebClient(logistics, {
gateway: { url: 'ws://127.0.0.1:4100' },
});
await legacyClient.actors.shipment.send({
type: 'CREATE_SHIPMENT',
shipmentId: 'shipment-1001',
destination: 'Chicago warehouse',
});Use createActorWebReadModelSource for explicit or generated-client paths.
Prefer the single-object shape. This creates a gateway-backed read-model source
for a client; it does not start an Actor-Web runtime node. Read-model sources
always keep the full projection subscription so snapshot(),
subscribe(listener), and subscribeEvent(...) remain available.
const shipmentSource = createActorWebReadModelSource({
actor: logistics.actors.shipment,
gateway: { url: 'ws://127.0.0.1:4100' },
});The topology descriptor convenience is equivalent and useful when the topology is already in scope:
const shipmentSource = logistics.actors.shipment.readModel({
gateway: { url: 'ws://127.0.0.1:4100' },
});Separate repos can use address metadata instead of the shared TypeScript topology:
const shipmentSource = createActorWebReadModelSource({
address: 'actor://logistics-server-runtime/logistics-shipment',
contractVersion: '1.0.0',
gateway: {
url: 'ws://127.0.0.1:4100',
scope: {
params: { tenantId: 'tenant-a' },
},
},
});Use createActorWebCommandSource(...) or topology.actors.name.commands(...)
only when the host intentionally owns command/control for that actor without a
projection stream. These helpers opt into the gateway's command-only subscribe
mode: they still go through gateway auth and scope resolution, but they wait for
the first post-subscribe status instead of an initial snapshot and they do not
request snapshot/event/transition replay.
const shipmentCommands = createActorWebCommandSource({
address: 'actor://logistics-server-runtime/logistics-shipment',
contractVersion: '1.0.0',
gateway: { url: 'ws://127.0.0.1:4100' },
});
await shipmentCommands.send({
type: 'CREATE_SHIPMENT',
shipmentId: 'shipment-1001',
destination: 'Chicago warehouse',
});This is the normal browser/UI entrypoint when the UI does not own an
ActorSystem. Use direct createActorWebReadModelSource(...) only when you need
one source without constructing a full topology client, or when a generated
client only has address metadata.
Starts one topology node in a Node/server environment. Use this in the process that owns one or more topology actors.
const server = await serveNode(logistics, {
node: 'server',
gateway: true,
transport: true,
tools: {
'routing.plan': async (input) => ({
carrier: 'Northline Express',
eta: '24h',
routeNotes: `Route through ${String(input.destination)}`,
}),
},
});
const shipment = server.requireActor('shipment');
console.log(server.getGatewayUrl());
console.log(server.getTransportUrl());gateway: true exposes topology actors that declare gateway: true.
transport: true opens the runtime-to-runtime WebSocket listener. Use object
options only when deployment details need explicit ports, hosts, heartbeat
settings, peer resolution, or auth.
Topology runners expose runtime transport status without requiring applications to inspect transport internals:
const transport = server.getTransportStatus();
const worker = server.getPeerStatus('logistics-worker-runtime');
if (!worker.connected || !worker.fresh) {
console.warn(worker.staleReason ?? worker.rejectedReason ?? 'worker unavailable');
}RuntimePeerStatus includes nodeAddress, state, connected, fresh,
lastSeenAt, disconnectedAt, rejectedReason, staleAfterMs, and
staleReason. RuntimeTransportStatus includes normalized connectedNodes,
all known peers, and optional startedAt / stoppedAt timestamps.
When transport: true or transport: { listen: true } is used,
serveNode(...) and startActorWebNode(...) let the WebSocket
transport heartbeat defaults apply: 15 seconds interval and 30 seconds timeout.
Set heartbeatIntervalMs: 0 explicitly to opt out for tests or custom
deployment constraints.
serveNode deliberately does not own REST routes, provider callbacks,
persistence, or business ingress. Those are application adapters around the
served node. It can enforce gateway and runtime-peer auth because those are
transport admission concerns.
Auth is optional by default so local examples and tests can run without secrets.
When configured, runtime peers are verified during the WebSocket handshake before
peer registration, and gateway clients are verified during hello before any
stream can subscribe, send, or ask.
const server = await serveNode(logistics, {
node: 'server',
gateway: {
auth: {
verifyToken: ({ token }) => token === process.env.ACTOR_WEB_GATEWAY_TOKEN,
},
},
transport: {
listen: true,
auth: {
token: () => process.env.ACTOR_WEB_NODE_TOKEN,
verifyToken: ({ token }) => token === process.env.ACTOR_WEB_NODE_TOKEN,
},
},
});
const client = createActorWebReadModelClient(logistics, {
gateway: {
url: server.getGatewayUrl() ?? '',
auth: {
token: () => browserSessionToken,
},
},
});Auth payloads are intentionally small: { scheme, token, metadata }. Do not put
secrets in metadata. Actor-Web emits auth accept/reject telemetry without echoing
token values; TLS, certificate management, OAuth, and secret rotation remain
deployment/application concerns.
Discovery is a separate runtime port. It tells topology runners where runtime
peers can be reached; it does not replace MessageTransport, gateway sources,
or HTTP ingress.
const discovery = createInMemoryRuntimePeerDiscoveryProvider();
const server = await serveNode(logistics, {
node: 'server',
transport: true,
discovery,
});
await startActorWebNode(logistics, {
node: 'worker',
discovery,
});serveNode(...) registers its listening transport URL when one exists
and unregisters on stop. Both serveNode(...) and
startActorWebNode(...) read discovery snapshots at startup and subscribe to
peer availability changes.
The public provider shape is intentionally small:
interface RuntimePeerDiscoveryProvider {
getPeers(): readonly RuntimePeerDiscoveryRecord[] | Promise<readonly RuntimePeerDiscoveryRecord[]>;
subscribe?(listener: (event: RuntimePeerDiscoveryEvent) => void): () => void;
registerSelf?(peer: RuntimePeerDiscoveryRecord): void | Promise<void>;
unregisterSelf?(nodeAddress: string): void | Promise<void>;
}When a deployment system gives you endpoint parts instead of a final URL, use
createRuntimePeerDiscoveryRecord(...) to normalize provider-managed host/port
input into the same RuntimePeerDiscoveryRecord shape the runtime already
consumes:
const peer = createRuntimePeerDiscoveryRecord({
nodeAddress: 'worker-node',
protocol: 'wss',
host: 'worker.internal',
port: 443,
path: '/runtime',
metadata: {
zone: 'use1',
role: 'worker',
},
});The helper is provider-neutral and deterministic. It is for endpoint mapping,
not secret distribution. Keep auth secrets in token factories and
verifyToken callbacks, not in discovery metadata. Secret-like discovery
metadata keys are dropped before they become runtime peer records. Secret-like
query parameter keys are also dropped from both endpoint-part input and raw
url input. Raw URLs must use ws: or wss: and must not include embedded
username/password credentials. The helper does not inspect values for
benign-looking keys, so secrets must stay in auth providers rather than
discovery inputs. The same public boundary is enforced when records are passed
directly to static or in-memory discovery providers.
Use createStaticRuntimePeerDiscoveryProvider(...) for static deployment
metadata and generated config. Use createInMemoryRuntimePeerDiscoveryProvider
for tests, examples, and local multi-process demos. Production adapters can map
the same port to Kubernetes, service discovery, a config service, or a broker
later without changing actor behavior.
Use the HTTP builder for explicit REST/application ports. HTTP routes are application adapters around a served node; they are not actor transport and they do not replace actor messages inside the runtime.
const http = await serveActorWebHttp(server)
.for(logistics.actors.shipment)
.post('/shipments', async (request, response, actorWeb) => {
const shipmentId = `shipment-${Date.now()}`;
await actorWeb.actor.send({
type: 'CREATE_SHIPMENT',
shipmentId,
destination: String(request.body.destination),
});
return response.accepted({ shipmentId });
})
.listen({ host: '127.0.0.1', port: 4100 });Starts one topology node in a browser/WebWorker-compatible environment. Use this only when that worker/process owns actors from the topology.
const worker = await startActorWebNode(logistics, {
node: 'worker',
peers: {
server: 'ws://127.0.0.1:4102',
},
transport: {
heartbeatIntervalMs: 5000,
heartbeatTimeoutMs: 15000,
},
tools: {
'routing.plan': async (input) => ({
carrier: 'Northline Express',
eta: '24h',
routeNotes: `Route through ${String(input.destination)}`,
}),
},
});Browser nodes only open outbound WebSocket connections. They do not listen for runtime peers.
Starts a whole topology, or an explicit subset of nodes, inside one process with an in-memory transport network. Use it for demos, product proofs, and local Ignite validation where the app owns runtime startup and teardown.
const runtime = await startRuntime(logistics);
const shipmentProjection = runtime.shipment.readModel();
const shipmentCommands = runtime.shipment.commands();
await shipmentCommands.send({
type: 'CREATE_SHIPMENT',
shipmentId: 'shipment-1001',
});
await runtime.stop();The returned runtime exposes:
runtime.actorKey.readModel({ host?, signal? })runtime.actorKey.source({ host?, signal? })runtime.actorKey.commands({ host?, signal? })runtime.actorKey.session({ host?, signal? })runtime.actorKey.actor()runtime.actors.actorKeyfor the same helpers without top-level accessruntime.nodesfor node handles and focused test flushingruntime.getActor(key)/runtime.requireActor(key)runtime.stop()
Gateway is the thin-host projection/control channel. It is used by browser UIs,
Ignite Element sources, dashboards, and other clients that need live actor
snapshots or command routing without becoming runtime peers. HTTP/REST ingress
is a separate application adapter built with serveActorWebHttp(...).
Gateway is not runtime transport. Runtime-to-runtime ownership still flows
through MessageTransport.
Normal applications do not construct gateway hubs directly. Use:
serveNode(logistics, { gateway: true })on the runtime owner.createActorWebReadModelClient(logistics, { gateway: { url } })in the UI/client.createActorWebReadModelSource({ address, contractVersion, gateway })for generated or separate-repo clients.
Scope descriptors:
{
kind: 'shipment',
params: { tenantId: 'tenant-a', shipmentId: 'shipment-1001' }
}For normal topology actors, kind defaults to the topology actor key.
params exist for advanced routed projections such as tenant, document,
inspection, or shipment-specific scopes.
Example: a fleet app can expose one vehicleInspections actor while allowing a
dashboard to subscribe to only one vehicle's projection:
const inspections = fleet.actors.vehicleInspections.readModel({
gateway: {
url: 'wss://fleet.example.com/gateway',
scope: {
params: {
fleetId: host.getAttribute('fleet-id') ?? 'default',
vehicleId: host.getAttribute('vehicle-id') ?? 'all',
},
},
},
});Use explicit kind only when the gateway intentionally exposes a public scope
that is different from the topology actor key, such as a read-model projection
or a compatibility alias for a generated client.
Gateway replay/resync:
- Each gateway stream is ordered by
sequence. - Gateway-backed sources detect missing sequence numbers, mark transport status
as
degraded, and requestresyncfrom the first missing sequence. - The served gateway keeps a bounded replay tail for snapshots, events, and transition frames.
- By default that replay tail is in-memory only.
createRuntimeGatewayHub()also accepts an optionalreplayStorageprovider that can load/store the bounded tail byreplaySessionIdplus an internal scoped storage stream key derived from the visiblestreamIdand subscriptionscope. - Authenticated resume uses owner-bound replay session ids as an intentional
storage-key rotation. The gateway does not dual-read legacy plain
lastConnectionIdstorage keys because that would weaken the owner binding that prevents cross-client replay reuse. - Replay storage load/store failures stay non-fatal. Use
onReplayStorageErrorif you need an operator hook for degraded durable recovery; those events still report the visible clientstreamId. - If the requested range is still buffered, the gateway replays those frames.
- If the range is unavailable, the gateway sends a fresh latest snapshot and then resumes live status/events.
- Rolling forward to owner-bound replay keys and then rolling back to older binaries can lose replay continuity for existing sessions. Preserve continuity only by planning a separate replay-storage migration or storage-contract task; this gateway API does not provide a compatibility dual-read path.
This is projection recovery, not event sourcing, exactly-once delivery, or cluster transport. The optional provider only hardens the gateway-owned replay tail; if the stored range is unavailable, clients still recover to the latest snapshot.
Transport membership and remote actor-directory readiness are separate runtime
facts. A peer enters ClusterState.nodes when ActorSystem admits its live
transport connection. ActorSystem then synchronizes the peer's exported actor
addresses before publishing that peer as directory-ready.
Read the current state through system.getClusterState() or the clusterState
field returned by system.getSystemStats():
import type { ClusterState, DirectoryReadinessFact } from '@actor-web/runtime';
const cluster: ClusterState = system.getClusterState();
const readiness: readonly DirectoryReadinessFact[] | undefined =
cluster.directoryReadiness;DirectoryReadinessFact is a per-peer discriminated union:
syncing: the current peer incarnation's directory request is in flight;ready: ActorSystem accepted the current incarnation's directory snapshot;degraded: synchronization failed, with a serializabledirectory_sync_failedcode and message.
Each fact includes nodeAddress and includes nodeId and incarnation when
the transport supplies peer identity telemetry. Actor-Web returns defensive
snapshots, so mutating a returned nodes array, readiness array, or failure
object cannot alter runtime state.
Compatibility semantics are explicit:
directoryReadiness === undefinedmeans theClusterStateproducer does not support readiness reporting;directoryReadiness: []means the producer supports it and has no remote peers;- Actor-Web's
ActorSystemImplalways supplies the collection.
Automatic transport connection, explicit join(), and a connected-peer
lookup() miss share one deduplicated readiness attempt. join() remains
pending until that attempt succeeds. A failed attempt leaves a degraded fact
but is evicted so a later join or lookup can retry. A new same-address peer
incarnation supersedes the old attempt; late old responses cannot install
directory entries or overwrite the replacement's status. Disconnect removes
the peer's readiness fact and remote directory entries.
Membership up is therefore a transport-liveness fact, not a guarantee that
remote actor names already resolve. This preserves Actor-Web's scoped location
transparency: local and remote actors use the same ActorRef APIs, while
latency and failure remain observable. Ordinary send() remains at-most-once;
Actor-Web does not buffer or retry application messages while directory sync is
pending.
DirectoryReadinessFact is exported from both @actor-web/runtime and
@actor-web/runtime/browser. It is ActorSystem lifecycle observability, not a
new actor behavior, topology, transport, or delivery primitive. See
Actor-Web Directory Readiness for
the architecture and non-goals.
MessageTransport is the distributed runtime seam:
interface MessageTransport {
send(destination: string, message: ActorMessage): Promise<void>;
subscribe(listener: (event: { source: string; message: ActorMessage }) => void): () => void;
connect(address: string): Promise<void>;
disconnect(address: string): Promise<void>;
getConnectedNodes(): string[];
isConnected(address: string): boolean;
}Application code should start runtime peers through topology runners instead of creating concrete WebSocket adapters directly:
const server = await serveNode(logistics, {
node: 'server',
transport: true,
});
const worker = await startActorWebNode(logistics, {
node: 'worker',
peers: {
server: server.getTransportUrl() ?? '',
},
});The direct WebSocket adapters are internal implementation details of those
runners. Public app code should treat MessageTransport as the seam and use
runner options for host, port, peers, heartbeat settings, telemetry, and
explicit custom transport injection when needed.
Do not use a runtime transport URL for createActorWebReadModelClient(...),
createActorWebReadModelSource(...), or createActorWebCommandSource(...);
sources connect to the gateway URL. Do not use a
gateway URL for startActorWebNode(...) peers; runtime peers connect to
transport URLs.
createMessagePortTransport(options) adapts an existing MessagePort into the
MessageTransport seam. It is exported from @actor-web/runtime/browser.
const channel = new MessageChannel();
worker.postMessage({ type: 'bind-runtime-port' }, [channel.port2]);
const transport = createMessagePortTransport({
nodeAddress: 'browser-host',
peerAddress: 'service-worker-runtime',
port: channel.port1,
});
await transport.connect();Use this for browser-local topology proofs and embedded runtime edges such as page-to-worker, iframe-to-host, or page-to-service-worker links. The app shell owns creating and transferring the port.
createBroadcastChannelMessageTransport(options) adapts the browser
BroadcastChannel API into the MessageTransport seam. It is exported from
@actor-web/runtime/browser.
Use this for same-origin browser contexts that can share a named channel, such as tabs, workers, and embedded browser runtime hosts. BroadcastChannel is a browser-local bus, so the adapter validates runtime handshakes and negotiated node identity before delivering peer payloads to the transport core.
createWebRtcMessageTransport(options) adapts browser RTCDataChannel
connections into the MessageTransport seam. It is exported from
@actor-web/runtime/browser.
const transport = createWebRtcMessageTransport({
nodeAddress: 'browser-a',
bootstrap: {
openDataChannel: ({ remoteAddress }) =>
signaling.openDataChannel(remoteAddress),
listen: (listener) => signaling.listenForDataChannels(listener),
},
});
await transport.start();
await transport.connect('browser-b');The bootstrap port is intentionally narrow: it opens outgoing data channels
and listens for incoming data channels. Discovery, SDP/ICE signaling,
permissions, and mesh route selection stay outside the adapter. The adapter
runs Actor-Web's runtime handshake over the data channel, then delegates
ack/retry, duplicate suppression, heartbeat frames, stats, and telemetry to the
shared transport core.
The runtime transport contract exports:
RuntimeNodeIdentityRuntimeTransportFrameRuntimeTransportAckFrameRuntimeTransportHandshakeRuntimeTransportHeartbeatFramecreateRuntimeTransportMessageId(...)RUNTIME_TRANSPORT_PROTOCOL_VERSIONDEFAULT_RUNTIME_TRANSPORT_MAX_FRAME_BYTEScreateRuntimeTransportStreamHost(...)- validation helpers for identity, handshake, runtime frames, ack frames, and heartbeat frames
measureRuntimeTransportFrameBytes(...),normalizeRuntimeTransportMaxFrameBytes(...), andvalidateRuntimeTransportFramePayloadSize(...)
Runtime transport frames include a messageId. Built-in WebSocket,
BroadcastChannel, and WebRTC transports keep a bounded per-peer idempotency
cache and drop duplicate frame IDs before runtime subscriber delivery.
When a transport is configured with idempotencyProvider, the transport also
issues an atomic claim keyed by the stable local/peer node identity context plus
the frame messageId. This is opt-in and additive:
- default in-memory duplicate suppression remains unchanged;
- provider-backed duplicate detection can survive runtime restarts;
- provider duplicates are acked and dropped;
- provider claim failures are surfaced through stats/telemetry and the frame is not treated as accepted.
Node and browser WebSocket transports also exchange runtime.transport.ack
frames for valid runtime frames. Bounded retry is limited to internal
__runtime.* control traffic. User actor send still remains at-most-once by
default.
Both WebSocket transports keep a bounded per-peer outbound queue. The default
limit is 1024 queued data frames. When the queue is full, send(...) rejects
and the transport emits backpressure telemetry instead of buffering without a
limit. Ack and heartbeat control frames stay minimal and direct.
Both WebSocket transports also enforce a max serialized runtime-frame size before
enqueueing. The default is DEFAULT_RUNTIME_TRANSPORT_MAX_FRAME_BYTES (1 MiB)
and can be overridden with maxFrameBytes on the Node or browser WebSocket
transport options. Oversized sends reject with payload_too_large, emit
frame.dropped telemetry with frameBytes and maxFrameBytes, and do not
consume the peer sequence or hit the wire.
createRuntimeTransportStreamHost(...) adds a credit-based stream protocol over
the existing MessageTransport. It is intended for incremental agent and tool
output such as LLM tokens, progress facts, and stdout-like text chunks. Stream
messages are ordinary __runtime.stream.* runtime control frames, so existing
ordering, max-frame validation, ack/retry for runtime control traffic, and
transport queue backpressure still apply. A sender can write only while it has
receiver credit; the receiver grants more credit after each chunk handler
settles.
const outputStreams = createRuntimeTransportStreamHost({
transport,
nodeAddress: 'worker',
initialCredit: 1,
});
outputStreams.subscribe((stream) => ({
async onChunk(chunk) {
await appendAgentOutput(stream.streamId, chunk.payload);
},
}));
const stream = await outputStreams.open('coordinator', {
metadata: { kind: 'agent-output' },
});
await stream.write({ token: 'Planning' });
await stream.write({ token: ' complete.' });
await stream.close();Large prompts, model outputs, context packs, and binary/blob payloads should still be externalized into a durable artifact store and sent as stable references when they do not fit the configured frame limit.
Runtime transport rejection covers:
- missing node identity
- same-node connections
- incompatible protocol versions
- malformed frame envelopes
- oversized runtime frames
Runtime-native telemetry is available without adding OpenTelemetry:
RuntimeTransportTelemetryEventRuntimeTransportStatsRuntimeTransportPeerStatsRuntimeTransportTelemetryObservercreateRuntimeTransportTelemetryExporter(...)createInMemoryRuntimeTransportTelemetrySink(...)
Telemetry/stats cover connection state, handshake accept/reject, malformed frames, reconnect/disconnect count, heartbeat timeout, frame send/receive count, ack received count, retry count, retry exhaustion, duplicate drops, idempotency cache evictions, idempotency provider claim/duplicate/error counters, outbound queue depth, backpressure drops, validation drops, sequence gaps, and last-seen timestamps.
getRuntimeTransportStatus(...) and getRuntimePeerStatus(...) also expose an
additive idempotency view with:
- cache window size,
- provider enabled/disabled state,
- provider claim, duplicate, and error counters,
- last provider error timestamp/message when applicable.
Use the exporter when transport events need to outlive an individual transport instance:
import {
createRuntimeTransportTelemetryExporter,
createRuntimeTransportTelemetryJsonlFileSink,
serveNode,
} from '@actor-web/runtime/node';
const telemetry = createRuntimeTransportTelemetryExporter({
sink: createRuntimeTransportTelemetryJsonlFileSink('./runtime-telemetry.jsonl'),
});
const server = await serveNode(logistics, {
node: 'server',
transport: {
listen: true,
telemetry: telemetry.observe,
},
gateway: true,
});
// During shutdown:
await server.stop();
await telemetry.close();The JSONL sink is Node-only. Browser and shared runtime code can use the in-memory sink or provide a custom sink that forwards events to the host application.
For deployment, rollback, secret rotation, stale-peer, replay-recovery, duplicate-drop, and backpressure incident procedures for the current direct WebSocket transport path, see operations/actor-web-production-operations.md.
@actor-web/runtime: universal actor behavior, actor system,MessageTransport, runtime contracts, telemetry types, and low-level shared types.@actor-web/runtime/topology: browser-safe topology descriptor helpers.@actor-web/runtime/browser: browser-safe Actor-Web client/source, MessagePort and BroadcastChannel transports, and browser worker runtime hosting.@actor-web/runtime/node: Node/server topology runner and HTTP ingress adapter.
Node-only APIs are not exported from the browser entrypoint.