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.
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:
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 }
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.
Coalesce bursts with throttleMs
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
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 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.
