⚠️ Disclaimer — please read before using. This is a complex, AI-assisted hobby project — a from-scratch port of the actor-model stack (actors, supervision, cluster, sharding, persistence, HTTP) to TypeScript, running on Bun, Node.js, and Deno. Large parts were written with AI pair-programming and have not been battle-tested in production. Test coverage is good (~1783 tests, ~86 % line) but the surface area is enormous. Do not deploy this to anything that matters yet. Use it to learn, to prototype, to benchmark ideas — not to handle real money, users, or data.
actor-ts is a batteries-included actor-model runtime for TypeScript —
messages, mailboxes, supervisors, location-transparent refs, the whole
Erlang-style actor toolkit — running natively on Bun, Node.js, and
Deno.
A short tour of what's in the box:
- Actors — single-threaded per-mailbox processing, lifecycle hooks, stash, timers, become/unbecome, supervision (restart / resume / stop / escalate).
- Cluster — gossip membership, φ-accrual failure detection, split-brain resolvers, weakly-up, multiple transports (TCP, MessageChannel, in-memory).
- Cluster sharding + singleton + pub-sub + reliable delivery + receptionist — production patterns from the actor-model tradition.
- Distributed Data — eight CRDTs (counters, registers, sets, maps) with durable-storage backend, quorum reads/writes, automatic gossip.
- Persistence —
PersistentActor,DurableState, snapshots, projections, persistence-query, replicated event sourcing. Journals for in-memory, SQLite (via Bun-SQLite + better-sqlite3), Cassandra / ScyllaDB. - Object storage — S3 / MinIO / R2 / filesystem with optional gzip/zstd compression and client-side AES-256-GCM encryption (per-tenant subkeys via HKDF).
- HTTP — directive-style routing DSL with Fastify default, Express + Hono backends, response caching, rate-limiting, idempotency-key dedup.
- Message brokers — single
BrokerActorbase with Kafka, MQTT, AMQP, NATS, Redis-Streams, gRPC, WebSocket, SSE, raw TCP/UDP integrations. Reconnect-with-backoff, outbound buffer, subscriber fan-out are baked in. - Caching — pluggable Cache with in-memory, Redis, Memcached backends.
- Observability — Prometheus exporter, OTel tracing + metrics, management
HTTP endpoints (
/health,/ready,/cluster/members,/sharding/regions), out-of-the-box stock metrics. - TestKit —
TestProbe,ManualScheduler,MultiNodeSpecfor deterministic tests including cluster scenarios.
Everything works under any of the three runtimes — runtime-specific backends
(TCP sockets, worker threads, SQLite, HTTP serve) live behind small
abstractions in src/runtime/ and auto-detect at startup.
bun add actor-ts # Bun
npm install actor-ts # Node
# Deno: no install — import via `npm:actor-ts`import { Actor, ActorSystem, Props } from 'actor-ts';
class Greeter extends Actor<string> {
override onReceive(name: string): void {
console.log(`hello, ${name}!`);
}
}
const system = ActorSystem.create('hello');
const ref = system.spawn(Props.create(() => new Greeter()), 'greeter');
ref.tell('world');
await new Promise(r => setTimeout(r, 20));
await system.terminate();The same file runs unchanged under bun run, node and deno run.
A flavour of what idiomatic actor-ts code looks like — pick the
snippet that matches what you're reaching for.
Discriminated-union messages plus match().exhaustive() from
ts-pattern give you a
compile-time check that every variant is handled. Add a new variant
to Cmd without a matching with(...) arm and TypeScript fails the
build.
import { Actor, ActorSystem, Props, type ActorRef } from 'actor-ts';
import { match } from 'ts-pattern';
type Cmd =
| { kind: 'inc' }
| { kind: 'dec' }
| { kind: 'get'; replyTo: ActorRef<number> };
class Counter extends Actor<Cmd> {
private count = 0;
override onReceive(cmd: Cmd): void {
match(cmd)
.with({ kind: 'inc' }, () => { this.count++; })
.with({ kind: 'dec' }, () => { this.count--; })
.with({ kind: 'get' }, m => m.replyTo.tell(this.count))
.exhaustive();
}
}tell is fire-and-forget; ref.ask<Reply>(msg) awaits a typed
reply with a configurable timeout. The framework spawns a
one-shot reply actor, wires it as both replyTo and
context.sender, and resolves the promise when the target replies.
import { ActorSystem, Props } from 'actor-ts';
const system = ActorSystem.create('demo');
const counter = system.spawnAnonymous(Props.create(() => new Counter()));
counter.tell({ kind: 'inc' });
counter.tell({ kind: 'inc' });
const value = await counter.ask<number>({ kind: 'get' }, 5_000);
console.log(value); // 2State is rebuilt from a journal on every restart — no in-place
mutation, no "did this write commit?" question. Same Counter API
the rest of the app sees, every mutation durable.
import { PersistentActor, ActorSystem, Props } from 'actor-ts';
type Cmd = { kind: 'inc' } | { kind: 'dec' };
type Event = { kind: 'incremented' } | { kind: 'decremented' };
interface State { count: number }
class Counter extends PersistentActor<Cmd, Event, State> {
readonly persistenceId = 'counter-1';
initialState(): State { return { count: 0 }; }
onEvent(s: State, e: Event): State {
return e.kind === 'incremented'
? { count: s.count + 1 }
: { count: s.count - 1 };
}
onCommand(_state: State, cmd: Cmd): void {
this.persist({
kind: cmd.kind === 'inc' ? 'incremented' : 'decremented',
});
}
}Same actor code; the framework routes per-entity messages to the
correct node in the cluster and migrates entities when nodes come
and go. The ShardRegion ref you get back behaves like any other
ActorRef to callers.
import { Cluster } from 'actor-ts';
// One-call bootstrap — system + cluster + receptionist + SIGTERM
// wiring in one line. Discovery defaults to an env-driven chain
// (CLUSTER_SEEDS → K8s API → DNS); local dev with no env produces
// a single-node cluster, which is exactly what you want.
const { system, cluster } = await Cluster.bootstrap({ name: 'app' });
const cartRegion = cluster.sharding.start('cart', CartActor, {
extractEntityId: (msg: CartCmd) => msg.entityId,
});
cartRegion.tell({ entityId: 'user-42', kind: 'add', sku: 'book-1' });📚 actor-ts.dev — full documentation site with concept guides, runnable examples, and an auto-generated API reference.
The docs site is the canonical entry point. Highlights:
- Quickstart — hello-actor in five minutes.
- Why actors? — what the actor model gives you that Promise/Worker code doesn't.
- Migrating from Akka / Pekko / Orleans — for people coming from another actor framework.
- API reference — every public class, function, type generated from JSDoc.
Two end-to-end sample apps that exercise the framework comprehensively, each with six interchangeable frontends (Plain HTML, Lit, Angular, React, Next.js, SvelteKit) talking the same WebSocket protocol to a clustered backend:
examples/chat/— multi-room chat with sharding, persistence, DMs, typing indicators, read receipts, production-realistic auth. DemonstratesClusterSharding,DistributedPubSub,PersistentActor,DistributedData(ORSet, LWWMap),ClusterSingleton, failover.examples/voice/— voice rooms with PCM-encoded audio streaming over WebSocket. Same cluster infrastructure, different protocol shape.
Run either with bun examples/chat/backend/main.ts --port 2551 (then
--seeds localhost:2551 on additional terminals), open
http://localhost:8080, pick a frontend, and poke.
See ROADMAP.md for what's done and what's planned. The
CHANGELOG.md tracks per-version changes — pre-1.0 minor
bumps are potentially breaking; check the changelog before upgrading.
Issues and feature requests live on GitHub.
