soften the spine so the blade doesn't shatter
CSP/Actor model architecture for TypeScript applications.
Generic building blocks for actor-model concurrency. Each type maps to a common async communication pattern, eliminating the per-actor boilerplate of defining message types, resolver callbacks, and caller wrappers.
Three libraries, three languages, three diseases, one cure.
goroutines but no structure for the actor pattern. You reinvent it every time.
Arc<Mutex<T>>, lifetime annotations, &self vs &mut self - the borrow checker
fights you when all you need is message-passing.
and Promises shatter under composition. Callbacks nest, errors vanish, backpressure doesn't exist.
The sword analogy: the actor loop is the tempered cutting edge - hard, precise, one message at a time. The async integration layer around it is the annealed spine - flexible, absorbs shock without shattering. A good sword needs both.
These libraries implement the intra-domain actor patterns described in Moxie Architecture Patterns - specifically sections 3.1-3.6 (the actor loop, typed operation channels, synchronous-mutation rule, construction-is-spawning, post-mortem queries, context-aware calls). The five channel types (Func, Query, Proc, Signal, Inbox) are a direct realization of the typed operation channel taxonomy from section 3.2.
The architecture document covers the full CSP/Actor/DDD model including inter-domain patterns (spawn boundaries, worker pools, pipelines, supervision, shutdown) which apply at the process level in Moxie but whose structural principles - state ownership, backpressure by buffer, bilateral protocol changes, measurement before tuning - apply identically when building actor systems in any language.
| Type | Caller | Actor | Pattern |
|---|---|---|---|
Func<Req, Resp> | call(req) -> Promise<Resp> | msg.req, msg.reply(resp) | function call |
Query<Resp> | call() -> Promise<Resp> | msg.reply(resp) | getter |
Proc<Req> | call(req) -> Promise<void> | msg.req, msg.done() | setter / command |
Signal | call() -> Promise<void> | msg.done() | trigger / reset |
Inbox<T> | send(v) / trySend(v) | receive in select | fire-and-forget |
Lifecycle | stop() -> Promise<void> | .on() in select | shutdown |
All synchronous types resolve the caller's Promise only after the actor has processed the message. This eliminates races where a buffered write appears to succeed but the actor hasn't seen it yet.
import { Signal, Query, Lifecycle, spawn, loop, select, STOP } from 'anneal';
class Counter {
private inc = new Signal();
private get = new Query<number>();
private lc = new Lifecycle();
constructor() {
spawn(this.lc, async () => {
let n = 0;
await loop(
this.lc.on(() => STOP),
this.inc.on(msg => { n++; msg.done(); }),
this.get.on(msg => { msg.reply(n); }),
);
});
}
incr() { return this.inc.call(); }
value() { return this.get.call(); }
stop() { return this.lc.stop(); }
}
const c = new Counter();
await c.incr();
await c.incr();
await c.incr();
console.log(await c.value()); // 3
await c.stop();
state. Callers interact through typed channel objects.
the actor has processed the message. Use Inbox for intentionally
async paths.
concurrent access to actor state, ever.
extend, no framework, no registration.
lookup, filtered query)
config)
(set value, record event)
flush, increment)
Use trySend to drop on backpressure.
All synchronous types accept an optional AbortSignal:
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 1000);
const result = await func.call(req, ctrl.signal);
If the signal fires before the actor processes the message, the
caller's Promise rejects. The actor may still process the message -
same semantics as Go's CallCtx and Rust's call_timeout.
Cases are checked in declaration order. Place the lifecycle case first for reliable shutdown under load:
await loop(
this.lc.on(() => STOP), // checked first
this.work.on(msg => { ... }),
);
npm install anneal
AGPL-3.0-or-later