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.

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.

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 dropped roughly 45 lines of hand-written stream and reconnect code when it moved its current-state reads to { live: true }.

Next steps#

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