コンテンツにスキップ
日本語

Chat sample

このコンテンツはまだ日本語訳がありません。

The chat sample is a complete demo app showing how the framework’s pieces compose:

  • TCP cluster of 3 Bun processes, joined via gossip.
  • Sharded chat-room actors — one ChatRoomActor per room, spread across the cluster as 16 shards.
  • DistributedPubSub for cross-node chat-room broadcasts.
  • PersistentActor for chat-room history (SQLite journal + snapshots).
  • DistributedData for presence, the runtime room directory, and read receipts.
  • ClusterSingleton HTTP front door — one node binds :8080; a survivor re-binds it on failover.
  • WebSocket-only client protocol — a single /ws endpoint carries login, rooms, history, presence, and messages.

Find it under examples/chat/ in the repo.

3-node cluster (Bun)

join / send

persist + publish

deliver

presence / receipts

converged state

:8080

WebSocket clients

(6 frontends)

HTTP ingress

ClusterSingleton — binds :8080

serves static + /ws upgrade

UserSession actors

one per WS connection

holds their socket + state

ChatRoom actors (sharded PersistentActor)

one per room, 16 shards

persists message history

DistributedPubSub

per-room topic

rooms publish; sessions subscribe

DistributedData

ORSet presence + room directory

LWWMap read receipts

The full path of a “user sends message” flow:

1. The WS client sends a "send" frame over /ws to the ingress singleton.
2. The ingress routes it to that connection's UserSession actor (one per socket).
3. UserSession tells the relevant ChatRoom entity (sharded by room name).
4. ChatRoom persists the message via PersistentActor.persist().
5. ChatRoom publishes to DistributedPubSub on the room's topic.
6. Every UserSession subscribed to that topic receives the message.
7. Each UserSession pushes it to its own WS client.

Cluster + persistence + pubsub + websocket — all working together.

Terminal window
# Clone the repo and install dev deps:
git clone https://github.com/pathosDev/actor-ts.git
cd actor-ts
bun install
# Start a 3-node cluster — three terminals, same command, no flags:
bun examples/chat/backend/main.ts
bun examples/chat/backend/main.ts
bun examples/chat/backend/main.ts
# Open the chat UI (one URL, whichever node holds the singleton):
open http://localhost:8080/

Each node runs the same binary. They discover each other by scanning the cluster-port range from 2551, so the three terminals settle on 2551 / 2552 / 2553 with no seeds to configure. The cluster then elects one node to run the HTTP-ingress ClusterSingleton, which binds :8080 and serves the frontend selector plus the /ws endpoint. Persistence is a local SQLite journal + snapshot store under ./data/ — no external database. Kill the node holding the singleton and a survivor re-binds :8080 within a few seconds; the persisted history survives.

const chatRoomRegion = cluster.sharding.start('ChatRoom', ChatRoomActor,
StartShardingOptions.create<ChatRoomCommand>()
.withExtractEntityId((message) => message.room)
.withNumShards(16));

One ChatRoomActor per room, spread across the cluster as 16 shards — kill any node and the survivors take over its rooms. Direct-message channels use the same pattern in a separate region, keyed on the canonical DM pair-id.

class ChatRoomActor extends PersistentActor<ChatRoomCommand, ChatEvent, ChatState> {
readonly persistenceId = `room-${this.roomName}`;
// ... onCommand persists; onEvent updates state ...
}

Each room actor records every message; recovery replays.

// ChatRoom publishes after persisting:
ps.mediator.tell(new Publish(`room.${roomId}`, message));
// UserSession subscribes when user joins a room:
ps.mediator.tell(new Subscribe(`room.${roomId}`, this.self));

Sessions on any node receive room messages regardless of which node’s ChatRoom is publishing.

class UserSessionActor extends Actor<SessionMessage> {
private ws: WebSocket | null = null;
override onReceive(message: SessionMessage): void {
match(message)
.with({ kind: 'connect-ws' }, (m) => this.onConnectWs(m))
.with({ kind: 'inbound' }, (m) => this.onInbound(m))
.exhaustive();
}
private onConnectWs(message: ConnectWsMessage): void {
this.ws = message.socket;
}
private onInbound(message: InboundMessage): void {
this.ws?.send(JSON.stringify(message.payload));
}
}

Each session holds its user’s WebSocket; sends pushes straight to the client.

  • Sharded daemon processes — the chat sample doesn’t need fixed background workers.
  • Replicated event sourcing — single-writer per room is sufficient.

For those, see the stand-alone snippets or the voice sample.

examples/chat/
├── README.md
├── application.conf # HOCON: log level, gossip cadence
├── data/ # SQLite journal + snapshots (gitignored)
├── backend/
│ ├── main.ts # entry: wiring only (cluster, persistence, sharding, singleton)
│ ├── config.ts # CLI-argument parsing
│ ├── routes.ts # HTTP-DSL route (frontend selector)
│ ├── auth/ # scrypt password verify + HMAC session tokens
│ ├── discovery/ # same-host port-scan seed provider
│ └── actors/
│ ├── ChatRoomActor.ts # sharded PersistentActor (per room)
│ ├── ChatRoomDirectoryActor.ts # DistributedData ORSet (runtime rooms)
│ ├── DirectMessageChannelActor.ts # sharded PersistentActor (per DM pair)
│ ├── UserSessionActor.ts # per-WS-connection session
│ ├── OnlineUsersActor.ts # DistributedData ORSet (presence)
│ ├── ReadReceiptsActor.ts # DistributedData LWWMap (read pointers)
│ ├── HttpIngressActor.ts # ClusterSingleton: owns the :8080 bind
│ └── WebsocketIngressActor.ts # per-connection WS plumbing
├── shared/
│ ├── protocol.ts # shared WS message types
│ ├── rooms.ts # default room list
│ ├── users.ts # test credentials
│ └── directMessage.ts # DM pair-id canonicalization
├── static/ # built frontend assets (@fastify/static)
├── frontend-{plain,angular,react,next,svelte,lit}/ # six UI variants
├── smoke-test.ts # single-node messaging round-trip
└── failover-test.ts # HTTP-singleton fail-over

Every actor lives in its own file under backend/actors/; main.ts is pure wiring. Six frontends share one WebSocket protocol, so you can compare them side-by-side.