@tanstack/ai
Version:
Core TanStack AI library - Open source AI SDK
464 lines (373 loc) • 13.2 kB
Markdown
---
name: ai-core/custom-backend-integration
description: >
Connect useChat to a non-TanStack-AI backend through custom connection
adapters. ConnectConnectionAdapter (single async iterable) vs
SubscribeConnectionAdapter (separate subscribe/send). Customize
fetchServerSentEvents() and fetchHttpStream() with auth headers,
custom URLs, and request options. Import from framework package,
not @tanstack/ai-client.
type: composition
library: tanstack-ai
library_version: '0.10.0'
sources:
- 'TanStack/ai:docs/chat/connection-adapters.md'
---
# Custom Backend Integration
This skill builds on ai-core and ai-core/chat-experience. Read them first.
## Setup
Connect `useChat` to a custom SSE backend with auth headers:
```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage, isLoading } = useChat({
connection: fetchServerSentEvents('https://my-api.com/chat', {
headers: {
Authorization: `Bearer ${token}`,
},
}),
})
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong>
{msg.parts.map((part, i) => {
if (part.type === 'text') {
return <p key={i}>{part.content}</p>
}
return null
})}
</div>
))}
<button onClick={() => sendMessage('Hello')}>Send</button>
</div>
)
}
```
Both `fetchServerSentEvents` and `fetchHttpStream` accept a static URL string
or a function returning a string (evaluated per request), and a static options
object or a sync/async function returning options (also evaluated per request).
This allows dynamic auth tokens and URLs without re-creating the adapter.
## Core Patterns
### 1. Custom SSE Backend with fetchServerSentEvents
Use when your backend speaks SSE (`text/event-stream`) with `data: {json}\n\n`
framing. This is the recommended default.
**Static options:**
```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents('https://my-api.com/chat', {
headers: {
Authorization: `Bearer ${token}`,
'X-Tenant-Id': tenantId,
},
credentials: 'include',
}),
})
```
**Dynamic URL and options (evaluated per request):**
```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents(
() => `https://my-api.com/chat?session=${sessionId}`,
async () => ({
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
},
body: {
provider: 'openai',
model: 'gpt-4o',
},
}),
),
})
```
The `body` field in options is merged into the POST request body alongside
`messages` and `data`, so the server receives `{ messages, data, provider, model }`.
**Custom fetch client (for proxies, interceptors, retries):**
```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat', {
fetchClient: myCustomFetch,
}),
})
```
### 2. Custom NDJSON Backend with fetchHttpStream
Use when your backend sends newline-delimited JSON (`application/x-ndjson`)
instead of SSE. Each line is one JSON-encoded `StreamChunk` followed by `\n`.
```typescript
import { useChat, fetchHttpStream } from '@tanstack/ai-react'
const { messages, sendMessage } = useChat({
connection: fetchHttpStream('https://my-api.com/chat', {
headers: {
Authorization: `Bearer ${token}`,
},
}),
})
```
`fetchHttpStream` accepts the same URL and options signatures as
`fetchServerSentEvents` (static or dynamic, sync or async). The only difference
is the parsing: no `data:` prefix stripping, no `[DONE]` sentinel -- just one
JSON object per line.
**Dynamic options work identically:**
```typescript
import { useChat, fetchHttpStream } from '@tanstack/ai-react'
const { messages, sendMessage } = useChat({
connection: fetchHttpStream(
() => `/api/chat?region=${region}`,
async () => ({
headers: { Authorization: `Bearer ${await refreshToken()}` },
}),
),
})
```
### 3. Fully Custom Connection Adapter
For protocols that don't fit SSE or NDJSON (WebSockets, gRPC-web, custom binary,
server functions), implement the `ConnectionAdapter` interface directly.
There are two mutually exclusive modes:
**ConnectConnectionAdapter (pull-based / async iterable):**
Use when the client initiates a request and consumes the response as a stream.
This is the simpler model and covers most HTTP-based protocols.
```typescript
import { useChat } from '@tanstack/ai-react'
import type { ConnectionAdapter } from '@tanstack/ai-react'
import type { StreamChunk, UIMessage } from '@tanstack/ai'
const websocketAdapter: ConnectionAdapter = {
async *connect(
messages: Array<UIMessage>,
data?: Record<string, any>,
abortSignal?: AbortSignal,
): AsyncGenerator<StreamChunk> {
const ws = new WebSocket('wss://my-api.com/chat')
// Wait for connection
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = (e) => reject(e)
})
// Send messages
ws.send(JSON.stringify({ messages, ...data }))
// Create an async queue to bridge WebSocket events to an async iterable
const queue: Array<StreamChunk> = []
let resolve: (() => void) | null = null
let done = false
ws.onmessage = (event) => {
const chunk: StreamChunk = JSON.parse(event.data)
queue.push(chunk)
resolve?.()
}
ws.onclose = () => {
done = true
resolve?.()
}
ws.onerror = () => {
done = true
resolve?.()
}
abortSignal?.addEventListener('abort', () => {
ws.close()
})
// Yield chunks as they arrive
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!
} else {
await new Promise<void>((r) => {
resolve = r
})
}
}
},
}
function Chat() {
const { messages, sendMessage } = useChat({
connection: websocketAdapter,
})
// ... render messages
}
```
**SubscribeConnectionAdapter (push-based / separate subscribe + send):**
Use for push-based protocols where the server can send data at any time
(persistent WebSocket connections, MQTT, server push). The `subscribe` method
returns an `AsyncIterable<StreamChunk>` that stays open, and `send` dispatches
messages through it.
```typescript
import type { StreamChunk, UIMessage } from '@tanstack/ai'
// SubscribeConnectionAdapter is exported from @tanstack/ai-client
// (not re-exported by framework packages -- use ConnectionAdapter
// union type from @tanstack/ai-react for typing)
const pushAdapter = {
subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {
// Return a long-lived async iterable that yields chunks
// whenever the server pushes them
return createPersistentStream(abortSignal)
},
async send(
messages: Array<UIMessage>,
data?: Record<string, any>,
abortSignal?: AbortSignal,
): Promise<void> {
// Dispatch messages; chunks arrive through subscribe()
await persistentConnection.send(JSON.stringify({ messages, ...data }))
},
}
function Chat() {
const { messages, sendMessage } = useChat({
connection: pushAdapter,
})
// ... render messages
}
```
The `stream()` helper function (re-exported from `@tanstack/ai-react`) provides
a shorthand for creating a `ConnectConnectionAdapter` from an async generator:
```typescript
import { useChat, stream } from '@tanstack/ai-react'
import type { StreamChunk, UIMessage } from '@tanstack/ai'
const directAdapter = stream(async function* (
messages: Array<UIMessage>,
data?: Record<string, any>,
): AsyncGenerator<StreamChunk> {
const response = await fetch('https://my-api.com/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, ...data }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
yield JSON.parse(line) as StreamChunk
}
}
}
})
const { messages, sendMessage } = useChat({
connection: directAdapter,
})
```
## Common Mistakes
### a. HIGH: Providing both connect and subscribe+send in connection adapter
The `ConnectionAdapter` interface has two mutually exclusive modes. Providing
both throws at runtime.
```typescript
// WRONG -- throws "Connection adapter must provide either connect or both
// subscribe and send, not both modes"
const adapter = {
async *connect(messages) {
/* ... */
},
subscribe(signal) {
/* ... */
},
async send(messages) {
/* ... */
},
}
// CORRECT -- pick one mode
// Option A: ConnectConnectionAdapter (pull-based)
const pullAdapter = {
async *connect(messages, data, abortSignal) {
// ... yield StreamChunks
},
}
// Option B: SubscribeConnectionAdapter (push-based)
const pushAdapter = {
subscribe(abortSignal) {
return longLivedAsyncIterable
},
async send(messages, data, abortSignal) {
await connection.dispatch({ messages, ...data })
},
}
```
Source: `ai-client/src/connection-adapters.ts` line 116
### b. MEDIUM: SSE browser connection limits
Browsers limit SSE connections to 6-8 per domain (the HTTP/1.1 connection
limit). Multiple chat sessions on the same page, or multiple tabs to the
same origin, can exhaust this limit. New connections queue indefinitely until
an existing one closes.
Mitigations:
- Use HTTP/2 (multiplexes streams over a single TCP connection; no per-domain limit)
- Use `fetchHttpStream` instead of `fetchServerSentEvents` (each request is a
standard POST, not a long-lived EventSource)
- Close idle connections when not actively streaming
- Use a single persistent WebSocket via `SubscribeConnectionAdapter` instead of
per-request SSE connections
Source: `docs/chat/connection-adapters.md`
### c. MEDIUM: HTTP stream without implementing reconnection
SSE has built-in browser auto-reconnection via the `EventSource` API. HTTP
stream (NDJSON via `fetchHttpStream`) does not -- if the connection drops
mid-stream, the partial response is silently lost with no automatic retry.
If your application needs resilience to transient network errors with HTTP
streaming, implement retry logic in your connection adapter:
```typescript
import { useChat } from '@tanstack/ai-react'
import type { ConnectionAdapter } from '@tanstack/ai-react'
import type { StreamChunk, UIMessage } from '@tanstack/ai'
const resilientAdapter: ConnectionAdapter = {
async *connect(
messages: Array<UIMessage>,
data?: Record<string, any>,
abortSignal?: AbortSignal,
): AsyncGenerator<StreamChunk> {
const maxRetries = 3
let attempt = 0
while (attempt < maxRetries) {
try {
const response = await fetch('https://my-api.com/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, ...data }),
signal: abortSignal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
yield JSON.parse(line) as StreamChunk
}
}
}
return // Stream completed successfully
} catch (err) {
if (abortSignal?.aborted) throw err
attempt++
if (attempt >= maxRetries) throw err
// Exponential backoff
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt))
}
}
},
}
const { messages, sendMessage } = useChat({
connection: resilientAdapter,
})
```
Note: `fetchServerSentEvents` in TanStack AI uses `fetch()` under the hood (not
the browser `EventSource` API), so it also does not auto-reconnect. The SSE
auto-reconnection advantage only applies when using the native `EventSource` API
directly.
Source: `docs/protocol/http-stream-protocol.md`
## Cross-References
- See also: **ai-core/ag-ui-protocol/SKILL.md** -- Understanding the AG-UI protocol helps build compatible custom servers
- See also: **ai-core/chat-experience/SKILL.md** -- Full chat setup patterns including server-side `chat()` and `toServerSentEventsResponse()`
- See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events on the server side