diff --git a/README.md b/README.md index 04a05534..a1acebb6 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,106 @@ const data2 = await cachedApi.get('/users', {}, { cache: { ttl: 5000 } }) // cac const otherApi = new Request('/api') // isolated cache ``` +## SSE (Server-Sent Events) + +Supports SSE streaming requests, built on Fetch API with these advantages over native EventSource: +- ✅ Custom Headers support +- ✅ POST requests support +- ✅ All HTTP methods supported + +### Basic usage + +```ts +import { Request } from '@cc-heart/utils' + +const api = new Request('https://api.example.com') + +// GET SSE +const handle = api.sse('/events', { + onMessage(event) { + console.log('Received:', event.data) + }, + onOpen() { + console.log('Connection opened') + }, + onError(error) { + console.error('Connection error:', error) + }, + onClose() { + console.log('Connection closed') + } +}) + +// Cancel connection +handle.abort() +``` + +### POST SSE (e.g., AI streaming chat) + +```ts +const handle = api.sse('/chat/completions', { + method: 'POST', + data: { + prompt: 'Hello', + model: 'gpt-4' + }, + onMessage(event) { + // Parse JSON data + try { + const data = JSON.parse(event.data) + console.log('AI reply:', data.content) + } catch { + console.log('Raw data:', event.data) + } + }, + onError(err) { + console.error('Request failed:', err) + } +}) +``` + +### With interceptors + +```ts +import type { RequestInterceptor } from '@cc-heart/utils' + +const addAuth: RequestInterceptor = (config) => ({ + ...config, + headers: { + ...config.headers, + Authorization: `Bearer ${getToken()}` + } +}) + +const api = new Request('https://api.example.com') +api.useRequestInterceptor(addAuth) + +// SSE requests automatically include interceptor headers +const handle = api.sse('/protected/events', { + onMessage(event) { + console.log(event.data) + } +}) +``` + +### SSE Type definitions + +```ts +interface SSEMessageEvent { + event?: string // Event type + data: string // Message data + id?: string // Last event ID + retry?: number // Retry interval (ms) +} + +interface SSECallbacks { + onMessage?: (event: SSEMessageEvent) => void + onOpen?: () => void + onError?: (error: unknown) => void + onClose?: () => void +} +``` + ## LICENSE `@cc-heart/utils` is licensed under the [MIT License](./LICENSE). diff --git a/README_ZH.md b/README_ZH.md index 7b3e2a7b..39fe569f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -165,6 +165,106 @@ interface RequestConfig { } ``` +## SSE (Server-Sent Events) + +支持 SSE 流式请求,基于 Fetch API 实现,相比原生 EventSource 有以下优势: +- ✅ 支持自定义 Headers +- ✅ 支持 POST 请求 +- ✅ 支持所有 HTTP 方法 + +### 基础用法 + +```ts +import { Request } from '@cc-heart/utils' + +const api = new Request('https://api.example.com') + +// GET SSE +const handle = api.sse('/events', { + onMessage(event) { + console.log('收到消息:', event.data) + }, + onOpen() { + console.log('连接已建立') + }, + onError(error) { + console.error('连接错误:', error) + }, + onClose() { + console.log('连接已关闭') + } +}) + +// 取消连接 +handle.abort() +``` + +### POST SSE (如 AI 流式对话) + +```ts +const handle = api.sse('/chat/completions', { + method: 'POST', + data: { + prompt: '你好', + model: 'gpt-4' + }, + onMessage(event) { + // 解析 JSON 数据 + try { + const data = JSON.parse(event.data) + console.log('AI 回复:', data.content) + } catch { + console.log('原始数据:', event.data) + } + }, + onError(err) { + console.error('请求失败:', err) + } +}) +``` + +### 搭配拦截器使用 + +```ts +import type { RequestInterceptor } from '@cc-heart/utils' + +const addAuth: RequestInterceptor = (config) => ({ + ...config, + headers: { + ...config.headers, + Authorization: `Bearer ${getToken()}` + } +}) + +const api = new Request('https://api.example.com') +api.useRequestInterceptor(addAuth) + +// SSE 请求会自动携带拦截器添加的 Headers +const handle = api.sse('/protected/events', { + onMessage(event) { + console.log(event.data) + } +}) +``` + +### SSE 类型定义 + +```ts +interface SSEMessageEvent { + event?: string // 事件类型 + data: string // 消息数据 + id?: string // 最后事件 ID + retry?: number // 重连间隔(毫秒) +} + +interface SSECallbacks { + onMessage?: (event: SSEMessageEvent) => void + onOpen?: () => void + onError?: (error: unknown) => void + onClose?: () => void +} +``` + ## 返回类型 ```ts diff --git a/lib/request.ts b/lib/request.ts index deb198af..b8b60f05 100644 --- a/lib/request.ts +++ b/lib/request.ts @@ -34,6 +34,42 @@ export interface CacheConfig { ttl: number } +// ─── SSE types ─── + +/** SSE (Server-Sent Events) message event */ +export interface SSEMessageEvent { + /** Event type (from `event:` field) */ + event?: string + /** Message data */ + data: string + /** Last event ID (from `id:` field) */ + id?: string + /** Retry interval in milliseconds (from `retry:` field) */ + retry?: number +} + +/** SSE event handler callbacks */ +export interface SSECallbacks { + /** Called when a message is received */ + onMessage?: (event: SSEMessageEvent) => void + /** Called when the connection is opened */ + onOpen?: () => void + /** Called when an error occurs */ + onError?: (error: unknown) => void + /** Called when the connection is closed */ + onClose?: () => void +} + +/** SSE configuration */ +export interface SSEConfig extends SSECallbacks { + /** Enable SSE mode */ + sse: true + /** Custom headers for SSE request */ + headers?: Record + /** Request body for POST SSE */ + data?: unknown +} + // ─── Request config ─── export interface RequestConfig extends RequestInit { @@ -52,6 +88,8 @@ export interface RequestConfig extends RequestInit { cache?: boolean | CacheConfig /** Download progress callback. Receives (loadedBytes, totalBytes). */ onDownloadProgress?: (loaded: number, total: number) => void + /** SSE configuration. Enables Server-Sent Events mode when provided. */ + sse?: boolean | SSEConfig // ─── Lifecycle callbacks ─── onSuccess?: (data: unknown) => void @@ -420,6 +458,260 @@ export class Request { throw new Error('Unreachable') } + // ─── SSE Parser ─── + + private parseSSEStream( + response: Response, + callbacks: SSECallbacks, + controller: AbortController + ): void { + if (!response.body) { + callbacks.onError?.(new Error('Response body is empty')) + return + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let currentEvent: Partial = {} + + callbacks.onOpen?.() + + const processBuffer = () => { + const lines = buffer.split('\n') + // Keep incomplete line in buffer + buffer = lines.pop() || '' + + for (const line of lines) { + if (line === '') { + // Empty line = dispatch event + if (currentEvent.data !== undefined) { + callbacks.onMessage?.({ + event: currentEvent.event, + data: currentEvent.data, + id: currentEvent.id, + retry: currentEvent.retry + }) + } + currentEvent = {} + } else if (line.startsWith('data:')) { + const data = line.slice(5).trimStart() + currentEvent.data = currentEvent.data + ? currentEvent.data + '\n' + data + : data + } else if (line.startsWith('event:')) { + currentEvent.event = line.slice(6).trim() + } else if (line.startsWith('id:')) { + currentEvent.id = line.slice(3).trim() + } else if (line.startsWith('retry:')) { + const retry = parseInt(line.slice(6).trim(), 10) + if (!isNaN(retry)) { + currentEvent.retry = retry + } + } + // Lines starting with ':' are comments, ignore them + } + } + + const read = async () => { + try { + while (true) { + if (controller.signal.aborted) { + reader.cancel() + callbacks.onClose?.() + return + } + + const { done, value } = await reader.read() + if (done) { + // Process remaining buffer + processBuffer() + callbacks.onClose?.() + return + } + + buffer += decoder.decode(value, { stream: true }) + processBuffer() + } + } catch (error) { + if (!controller.signal.aborted) { + callbacks.onError?.(error) + } + callbacks.onClose?.() + } + } + + read() + } + + // ─── SSE convenience method ─── + + /** + * Create an SSE (Server-Sent Events) connection. + * + * Uses Fetch API under the hood, so it supports: + * - Custom headers (unlike native EventSource) + * - POST requests + * - All HTTP methods + * + * @example + * ```ts + * const api = new Request('https://api.example.com') + * + * // GET SSE (default) + * const handle = api.sse('/events', { + * onMessage: (event) => console.log(event.data), + * onError: (err) => console.error(err), + * }) + * + * // POST SSE with data + * const handle = api.sse('/chat/completions', { + * method: 'POST', + * data: { prompt: 'Hello' }, + * onMessage: (event) => { + * const parsed = JSON.parse(event.data) + * console.log(parsed) + * }, + * }) + * + * // Cancel the connection + * handle.abort() + * ```n */ + sse( + url: string, + config: Omit & SSECallbacks = {} + ): RequestHandle { + const controller = new AbortController() + const { onMessage, onOpen, onError, onClose, ...requestConfig } = config + + const callbacks: SSECallbacks = { + onMessage, + onOpen, + onError, + onClose + } + + const promise = this.executeSSE( + url, + requestConfig, + callbacks, + controller + ) + + const handle = { + then: (onfulfilled?: any, onrejected?: any) => + (promise as Promise).then(onfulfilled, onrejected), + catch: (onrejected?: any) => + (promise as Promise).catch(onrejected), + finally: (onfinally?: any) => + (promise as Promise).finally(onfinally), + abort: () => { + controller.abort() + } + } as RequestHandle + + return handle + } + + private async executeSSE( + url: string, + config: RequestConfig, + callbacks: SSECallbacks, + controller: AbortController + ): Promise { + const { + params, + data, + requestInterceptors = [], + errorInterceptors = [], + timeout, + ...requestConfig + } = config + + const method = (requestConfig.method ?? 'GET').toUpperCase() + const requestUrl = this.buildUrl(url, method, params) + + let requestInit = this.buildRequestInit( + method, + data, + requestConfig, + controller + ) + + // Set SSE headers + requestInit.headers = { + Accept: 'text/event-stream', + ...this.normalizeHeaders(requestInit.headers) + } + + try { + requestInit = await this.runRequestInterceptors(requestInit, [ + ...this.requestInterceptors, + ...requestInterceptors + ]) + + const response = await fetch(requestUrl, requestInit) + + if (!response.ok) { + throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`) + } + + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('text/event-stream')) { + throw new Error(`Expected text/event-stream, got ${contentType}`) + } + + // Start parsing SSE stream + this.parseSSEStream(response, callbacks, controller) + + // Return a promise that resolves when the stream ends + return new Promise((resolve, reject) => { + let abortHandler: (() => void) | undefined + + const cleanup = () => { + if (abortHandler) { + controller.signal.removeEventListener('abort', abortHandler) + } + } + + const onClose = () => { + cleanup() + resolve(undefined as T) + } + const onError = (error: unknown) => { + cleanup() + reject(error) + } + abortHandler = () => { + cleanup() + controller.abort() + } + + controller.signal.addEventListener('abort', abortHandler) + + // Override callbacks to also resolve/reject the promise + const originalOnClose = callbacks.onClose + const originalOnError = callbacks.onError + + callbacks.onClose = () => { + originalOnClose?.() + onClose() + } + callbacks.onError = (error) => { + originalOnError?.(error) + onError(error) + } + }) + } catch (error) { + const processedError = await this.runErrorInterceptors(error, [ + ...this.errorInterceptors, + ...errorInterceptors + ]) + callbacks.onError?.(processedError) + throw processedError + } + } + // ─── Progress reader ─── private async readResponseWithProgress(