UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

575 lines 21.5 kB
/** * Server-Sent Events (SSE) Client for NeuroLink SDK * * Provides a dedicated SSE client for server-push streaming connections * to NeuroLink servers. Supports automatic reconnection, event parsing, * and typed event handlers. * * @module @neurolink/client/sseClient */ import { SpanStatusCode } from "@opentelemetry/api"; import { logger } from "../utils/logger.js"; import { withClientSpan } from "../telemetry/withSpan.js"; import { tracers } from "../telemetry/tracers.js"; // ============================================================================= // SSE Client Implementation // ============================================================================= /** * SSE streaming client for NeuroLink * * Provides server-push streaming from NeuroLink servers using Server-Sent Events. * * @example Basic usage * ```typescript * const sseClient = new NeuroLinkSSE({ * baseUrl: 'https://api.neurolink.example.com', * apiKey: 'your-api-key', * }); * * // Stream with callbacks * await sseClient.stream('/api/generate', { * body: { prompt: 'Hello!' }, * }, { * onText: (text) => console.log('Text:', text), * onDone: (result) => console.log('Complete:', result), * onError: (error) => console.error('Error:', error), * }); * ``` */ export class NeuroLinkSSE { config; state = "disconnected"; abortController = null; reconnectAttempts = 0; eventHandlers = {}; constructor(config) { this.config = { baseUrl: config.baseUrl, apiKey: config.apiKey ?? "", token: config.token ?? "", timeout: config.timeout ?? 30000, headers: config.headers ?? {}, autoReconnect: config.autoReconnect ?? true, maxReconnectAttempts: config.maxReconnectAttempts ?? 5, reconnectDelay: config.reconnectDelay ?? 1000, maxReconnectDelay: config.maxReconnectDelay ?? 30000, useNativeEventSource: config.useNativeEventSource ?? false, }; } /** * Get current connection state */ getState() { return this.state; } /** * Check if connected */ isConnected() { return this.state === "connected"; } /** * Stream from an endpoint using SSE */ async stream(path, options = {}, callbacks = {}) { return withClientSpan({ name: "neurolink.client.sse.stream", tracer: tracers.http, attributes: { "http.method": options.body ? "POST" : "GET", "http.route": path, "http.url": this.buildUrl(path), "sse.auto_reconnect": this.config.autoReconnect, "sse.native_event_source": this.config.useNativeEventSource, }, }, async (span) => { // Wrap onError callback so the span is marked failed on stream errors const wrappedCallbacks = { ...callbacks, onError: (error) => { span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ?? "SSE stream error", }); callbacks.onError?.(error); }, }; return this._streamInternal(path, options, wrappedCallbacks); }); } async _streamInternal(path, options = {}, callbacks = {}) { const url = this.buildUrl(path); const isGetRequest = !options.body; // Use native EventSource for GET requests when configured and available if (this.config.useNativeEventSource && isGetRequest && typeof EventSource !== "undefined") { return this.streamWithNativeEventSource(url, options, callbacks); } this.setState("connecting"); this.abortController = new AbortController(); // Implement timeout via AbortController timer let timeoutId; const userSignal = options.signal; if (this.config.timeout > 0) { timeoutId = setTimeout(() => { this.abortController?.abort(); }, this.config.timeout); } // If the caller provided their own signal, abort our controller when it fires if (userSignal) { if (userSignal.aborted) { this.abortController.abort(); } else { userSignal.addEventListener("abort", () => this.abortController?.abort(), { once: true }); } } const headers = this.buildHeaders(options.headers); logger.debug(`[NeuroLinkSSE] Connecting to ${url}`); try { const response = await fetch(url, { method: isGetRequest ? "GET" : "POST", headers, body: options.body ? JSON.stringify(options.body) : undefined, signal: this.abortController.signal, }); if (!response.ok) { const errorData = await response .json() .catch((_parseError) => { // JSON parsing failed, return empty object as fallback return {}; }); const apiError = { code: "HTTP_ERROR", message: `HTTP ${response.status}: ${response.statusText}`, status: response.status, details: errorData, }; logger.debug(`[NeuroLinkSSE] Connection failed: HTTP ${response.status}`); callbacks.onError?.(apiError); this.setState("error"); return; } // Connection succeeded -- reset reconnect counter so future // disconnections get the full retry budget again. this.reconnectAttempts = 0; this.setState("connected"); this.eventHandlers.onOpen?.(); logger.debug(`[NeuroLinkSSE] Connected to ${url}`); // Clear the connection timeout once connected; the stream may run // indefinitely and should not be cut short by the initial timeout. if (timeoutId !== undefined) { clearTimeout(timeoutId); timeoutId = undefined; } await this.processStream(response, callbacks); this.setState("disconnected"); this.eventHandlers.onClose?.(); logger.debug(`[NeuroLinkSSE] Stream ended for ${url}`); } catch (error) { if (timeoutId !== undefined) { clearTimeout(timeoutId); } if (error.name === "AbortError") { this.setState("disconnected"); logger.debug("[NeuroLinkSSE] Stream aborted"); return; } this.setState("error"); const apiError = { code: "STREAM_ERROR", message: error.message, status: 500, }; logger.debug("[NeuroLinkSSE] Stream error", { message: error.message, }); callbacks.onError?.(apiError); this.eventHandlers.onError?.(error); // Handle reconnection if (this.config.autoReconnect) { await this.attemptReconnect(path, options, callbacks); } } } /** * Abort the current stream */ abort() { logger.debug("[NeuroLinkSSE] Aborting stream"); if (this.abortController) { this.abortController.abort(); this.abortController = null; } this.setState("disconnected"); } /** * Set global event handlers */ setEventHandlers(handlers) { this.eventHandlers = handlers; } // ============================================================================= // Streaming Helpers // ============================================================================= /** * Stream a generate request */ async generate(prompt, options = {}) { let fullContent = ""; await this.stream("/api/generate", { body: { input: { text: prompt }, provider: options.provider, model: options.model, temperature: options.temperature, maxTokens: options.maxTokens, systemPrompt: options.systemPrompt, stream: true, }, }, { onText: (text) => { fullContent += text; options.onText?.(text); }, onToolCall: options.onToolCall, onToolResult: options.onToolResult, onDone: options.onDone, onError: options.onError, }); return fullContent; } /** * Stream a chat request */ async chat(messages, options = {}) { let fullContent = ""; await this.stream("/api/chat", { body: { messages, agentId: options.agentId, sessionId: options.sessionId, stream: true, }, }, { onText: (text) => { fullContent += text; options.onText?.(text); }, onToolCall: options.onToolCall, onToolResult: options.onToolResult, onDone: options.onDone, onError: options.onError, }); return fullContent; } /** * Stream an agent execution */ async executeAgent(agentId, input, options = {}) { let fullContent = ""; await this.stream(`/api/agents/${agentId}/execute`, { body: { input, stream: true, }, }, { onText: (text) => { fullContent += text; options.onText?.(text); }, onToolCall: options.onToolCall, onToolResult: options.onToolResult, onDone: options.onDone, onError: options.onError, }); return fullContent; } // ============================================================================= // Private Methods // ============================================================================= /** * Stream using the browser's native EventSource API (GET requests only). * Provides automatic reconnection handled by the browser and a simpler * implementation, but only supports GET with limited header control. */ streamWithNativeEventSource(url, options, callbacks) { return new Promise((resolve, reject) => { this.setState("connecting"); // EventSource only supports query-param auth; we append apiKey if set. const esUrl = this.config.apiKey ? `${url}${url.includes("?") ? "&" : "?"}apiKey=${encodeURIComponent(this.config.apiKey)}` : url; const es = new EventSource(esUrl); let accumulatedContent = ""; // Implement timeout for the initial connection let timeoutId; if (this.config.timeout > 0) { timeoutId = setTimeout(() => { es.close(); this.setState("error"); const apiError = { code: "TIMEOUT", message: `Connection timed out after ${this.config.timeout}ms`, status: 408, }; callbacks.onError?.(apiError); reject(new Error(apiError.message)); }, this.config.timeout); } // Honour caller-provided abort signal if (options.signal) { if (options.signal.aborted) { es.close(); this.setState("disconnected"); resolve(); return; } options.signal.addEventListener("abort", () => { es.close(); if (timeoutId !== undefined) { clearTimeout(timeoutId); } this.setState("disconnected"); resolve(); }, { once: true }); } es.onopen = () => { if (timeoutId !== undefined) { clearTimeout(timeoutId); timeoutId = undefined; } this.reconnectAttempts = 0; this.setState("connected"); this.eventHandlers.onOpen?.(); logger.debug(`[NeuroLinkSSE] Native EventSource connected to ${url}`); }; es.onmessage = (messageEvent) => { const data = messageEvent.data.trim(); if (data === "[DONE]") { es.close(); const result = { content: accumulatedContent, finishReason: "stop", }; callbacks.onDone?.(result); this.setState("disconnected"); this.eventHandlers.onClose?.(); resolve(); return; } try { const event = JSON.parse(data); this.handleEvent(event, callbacks); if (event.type === "text" && event.content) { accumulatedContent += event.content; } } catch { callbacks.onText?.(data); accumulatedContent += data; } }; es.onerror = () => { if (timeoutId !== undefined) { clearTimeout(timeoutId); timeoutId = undefined; } // EventSource fires onerror on both transient and fatal failures. // readyState === CLOSED means the browser gave up. if (es.readyState === EventSource.CLOSED) { es.close(); this.setState("error"); const apiError = { code: "STREAM_ERROR", message: "EventSource connection closed", status: 500, }; callbacks.onError?.(apiError); this.eventHandlers.onError?.(new Error(apiError.message)); resolve(); } // Otherwise the browser is attempting its own reconnect -- let it. }; }); } buildUrl(path) { const baseUrl = this.config.baseUrl.replace(/\/$/, ""); const cleanPath = path.startsWith("/") ? path : `/${path}`; return `${baseUrl}${cleanPath}`; } buildHeaders(additionalHeaders) { const headers = new Headers({ Accept: "text/event-stream", "Cache-Control": "no-cache", ...this.config.headers, ...additionalHeaders, }); if (this.config.apiKey) { headers.set("X-API-Key", this.config.apiKey); } if (this.config.token) { headers.set("Authorization", `Bearer ${this.config.token}`); } // Add Content-Type for POST requests if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } return headers; } async processStream(response, callbacks) { const reader = response.body?.getReader(); if (!reader) { throw new Error("Response body is not readable"); } const decoder = new TextDecoder(); let buffer = ""; let accumulatedContent = ""; try { 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.startsWith("data: ")) { const data = line.slice(6).trim(); if (data === "[DONE]") { const result = { content: accumulatedContent, finishReason: "stop", }; callbacks.onDone?.(result); return; } try { const event = JSON.parse(data); this.handleEvent(event, callbacks); // Accumulate content for the final result if (event.type === "text" && event.content) { accumulatedContent += event.content; } } catch { // Non-JSON data, treat as text callbacks.onText?.(data); accumulatedContent += data; } } } } // Process any remaining buffer if (buffer.trim()) { if (buffer.startsWith("data: ")) { const data = buffer.slice(6).trim(); if (data !== "[DONE]") { try { const event = JSON.parse(data); this.handleEvent(event, callbacks); if (event.type === "text" && event.content) { accumulatedContent += event.content; } } catch { callbacks.onText?.(data); accumulatedContent += data; } } } } } finally { reader.releaseLock(); } } handleEvent(event, callbacks) { this.eventHandlers.onEvent?.(event); switch (event.type) { case "text": callbacks.onText?.(event.content ?? ""); break; case "tool-call": if (event.toolCall) { callbacks.onToolCall?.(event.toolCall); } break; case "tool-result": if (event.toolResult) { callbacks.onToolResult?.(event.toolResult); } break; case "done": { // Create a StreamResult from the event const result = { content: event.content ?? "", finishReason: "stop", }; callbacks.onDone?.(result); break; } case "error": if (event.error) { callbacks.onError?.(event.error); } break; case "metadata": // Metadata events are passed through onEvent handler break; } } setState(state) { this.state = state; this.eventHandlers.onStateChange?.(state); } async attemptReconnect(path, options, callbacks) { if (this.reconnectAttempts >= this.config.maxReconnectAttempts) { const error = new Error(`Max reconnection attempts (${this.config.maxReconnectAttempts}) exceeded`); logger.debug("[NeuroLinkSSE] Max reconnect attempts reached", { attempts: this.reconnectAttempts, }); this.eventHandlers.onError?.(error); callbacks.onError?.({ code: "RECONNECT_FAILED", message: error.message, status: 500, }); return; } this.reconnectAttempts++; const delay = Math.min(this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.config.maxReconnectDelay); logger.debug(`[NeuroLinkSSE] Reconnecting (attempt ${this.reconnectAttempts}, delay ${delay}ms)`); this.eventHandlers.onReconnect?.(this.reconnectAttempts); await new Promise((resolve) => setTimeout(resolve, delay)); await this.stream(path, options, callbacks); } } // ============================================================================= // Factory Function // ============================================================================= /** * Create an SSE client instance * * @example * ```typescript * const client = createSSEClient({ * baseUrl: 'https://api.neurolink.example.com', * apiKey: 'your-api-key', * autoReconnect: true, * }); * * // Generate with streaming * const content = await client.generate('Hello!', { * onText: (text) => process.stdout.write(text), * }); * ``` */ export function createSSEClient(config) { return new NeuroLinkSSE(config); } //# sourceMappingURL=sseClient.js.map