Skip to main content

tanstack-ai

TanStack AI integration with Durable Streams. durableStreamConnection() for useChat(), toDurableChatSessionResponse() for server-side streaming, SSR hydration with materializeSnapshotFromDurableStream(), multi-client sync with live: true, chunk sanitization, read proxy pattern. Load when building chat apps with TanStack AI (@tanstack/ai-react) and durable streams.

Quellinformationen

Repository
durable-streams/durable-streams
Letzte Quellaktivität
16. April 2026 um 13:06
Erkannte Sprache von SKILL.md
Englisch
Sterne
1.708
Forks
71

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
tanstack-ai
description
TanStack AI integration with Durable Streams. durableStreamConnection() for useChat(), toDurableChatSessionResponse() for server-side streaming, SSR hydration with materializeSnapshotFromDurableStream(), multi-client sync with live: true, chunk sanitization, read proxy pattern. Load when building chat apps with TanStack AI (@tanstack/ai-react) and durable streams.
type
composition
library
durable-streams
library_version
0.2.1
requires
["getting-started"]
sources
["durable-streams/durable-streams:packages/tanstack-ai-transport/src/client.ts","durable-streams/durable-streams:packages/tanstack-ai-transport/src/server.ts","durable-streams/durable-streams:packages/tanstack-ai-transport/src/types.ts"]
# Durable Streams — TanStack AI Connection adapter for TanStack AI's `useChat()`. Uses one stream per chat session: user messages are echoed into the stream alongside model responses, making it a complete transcript that supports multi-client sync and SSR hydration. ## The two auth layers — keep them separate | Auth | What | Where | How | | ------------------- | -------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------- | | **Durable Streams** | `DS_SECRET` | Server-only — POST `/api/chat` + GET `/api/chat-stream` | `Authorization: Bearer <DS_SECRET>` on upstream DS requests | | **AI model** | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | Server-only (read by the adapter) | `process.env.ANTHROPIC_API_KEY` is picked up automatically | NEVER mix these. The DS secret authenticates your server to the DS service. The AI key authenticates your server to Anthropic/OpenAI. The client NEVER sees either. For apps that let users supply their own AI key, see "User-supplied AI key" below. ## Prerequisites Install the DS client + this package: ```bash pnpm add @durable-streams/client @durable-streams/tanstack-ai-transport @tanstack/ai-react @tanstack/ai-anthropic ``` Set env vars. You need a running Durable Streams service — self-hosted (see `server-deployment` skill) or Electric Cloud (see blog post for setup). ```bash ELECTRIC_URL= # e.g. https://api.electric-sql.cloud (root API URL) DS_SERVICE_ID= # Durable Streams service id, e.g. svc-abc-123 DS_SECRET= # Bearer token for DS auth ANTHROPIC_API_KEY= # AI model auth (OR let user supply their own, see below) ``` Build the stream base URL from `ELECTRIC_URL + DS_SERVICE_ID` rather than trusting a single `DS_URL` env var — different environments populate it differently, and a mismatch silently 404s your PUT. ```ts // src/lib/ds-stream.ts const electricUrl = process.env.ELECTRIC_URL || "https://api.electric-sql.cloud" const serviceId = process.env.DS_SERVICE_ID if (!serviceId) throw new Error("DS_SERVICE_ID is required") export const DS_BASE = `${electricUrl.replace(/\/+$/, "")}/v1/stream/${serviceId}` export const DS_AUTH = { Authorization: `Bearer ${process.env.DS_SECRET}` } ``` ## Client ```tsx import { useMemo } from "react" import { useChat } from "@tanstack/ai-react" import { durableStreamConnection } from "@durable-streams/tanstack-ai-transport" function Chat({ id, initialMessages, resumeOffset, }: { id: string initialMessages?: Array<any> resumeOffset?: string }) { const connection = useMemo( () => durableStreamConnection({ sendUrl: `/api/chat?id=${encodeURIComponent(id)}`, readUrl: `/api/chat-stream?id=${encodeURIComponent(id)}`, initialOffset: resumeOffset, // from SSR loader, skips replay }), [id, resumeOffset] ) const { messages, sendMessage } = useChat({ id, initialMessages, connection, live: true, // keeps read subscription open for multi-client sync }) // TanStack AI UIMessage has `parts: Array<MessagePart>`. TextPart uses // `.content` (NOT `.text` — that silently renders empty strings). return ( <> {messages.map((m) => ( <div key={m.id}> {m.parts .filter((p) => p.type === "text") .map((p, i) => ( <span key={i}>{p.content}</span> ))} </div> ))} </> ) } ``` ## Server — POST /api/chat ```ts import { chat } from "@tanstack/ai" import { anthropicText } from "@tanstack/ai-anthropic" import { toDurableChatSessionResponse } from "@durable-streams/tanstack-ai-transport" export async function POST(request: Request) { const url = new URL(request.url) const body = await request.json() const id = url.searchParams.get("id") ?? body.id if (!id) return Response.json({ error: "Missing chat id" }, { status: 400 }) const latestUserMessage = body.messages.findLast( (m: any) => m.role === "user" ) const responseStream = chat({ adapter: anthropicText("claude-sonnet-4-6"), messages: body.messages, }) return await toDurableChatSessionResponse({ stream: { writeUrl: `${DS_BASE}/chat-${id}`, headers: DS_AUTH, createIfMissing: true, }, newMessages: latestUserMessage ? [latestUserMessage] : [], responseStream, }) } ``` **Available adapters:** - `anthropicText("claude-sonnet-4-6")` from `@tanstack/ai-anthropic` - `openaiText("gpt-4o-mini")` from `@tanstack/ai-openai` Both read credentials from their standard env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). ## Server — GET /api/chat-stream (read proxy) Never expose the DS write URL to the client. Proxy reads through your server so the DS secret stays server-side. ```ts export async function GET(request: Request) { const url = new URL(request.url) const id = url.searchParams.get("id") if (!id) return Response.json({ error: "Missing id" }, { status: 400 }) const upstream = new URL(`${DS_BASE}/chat-${id}`) // Forward offset/live/sse params from the browser's DS client for (const [k, v] of url.searchParams) { if (k !== "id") upstream.searchParams.set(k, v) } const response = await fetch(upstream, { headers: { ...DS_AUTH, ...(request.headers.get("accept") && { Accept: request.headers.get("accept")!, }), }, }) // Strip hop-by-hop headers before forwarding const headers = new Headers() for (const [k, v] of response.headers) { const lk = k.toLowerCase() if ( lk === "connection" || lk === "transfer-encoding" || lk === "content-length" || lk === "content-encoding" ) continue headers.set(k, v) } return new Response(response.body, { status: response.status, headers }) } ``` Use the chat id as a **query parameter** — not a dynamic route segment. Segments like `/api/chat-stream/$id` break when the stream path contains slashes. ## SSR hydration + resume In your route loader, materialize the snapshot server-side and pass the offset down: ```ts import { materializeSnapshotFromDurableStream } from "@durable-streams/tanstack-ai-transport" export const loader = async ({ params }: { params: { id: string } }) => { const snapshot = await materializeSnapshotFromDurableStream({ readUrl: `${DS_BASE}/chat-${params.id}`, headers: DS_AUTH, }) return { messages: snapshot.messages, resumeOffset: snapshot.offset } } ``` Pass `resumeOffset` to `durableStreamConnection` — this skips replaying the history on first subscribe. ## User-supplied AI key If users enter their own AI key in a settings UI: 1. Store the key in a shared store (Context, Zustand, Jotai) — NOT per-hook `useState`, otherwise different components see different values. 2. Pass it via `headers` on `durableStreamConnection` (NOT on `useChat` — those headers aren't forwarded): ```tsx const { apiKey } = useSettings() // from Context/store, shared across components const connection = useMemo( () => durableStreamConnection({ sendUrl: `/api/chat?id=${encodeURIComponent(id)}`, readUrl: `/api/chat-stream?id=${encodeURIComponent(id)}`, headers: { "x-api-key": apiKey }, }), [id, apiKey] ) ``` Server reads the header and sets it for the adapter: ```ts const apiKey = request.headers.get("x-api-key") if (!apiKey) return Response.json({ error: "Missing API key" }, { status: 401 }) process.env.ANTHROPIC_API_KEY = apiKey // ... rest of handler ``` ## Common Mistakes ### CRITICAL Sending full message history as newMessages Wrong: `newMessages: messages` — echoes the entire conversation every request. Correct: only pass what's new since the last request: ```ts const latestUserMessage = messages.findLast((m) => m.role === "user") newMessages: latestUserMessage ? [latestUserMessage] : [] ``` ### CRITICAL Exposing the DS write URL to the client Setting `readUrl` on the server stream config to the durable stream's write URL leaks the secret in the `Location` header. Always use a read proxy route for `readUrl`. ### CRITICAL First assistant response invisible until refresh — dead subscription `durableStreamConnection` opens its live SSE read on mount. If the stream doesn't exist yet (new conversation), the read fails with `STREAM_NOT_FOUND` and **the subscription terminates — it does NOT retry on 404**. When the user's first POST then creates the stream and the server streams chunks, nothing is listening. After a refresh, the subscription is re-opened against an existing stream and everything works — which is exactly what the user describes when they say "the first response only shows after refresh". **Fix**: create the stream eagerly when you create the conversation row, so the client's subscription has something to attach to. PUT is idempotent — catch `CONFLICT_EXISTS` / `CONFLICT_SEQ` and treat as success. ```ts // src/routes/api/conversations.ts — after inserting the conversation row import { DurableStream, DurableStreamError } from "@durable-streams/client" async function ensureChatStream(streamId: string): Promise<void> { try { const stream = new DurableStream({ url: `${DS_BASE}/chat-${streamId}`, headers: DS_AUTH, contentType: "application/json", }) await stream.create({ contentType: "application/json" }) } catch (err) { if ( err instanceof DurableStreamError && err.status === 409 && (err.code === "CONFLICT_EXISTS" || err.code === "CONFLICT_SEQ") ) { return // already exists — fine } throw err } } ``` Do NOT rely on `toDurableChatSessionResponse`'s `createIfMissing` to cover this case. That handler runs during the first POST, which is AFTER the client's read subscription has already died. ### CRITICAL Switching conversations shows stale data — missing useLiveQuery deps + missing component key Two separate React pitfalls compound into the same symptom (header/messages don't update when the user clicks a different conversation): 1. `useLiveQuery` needs explicit deps — without them, the query closure captures the initial id and never re-runs: ```ts // WRONG — no deps, closure captures initial conversationId forever const { data } = useLiveQuery((q) => q.from({ conv }).where(({ conv }) => eq(conv.id, conversationId)) ) // RIGHT — deps array pins re-evaluation to the param const { data } = useLiveQuery( (q) => q.from({ conv }).where(({ conv }) => eq(conv.id, conversationId)), [conversationId] ) ``` 2. `useChat`'s internal `ChatClient` is memoized per-hook and keeps previous messages in a ref even when the `id` prop changes. Force a full remount by keying the component on the stream id: ```tsx return ( <ChatInner key={streamId} streamId={streamId} connection={connection} /> ) ``` ### CRITICAL sendMessage signature — crash on submit `useChat().sendMessage` takes either a string **or** `{ content: Array<ContentPart>, id? }` (for multimodal). The intuitive-looking `{ text: "hi" }` form is NOT supported — it normalizes to `{ content: undefined }` and crashes inside `StreamProcessor.addUserMessage` with: ``` TypeError: Cannot read properties of undefined (reading 'map') ``` ```ts // WRONG — passes { text } which is neither a string nor a valid object shape sendMessage({ text: input.trim() }) // RIGHT — pass the string directly sendMessage(input.trim()) // RIGHT — multimodal (explicit content parts) sendMessage({ content: [ { type: "text", content: input.trim() }, { type: "image", source: { type: "url", value: imageUrl } }, ], }) ``` ### CRITICAL Wrong field on UIMessage parts — empty bubbles
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen