@durable-streams/aisdk-transport
Version:
Vercel AI SDK transport adapters for Durable Streams
198 lines (175 loc) • 5.41 kB
text/typescript
import { stream } from "@durable-streams/client"
import type { ChatTransport, UIMessage, UIMessageChunk } from "ai"
import type { DurableChatTransportOptions } from "./types"
function mergeHeaders(headers?: HeadersInit): Record<string, string> {
if (!headers) return {}
if (headers instanceof Headers) return Object.fromEntries(headers.entries())
if (Array.isArray(headers)) return Object.fromEntries(headers)
return { ...headers }
}
function parseBodyStreamUrl(body: unknown): string | undefined {
if (body && typeof body === `object` && `streamUrl` in body) {
const streamUrl = (body as { streamUrl?: unknown }).streamUrl
if (typeof streamUrl === `string` && streamUrl.length > 0) {
return streamUrl
}
}
return undefined
}
async function parseJsonSafely(response: Response): Promise<unknown> {
const contentType = response.headers.get(`content-type`) ?? ``
if (!contentType.includes(`application/json`)) return undefined
try {
return await response.json()
} catch {
return undefined
}
}
function isAbsoluteUrl(url: string): boolean {
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)
}
function resolveStreamUrl(
streamUrl: string,
responseUrl: string,
postUrl: string
): string {
if (isAbsoluteUrl(streamUrl)) return streamUrl
const candidateBaseUrls = [responseUrl, postUrl]
if (typeof window !== `undefined`) {
candidateBaseUrls.push(window.location.href)
}
for (const baseUrl of candidateBaseUrls) {
if (!baseUrl) continue
try {
return new URL(streamUrl, baseUrl).toString()
} catch {
// Try the next fallback.
}
}
throw new Error(
`Failed to resolve durable stream URL from relative path "${streamUrl}".`
)
}
function toReadableStream<T>(iterable: AsyncIterable<T>): ReadableStream<T> {
const iterator = iterable[Symbol.asyncIterator]()
return new ReadableStream<T>({
async pull(controller) {
const result = await iterator.next()
if (result.done) {
controller.close()
return
}
controller.enqueue(result.value)
},
async cancel() {
await iterator.return?.()
},
})
}
async function readUiMessageChunksFromDurableStream(
streamUrl: string,
abortSignal?: AbortSignal
): Promise<ReadableStream<UIMessageChunk>> {
const streamResponse = await stream<UIMessageChunk>({
url: streamUrl,
live: `sse`,
json: true,
signal: abortSignal,
})
return toReadableStream(streamResponse.jsonStream())
}
export function createDurableChatTransport<
UIMessageT extends UIMessage = UIMessage,
>({
api,
reconnectApi,
headers,
fetchClient,
}: DurableChatTransportOptions): ChatTransport<UIMessageT> {
return {
async sendMessages({
trigger,
chatId,
messageId,
messages,
abortSignal,
body,
headers: requestHeaders,
}) {
const response = await (fetchClient ?? fetch)(api, {
method: `POST`,
headers: {
"Content-Type": `application/json`,
...mergeHeaders(headers),
...mergeHeaders(requestHeaders),
},
body: JSON.stringify({
...(body ?? {}),
id: chatId,
messages,
trigger,
messageId,
}),
signal: abortSignal,
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(
errorText.length > 0
? errorText
: `HTTP error! status: ${response.status} ${response.statusText}`
)
}
const headerUrl = response.headers.get(`Location`)
if (headerUrl) {
// Prefer Location so the client can attach to the stream immediately.
return readUiMessageChunksFromDurableStream(
resolveStreamUrl(headerUrl, response.url, api),
abortSignal
)
}
const streamUrl = parseBodyStreamUrl(await parseJsonSafely(response))
if (!streamUrl) {
throw new Error(
`Missing durable stream URL. Expected Location header or JSON body with streamUrl.`
)
}
return readUiMessageChunksFromDurableStream(
resolveStreamUrl(streamUrl, response.url, api),
abortSignal
)
},
async reconnectToStream({ chatId, body: _body, headers: requestHeaders }) {
const endpoint =
reconnectApi ?? `${api.replace(/\/$/, ``)}/${chatId}/stream`
const response = await (fetchClient ?? fetch)(endpoint, {
method: `GET`,
headers: {
...mergeHeaders(headers),
...mergeHeaders(requestHeaders),
},
})
if (response.status === 204) return null
// 204 means there is no in-flight generation to resume.
if (!response.ok) {
const errorText = await response.text()
throw new Error(
errorText.length > 0
? errorText
: `HTTP error! status: ${response.status} ${response.statusText}`
)
}
const streamUrl =
response.headers.get(`Location`) ??
parseBodyStreamUrl(await parseJsonSafely(response))
if (!streamUrl) {
throw new Error(
`Missing durable stream URL. Expected Location header or JSON body with streamUrl.`
)
}
return readUiMessageChunksFromDurableStream(
resolveStreamUrl(streamUrl, response.url, endpoint)
)
},
}
}