Skip to content

Repository files navigation

webactor

CI npm

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); // 5

Why 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 webactor

Core 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 rebuilt

There'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/SharedWorker side 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 extension

See 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 tests

When to use it (and when not)

Great fit

  • Apps with heavy Worker / SharedWorker use, 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.

About

No description, website, or topics provided.

Resources

Contributing

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages