A self-hosted actor framework for JavaScript and TypeScript backends.
Actors are addressable, single-writer objects with durable state. Each actor's mailbox runs serially; concurrent callers get JSON-RPC over a WebSocket and see state changes as RFC 6902 patches (single-writer mailbox actors) or raw events (event-sourced actors). Class source is a first-class entity with semver, content-addressed bytes, and a frozen dependency graph — once a client has resolved a manifest, every actor call rooted in that request runs against the exact same class versions end-to-end.
- Concurrency model. Single-writer mailbox per actor; opt-in event sourcing per class.
- Wire protocol. URL-versioned REST + JSON-RPC 2.0 over WebSocket; SSE fallback.
- Frontend story. Typed
@jplevyak/actjs/clientplus@jplevyak/actjs/bindings/reactand@jplevyak/actjs/bindings/sveltebindings;actctl codegenemits a.d.tsbundle and amanifest.jsonper build so calling a non-existent method is a compile error. - Storage. Valkey (hot path, mailboxes, locks) + Postgres (system of record: classes, snapshots, event log, audit).
See PLAN.md for the v1 design, tasks/ROADMAP.md for what's queued post-8.2, DESIGN.md for the architecture, MONOREPO.md for the frontend↔backend monorepo layout, and CHANGELOG.md for what shipped in which release.
- Actor. Addressable by
(class, id). Methods marked@handlerare exposed over the wire. The actor's mailbox is the single serial writer — handlers never race with each other. - Class. Published TypeScript source, versioned by semver,
content-addressed by
sha256(source). Publishing is immutable; deprecation is a separate lifecycle. - Manifest. A frozen
ClassName → Versionmap resolved from a set of root requirements. Once a client pins a manifest sha, every call rooted at that request uses the same versions, including through actor-to-actor calls. - SWM actors. Subclass
Actor<S>. Handlers mutatethis.state; the host snapshots on a trailing debounce. Subscribers see RFC 6902 patches. - ES actors. Subclass
EventSourced<S, E>. Handlers return events; the host appends atomically, then folds with the user's purereduce. Subscribers see the appended events. Replay is the source of truth. - Reminders.
actjs.scheduleAt(when, type, payload)enqueues a durabletellthat fires on a wall-clock deadline; survives restarts. - Capabilities. Short-lived signed tokens scoped to an actor and a method set. Useful for shareable read links and SSR.
docker compose up --build # actjs + Valkey + PostgresPublish a class:
actctl publish ./src/actors/Cart.ts --version 1.4.2Resolve a manifest and grab its sha:
actctl manifest show --root Cart@1.4.2
# → { sha256: "...", resolved: { Cart: "1.4.2", Item: "1.0.9" } }Generate the typed client bundle:
actctl codegen --out ./client-typesUse it from a React app:
import { Client } from '@jplevyak/actjs/client';
import { useActor, useActorValue } from '@jplevyak/actjs/bindings/react';
import type { Cart } from './client-types';
const client = new Client({ url: '/api', token });
function CartView({ id }: { id: string }) {
const cart = useActor<Cart>('Cart', id);
const total = useActorValue<Cart, number>('Cart', id, (c) => c.total);
return (
<>
<p>Total: {total}</p>
<button onClick={() => cart.call.addItem({ sku: 'X', qty: 1 })}>Add</button>
</>
);
}import { Actor, handler } from '@jplevyak/actjs';
interface CartState {
items: Array<{ sku: string; qty: number }>;
}
export class Cart extends Actor<CartState> {
override onInit(): void {
this.state = { items: [] };
}
@handler('addItem')
addItem(args: { sku: string; qty: number }): number {
this.state.items.push(args);
return this.state.items.length;
}
@handler('total')
total(): number {
return this.state.items.reduce((s, i) => s + i.qty, 0);
}
}Inside a handler you have access to a per-instance this.actjs bridge:
| Method | What it does |
|---|---|
this.actjs.self |
{ class, id } of the current actor |
this.actjs.call(ref, method, args) |
Request/response to another actor (runs under the same manifest) |
this.actjs.tell(ref, type, payload) |
Fire-and-forget; entry is durable before the call returns |
this.actjs.scheduleAt(when, type, p) |
Durable reminder; auto-rebound from PG on restart |
this.actjs.now() |
Test-seam clock |
this.actjs.log |
Structured pino logger; requestId / actorId already bound |
this.actjs.abort(reason) |
Aborts the in-flight handler; the caller sees ActorAbort |
import { EventSourced, handler } from '@jplevyak/actjs';
interface LedgerState {
balance: number;
}
type LedgerEvent = { type: 'Deposited'; amount: number } | { type: 'Withdrawn'; amount: number };
export class Ledger extends EventSourced<LedgerState, LedgerEvent> {
initialState(): LedgerState {
return { balance: 0 };
}
reduce(s: LedgerState, e: LedgerEvent): LedgerState {
return e.type === 'Deposited'
? { balance: s.balance + e.amount }
: { balance: s.balance - e.amount };
}
@handler('deposit')
deposit(args: { amount: number }): LedgerEvent[] {
return [{ type: 'Deposited', amount: args.amount }];
}
}ES handlers don't write state directly — they return events. The host
appends them atomically, then folds via reduce. Cold-start reads the
last snapshot + every event after it and rebuilds state by re-applying
reduce.
Each class can declare a policy() static. The runtime invokes it on
every call before dispatch:
static policy(p: Principal, action: PolicyCtx<Cart>): PolicyDecision {
if (action.kind === 'call' && action.method === 'addItem') {
return p.sub === action.actor.state.ownerId ? 'allow' : 'deny';
}
return 'deny';
}A YAML DSL for the common owner-only / role-match / tag-match cases is
roadmapped — see tasks/ROADMAP.md Tier 5. JS
policy() statics are the supported v1 surface.
When a class is registered at a newer version than a previously
persisted snapshot, the host runs migrate() (SWM) or migrateEvent()
(ES) before opening the actor to traffic. The prior snapshot is
retained at seq = -1 for the configured rollback window.
override migrate(prev: unknown, prevVersion: string): CartV2State {
const old = prev as CartV1State;
return { ...old, currency: 'USD' };
}# Publish a new version of Cart.
actctl publish ./src/actors/Cart.ts --version 1.4.2
# Resolve a dep tree.
actctl manifest show --root Cart@1.4.2 --range Item@^1.0.0
# → sha256: ..., resolved: { Cart: "1.4.2", Item: "1.0.9" }
# Deprecate (hides from new resolutions; pinned clients keep working).
actctl deprecate Cart@1.4.0 --reason "use 1.4.2+" --grace 30d
# Inspect who's still pinned to a deprecated version.
actctl manifest in-useManifests are content-addressed: the same set of resolved versions
always hashes to the same sha. The SDK build embeds the sha returned
by actctl codegen and sends it as X-Actjs-Manifest: <sha> on every
call. Server behavior:
| Pin status | Server response |
|---|---|
| Unknown sha | 400 ManifestUnknown |
| Sha references a deprecated version | 200 with Warning: 299 - "VersionDeprecated <ref>" |
| Sha references an expired version | 410 Gone with the offending refs |
| Healthy | 200, no warning |
The resolver rejects with a structured 409 DepConflict that includes
the path through deps producing every incompatible constraint, so the
caller can see exactly which transitive range disagreed.
Per-actor activation policy:
- Sticky by default — an actor keeps running the class version it
was created with. Older versions are loaded by the in-process
ClassLoader(LRU + refcount). - Floating opt-in —
register({ floating: true })makes every activation use the latest registered version, walkingmigrate()if the snapshot predates it. - Refusing to run older code against newer state is a hard error
(
ManifestRegression).
The HTTP layer is Fastify + Zod; routes are validated and typed
end-to-end. OpenAPI 3.1 is exported at GET /openapi.json.
# Create an actor (mints a fresh id; onInit fires on first call).
curl -X POST http://127.0.0.1:3000/v1/actors/Cart -d '{}' \
-H "Content-Type: application/json"
# → 201 { class: "Cart", id: "0190..." }
# Invoke a handler. Idempotency-Key turns network retries into a
# one-line contract; replays are byte-identical and carry
# `Idempotency-Replayed: true`.
curl -X POST http://127.0.0.1:3000/v1/actors/Cart/<id>/addItem \
-H "Content-Type: application/json" \
-H "X-Actjs-Manifest: <sha>" \
-H "Idempotency-Key: <uuid>" \
-d '{"sku": "X", "qty": 1}'
# Read the snapshot.
curl http://127.0.0.1:3000/v1/actors/Cart/<id>
# Tombstone.
curl -X DELETE http://127.0.0.1:3000/v1/actors/Cart/<id>Errors use application/problem+json with a stable code field:
DepConflict, ManifestUnknown, Gone, Forbidden, MailboxFull,
SchemaInvalid, SyntaxInvalid, ManifestRegression, etc. The SDK
switches on code, not on HTTP status or title.
A single endpoint at /v1/ws speaks JSON-RPC 2.0 and multiplexes
calls and subscriptions over one connection. Methods:
actor.call(class, id, method, args)→{result}.actor.subscribe(class, id)→{subscriptionId}. The firstactor.eventnotification carries the initialsnapshot; subsequent notifications carrypatch(SWM) orevent(ES).actor.unsubscribe(subscriptionId)→{ok}.
const ws = new WebSocket('ws://127.0.0.1:3000/v1/ws');
ws.onmessage = (m) => console.log(JSON.parse(m.data));
ws.send(
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'actor.subscribe',
params: { class: 'Cart', id: '<actor-id>' },
}),
);
// ← { id: 1, result: { subscriptionId: '...' } }
// ← { method: 'actor.event', params: { kind: 'snapshot', data: { ... } } }
// ← { method: 'actor.event', params: { kind: 'patch', patch: [...] } }Heartbeats: server pings every 30s; the connection closes with
1001 heartbeat timeout if no pong arrives within 90s. Per-actor
subscriber cap (default 1000) protects fanout from a single hot key.
Some corporate proxies eat WS traffic. GET /v1/sse/:class/:id
returns a server-sent-event stream of the same actor.event
notifications; actor.call is still a regular POST. The SDK falls
back automatically.
The server takes a BYO auth(req): Promise<Principal> hook at
boot. The resolved principal is bound on req.principal and threaded
into the class policy() static.
import { buildApp } from '@jplevyak/actjs/server';
const app = await buildApp({
driver,
runtime,
auth: async (req) => verifyJwt(req.headers.authorization),
});Token formats — JWT, opaque session, capability — are entirely
operator-chosen. The runtime only sees the resulting Principal.
import { Client } from '@jplevyak/actjs/client';
import type { Cart } from './client-types';
const client = new Client({ url: '/api', token });
const cart = client.actor<Cart>('Cart', cartId);
await cart.call.addItem({ sku: 'X', qty: 2 });
const total = await cart.get.total();
cart.subscribe((state) => render(state)); // WS under the hood- Method signatures come from the
actctl codegen.d.tsbundle. - One WebSocket per
Client, multiplexed across everysubscribeandcall. Exponential backoff + replay on reconnect. - Optimistic updates (SWM):
cart.optimistic(draft => draft.items.push(x))applies locally via Immer, sends the call, reverts on failure. - Offline queue: mutations persist to IndexedDB keyed by their
Idempotency-Keyand replay on reconnect. - ES clients receive events and fold them with the generated reducer client-side; the local state is byte-identical to the server's by construction.
const cart = useActor<Cart>('Cart', id); // Suspense for initial load
const total = useActorValue<Cart, number>('Cart', id, (c) => c.total);Built on useSyncExternalStore for updates, Suspense for cold load,
useTransition for optimistic. @jplevyak/actjs/bindings/react/server exports
fetchActor(class, id, { manifest }) for RSC hydration.
<script lang="ts">
import { actor } from '@jplevyak/actjs/bindings/svelte';
import type { Cart } from './client-types';
const cart = actor<Cart>('Cart', id);
</script>
<p>Total: {$cart.total}</p>Svelte 5 runes API; $cart is a readable store with call,
loading, error siblings.
The subcommands marked shipped are wired today. Everything else is roadmapped to 8.2b — see tasks/ROADMAP.md Tier 3.
| Command | Status | What it does |
|---|---|---|
actctl codegen |
shipped | Generate the .d.ts bundle + manifest.json for the SDK |
actctl publish --name --version --source |
shipped | Publish a class version (Ed25519 --sign optional) |
actctl key add --kid --pem |
shipped | Register a publish signing key with the server |
actctl key revoke --kid |
shipped | Revoke a signing key |
actctl dev |
8.2b | Watch local class dir; republish pre-release versions |
actctl list |
8.2b | List class versions and their deprecation state |
actctl deprecate <ref> --grace 30d |
8.2b | Mark a version deprecated with a grace window |
actctl promote <ref> --env prod |
8.2b | Promote a version through environments |
actctl manifest show --root <ref> |
8.2b | Resolve a manifest; show the pinned versions and sha |
actctl manifest in-use |
8.2b | Which manifest shas are currently being sent by clients |
actctl shell |
8.2b | Admin REPL; arbitrary await snippets routed through call |
actctl actor inspect <id> |
8.2b | State, recent envelopes, mailbox depth, resolved manifest |
actctl migrate dry-run |
8.2b | Replay a snapshot corpus through every migration chain |
actctl logs follow --actor <id> |
8.2b | Tail structured logs for one actor |
actctl audit follow |
8.2b | Tail the audit log (filterable by principal / action) |
For tests and embedded deployments, talk to the Runtime directly:
import { Runtime } from '@jplevyak/actjs/runtime';
import { MemoryStorageDriver } from '@jplevyak/actjs/storage';
import { asClassName, asVersion, mkActorId } from '@jplevyak/actjs/types';
const driver = new MemoryStorageDriver();
await driver.init();
const runtime = new Runtime(driver);
runtime.register({
name: asClassName('Cart'),
version: asVersion('1.0.0'),
ctor: Cart,
});
const id = mkActorId();
await runtime.call(asClassName('Cart'), id, 'addItem', { sku: 'X', qty: 1 });
await runtime.shutdown();
await driver.close();MemoryStorageDriveris the in-process driver used by tests andactctl dev.ValkeyPgStorageDriveris the production driver (Valkey for hot state, PG for the durable record).
@jplevyak/actjs/test (actjs/test export) wraps this with a TestRuntime
that mints actors via t.actor(Ctor), drives time deterministically
via t.advanceTime(ms) (fires due reminders), and ships
framework-agnostic assertions:
import { TestRuntime, assertSnapshot, assertEmitted } from '@jplevyak/actjs/test';
const t = await TestRuntime.create({ classes: { Cart } });
const cart = t.actor(Cart);
await cart.call.addItem({ sku: 'X', qty: 1 });
await assertSnapshot(cart, { items: [{ sku: 'X', qty: 1 }] });
await t.close();replayMigrations({ ctor, snapshots }) reports a per-snapshot diff
through the migration chain. Property-test integration (fast-check
arbitraries) is roadmapped to 8.2c. See docs/testing.md.
Shipped today:
- Logs.
pinovia theLoggersurface inactjs/log; one JSON line per event withrequestId,actorId,class@version,principal.sub,manifestSha. Sensitive headers (Authorization,Idempotency-Key, capability JWT) are redacted. Per-subsystem env overrides (ACTJS_LOG_LEVEL_DRIVER, etc.); the driver defaults towarn. - Metrics.
prom-clientregistry exposed at/metricswhen theRuntimeis built with aMetricsRegistry:actjs_actor_message_total{class,method,outcome}— method label allow-listed (default 50/class →_other).actjs_actor_active{class,version},actjs_actor_mailbox_depth{class}actjs_clients_by_manifest{sha}(capped via the manifest tracker)actjs_manifest_resolution_secondsactjs_event_append_total{class},actjs_event_snapshot_total{class}actjs_policy_decision_total{class,decision},actjs_rate_limit_drop_total{subject},actjs_capacity_exhausted_total{class},actjs_capability_minted_total{class}- Standard Node + process collectors See docs/observability.md for the full runbook and starter PromQL.
- Audit. Every publish, deprecate, signing-key change,
capability mint, admin RPC, and tombstone is written to the
auditPG table via the strict-modeAuditor(best-effort opt-in).
Roadmapped (8.1b — see tasks/ROADMAP.md):
- OpenTelemetry SDK + W3C trace-context propagation through
Envelope.causation. ops/grafana/dashboards/JSON bundle,ops/prometheus/alerts.yml,compose.observability.yml.- S3 audit mirror (optional, append-only with object-lock).
- Node.js ≥ 20
- Valkey (or Redis) on
REDIS_URL - Postgres on
DATABASE_URL(orPOSTGRES_URL)
| Var | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP listen port |
REDIS_URL |
— | Valkey hot-path connection |
DATABASE_URL |
— | Postgres connection |
Without DATABASE_URL the server falls back to the in-memory storage
driver with a startup warning. Suitable for tests and demos; not for
production.
| Script | What it does |
|---|---|
npm run build |
tsc -b → dist/ |
npm run dev |
tsx watch src/main.ts for hot reload |
npm start |
node dist/main.js (requires a prior build) |
npm run typecheck |
tsc --noEmit |
npm run lint |
ESLint over src/, tests/, configs |
npm run format |
Prettier write |
npm test |
Vitest run |
npm run test:coverage |
Vitest + v8 coverage with thresholds |
| Path | Purpose |
|---|---|
src/actor.ts |
Actor<S> base class (SWM) |
src/event-sourced.ts |
EventSourced<S, E> base class |
src/replica.ts |
Replica<S> opt-out-of-persistence base class |
src/handler.ts |
@handler decorator + getHandlers |
src/types/ |
ActorId, ClassName, Version, Manifest, Envelope<T>, Principal |
src/storage/ |
StorageDriver interface + memory and Valkey/PG drivers (incl. fence token) |
src/runtime/ |
Runtime, ActorHost, Mailbox, ClassLoader, reminder dispatcher |
src/registry/ |
Resolver, publisher, manifest tracker, MemorySigningKeyRegistry |
src/policy/ |
policy() evaluation, capability JWTs, MemoryBlocklist |
src/limits/ |
RateLimiter (token bucket), CapacityExhaustedError |
src/audit/ |
Auditor, AUDIT_ACTIONS, strict/best-effort write modes |
src/log/ |
Logger surface (pino-backed, noop, collecting), redaction list |
src/metrics/ |
MetricsRegistry (prom-client) + cardinality guards |
src/wire/ |
JSON-RPC envelope types shared between server + client |
src/codegen/ |
actctl codegen — .d.ts + manifest emitter |
src/client/ |
@jplevyak/actjs/client — WS transport, RPC, subscriptions, offline queue |
src/bindings/ |
@jplevyak/actjs/bindings/react + @jplevyak/actjs/bindings/svelte adapters; refcounted ActorStore |
src/cli/ |
actctl CLI |
src/test/ |
@jplevyak/actjs/test — TestRuntime, assertions, replayMigrations |
src/server/ |
Fastify app, REST routes, WS endpoint, hooks (pin, idempotency, auth) |
src/server/subscription-registry.ts |
Per-actor subscription fan-out for WS / SSE |
migrations/ |
Postgres schema (0001_init, 0002_reminders, 0003_signing_keys, 0004_actor_fence) |
docs/ |
Operator runbooks (auth, observability, ops-hardening, testing) |
tasks/ |
Per-phase implementation tasks + ADRs + ROADMAP.md |
tests/ |
Vitest unit + integration tests |
Dockerfile |
Multi-stage build, distroless final stage |
docker-compose.yml |
Local stack: actjs + Valkey + Postgres |
actjs is built phase-by-phase against PLAN.md. Phases
0 through 8.2 have shipped, plus the Phase 9 cluster-seam audit;
follow-up work that was deferred from each phase is tracked in
tasks/ROADMAP.md. The CHANGELOG
is the authoritative record of what has shipped in each release;
this README describes the v1.0 surface area the PLAN targets, with
explicit roadmap markers wherever a piece is queued for a follow-up.
Per-phase task files and ADRs live in tasks/.
Phase 9 (multi-node clustering) is sketched in PLAN.md but
deliberately not in v1. The 2026-05-20 cluster-seam audit verified
the directory boundary, idempotency, durable inbox, manifest envelope,
reminder durability, and storage driver boundary; the audit closed
three gaps (fence-token plumbing through driver + host, WS
manifest-pin capture per connection, reminders dispatcher key
parameterization) so the v2 implementation tasks (9.1 through
9.7) land without rewriting earlier phases. See
phase-9-cluster-seams.adr.md.