Streams#

Declare server-to-client streams in the streams: factory as async generators; clients get them as an AsyncIterable over NDJSON.

TypeScript
streams: (ctx) => ({
    async *watch() {
        yield* ctx.changes({ initial: true });
    },
})

A stream arrives as an AsyncIterable, so a component consumes it with an ordinary for await:

TSX
import { component, onMounted, signal } from 'sigx';

const CartView = component<{ id: string }>(({ props }) => {
    const cart = signal<CartState | null>(null);

    onMounted(async () => {
        for await (const next of actor(CartActor, props.id).watch()) cart.value = next;
    });

    return () => <p>{cart.value?.items.length ?? 0} items</p>;
});

Bodies are observers, not turns#

Stream bodies run outside the turn sequence. A stream that waited on its own actor's next turn while occupying it would deadlock against itself.

The consequence is a rule: read ctx.snapshot() or ctx.changes(), and never mutate live state from a stream body. Dev builds warn when a body reads live ctx.state.

ctx.changes() yields a detached snapshot after every mutating turn, through a bounded buffer that drops oldest.

Snapshot a subtree, not the whole state#

ctx.snapshot() clones everything, and on a large state that is an O(state) cost per read. ctx.snapshot(value) clones an arbitrary value through the host codec instead — the same encode-and-revive a state snapshot uses, so custom types: handlers round-trip where a structuredClone would throw or strip them:

TypeScript
methods: (ctx) => ({
    items: () => ctx.snapshot(ctx.state.items),   // detached copy of one subtree
}),

Reach for it whenever a read must return part of a large state detached.

Seed with { initial: true }#

TypeScript
async *watch() {
    yield* ctx.changes({ initial: true });   // ✓
}

Not this:

TypeScript
async *watch() {
    yield ctx.snapshot();        // ✗ subscribes late
    yield* ctx.changes();
}

The prologue subscribes only once the consumer resumes past that first yield, so every mutation in between is lost — and the snapshot it yielded is already stale by then. { initial: true } queues the current snapshot in the same synchronous call that registers the subscription, leaving no gap.

Coalesce bursts with throttleMs#

TypeScript
async *watch() {
    yield* ctx.changes({ initial: true, throttleMs: 100 });
}

At most one snapshot per window, leading edge plus trailing edge, and the trailing snapshot is taken fresh when the window closes — so a throttled consumer never receives state older than the window it waited out.

Reach for it when the consumer redraws rather than accumulates. A snapshot is a full encode+revive of the whole state, and a boundary landing inside an open window builds none at all. The case that makes this obvious is an actor whose state grows through a run — a job appending a step's output per turn and reporting progress as it goes — which otherwise clones everything it has accumulated on every single step.

The final state is never dropped. A window still owing an emit when the actor deactivates is flushed before the feed ends.

throttleMs must be a non-negative finite number; anything else throws rather than quietly reading as unthrottled. Omitting it, or 0, keeps one snapshot per mutating turn.

The factory runs once per subscription#

Each subscription gets its own context. That is what lets a disconnect close the feeds a body opened: an async generator parked inside ctx.changes() is suspended at an internal await, where the spec queues return() rather than running it, so the subscription has to be closable from outside the body.

Nothing an author writes changes because of this — but it does mean the factory is a table constructor, not a place for per-activation state. Two rules follow:

  • Do not touch ctx while constructing the table; its method names are read at definition time. Inside generator bodies, anything goes.
  • computed and watch setup belongs in methods:.

Closing the feed unwinds the body through its own finally, so cleanup works normally.

Keep-alive at the byte layer#

A stream that yields nothing sends nothing, and every intermediary with an idle timeout — ingress at 60s, cloud load balancers at ~4 minutes, mobile NATs — closes it. The client then sees a stream that "ended without a done/error terminator".

So the endpoint emits a {"ping":1} line after 30 seconds of silence, which the client's reader skips.

TypeScript
handleActorRequest(request, { host, streamPingMs: 30_000 });   // 0 disables

An open stream also keeps the activation alive, so idle collection cannot pull it out from under a consumer.

Prefer a live read for current state#

Reading current state? Use useActorState(…, { live: true }) rather than a hand-written streams: body. It pushes the result of the read you already declared, multiplexes every live read on the page onto one connection, and reconnects on its own.

streams: is for a feed that is not a read of current state: a log tail, a progress sequence, an event history where each item matters in its own right.

The examples/chat app in the actors repo is the worked case for the other side of that line: its message list, topic and activity feed are all reads of current state, so it declares { live: true } on each and carries no streams: at all.

Next steps#

  • Live reads — the multiplexed alternative.
  • Tasks — long work that reports progress.
  • Jobs — durable work with a watch() stream built in.