Pure Actor Model for JavaScript/TypeScript - Build resilient systems with location-transparent actors across local and directly connected runtime nodes, inspired by Erlang/OTP. Dynamic membership and production multi-machine transport remain in progress; see the external transport roadmap.
π Documentation: 0xjcf.github.io/actor-web
JavaScript lacks built-in primitives for actor-based concurrency and fault tolerance. Actor-Web brings Erlang/OTP's battle-tested patterns to JavaScript with:
- π― Pure Actor Model - No shared state, message-only communication
- π Scoped Location Transparency - Actor code and refs use the same API locally and across directly connected runtime nodes; dynamic membership and production multi-machine hardening are still roadmap work.
- π‘οΈ Fault Tolerance - Supervisor trees with "let it crash" philosophy
- π¦ Workspace Packages - Runtime, testing utilities, agent utilities,
lattice coordination, and workflow CLI ship from
packages/* - π Unified API - Single
defineBehavior()for all patterns
npm install @actor-web/runtimeimport { createActorSystem, defineBehavior } from '@actor-web/runtime';
// Define an actor with the unified API
const counterActor = defineBehavior<{ type: 'INCREMENT' | 'GET_COUNT' }>()
.withContext({ count: 0 })
.onMessage(({ message, actor }) => {
const { count } = actor.getSnapshot().context;
switch (message.type) {
case 'INCREMENT':
return {
context: { count: count + 1 },
emit: [{ type: 'COUNT_CHANGED', newValue: count + 1 }]
};
case 'GET_COUNT':
return { reply: { count } };
}
});
// .build() is optional β the framework builds the behavior when you spawn it
// Create and use the actor system
const system = await createActorSystem({ nodeAddress: 'localhost:0' });
await system.start();
const counter = await system.spawn(counterActor, { id: 'counter-1' });
await counter.send({ type: 'INCREMENT' });
const { count } = await counter.ask({ type: 'GET_COUNT' });
console.log(count); // 1- API reference documents the public runtime surface, including the runtime gateway API.
- Ignite host bridge example shows how Ignite consumes Actor-Web snapshots and emitted events from local refs or remote projection refs.
- External transport roadmap separates what is proven today from the remaining production multi-machine transport work.
The browser/service-worker Ignite demo is a topology prove-out, and the runtime/gateway transport slice now covers stable node identity at the WebSocket edge, static auth hooks, bounded gateway replay, and dependency-free telemetry sinks. Production distributed runtime work still needs durable membership/discovery, deployment-grade auth/TLS, application-level delivery guarantees beyond at-most-once send, broader durable replay/event logging, metrics/tracing integration, and true multi-host rehearsals.
This framework strictly follows the pure actor model:
- β Message-Only Communication - No shared state or direct method calls
- β Scoped Location Transparency - Same actor API for local actors and directly connected runtime nodes; true multi-host operation still depends on the external transport roadmap.
- β Asynchronous Processing - No blocking operations
- β Fault Isolation - Actor failures don't cascade
- β JSON Serialization - All messages are network-ready
- β No Arbitrary Effects - Actor logic does not reach directly into I/O,
clocks, network clients, databases, or global process state. External effects
must go through declared tool ports such as
tools.execute(...), with concrete adapters supplied by the runtime boundary. - β No Singletons - No global state or shared instances
- β No Timeouts - Use actor-based scheduling instead
- β No Direct State Access - Only through messages
One API for all actor patterns - no need to choose between different builder types:
// Stateless actor (pure message router)
const routerActor = defineBehavior<RouterMessage>()
.onMessage(({ message }) => {
return { emit: [{ type: 'ROUTED', to: message.target }] };
});
// Stateful actor (with context)
const accountActor = defineBehavior<AccountMessage>()
.withContext({ balance: 0 })
.onMessage(({ message, actor }) => {
// Handle deposits, withdrawals, etc.
});
// State machine actor (with XState)
const orderActor = defineBehavior<OrderMessage>()
.withMachine(orderStateMachine)
.onMessage(({ message, actor }) => {
// Handle based on current state
});Following Erlang/Elixir conventions:
// Update context (like {:noreply, new_state})
return { context: { count: newCount } };
// Reply to ask pattern (like {:reply, response, new_state})
return {
context: { processed: true },
reply: { status: 'success' }
};
// Emit events (broadcast to subscribers)
return {
emit: [
{ type: 'USER_CREATED', userId },
{ type: 'EMAIL_QUEUED', email }
]
};Built-in utilities for deterministic testing:
// Enable synchronous message processing
system.enableTestMode();
// Send messages - processed immediately!
await actor.send({ type: 'INCREMENT' });
// Or wait for all actors to process messages
await system.flush();
// Collect and verify events
const collector = await system.spawn(createEventCollectorBehavior());
await system.subscribe(actor, {
subscriber: collector,
events: ['COUNT_CHANGED']
});Erlang-style fault tolerance, declared in the topology:
import { actor, defineActorWebTopology, node, supervisor } from '@actor-web/runtime/topology';
const topology = defineActorWebTopology({
nodes: { local: node('local-node') },
actors: {
worker: actor({
id: 'worker',
node: 'local',
behavior: workerBehavior,
// Per-actor restart policy bounds crash loops
supervision: { strategy: 'restart', maxRestarts: 3, withinMs: 60_000 },
}),
dbPool: actor({ id: 'db-pool', node: 'local', behavior: databaseActor }),
},
supervisors: {
main: supervisor({
node: 'local',
strategy: 'one-for-one', // 'one-for-all' | 'rest-for-one' | 'escalate'
children: ['worker', 'dbPool'],
}),
},
});const trafficLightActor = defineBehavior<{ type: 'TIMER' | 'EMERGENCY' }>()
.withMachine(trafficLightMachine)
.onMessage(({ message, actor }) => {
const snapshot = actor.getSnapshot();
// Use state.matches for conditional behavior
if (message.type === 'EMERGENCY' && snapshot.matches('green')) {
return {
emit: [{ type: 'SWITCHING_TO_RED' }]
};
}
// Let the state machine handle normal transitions
actor.send(message);
});// IMPORTANT: External resources (DB, APIs) must be wrapped in dedicated actors
// This maintains actor isolation and enables location transparency
// Create a database actor that manages its own connection pool
const databaseActor = defineBehavior<
| { type: 'QUERY'; sql: string; params?: any[] }
| { type: 'INSERT'; table: string; data: Record<string, any> }
| { type: 'INIT_POOL'; config: any }
>()
.withContext({
isInitialized: false,
// In real implementation, you'd store pool reference here
// But remember: context must be JSON-serializable
poolConfig: null as any
})
.onMessage(({ message, actor }) => {
const { isInitialized } = actor.getSnapshot().context;
switch (message.type) {
case 'INIT_POOL':
// Store configuration, actual pool creation happens in onStart
return {
context: {
isInitialized: true,
poolConfig: message.config
},
emit: [{ type: 'DATABASE_INITIALIZED' }]
};
case 'QUERY':
if (!isInitialized) {
return {
reply: { error: 'Database not initialized' }
};
}
// In a real implementation, you would:
// 1. Send message to a worker actor that owns the actual connection
// 2. Use correlation to match the response
// 3. Return the result via reply
// For this example, we simulate the async query result
return {
reply: {
rows: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
],
rowCount: 2
},
emit: [{
type: 'QUERY_EXECUTED',
sql: message.sql,
timestamp: Date.now()
}]
};
case 'INSERT':
if (!isInitialized) {
return {
reply: { error: 'Database not initialized' }
};
}
// Simulate insert with generated ID
const newId = Date.now();
return {
reply: {
id: newId,
...message.data
},
emit: [{
type: 'RECORD_INSERTED',
table: message.table,
id: newId
}]
};
}
})
.onStart(() => {
// This is where you'd actually create the connection pool
// But remember: no direct external calls here either!
// Instead, emit a message that a worker actor handles
console.log('Database actor started');
})
.onStop(() => {
// Cleanup would also be message-based
console.log('Database actor stopped');
});
// Usage: Database operations through actor messages
const system = await createActorSystem({ nodeAddress: 'localhost:0' });
await system.start();
const dbActor = await system.spawn(databaseActor, { id: 'database' });
// Initialize the database connection
await dbActor.send({
type: 'INIT_POOL',
config: {
host: 'localhost',
database: 'myapp',
max: 20
}
});
// Query data using ask pattern
const queryResult = await dbActor.ask({
type: 'QUERY',
sql: 'SELECT * FROM users WHERE active = $1',
params: [true]
});
if ('error' in queryResult) {
console.error('Query failed:', queryResult.error);
} else {
console.log('Active users:', queryResult.rows);
}
// Insert data
const newUser = await dbActor.ask({
type: 'INSERT',
table: 'users',
data: { name: 'Charlie', email: 'charlie@example.com' }
});
console.log('Created user:', newUser);actor-web- Private workspace root for scripts, examples, and verification@actor-web/runtime- Core actor system implementation@actor-web/testing- Testing utilities and mocks@actor-web/cli- actor-web runtime host CLI (work in progress)
// β NEVER: Direct external calls in actors
const myActor = defineBehavior()
.onMessage(async ({ message }) => {
await database.save(data); // VIOLATION! Breaks actor isolation
await fetch('/api/endpoint'); // VIOLATION! Not message-based
});
// β
CORRECT: Create dedicated actors for external systems
const databaseActor = defineBehavior<{ type: 'SAVE'; data: any }>()
.onMessage(async ({ message }) => {
// This actor's sole responsibility is database interaction
await database.save(message.data);
return { emit: [{ type: 'DATA_SAVED', id: message.data.id }] };
});
// Then send messages to it
return { emit: [{ type: 'SAVE', data }] };
// β NEVER: Shared state or singletons
const globalCache = new Map(); // VIOLATION!
class MySingleton {
static instance = new MySingleton(); // VIOLATION!
}
// β
CORRECT: State as actor context
const cacheActor = defineBehavior<{ type: 'GET' | 'SET'; key: string; value?: any }>()
.withContext({ cache: new Map() })
.onMessage(({ message, actor }) => {
const { cache } = actor.getSnapshot().context;
if (message.type === 'GET') {
return { reply: cache.get(message.key) };
}
// Return new context with updated cache
const newCache = new Map(cache);
newCache.set(message.key, message.value);
return { context: { cache: newCache } };
});
// β NEVER: Direct state access
const count = actor.getSnapshot().context.value; // VIOLATION!
// β
CORRECT: Use ask pattern
const { value } = await actor.ask({ type: 'GET_VALUE' });
// β NEVER: Blocking operations
const actor = defineBehavior()
.onMessage(({ message }) => {
const result = someSyncBlockingOperation(); // VIOLATION!
while (condition) { /* busy wait */ } // VIOLATION!
});
// β
CORRECT: All operations must be async and message-based
const actor = defineBehavior()
.onMessage(({ message }) => {
// Delegate to another actor or return immediately
return { emit: [{ type: 'PROCESS_ASYNC', data: message.data }] };
});import { createActorSystem, createEventCollectorBehavior } from '@actor-web/runtime';
describe('Counter Actor', () => {
it('should increment and emit events', async () => {
const system = await createActorSystem();
system.enableTestMode(); // Synchronous processing
const counter = await system.spawn(counterActor);
const collector = await system.spawn(createEventCollectorBehavior());
await system.subscribe(counter, {
subscriber: collector,
events: ['COUNT_CHANGED']
});
await counter.send({ type: 'INCREMENT' });
const events = await collector.ask({ type: 'GET_EVENTS' });
expect(events.collectedEvents).toHaveLength(1);
expect(events.collectedEvents[0]).toMatchObject({
type: 'COUNT_CHANGED',
newValue: 1
});
});
});- Actor-Web Documentation Site
- Learn Actor-Web: Guide, Workbook, and Labs
- Architecture Study Guide and Ten-Week Curriculum
- API Reference
- Runtime Package
- CLI Package
We welcome contributions! Please ensure:
- No
anytypes - Use proper TypeScript types - Pure actor model - No shared state or side effects
- Test coverage - All features must have tests
- Documentation - Update docs for API changes
# Clone and setup
git clone https://github.com/0xjcf/actor-web
cd actor-web
pnpm install
# Run tests
pnpm test
# Build all packages
pnpm build
# Run in dev mode
pnpm devMIT Β© JosΓ© Flores
Built with β€οΈ following Erlang/OTP principles for the JavaScript ecosystem