UNPKG

ai

Version:

AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.

988 lines (873 loc) • 27.8 kB
import { generateId, getWebSocketConstructor, normalizeHeaders, readWebSocketMessageText, resolve, safeParseJSON, safeValidateTypes, toWebSocketUrl, waitForWebSocketBufferDrain, type Resolvable, type WebSocketConstructor, type WebSocketLike, } from '@ai-sdk/provider-utils'; import { uiMessageChunkSchema, type UIMessageChunk, } from '../ui-message-stream/ui-message-chunks'; import type { ChatTransport } from './chat-transport'; import { safeValidateUIMessages } from './validate-ui-messages'; import type { UIMessage } from './ui-messages'; export type WebSocketChatTransportSendRequest< UI_MESSAGE extends UIMessage = UIMessage, > = { type: 'send'; requestId: string; id: string; trigger: 'submit-message' | 'regenerate-message'; messageId: string | undefined; messages: UI_MESSAGE[]; headers: Record<string, string>; body: object; metadata: unknown; }; export type WebSocketChatTransportResumeRequest = { type: 'resume'; requestId: string; id: string; /** Omitted by this transport to request a complete UI message replay. */ lastSequence: number | undefined; headers: Record<string, string>; body: object; metadata: unknown; }; export type WebSocketChatTransportAbortRequest = { type: 'abort'; requestId: string; }; /** * A client-to-server frame used by `WebSocketChatTransport`. */ export type WebSocketChatTransportRequest< UI_MESSAGE extends UIMessage = UIMessage, > = | WebSocketChatTransportSendRequest<UI_MESSAGE> | WebSocketChatTransportResumeRequest | WebSocketChatTransportAbortRequest; /** * A server-to-client frame used by `WebSocketChatTransport`. */ export type WebSocketChatTransportResponse = | { type: 'start'; requestId: string } | { type: 'chunk'; requestId: string; sequence: number; chunk: UIMessageChunk; } | { type: 'end'; requestId: string } | { type: 'error'; requestId: string; errorText?: string } | { type: 'no-active'; requestId: string }; export type SafeValidateWebSocketChatTransportRequestResult< UI_MESSAGE extends UIMessage, > = | { success: true; data: WebSocketChatTransportRequest<UI_MESSAGE>; } | { success: false; error: Error; }; export type PrepareWebSocketChatTransportSendMessagesRequest< UI_MESSAGE extends UIMessage, > = (options: { id: string; messages: UI_MESSAGE[]; requestMetadata: unknown; body: object; headers: Record<string, string>; trigger: 'submit-message' | 'regenerate-message'; messageId: string | undefined; }) => | { messages?: UI_MESSAGE[]; body?: object; headers?: HeadersInit; } | PromiseLike<{ messages?: UI_MESSAGE[]; body?: object; headers?: HeadersInit; }>; export type PrepareWebSocketChatTransportReconnectToStreamRequest = (options: { id: string; requestMetadata: unknown; body: object; headers: Record<string, string>; }) => | { body?: object; headers?: HeadersInit; } | PromiseLike<{ body?: object; headers?: HeadersInit; }>; /** * Options for `WebSocketChatTransport`. */ export type WebSocketChatTransportInitOptions<UI_MESSAGE extends UIMessage> = { /** * WebSocket endpoint URL. HTTP(S) URLs are converted to WS(S), and relative * URLs are resolved against the current browser location. */ url: string; /** * Optional WebSocket subprotocols used during the connection handshake. */ protocols?: string | string[]; /** * Query parameters appended when a new connection is opened. */ params?: Resolvable<Record<string, string>>; /** * Headers included in each application-level request frame. * * Browser WebSocket APIs cannot set handshake headers. Use cookies, query * parameters, or subprotocols for handshake authentication. */ headers?: Resolvable<Record<string, string> | Headers>; /** * Extra application data included in each send and resume request. */ body?: Resolvable<object>; /** * Custom WebSocket implementation, for example the `ws` package in Node.js. */ webSocket?: WebSocketConstructor; /** * Customizes send request frames before they are transmitted. */ prepareSendMessagesRequest?: PrepareWebSocketChatTransportSendMessagesRequest<UI_MESSAGE>; /** * Customizes resume request frames before they are transmitted. */ prepareReconnectToStreamRequest?: PrepareWebSocketChatTransportReconnectToStreamRequest; }; type ConnectionState = { promise: Promise<WebSocketLike>; resolve: (socket: WebSocketLike) => void; reject: (error: Error) => void; socket?: WebSocketLike; settled: boolean; }; type ActiveRequest = { requestId: string; kind: 'send' | 'resume'; lastSequence: number; stream: ReadableStream<UIMessageChunk>; controller: ReadableStreamDefaultController<UIMessageChunk>; responseSettled: boolean; resolveResponse?: (stream: ReadableStream<UIMessageChunk> | null) => void; rejectResponse?: (error: Error) => void; abortSignal?: AbortSignal; abortListener?: () => void; }; const WEBSOCKET_OPEN_STATE = 1; function createAbortError(signal?: AbortSignal): Error { const reason = signal?.reason; if (reason instanceof Error) { return reason; } const error = new Error('The WebSocket chat request was aborted.'); error.name = 'AbortError'; return error; } function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } function isRecord(value: unknown): value is Record<string, unknown> { return value != null && typeof value === 'object' && !Array.isArray(value); } function isStringRecord(value: unknown): value is Record<string, string> { return ( isRecord(value) && Object.values(value).every(item => typeof item === 'string') ); } function isSequence(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } /** * Validates an untrusted client-to-server WebSocket chat frame. */ export async function safeValidateWebSocketChatTransportRequest< UI_MESSAGE extends UIMessage = UIMessage, >({ value, }: { value: unknown; }): Promise<SafeValidateWebSocketChatTransportRequestResult<UI_MESSAGE>> { try { if (!isRecord(value) || typeof value.type !== 'string') { throw new Error('Invalid WebSocket chat request frame.'); } if (value.type === 'abort') { if (typeof value.requestId !== 'string') { throw new Error('Invalid WebSocket chat abort request.'); } return { success: true, data: value as WebSocketChatTransportAbortRequest, }; } if ( typeof value.requestId !== 'string' || typeof value.id !== 'string' || !isStringRecord(value.headers) || !isRecord(value.body) ) { throw new Error('Invalid WebSocket chat request frame.'); } if (value.type === 'resume') { if (value.lastSequence !== undefined && !isSequence(value.lastSequence)) { throw new Error('Invalid WebSocket chat resume sequence.'); } return { success: true, data: value as WebSocketChatTransportResumeRequest, }; } if ( value.type !== 'send' || !['submit-message', 'regenerate-message'].includes( value.trigger as string, ) || (value.messageId !== undefined && typeof value.messageId !== 'string') ) { throw new Error('Invalid WebSocket chat send request.'); } const messages = await safeValidateUIMessages<UI_MESSAGE>({ messages: value.messages, }); if (!messages.success) { throw new Error('Invalid UI messages in WebSocket chat request.', { cause: messages.error, }); } return { success: true, data: { ...(value as Omit< WebSocketChatTransportSendRequest<UI_MESSAGE>, 'messages' >), messages: messages.data, }, }; } catch (error) { return { success: false, error: asError(error), }; } } /** * A `ChatTransport` that multiplexes correlated UI message streams over one * persistent WebSocket connection. * * The server must implement the protocol represented by * `WebSocketChatTransportRequest` and `WebSocketChatTransportResponse`. */ export class WebSocketChatTransport< UI_MESSAGE extends UIMessage, > implements ChatTransport<UI_MESSAGE> { readonly resumeStreamIsReplay = true; private readonly url: string; private readonly protocols?: string | string[]; private readonly params?: Resolvable<Record<string, string>>; private readonly headers?: Resolvable<Record<string, string> | Headers>; private readonly body?: Resolvable<object>; private readonly webSocket?: WebSocketConstructor; private readonly prepareSendMessagesRequest?: PrepareWebSocketChatTransportSendMessagesRequest<UI_MESSAGE>; private readonly prepareReconnectToStreamRequest?: PrepareWebSocketChatTransportReconnectToStreamRequest; private connection?: ConnectionState; private readonly activeRequests = new Map<string, ActiveRequest>(); private sendQueue: Promise<void> = Promise.resolve(); constructor(options: WebSocketChatTransportInitOptions<UI_MESSAGE>) { this.url = options.url; this.protocols = options.protocols; this.params = options.params; this.headers = options.headers; this.body = options.body; this.webSocket = options.webSocket; this.prepareSendMessagesRequest = options.prepareSendMessagesRequest; this.prepareReconnectToStreamRequest = options.prepareReconnectToStreamRequest; } async sendMessages({ abortSignal, ...options }: Parameters<ChatTransport<UI_MESSAGE>['sendMessages']>[0]): Promise< ReadableStream<UIMessageChunk> > { this.throwIfAborted(abortSignal); const [resolvedHeaders, resolvedBody] = await Promise.all([ resolve(this.headers), resolve(this.body), ]); this.throwIfAborted(abortSignal); const headers = { ...normalizeHeaders(resolvedHeaders), ...normalizeHeaders(options.headers), }; const body = { ...resolvedBody, ...options.body }; const preparedRequest = await this.prepareSendMessagesRequest?.({ id: options.chatId, messages: options.messages, requestMetadata: options.metadata, body, headers, trigger: options.trigger, messageId: options.messageId, }); this.throwIfAborted(abortSignal); const socket = await this.getSocket(abortSignal); const requestId = generateId(); const request = this.createActiveRequest({ requestId, kind: 'send', lastSequence: -1, abortSignal, }); const frame: WebSocketChatTransportSendRequest<UI_MESSAGE> = { type: 'send', requestId, id: options.chatId, trigger: options.trigger, messageId: options.messageId, messages: preparedRequest?.messages ?? options.messages, headers: preparedRequest?.headers == null ? headers : normalizeHeaders(preparedRequest.headers), body: preparedRequest?.body ?? body, metadata: options.metadata, }; try { await this.sendFrame(socket, frame, abortSignal); return request.stream; } catch (error) { const requestError = asError(error); this.failRequest(request, requestError); throw requestError; } } async reconnectToStream( options: Parameters<ChatTransport<UI_MESSAGE>['reconnectToStream']>[0], ): Promise<ReadableStream<UIMessageChunk> | null> { this.throwIfAborted(options.abortSignal); const [resolvedHeaders, resolvedBody] = await Promise.all([ resolve(this.headers), resolve(this.body), ]); this.throwIfAborted(options.abortSignal); const headers = { ...normalizeHeaders(resolvedHeaders), ...normalizeHeaders(options.headers), }; const body = { ...resolvedBody, ...options.body }; const preparedRequest = await this.prepareReconnectToStreamRequest?.({ id: options.chatId, requestMetadata: options.metadata, body, headers, }); this.throwIfAborted(options.abortSignal); const socket = await this.getSocket(options.abortSignal); const requestId = generateId(); let resolveResponse: | ((stream: ReadableStream<UIMessageChunk> | null) => void) | undefined; let rejectResponse: ((error: Error) => void) | undefined; const responsePromise = new Promise<ReadableStream<UIMessageChunk> | null>( (resolve, reject) => { resolveResponse = resolve; rejectResponse = reject; }, ); // The request can be aborted between registration and transmission. Mark // the promise as handled immediately while preserving its rejection for // the caller that receives it below. void responsePromise.catch(() => {}); const request = this.createActiveRequest({ requestId, kind: 'resume', // Chat reconstructs a resumed message with a fresh stream parser. Replay // from the beginning so text, reasoning, and tool deltas have their // prerequisite chunks, even when this transport received them before. lastSequence: -1, abortSignal: options.abortSignal, resolveResponse, rejectResponse, }); const frame: WebSocketChatTransportResumeRequest = { type: 'resume', requestId, id: options.chatId, lastSequence: undefined, headers: preparedRequest?.headers == null ? headers : normalizeHeaders(preparedRequest.headers), body: preparedRequest?.body ?? body, metadata: options.metadata, }; try { await this.sendFrame(socket, frame, options.abortSignal); } catch (error) { const requestError = asError(error); request.responseSettled = true; this.failRequest(request, requestError); throw requestError; } return responsePromise; } /** * Closes the current connection and errors any active response streams. * A later request opens a new connection. */ close(): void { const error = new Error('WebSocket chat transport was closed.'); const connection = this.connection; this.connection = undefined; this.failAllRequests(error); if (connection != null) { this.rejectConnection(connection, error); try { connection.socket?.close(1000, 'Client closed transport'); } catch { // The socket may already be closed. } } } private throwIfAborted(abortSignal: AbortSignal | undefined): void { if (abortSignal?.aborted) { throw createAbortError(abortSignal); } } private async getSocket( abortSignal: AbortSignal | undefined, ): Promise<WebSocketLike> { const existingSocket = this.connection?.socket; if (existingSocket?.readyState === WEBSOCKET_OPEN_STATE) { return existingSocket; } if (this.connection == null) { let resolveConnection!: (socket: WebSocketLike) => void; let rejectConnection!: (error: Error) => void; const promise = new Promise<WebSocketLike>((resolve, reject) => { resolveConnection = resolve; rejectConnection = reject; }); const connection: ConnectionState = { promise, resolve: resolveConnection, reject: rejectConnection, settled: false, }; this.connection = connection; void this.initializeConnection(connection); } const connection = this.connection; if (connection == null) { throw new Error('Failed to initialize WebSocket connection.'); } return this.waitForConnection(connection.promise, abortSignal); } private async initializeConnection( connection: ConnectionState, ): Promise<void> { try { const params = await resolve(this.params); if (this.connection !== connection) { return; } const url = this.buildUrl(params); const WebSocketConstructor = getWebSocketConstructor(this.webSocket); const socket = new WebSocketConstructor(url, this.protocols); connection.socket = socket; let messageTail: Promise<void> = Promise.resolve(); socket.onopen = () => { if (this.connection !== connection) { return; } this.resolveConnection(connection, socket); }; socket.onmessage = event => { messageTail = messageTail .then(() => readWebSocketMessageText(event.data)) .then(text => this.handleResponseFrame(connection, text)) .catch(error => { this.terminateConnection(connection, asError(error)); }); }; socket.onerror = () => { this.terminateConnection( connection, new TypeError('WebSocket chat network connection failed.'), ); }; socket.onclose = event => { messageTail = messageTail .then(() => { if (this.connection !== connection) { return; } const closeEvent = event as | { code?: unknown; reason?: unknown } | null | undefined; const code = typeof closeEvent?.code === 'number' ? closeEvent.code : undefined; const reason = typeof closeEvent?.reason === 'string' && closeEvent.reason !== '' ? `: ${closeEvent.reason}` : ''; this.terminateConnection( connection, new TypeError( `WebSocket chat network connection closed${ code == null ? '' : ` (code ${code}${reason})` }.`, ), false, ); }) .catch(error => { this.terminateConnection(connection, asError(error)); }); }; } catch (error) { this.terminateConnection(connection, asError(error), false); } } private buildUrl(params: Record<string, string> | undefined): URL { let url: URL; try { const baseUrl = typeof globalThis.location === 'undefined' ? undefined : globalThis.location.href; url = new URL(this.url, baseUrl); url = toWebSocketUrl(url); } catch { throw new Error( 'Invalid WebSocket chat URL. Use an absolute URL outside the browser.', ); } if (!['ws:', 'wss:'].includes(url.protocol) || url.hostname === '') { throw new Error('Invalid WebSocket chat URL. Expected ws:// or wss://.'); } if (url.hash !== '') { throw new Error('Invalid WebSocket chat URL. Fragments are not allowed.'); } for (const [key, value] of Object.entries(params ?? {})) { url.searchParams.set(key, value); } return url; } private waitForConnection( promise: Promise<WebSocketLike>, abortSignal: AbortSignal | undefined, ): Promise<WebSocketLike> { if (abortSignal == null) { return promise; } if (abortSignal.aborted) { return Promise.reject(createAbortError(abortSignal)); } return new Promise<WebSocketLike>((resolve, reject) => { const onAbort = () => { reject(createAbortError(abortSignal)); }; abortSignal.addEventListener('abort', onAbort, { once: true }); void promise.then( socket => { abortSignal.removeEventListener('abort', onAbort); resolve(socket); }, error => { abortSignal.removeEventListener('abort', onAbort); reject(error); }, ); }); } private resolveConnection( connection: ConnectionState, socket: WebSocketLike, ): void { if (connection.settled) { return; } connection.settled = true; connection.resolve(socket); } private rejectConnection(connection: ConnectionState, error: Error): void { if (connection.settled) { return; } connection.settled = true; connection.reject(error); } private terminateConnection( connection: ConnectionState, error: Error, closeSocket = true, ): void { if (this.connection !== connection) { return; } this.connection = undefined; this.rejectConnection(connection, error); this.failAllRequests(error); if (closeSocket) { try { connection.socket?.close(1011, 'WebSocket chat transport error'); } catch { // The socket may already be closed. } } } private createActiveRequest({ requestId, kind, lastSequence, abortSignal, resolveResponse, rejectResponse, }: { requestId: string; kind: ActiveRequest['kind']; lastSequence: number; abortSignal: AbortSignal | undefined; resolveResponse?: ActiveRequest['resolveResponse']; rejectResponse?: ActiveRequest['rejectResponse']; }): ActiveRequest { let controller!: ReadableStreamDefaultController<UIMessageChunk>; const stream = new ReadableStream<UIMessageChunk>({ start(streamController) { controller = streamController; }, cancel: () => { this.abortRequest(requestId, false); }, }); const request: ActiveRequest = { requestId, kind, lastSequence, stream, controller, responseSettled: false, resolveResponse, rejectResponse, abortSignal, }; this.activeRequests.set(requestId, request); if (abortSignal != null) { const abortListener = () => { this.abortRequest(requestId, true); }; request.abortListener = abortListener; abortSignal.addEventListener('abort', abortListener, { once: true }); if (abortSignal.aborted) { abortListener(); } } return request; } private abortRequest(requestId: string, closeStream: boolean): void { const request = this.activeRequests.get(requestId); if (request == null) { return; } const error = createAbortError(request.abortSignal); if (request.kind === 'resume' && !request.responseSettled) { request.responseSettled = true; request.rejectResponse?.(error); } this.cleanupRequest(request); if (closeStream) { try { request.controller.close(); } catch { // The stream may already be closed or canceled. } } const socket = this.connection?.socket; if (socket?.readyState === WEBSOCKET_OPEN_STATE) { void this.sendFrame(socket, { type: 'abort', requestId, } satisfies WebSocketChatTransportAbortRequest).catch(() => { // Aborting is best-effort once the local stream has been closed. }); } } private cleanupRequest(request: ActiveRequest): void { this.activeRequests.delete(request.requestId); if (request.abortListener != null) { request.abortSignal?.removeEventListener('abort', request.abortListener); request.abortListener = undefined; } } private failRequest(request: ActiveRequest, error: Error): void { if (!this.activeRequests.has(request.requestId)) { return; } if (request.kind === 'resume' && !request.responseSettled) { request.responseSettled = true; request.rejectResponse?.(error); } this.cleanupRequest(request); try { request.controller.error(error); } catch { // The stream may already be closed or canceled. } } private failAllRequests(error: Error): void { for (const request of [...this.activeRequests.values()]) { this.failRequest(request, error); } } private resolveResumeStream(request: ActiveRequest): void { if (request.kind !== 'resume' || request.responseSettled) { return; } request.responseSettled = true; request.resolveResponse?.(request.stream); } private async validateResponseChunk(value: unknown): Promise<UIMessageChunk> { const chunk = await safeValidateTypes<UIMessageChunk>({ value, schema: uiMessageChunkSchema, }); if (!chunk.success) { throw new Error('Invalid UI message chunk in WebSocket response.'); } return chunk.value; } private async handleResponseFrame( connection: ConnectionState, text: string, ): Promise<void> { if (this.connection !== connection) { return; } const parsed = await safeParseJSON({ text }); if (!parsed.success || !isRecord(parsed.value)) { throw new Error('Invalid WebSocket chat response frame.'); } const type = parsed.value.type; const requestId = parsed.value.requestId; if (typeof type !== 'string' || typeof requestId !== 'string') { throw new Error('Invalid WebSocket chat response frame.'); } const request = this.activeRequests.get(requestId); if (request == null) { return; } switch (type) { case 'start': { this.resolveResumeStream(request); return; } case 'chunk': { if (!isSequence(parsed.value.sequence)) { throw new Error('Invalid WebSocket chat chunk sequence.'); } if (parsed.value.sequence <= request.lastSequence) { return; } if (parsed.value.sequence !== request.lastSequence + 1) { throw new Error('Out-of-order WebSocket chat chunk sequence.'); } const chunk = await this.validateResponseChunk(parsed.value.chunk); // Validation is asynchronous, so the request may have been aborted or // canceled while it was in progress. Ignore the stale frame instead of // touching a closed controller and terminating the shared connection. if (this.activeRequests.get(requestId) !== request) { return; } this.resolveResumeStream(request); try { request.controller.enqueue(chunk); } catch { // The stream may already be closed or canceled. return; } request.lastSequence = parsed.value.sequence; return; } case 'end': { this.resolveResumeStream(request); this.cleanupRequest(request); request.controller.close(); return; } case 'no-active': { if (request.kind === 'resume' && !request.responseSettled) { request.responseSettled = true; request.resolveResponse?.(null); } this.cleanupRequest(request); request.controller.close(); return; } case 'error': { if ( parsed.value.errorText !== undefined && typeof parsed.value.errorText !== 'string' ) { throw new Error('Invalid WebSocket chat error response.'); } this.failRequest( request, new Error( parsed.value.errorText ?? 'WebSocket chat server returned an error.', ), ); return; } default: throw new Error(`Unknown WebSocket chat response type: ${type}.`); } } private async sendFrame( socket: WebSocketLike, frame: WebSocketChatTransportRequest<UI_MESSAGE>, abortSignal?: AbortSignal, ): Promise<void> { const send = this.sendQueue.then(async () => { await waitForWebSocketBufferDrain(socket, { abortSignal }); this.throwIfAborted(abortSignal); if ( this.connection?.socket !== socket || socket.readyState !== WEBSOCKET_OPEN_STATE ) { throw new TypeError('WebSocket chat network connection is not open.'); } socket.send(JSON.stringify(frame)); }); this.sendQueue = send.catch(() => {}); return send; } }