neuron-explorer
Steering-first neuron explorer for Qwen3.5-4B: push every MLP neuron, keep every rollout, browse the results
翻譯準備中
一個適用於 TypeScript 的 actor-model 框架 — 可在 Bun、Node.js 與 Deno 上執行。
AI 翻譯
An actor-model framework for TypeScript — runs on Bun, Node.js and Deno
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
⚠️ 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 (~13095 tests, ~94 % 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.檢視完整 README ↗
⚠️ 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 (~13095 tests, ~94 % 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.
Those figures are measured on GitHub's hosted runners over the whole suite. Three multi-node suites used to be skipped there and are not any more; the reasons they came back, and the two mechanisms that keep a suite from quietly leaving CI again, are in Diagnosing test flakes.
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:
cluster.awaitReady), multiple
transports (TCP, MessageChannel, in-memory), death watch and context.sender
across nodes.actor-ts.parallelism.workers = auto
puts top-level actors on worker threads (Bun, Node, Deno) with the
application code unchanged: system.spawn returns a ref that buffers until
the worker acknowledges, ask, watch and context.sender cross the
thread, terminate() takes the threads down. Offloaded actors stay
visible on /metrics: the mesh pulls every worker's registry into the
main thread's exposition, each series labelled thread="main" or
thread="worker-N". workers = 0 is today's system, bit for bit;
children stay with their parent; what cannot cross — a factory closure, a
class the actor module does not export — fails loudly.context.offload(task, args) runs a pure function on
a worker thread and resolves with the result: the single-threaded runtime's
blocking dispatcher, with a queue bound, deadlines that stop a busy thread,
a restart budget and structured-clone arguments; defineOffloadTask names
the function by module URL and export, because a function cannot cross a
thread.WorkerMesh joins the main thread to N worker threads
(Bun, Node, Deno) that host actors from a module you name; refs, ask,
sharding and role-restricted singletons work across the threads — the
layer underneath the config knob.system.eventStream is
node-local, cluster.eventStream carries the same class and kind
channels to every node, and a local channel can be bridged onto the
cluster one per channel.PersistentActor, DurableState, snapshots, projections
(with a per-projection handler-failure strategy: retry with backoff, skip to
dead letters, or stop), persistence-query, replicated event sourcing. Journals for in-memory,
SQLite (built-in driver on every runtime — bun:sqlite, node:sqlite, or
better-sqlite3), libSQL / Turso, PostgreSQL, MariaDB,
Microsoft SQL Server, MongoDB, DynamoDB, Cloudflare D1,
Cassandra / ScyllaDB.websocket(path, actorRef) binds a
WebsocketServerActor (typed messages, reply / broadcast,
connect/disconnect hooks); WebsocketClientActor is the reconnecting
client half. Scoped error handling (handleErrors) + fallback routes;
a security-middleware suite (CORS, CSRF, HSTS, CSP, security headers,
Basic auth, request-id, timeout); HTML-escaping helpers; and
backend-agnostic static file serving (getFromFile /
getFromDirectory — MIME detection, conditional requests, Range,
directory browsing).BrokerActor base with Kafka, MQTT, AMQP,
NATS, Redis-Streams, gRPC, SSE, raw TCP/UDP and IMAP/SMTP email
integrations.
Reconnect-with-backoff, outbound buffer, subscriber fan-out are baked in.XOptions builder per
configurable thing (or a plain object), with values validated once on the
merged settings (builder / object / HOCON alike) — a bad port, timeout, or
URL throws an OptionsError at startup, not deep in a later code path.Option<T>, Try<T>, Either<L, R> and
Lazy<T> for Scala-style ergonomics; BidirectionalMap<K, V>, a Map that
also answers value → key and keeps the inverse in step for you, and
BidirectionalMultiMap<L, R>, which does the same for a many-to-many
relation and leaves nothing behind when a participant drops out (both,
unlike an ordinary class, survive a snapshot as real instances — no
adapter); plus
the small helpers the framework itself runs on: randomString / randomHex
/ randomId (crypto entropy, no modulo bias, exact length) and randomUuid
(a v4 UUID, for when the identifier has to be globally unique too) — all four
take an optional exists predicate and redraw while it says the candidate is
taken, so "keep going until one is free" is the call itself rather than a
do/while around it;
safeStringify
(a JSON.stringify for log and error paths that cannot throw),
lazyImportModule (import an optional peer dependency, or fail with a
message naming the install command).system.deadLetterQueue and they are also kept: a bounded ring, or a
journal-backed one that survives a restart, filterable by recipient and
time and replayable by id — with a replay cap so a poison message cannot
be retried into an ever-growing queue./health, /ready, /cluster/members, /sharding/regions),
out-of-the-box stock metrics.transform hook redacts
once, before fan-out.DevTools.attach(system) opens an embedded web UI: live
actor tree and mailbox depths, cluster topology and shard distribution,
a span flame graph, a per-actor explain plan, time travel over a
persistence journal, a profiler, a dead-letter inspector, and a live tail
of the event bus, and a resolved-configuration inspector. One Pause
in the header stops every panel at once — and the clock they are read
against, so a stopped actor is still on screen when you get to it. It
reads only — sending a message into the system is a separate,
off-by-default acknowledgement. Built with
Angular and ECharts and embedded as a gzipped bundle — both are
build-time only, so neither appears in this package's dependencies and
the served page loads nothing over the network. Loopback-only and
unauthenticated by default, and it refuses a routable bind without a
gate.TestProbe, ManualScheduler, MultiNodeSpec for
deterministic tests including cluster scenarios; the message-boundary check
is on by default there, so a message that would lose its prototype on a
worker hop fails the test that sends it instead of the first deployment
with workers = auto.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 } 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(Greeter, 'greeter');
ref.tell('world');
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 Command without a matching with(...) arm and TypeScript fails the
build.
import { Actor, ActorSystem, type ActorRef } from 'actor-ts';
import { match } from 'ts-pattern';
type IncrementCommand = { kind: 'increment' };
type DecrementCommand = { kind: 'decrement' };
type GetCommand = { kind: 'get'; replyTo: ActorRef<number> };
type Command = IncrementCommand | DecrementCommand | GetCommand;
class Counter extends Actor<Command> {
private count = 0;
override onReceive(cmd: Command): void {
match(cmd)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'decrement' }, () => this.onDecrement())
.with({ kind: 'get' }, m => this.onGet(m))
.exhaustive();
}
private onIncrement(): void { this.count++; }
private onDecrement(): void { this.count--; }
private onGet(m: GetCommand): void { m.replyTo.tell(this.count); }
}
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 } from 'actor-ts';
const system = ActorSystem.create('demo');
const counter = system.spawnAnonymous(Counter);
counter.tell({ kind: 'increment' });
counter.tell({ kind: 'increment' });
const value = await counter.ask<number>({ kind: 'get' }, 5_000);
console.log(value); // 2
State 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 { ActorSystem } from 'actor-ts';
import { PersistentActor } from 'actor-ts/persistence';
import { match } from 'ts-pattern';
type IncrementCommand = { kind: 'increment' };
type DecrementCommand = { kind: 'decrement' };
type Command = IncrementCommand | DecrementCommand;
type IncrementedEvent = { kind: 'incremented' };
type DecrementedEvent = { kind: 'decremented' };
type Event = IncrementedEvent | DecrementedEvent;
type State = { count: number };
class Counter extends PersistentActor<Command, Event, State> {
readonly persistenceId = 'counter-1';
initialState(): State { return { count: 0 }; }
// A fold that computes a value — arms stay inline.
onEvent(s: State, e: Event): State {
return match(e)
.with({ kind: 'incremented' }, () => ({ count: s.count + 1 }))
.with({ kind: 'decremented' }, () => ({ count: s.count - 1 }))
.exhaustive();
}
// A command dispatch — every arm delegates to an `onXxx` handler.
onCommand(_state: State, cmd: Command): void {
match(cmd)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'decrement' }, () => this.onDecrement())
.exhaustive();
}
private onIncrement(): void { this.persist({ kind: 'incremented' }); }
private onDecrement(): void { this.persist({ kind: '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/cluster';
// 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' });
// `CartActor` declares its own identity, so neither the type name nor
// the entity-id extractor is repeated here:
// static readonly shard = ShardKey.of<CartCommand>('cart', (c) => c.entityId);
const cartRegion = cluster.sharding.start(CartActor);
cartRegion.tell({ entityId: 'user-42', kind: 'add', sku: 'book-1' });
// A handle on one entity, wherever it lives — no routing key needed
// in the message, because the handle names its entity.
const cart = cluster.sharding.entityRefFor(CartActor, 'user-42');
cart.tell({ kind: 'add', sku: 'book-2' });
// And the shards themselves are addressable: where they live, how
// full they are, and a live ref to each.
for (const shard of await cluster.sharding.shards('cart')) {
console.log(shard.shardId, `${shard.node}`, shard.entityCount);
}
// Inside the entity, its own id — the value `extractEntityId` returned,
// not the sanitized form in the actor path. Usually spent on a
// per-entity journal stream. The cluster it runs in is right there
// too, so nothing has to be threaded through the constructor:
class CartActor extends PersistentActor<CartCommand, CartEvent, CartState> {
override get persistenceId(): string { return `cart-${this.entityId}`; }
// Names itself once, instead of every message repeating the id.
override displayName(): string { return `Cart(${this.entityId})`; }
override preStart(): void {
this.log.info(`starting on ${this.cluster.selfAddress}`);
// → [...] INFO actor-ts://shop/.../entity-user-42 - Cart(user-42) - starting on ...
}
}
this.cluster throws on a system that never joined one; ask
this.context.cluster (an Option<Cluster>, matching system.cluster)
when the actor has to work either way.
displayName() is a label, not an address: it joins the log line and
the DevTools tree beside the path, never in place of it, so metrics,
tracing and every wire identifier stay on the path. It defaults to the
path, so an actor that doesn't override it logs exactly as before.
One node hosts it; the rest hold a forwarding ref. Failover moves it without callers changing anything.
class JobScheduler extends Actor<JobCommand> {
static readonly singleton = SingletonKey.of<JobCommand>('job-scheduler');
override onReceive(command: JobCommand): void { /* ... */ }
}
// On every node that may host it — get-or-create, so calling this
// from several modules is safe.
const scheduler = cluster.singleton.start(JobScheduler);
scheduler.tell({ kind: 'schedule', jobId: '42' });
// On a node that should only talk to it, never host it:
cluster.singleton.ref(JobScheduler).tell({ kind: 'schedule', jobId: '43' });
📚 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:
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. Demonstrates ClusterSharding, 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.
Measured rather than asserted: one machine, one harness, one workload, a hundred interleaved rounds, and every row verified against work the system actually completed rather than work it was asked for. Each figure is the mean of those rounds; 🥇 marks the best value in its row.
Bun 1.3.14 · 10 cores of an Intel i9-12900K · Linux.
| Scenario | actor-ts | nact | XState |
|---|---|---|---|
| tell throughput (batch 1k) | 13.82M/s 🥇 | 1.59M/s | 878k/s |
| tell throughput (batch 10k) | 19.05M/s 🥇 | 1.64M/s | 961k/s |
| ping-pong (10k exchanges) | 2.82M/s 🥇 | 811k/s | 508k/s |
| spawn → started → stopped | 355k/s | 698k/s 🥇 | 332k/s |
| ask round-trip (p50) | 0.9 µs 🥇 | 1.5 µs | 2.2 µs * |
* XState has no request/response primitive — that row is send plus a
snapshot wait.
Each framework appears through both its Java and its Scala API at the same pinned version, so a gap inside a pair is the language binding.
| Scenario | actor-ts (Bun) | Akka (Java) | Akka (Scala) | Pekko (Java) | Pekko (Scala) |
|---|---|---|---|---|---|
| tell throughput (batch 1k) | 13.82M/s 🥇 | 7.23M/s | 5.37M/s | 6.76M/s | 4.34M/s |
| tell throughput (batch 10k) | 19.05M/s 🥇 | 9.18M/s | 9.03M/s | 9.36M/s | 9.37M/s |
| ping-pong (10k exchanges) | 2.82M/s 🥇 | 1.99M/s | 1.94M/s | 1.99M/s | 1.99M/s |
| spawn → started → stopped | 355k/s 🥇 | 105k/s | 97k/s | 102k/s | 90k/s |
| ask round-trip (p50) | 0.9 µs 🥇 | 3.7 µs | 3.5 µs | 3.6 µs | 3.5 µs |
| Licence | MIT | BUSL-1.1 | BUSL-1.1 | Apache-2.0 | Apache-2.0 |
| Scenario | actor-ts (Bun) | Akka.NET (C#) | Orleans (C#) |
|---|---|---|---|
| tell throughput (batch 1k) | 13.82M/s 🥇 | 5.96M/s | 695k/s |
| tell throughput (batch 10k) | 19.05M/s 🥇 | 6.37M/s | 792k/s |
| ping-pong (10k exchanges) | 2.82M/s 🥇 | 499k/s | 357k/s |
| spawn → started → stopped | 355k/s 🥇 | 85k/s | 30k/s ‡ |
| ask round-trip (p50) | 0.9 µs 🥇 | 3.2 µs | 5.5 µs |
| Licence | MIT | Apache-2.0 | MIT |
‡ Orleans has no caller-visible create or stop; grains activate on first call, so that row is activation latency rather than a comparable lifecycle.
These are ratios, not absolutes, on one machine, over local in-process message paths only — nothing here says anything about clustering, persistence or a real network.
Full tables and methodology ·
what the numbers mean — the
spread behind every figure, the pinned version of every arm, and what is
deliberately not measured yet. Reproduce with
bun run bench:compare -- --rounds=100.
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. A vulnerability
goes through SECURITY.md instead, which names the
private reporting channel, the supported versions, and what is in and out
of scope.