Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
db8b0c0
feat(ai): ws frame codec for websocket transport
AlemTuzlak Jul 20, 2026
22f2d74
feat(ai): WebSocketLike surface + fake socket test double
AlemTuzlak Jul 20, 2026
cfbc42a
feat(ai): WsRunContext + synthetic per-turn request builder
AlemTuzlak Jul 20, 2026
4683e31
feat(ai): toWebSocketStream conversation loop (non-durable happy path)
AlemTuzlak Jul 20, 2026
ee44d8f
fix(ai): widen WsRunContext.messages to drop unsound cast
AlemTuzlak Jul 20, 2026
df45ae6
feat(ai): durable ws pumping via shared durableStreamSource
AlemTuzlak Jul 20, 2026
1832912
feat(ai): ws abort frame + close/idle/heartbeat lifecycle + malformed…
AlemTuzlak Jul 20, 2026
9d9196f
feat(ai): resumeWebSocketStream read-only log replay
AlemTuzlak Jul 20, 2026
6d51a4d
fix(ai): guard resumeWebSocketStream replay pump against unhandled re…
AlemTuzlak Jul 20, 2026
3cbd47d
feat(ai): toWebSocketResponse/resumeWebSocketResponse CF wrappers + e…
AlemTuzlak Jul 20, 2026
a43820a
fix(ai): drop unused request param from resumeWebSocketResponse
AlemTuzlak Jul 20, 2026
f8802ab
feat(ai-client): webSocket() subscribe/send connection adapter
AlemTuzlak Jul 20, 2026
b62f4b2
fix(ai-client): memoize webSocket open-promise + cover bare-chunk/joi…
AlemTuzlak Jul 20, 2026
229c7d9
fix(ai-client): silence eager open-promise rejection + test tidy
AlemTuzlak Jul 20, 2026
cdfe2bf
refactor(ai-client): extract createReconnectTracker from resumableStream
AlemTuzlak Jul 20, 2026
0092106
feat(ai-client): auto-resume webSocket subscribe on drop via ?offset
AlemTuzlak Jul 20, 2026
2721eb7
fix(ai-client): surface fatal ws drop to consumer instead of hanging …
AlemTuzlak Jul 20, 2026
0b116f0
test(e2e): websocket transport delivery + reconnect-resume
AlemTuzlak Jul 20, 2026
580f7b1
feat(examples): websocket chat route in ts-react-chat
AlemTuzlak Jul 20, 2026
b8513d7
feat(framework): re-export webSocket connection adapter from solid/vu…
AlemTuzlak Jul 20, 2026
1522e6a
docs(resumable-streams): websocket transport
AlemTuzlak Jul 20, 2026
cc6cefa
chore: changeset for websocket transport
AlemTuzlak Jul 20, 2026
1433ca3
fix(ai,ai-client): close resume ws on log exhaustion; guard idle-reap…
AlemTuzlak Jul 20, 2026
2225a78
ci: apply automated fixes
autofix-ci[bot] Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/websocket-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@tanstack/ai': minor
'@tanstack/ai-client': minor
'@tanstack/ai-react': minor
'@tanstack/ai-solid': minor
'@tanstack/ai-vue': minor
'@tanstack/ai-svelte': minor
'@tanstack/ai-angular': minor
---

WebSocket transport: a full-duplex, resumable third transport alongside SSE and
NDJSON, reusing the same delivery-durability seam.

On the server, `@tanstack/ai` adds `toWebSocketStream(socket, request, { onRun,
durability, batch, heartbeatMs, idleTimeoutMs, debug })` — a portable core that
pumps a conversation over an already-accepted WHATWG `WebSocketLike` server
socket (Node via `ws`, Bun, etc.), and `toWebSocketResponse(request, { onRun,
… })`, a thin wrapper that upgrades via `WebSocketPair` and returns a 101
`Response` on Cloudflare Workers/Durable Objects (it throws elsewhere, pointing
you at `toWebSocketStream`). Because one socket outlives many `chat()` turns
(client-tool resubmits, follow-up user messages), you pass an `onRun(ctx) =>
AsyncIterable<StreamChunk>` factory instead of a prebuilt stream — the helper
calls it per inbound `RunAgentInput` frame. The socket is conversation-scoped:
it stays open across turns and closes on client close, an `{ type: 'abort',
runId }` control frame (which aborts only that turn), or the idle timeout, with a
periodic `{ type: 'ping' }` heartbeat. Durability is keyed per turn and reuses
the existing `durableStreamSource`, so server→client frames carry the same
`{ id, chunk }` envelope as NDJSON. `resumeWebSocketStream(socket, { adapter })`
and `resumeWebSocketResponse({ adapter })` replay a run read-only from the
durability log (no model call).

On the client, `webSocket(url, options)` (in `@tanstack/ai-client`, re-exported
from `@tanstack/ai-react`, `-solid`, `-vue`, `-svelte`, and `-angular`) is a
full-duplex `subscribe` + `send` connection adapter for `useChat`. `send()`
writes a `RunAgentInput` frame; `subscribe()` yields inbound chunks, ignores
heartbeats, unwraps durable envelopes, and auto-reconnects a dropped durable run
by reopening with `?runId=&offset=` (browsers can't set a `Last-Event-ID`
handshake header, so the offset rides in the URL). The reconnect bookkeeping
(offset de-dupe, no-progress ceiling → `StreamReconnectLimitError`) is shared
with the HTTP adapters via the new `createReconnectTracker`, and a fatal drop
surfaces to the consumer (`StreamReadError` / `StreamReconnectLimitError`)
instead of hanging.
23 changes: 20 additions & 3 deletions docs/chat/connection-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ This page covers every supported transport, when to pick which, and how to build
| Code that **synchronously** returns an `AsyncIterable<StreamChunk>` (in-process `chat()`, an RSC stream, tests) | [`stream`](#server-functions-and-direct-async-iterables) |
| An **async** call — a TanStack Start server function or any `Promise`-returning function — resolving to a `Response` or an `AsyncIterable<StreamChunk>` | [`fetcher`](#server-functions-via-fetcher) |
| An RPC framework like Cap'n Web, gRPC-Web, or tRPC | [`rpcStream`](#rpc-streams) |
| A single long-lived WebSocket (or BroadcastChannel, postMessage, shared worker) serving many runs | [Custom `subscribe` / `send` adapter](#persistent-transports-websockets-and-friends) |
| A single long-lived, resumable WebSocket serving many runs | [`webSocket`](#websockets) |
| BroadcastChannel, postMessage, a shared worker, or another persistent transport | [Custom `subscribe` / `send` adapter](#persistent-transports-websockets-and-friends) |
| Standard SSE but with custom fetch wrapping (auth refresh, retries) | [`fetchServerSentEvents` with `fetchClient`](#custom-fetch-client) |
| Something else entirely (HTTP/3, Server-Sent Events over a different protocol, etc.) | [Custom `connect` adapter](#custom-request-scoped-adapters) |

Expand Down Expand Up @@ -295,11 +296,25 @@ const { messages } = useChat({
});
```

## WebSockets

For a persistent, resumable WebSocket, use the built-in `webSocket()` adapter instead of hand-rolling a `SubscribeConnectionAdapter`. It opens one socket for the whole conversation, reconnects a dropped durable run automatically, and pairs with the server's `toWebSocketStream` / `toWebSocketResponse`:

```typescript
import { useChat, webSocket } from "@tanstack/ai-react";

const connection = webSocket("/api/chat-ws");

const { messages, sendMessage } = useChat({ connection });
```

See [WebSockets](../resumable-streams/websockets) for the server side, the wire protocol, reconnect details, and hosting on Node vs Cloudflare.

Comment on lines +299 to +312

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a server endpoint snippet for WebSockets.

As per coding guidelines, when a documentation page covers both server and client behavior, snippets for both halves (the server endpoint and the client consumption) must be included. The SSE section on this page includes a server snippet, but the new WebSockets section only provides the client webSocket snippet. Please add a brief example of the paired server endpoint (e.g., using toWebSocketStream or toWebSocketResponse).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/chat/connection-adapters.md` around lines 299 - 312, Add a concise
server endpoint example to the WebSockets section alongside the existing client
webSocket usage, demonstrating the paired toWebSocketStream or
toWebSocketResponse integration. Keep the example consistent with the documented
server-side WebSocket APIs and retain the existing link for protocol and hosting
details.

Source: Coding guidelines

## Persistent Transports (WebSockets and Friends)

A persistent transport — WebSocket, BroadcastChannel, postMessage between iframes, a shared worker — is fundamentally different from request/response. You open the channel **once**, then send and receive over it for the lifetime of the client. `stream()`/`connect()` can't model this cleanly because they assume one async iterable per request.

For these cases, implement the `SubscribeConnectionAdapter` interface directly. The shape (full definition in [The Adapter Interface](#the-adapter-interface)):
The built-in `webSocket()` adapter above covers the common resumable WebSocket case. For anything else persistent, implement the `SubscribeConnectionAdapter` interface directly. The shape (full definition in [The Adapter Interface](#the-adapter-interface)):

```typescript
import type { SubscribeConnectionAdapter } from "@tanstack/ai-react";
Expand All @@ -313,7 +328,9 @@ import type { SubscribeConnectionAdapter } from "@tanstack/ai-react";

The runtime correlates them: chunks emitted on the subscription queue between `send()` and the next terminal event (`RUN_FINISHED` / `RUN_ERROR`) are attributed to that run.

### WebSocket example
### Custom WebSocket example

Building your own protocol instead of the built-in `webSocket()` adapter (a different wire format, no resume support needed, or a server you don't control)? Implement `SubscribeConnectionAdapter` by hand:

```typescript
import { useChat, type SubscribeConnectionAdapter } from "@tanstack/ai-react";
Expand Down
13 changes: 10 additions & 3 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@
"label": "Connection Adapters",
"to": "chat/connection-adapters",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-17"
"updatedAt": "2026-07-20"
},
{
"label": "Thinking & Reasoning",
Expand All @@ -188,12 +188,19 @@
{
"label": "Overview",
"to": "resumable-streams/overview",
"addedAt": "2026-07-17"
"addedAt": "2026-07-17",
"updatedAt": "2026-07-20"
},
{
"label": "Advanced",
"to": "resumable-streams/advanced",
"addedAt": "2026-07-17"
"addedAt": "2026-07-17",
"updatedAt": "2026-07-20"
},
{
"label": "WebSockets",
"to": "resumable-streams/websockets",
"addedAt": "2026-07-20"
},
{
"label": "Custom Durability Adapter",
Expand Down
44 changes: 44 additions & 0 deletions docs/resumable-streams/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,50 @@ read failure it retries from the last valid position, capping consecutive
failures (`reconnect: { maxReadFailures: 10, delayMs: 250 }`). Normal long-poll
advancement is never throttled.

## WebSocket transport

Everything above describes SSE and NDJSON: one connection per turn. A
WebSocket instead stays open for the whole conversation, so a few things work
differently. See [WebSockets](./websockets) for the full protocol and code;
this section covers only what changes relative to the rest of this page.

**Reconnect rides in the URL, not a header.** SSE and NDJSON resume with
`Last-Event-ID` because `fetch`/XHR can set arbitrary headers before opening a
request. A browser's `WebSocket` constructor can't set custom headers on the
handshake, so the offset instead rides in the URL: `?runId=<id>&offset=<lastId>`.
`webSocket()` reopens at that URL automatically on a durable run's drop, the
same de-dupe guarantee as `fetchServerSentEvents`, just carried differently on
the wire.

**The socket is conversation-scoped, not run-scoped.** One socket carries many
turns. `toWebSocketStream`'s `durability` option is therefore a factory keyed
by each turn's `runId` (via a synthetic per-turn request), not a single value
built once. The socket itself closes on client close, an idle timeout, or
process shutdown, not when one turn's `RUN_FINISHED` arrives.

**Heartbeat and idle timeout replace the transport-level keepalive SSE gets
for free.** `toWebSocketStream` pings every `heartbeatMs` (default 30s) and
closes the socket after `idleTimeoutMs` (default 5 minutes) with no inbound
frame.

**An abort frame targets one turn, not the socket.** `{ type: 'abort', runId }`
aborts only that turn's `onRun` iteration; the socket stays open for the next
turn. Closing the socket aborts every turn still in flight on it.

**Hosting differs by runtime.** On Cloudflare Workers or Durable Objects,
`toWebSocketResponse` uses the global `WebSocketPair` and needs no manual
upgrade. On Node (or anywhere without `WebSocketPair`), you upgrade the
connection yourself, typically with `ws`'s `WebSocketServer({ noServer: true })`
hooked into the HTTP server's `upgrade` event, and hand the resulting socket to
`toWebSocketStream` / `resumeWebSocketStream` directly.

**The producer-vs-socket caveat from [memoryStream in
production](#memorystream-in-production) applies unchanged.** With
`memoryStream`, a dropped WebSocket aborts the `chat()` call backing it just
like a dropped SSE connection does; reconnecting replays the log rather than
resuming a still-running model call. `durableStream` decouples the two exactly
as it does for SSE and NDJSON.

## Offset ownership

`StreamDurability<TOffset>` owns its offset format. Core only passes returned
Expand Down
39 changes: 39 additions & 0 deletions docs/resumable-streams/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,45 @@ For NDJSON, swap `fetchServerSentEvents` for `fetchHttpStream` (with the server
on `toHttpResponse`). The XHR adapters (`xhrServerSentEvents`, `xhrHttpStream`)
work the same way, for runtimes without streaming `fetch`.

## Or go full-duplex: WebSockets

SSE and NDJSON open one connection per turn. If you want a single persistent
socket that carries the whole conversation instead, wrap it with
`toWebSocketStream` (or `toWebSocketResponse` on Cloudflare) and pair it with
the client's `webSocket()` adapter:

```ts
import { chat, memoryStream, toWebSocketStream } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import type { WebSocketLike } from '@tanstack/ai'

// `socket` is a server socket you already accepted (see WebSockets for how,
// on Node vs Cloudflare) and `request` is the handshake request.
function handleChatSocket(socket: WebSocketLike, request: Request) {
toWebSocketStream(socket, request, {
durability: (ctx) => memoryStream(ctx.request),
onRun: ({ messages, threadId, runId }) =>
chat({ adapter: openaiText('gpt-5.5'), messages, threadId, runId }),
})
}
```

```tsx
import { useChat, webSocket } from '@tanstack/ai-react'

const connection = webSocket('/api/chat-ws')

export function Chat() {
const { messages, sendMessage } = useChat({ connection })
return <button onClick={() => void sendMessage('Hello')}>Send</button>
}
```

The socket resumes the same way SSE and NDJSON do: a dropped connection
reopens with the last offset and replays only what's missing. See
[WebSockets](./websockets) for the wire protocol, reconnect details, and
hosting on Node vs Cloudflare.

That covers the common case. For the durability contract, terminal and error
handling, reconnect tuning, attaching to a run by id, Cloudflare deployment, and
production process-death concerns, see [Advanced](./advanced).
Loading
Loading