Streams
Declare server-to-client streams in the streams: factory as async generators;
clients get them as an AsyncIterable over NDJSON.
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:
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 }
async *watch() {
yield* ctx.changes({ initial: true }); // ✓
}
Not this:
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
ctxwhile constructing the table; its method names are read at definition time. Inside generator bodies, anything goes. computedandwatchsetup belongs inmethods:.
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.
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-writtenstreams: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.
