How do demos/tanstack-ai-durable-session streams work?
tell me how demos/tanstack-ai-durable-session work. is it a single stream per session? how are non-ai events (like presence) emmitted? in a child stream, or everything's in the same stream? when i reload, the stream loads in the client? so no SSR possible?
Now I have a complete picture. Here's the comprehensive answer:
##How demos/tanstack-ai-durable-session works
###Single Stream Per Session — Yes, Everything in One Stream
The entire architecture is built around one Durable Stream per session. All data types — AI chat chunks, presence events, and agent registrations — are multiplexed onto the same stream using a STATE-PROTOCOL with typed events.
The stream is created in the proxy (packages/durable-session-proxy/src/protocol.ts):
const stream = new DurableStream({
url: `${this.baseUrl}/v1/stream/sessions/${sessionId}`,
})
await stream.create({ contentType: 'application/json' })
And the client connects to it via a single URL (packages/durable-session/src/collection.ts):
const streamUrl = `${baseUrl}/v1/stream/sessions/${sessionId}`
const rawDb = createStreamDB({
streamOptions: { url: streamUrl, headers, signal },
state: sessionStateSchema,
})
###How Non-AI Events (Presence, Agents) Are Emitted
There are no child streams. Everything goes through the same stream, differentiated by a type field in each event. The schema (packages/durable-session/src/schema.ts) defines three event types:
export const sessionStateSchema = createStateSchema({
chunks: { // AI message chunks
schema: chunkValueSchema,
type: 'chunk',
primaryKey: 'id',
},
presence: { // User/agent online status
schema: presenceValueSchema,
type: 'presence',
primaryKey: 'id',
},
agents: { // Registered webhook agents
schema: agentValueSchema,
type: 'agent',
primaryKey: 'agentId',
},
})
The @durable-streams/state library (createStreamDB) reads this single event stream and demuxes events by type into separate TanStack DB collections on the client. So when the proxy writes a presence event:
// protocol.ts:writePresence()
const event = sessionStateSchema.presence.upsert({
key: `${actorId}:${deviceId}`,
value: { actorId, deviceId, actorType, name, status, lastSeenAt },
})
await stream.append(event) // same stream as chat chunks!
…it goes into the same stream as chat chunks and agent registrations, but the client-side stream-db routes it to db.collections.presence based on the type: 'presence' field.
On top of these three raw collections, the DurableChatClient builds derived collections (packages/durable-session/src/client.ts:createCollections()):
| Collection | Source | Description |
|---|---|---|
chunks | Raw from stream | All chunk events |
messages | Derived from chunks | Materialized MessageRow[] (chunks grouped by messageId) |
toolCalls | Derived from messages | Messages filtered to tool-call parts |
pendingApprovals | Derived from messages | Tool calls awaiting approval |
activeGenerations | Derived from messages | In-flight assistant responses |
presence | Derived from raw presence | Aggregated per-actor (not per-device) |
agents | Raw from stream | Registered agents |
sessionMeta | Local-only | Connection status etc. |
sessionStats | Derived from chunks | Aggregated stats |
###On Reload: Stream Replays on the Client
When you reload the page, the useDurableChat hook:
- Constructs the
DurableChatClientsynchronously (creates empty collections) - Connects via
client.connect()in auseEffect(theautoConnect: truedefault):// use-durable-chat.ts useEffect(() => { if (autoConnect && client.connectionStatus === 'disconnected') { client.connect().catch(...) } return () => { client.dispose() } }, [client, autoConnect, providedClient]) connect()callsPUT /v1/sessions/${sessionId}to ensure the session exists, then callsthis._db.preload()— which reads the entire stream from the beginning (all historical events) into the local TanStack DB collections.
So yes, on reload, the full message history, presence state, and agent registrations are all replayed from the stream into the client. There is no separate database query — the stream is the source of truth.
###SSR: Partially Possible, But Limited Today
The demo is not fully SSR'd for the chat data. Here's why:
-
The route loader only does login — it calls
POST /v1/sessions/${sessionId}/loginand returns params. It does NOT fetch or render messages server-side. -
useDurableChatconnects client-side — the stream connection (preload()) happens in auseEffect, which only runs in the browser. -
useLiveQuerydoesn't support SSR — TanStack DB'suseLiveQuerylacksgetServerSnapshot, so every component that queries collections is wrapped in aClientOnlypattern:// chat.$sessionId.$username.tsx function ClientOnlyPresenceBar(...) { const [isClient, setIsClient] = useState(false) useEffect(() => { setIsClient(true) }, []) if (!isClient) return <div>Loading...</div> return <PresenceBar ... /> }There's even a comment referencing TanStack/db#709 as the upstream fix.
-
However,
useDurableChatitself does try to be SSR-safe — the hook usesuseSyncExternalStorewith agetServerSnapshotfor its core data (messages, isLoading, connectionStatus):// use-durable-chat.ts:useCollectionData() return useSyncExternalStore( subscribeRef.current, getSnapshotRef.current, getSnapshotRef.current // same fn for server snapshot → empty array )This means the hook itself won't crash during SSR — it'll just return empty messages. The shell renders fine, but the actual chat data only appears after the client-side stream connection completes.
In summary: SSR of the page shell works fine, but the chat messages/presence/agents data is client-only because it depends on connecting to the Durable Stream, which is an inherently client-side operation (long-lived event stream). You could theoretically pre-fetch messages from the proxy in the route loader and pass them as initial data, but the current architecture doesn't do this — the stream is the single source of truth and it's consumed client-side.
END OF POST