webactor
v1.0.0
Published
Everything that you need for actor architecture on client
Readme
webactor
Actor-model architecture for the browser. One programming model for every boundary in your app — components, tabs, and Web Workers — built on plain message passing.
import { createActor, request, response } from 'webactor';
const math = createActor('math', (ctx) => {
ctx.addEventListener('message', (e) => {
if (e.data.type === 'sum') response(ctx, e, e.data.a + e.data.b);
});
});
math.launch();
const res = await request(math, { type: 'sum', a: 2, b: 3 });
console.log(res.data); // 5Why actors, why now
Your app is already concurrent — workers, tabs, streams, agents — but the browser's primitives for it (Worker, SharedWorker, MessagePort, postMessage) are low-level and inconsistent. webactor is one uniform actor model over all of them.
- Isolated state. An actor owns its data; the only way in or out is a message.
- Location transparency. Same API in-thread, across tabs, across workers. Move an actor into a worker without touching its logic.
- Fault tolerance. Supervisors restart crashed actors and dead workers — "let it crash" on the client.
- Small blast radius. A mistake stops at one mailbox, which is what lets people and agents work on the same app in parallel.
Install
npm install webactor
# or: pnpm add webactorCore idea in one picture
An actor is an isolated unit with a mailbox. You never call it — you send it an envelope (a typed message). Actors are wired together with connections. Once connected, three communication patterns cover almost everything:
| Pattern | Function | Use it for |
| ---------------------- | -------------------------------- | -------------------------------------------------------- |
| Fire-and-forget | postMessage | events, notifications, state broadcasts |
| Request / response | request / response | "ask and await an answer", RPC-style calls |
| Channel | openChannel / supportChannel | a dedicated, disconnect-aware session between two actors |
The same three patterns work identically across a Worker boundary.
60-second tour
Two actors talking
import { createActor, connectActors, ActorContext } from 'webactor';
// A stateful business actor — owns the counter, no one else can touch it.
const counter = createActor('counter', (ctx: ActorContext) => {
let value = 0;
ctx.addEventListener('message', (e) => {
if (e.data.type === 'inc') value++;
if (e.data.type === 'dec') value--;
ctx.postMessage({ type: 'value', value }); // broadcast new state
});
});
// A UI actor — renders, never owns business state.
const ui = createActor('ui', (ctx: ActorContext) => {
ctx.addEventListener('message', (e) => {
if (e.data.type === 'value') render(e.data.value);
});
document.querySelector('#inc')!.addEventListener('click', () => ctx.postMessage({ type: 'inc' }));
});
const disconnect = connectActors(ui, counter);
ui.launch();
counter.launch();
// later: disconnect(); ui.close(); counter.close();Move an actor into a Worker — same code
counter doesn't care where it lives. Put it in a SharedWorker (shared across every tab) and connect the whole thing with a dense network:
// main.ts
import { createDenseNetwork } from 'webactor';
import { createUIActor } from './ui-actor';
const worker = new SharedWorker(new URL('./server.worker.ts', import.meta.url), { type: 'module' });
const network = createDenseNetwork(createUIActor(), worker); // auto-detects the worker
network.launch();// server.worker.ts
import { createDenseNetwork, useContextMessagePort } from 'webactor';
import { createCounterActor } from './counter-actor';
createDenseNetwork(useContextMessagePort(), createCounterActor()).launch();The UI actor's request(...) / postMessage(...) calls are unchanged. Routing across the thread boundary is automatic.
Keep it alive — supervision
import { applyActorSupervisor, Reasons } from 'webactor';
const supervised = applyActorSupervisor(
() => createCounterActor(),
{ shouldRetry: (reason) => reason !== Reasons.Close }, // restart on crash, not on intentional close
);
supervised.launch(); // if the inner actor throws/closes unexpectedly, it's rebuiltThere's an applyWorkerSupervisor too — it detects a dead/crashed Worker (via the Web Locks API) and respawns it.
What's in the box
createActor/createActorFactory— build isolated units.connectActors/connectActorToWorker/connectActorToMessagePort— wire units across any boundary.createDenseNetwork— full-mesh several actors and workers at once.createRetranslator— a transparent relay node for building hub/bridge topologies.request/response— awaitable RPC with automatic back-routing across the mesh.openChannel/supportChannel— dedicated, disconnect-aware pipes (great for per-client sessions).applyActorSupervisor/applyWorkerSupervisor— "let it crash" restart strategies.useContextMessagePort/onConnectMessagePort— the Worker/SharedWorkerside of the wire.- Envelopes, transferables, pluggable providers (timers, locks, logger) for testing & non-browser runtimes.
Full API, internals, routing model, and patterns: documentation.md.
DevTools
A Chrome DevTools panel ships alongside the library: the live actor graph across every thread, the connections between actors, and the envelopes flowing over them — plus per-actor message history with a payload inspector.
pnpm --filter webactor-devtools build # load packages/devtools/dist as an unpacked extensionSee packages/devtools. The recorder lives in the library but stays inert until something installs a sink, so there is no cost when the extension is not present. You can also drive it yourself:
import { enableDevtools, getDevtoolsSnapshot } from 'webactor';
enableDevtools();
console.log(getDevtoolsSnapshot()); // { thread, nodes, links, messages }Repository layout
This is a pnpm workspace:
| Package | What it is |
| --------------------------------------------- | ------------------------------------------------------------- |
| packages/webactor | the library (src), unit tests (tests), load tests (e2e) |
| packages/devtools | webactor-devtools, the Chrome DevTools extension |
| examples/simple | minimal UI ↔ business actor split |
| examples/chat | multi-tab chat over a SharedWorker |
pnpm install
pnpm build # every package
pnpm test # unit + e2e + devtools
pnpm test:unit # vitest, packages/webactor/tests
pnpm test:e2e # playwright load tests, packages/webactor/e2e
pnpm test:devtools # playwright panel + unpacked-extension testsWhen to use it (and when not)
Great fit
- Apps with heavy
Worker/SharedWorkeruse, or that want to move work off the main thread. - Multi-tab coordination and real-time sync through a
SharedWorker. - Clear separation of UI ↔ domain logic, offline-first, or long-lived background processing.
- Anything that needs fault isolation and automatic restart of subsystems.
Probably overkill
- "Call one function in a worker and get a result." Reach for Comlink — it's a thinner RPC wrapper.
- Small apps where a component tree + a state manager already fit comfortably.
webactor is a model, not just an RPC shim: you adopt actors, envelopes, and supervision. That's a real mental investment — worth it when the payoff (isolation, fault tolerance, location transparency) matters.
vs. Comlink
| | webactor | Comlink | | ------------------------------------- | ----------------------- | --------------------- | | Model | Actor / message passing | Proxy-based RPC | | In-thread + cross-worker with one API | ✅ | Worker-focused | | Request/response | ✅ | ✅ (as proxied calls) | | Fire-and-forget broadcasts | ✅ | Awkward | | Dedicated sessions (channels) | ✅ | Manual | | Supervision / restart | ✅ | ❌ | | Multi-node topologies (mesh, relay) | ✅ | ❌ | | Bundle / surface | Larger, opinionated | Tiny, minimal |
Status
Single-maintainer project · MIT. The API described here is exercised by the test suite (pnpm test). Feedback and issues welcome on the repository.
Releases are automated with changesets — see CONTRIBUTING.md for how to land a change and cut a version.
