anneal

helpers for applying Actor model CSP for writing Typescript code with reliable and safe concurrency

git clone https://git.smesh.lol/anneal.git

anneal

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.

Naming

Three libraries, three languages, three diseases, one cure.

  • actor (Go) - names Go's missing enforcement. Go has channels and

goroutines but no structure for the actor pattern. You reinvent it every time.

  • pickle (Rust) - names Rust's encrustation. Arc<Mutex<T>>,

lifetime annotations, &self vs &mut self - the borrow checker fights you when all you need is message-passing.

  • anneal (TypeScript) - names TypeScript's brittleness. async/await

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.

Architecture Guide

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.

Types

TypeCallerActorPattern
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
Signalcall() -> Promise<void>msg.done()trigger / reset
Inbox<T>send(v) / trySend(v)receive in selectfire-and-forget
Lifecyclestop() -> Promise<void>.on() in selectshutdown

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.

Example

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();

Design Principles

  • No shared mutable state. The actor function owns all mutable

state. Callers interact through typed channel objects.

  • Synchronous resolution. The caller's Promise resolves only after

the actor has processed the message. Use Inbox for intentionally async paths.

  • Sequential processing. Messages are handled one at a time. No

concurrent access to actor state, ever.

  • Composable. Use the channel types you need. No base class to

extend, no framework, no registration.

When to use what

  • Func - caller needs a computed result based on arguments (map

lookup, filtered query)

  • Query - caller needs current state, no arguments (get count, get

config)

  • Proc - caller needs to mutate state and wait for confirmation

(set value, record event)

  • Signal - caller needs to trigger an action and wait (reset,

flush, increment)

  • Inbox - caller must not block (logging, metrics, notifications).

Use trySend to drop on backpressure.

Cancellation

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.

Select priority

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 => { ... }),
);

Install

npm install anneal

License

AGPL-3.0-or-later

files