UNPKG

@durable-streams/aisdk-transport

Version:

Vercel AI SDK transport adapters for Durable Streams

169 lines (115 loc) 4.92 kB
# @durable-streams/aisdk-transport Vercel AI SDK transport adapters for Durable Streams. ## Goal Use Durable Streams with AI SDK `useChat` so generations can survive refreshes and reconnect cleanly. This guide moves from: 1. No integration (regular AI SDK chat) 2. Basic Durable Streams integration 3. Optimal resumable generations (recommended) ## 0) Starting point (no Durable Streams) Client: ```ts import { useChat } from "@ai-sdk/react" import { DefaultChatTransport } from "ai" const transport = new DefaultChatTransport({ api: "/api/chat" }) const chat = useChat({ transport }) ``` Server: ```ts return result.toUIMessageStreamResponse() ``` This works, but refreshing during generation can lose the in-flight stream. ## 1) Basic Durable Streams integration ### Client Swap to `createDurableChatTransport`: ```ts import { useChat } from "@ai-sdk/react" import { createDurableChatTransport } from "@durable-streams/aisdk-transport" const transport = createDurableChatTransport({ api: "/api/chat" }) const chat = useChat({ transport }) ``` ### Server Wrap your UI message stream with `toDurableStreamResponse`: ```ts import { toDurableStreamResponse } from "@durable-streams/aisdk-transport" return toDurableStreamResponse({ source: result.toUIMessageStream(), stream: { writeUrl: buildWriteStreamUrl(streamPath), readUrl: buildReadProxyUrl(request, streamPath), headers: DURABLE_STREAMS_WRITE_HEADERS, }, }) ``` At this point you get durable writes + durable reads, but no automatic resume after refresh yet. ## 2) Optimal setup: resumable generations This is the recommended production flow. ### A. Persist active stream id per chat When a generation starts, save `activeStreamId = streamPath`. When it finishes, save final messages and clear `activeStreamId`. ```ts await saveChat({ id, activeStreamId: streamPath }) return toDurableStreamResponse({ source: result.toUIMessageStream({ originalMessages: messages, onFinish: ({ messages: finalMessages }) => { void saveChat({ id, messages: finalMessages, activeStreamId: null }) }, }), stream: { writeUrl, readUrl, headers }, }) ``` ### B. Add reconnect endpoint Implement `GET /api/chat/:id/stream`: - Return `204` when there is no active generation. - Return `200` with `Location` and `{ streamUrl }` when there is one. ```ts if (!chat.activeStreamId) return new Response(null, { status: 204 }) const streamUrl = buildReadProxyUrl(request, chat.activeStreamId) return Response.json( { streamUrl }, { status: 200, headers: { Location: streamUrl } } ) ``` ### C. Enable `resume` on `useChat` ```ts const transport = createDurableChatTransport({ api: "/api/chat" }) const chat = useChat({ id, transport, resume: true }) ``` On page load, AI SDK will call `reconnectToStream` automatically. This transport resolves the stream URL from `Location` first (or `{ streamUrl }` fallback), then reconnects to the durable stream. ## API reference ### `createDurableChatTransport({ api, reconnectApi?, headers?, fetchClient? })` Creates an AI SDK `ChatTransport` that: 1. `POST`s messages to `api` 2. Resolves read URL from: - `Location` header (preferred) - JSON body `{ streamUrl }` fallback 3. Reads the durable stream as JSON+SSE and returns `ReadableStream<UIMessageChunk>` 4. Supports reconnect via `GET reconnectApi` (or default `${api}/${chatId}/stream`) ### `toDurableStreamResponse({ source, ...options })` Writes AI SDK UI message chunks into Durable Streams and returns a pointer response. Options: - `source`: required async iterable of AI SDK UI message chunks - `stream.writeUrl`: required durable write URL - `stream.readUrl`: optional URL exposed to clients (`Location` + JSON body) - `stream.headers`: optional write headers - `mode`: - `immediate` (default): return once stream is prepared - `await`: return after generation/write completes - `waitUntil`: optional runtime keep-alive hook (useful in worker runtimes) ## Response contract ### `mode: "immediate"` The initial response returns only stream location metadata, not generated content: - `Location: <read-url>` - `{ "streamUrl": "<read-url>" }` The client then connects to that read URL to consume generated chunks, while generation continues in the background. Use `waitUntil` when your runtime needs an explicit keep-alive signal so the worker stays alive until streaming work is complete. - Status: `201` - Header: `Location: <read-url>` - Body: `{ "streamUrl": "<read-url>" }` ### `mode: "await"` Headers are still sent immediately, so clients can read `Location` and start consuming the stream early, but the HTTP connection stays open until generation/write completes. This is useful on runtimes that only allow long-running work while an inbound request remains active. - Status: `200` - Header: `Location: <read-url>` - Body: `{ "streamUrl": "<read-url>", "finalOffset": "<offset>" }`