@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
963 lines • 32.1 kB
JavaScript
/**
* Real-time Streaming Support
*
* Provides dedicated streaming capabilities including Server-Sent Events (SSE),
* WebSocket connections, and async iterators for real-time data streaming.
*
* @module @neurolink/client/streaming
*/
import { logger } from "../utils/logger.js";
import { combineSignals, sleep } from "./httpClient.js";
import { withClientSpan } from "../telemetry/withSpan.js";
import { tracers } from "../telemetry/tracers.js";
// =============================================================================
// Types
// =============================================================================
// =============================================================================
// SSE Client
// =============================================================================
/**
* Server-Sent Events (SSE) Client
*
* Provides a robust SSE connection with automatic reconnection,
* event parsing, and async iterator support.
*
* @example Basic usage
* ```typescript
* const sse = new SSEClient('https://api.example.com/stream');
*
* sse.on('message', (data) => console.log(data));
* sse.on('error', (error) => console.error(error));
*
* await sse.connect({ body: { prompt: 'Hello' } });
* ```
*
* @example Async iterator usage
* ```typescript
* const sse = new SSEClient('https://api.example.com/stream');
*
* for await (const event of sse.events({ body: { prompt: 'Hello' } })) {
* if (event.type === 'text') {
* console.log(event.content);
* }
* }
* ```
*/
export class SSEClient {
url;
options;
state = "disconnected";
abortController = null;
reconnectAttempts = 0;
eventHandlers = new Map();
constructor(url, options = {}) {
this.url = url;
this.options = {
autoReconnect: options.autoReconnect ?? false,
reconnectDelay: options.reconnectDelay ?? 1000,
maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
...options,
};
}
/**
* Connect to SSE endpoint
*/
async connect(requestOptions = {}) {
return withClientSpan({
name: "neurolink.client.streaming.sse.connect",
tracer: tracers.http,
attributes: {
"http.url": this.url,
"http.method": requestOptions.body ? "POST" : "GET",
},
}, async () => this._connectSSE(requestOptions));
}
async _connectSSE(requestOptions = {}) {
if (this.state === "connected" || this.state === "connecting") {
return;
}
this.state = "connecting";
this.abortController = new AbortController();
logger.debug(`[SSEClient] Connecting to ${this.url}`);
// Combine signals if external signal provided
const signal = this.options.signal
? combineSignals(this.options.signal, this.abortController.signal)
: this.abortController.signal;
try {
const response = await fetch(this.url, {
method: requestOptions.body ? "POST" : "GET",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
...this.options.headers,
...requestOptions.headers,
},
credentials: this.options.credentials,
body: requestOptions.body
? JSON.stringify(requestOptions.body)
: undefined,
signal,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
code: "SSE_ERROR",
message: `HTTP ${response.status}`,
status: response.status,
}));
logger.debug(`[SSEClient] Connection failed: HTTP ${response.status}`);
throw error;
}
this.state = "connected";
this.reconnectAttempts = 0;
this.emit("connected");
logger.debug(`[SSEClient] Connected to ${this.url}`);
await this.processStream(response);
}
catch (error) {
if (error.name === "AbortError") {
this.state = "disconnected";
logger.debug("[SSEClient] Connection aborted");
return;
}
this.state = "error";
this.emit("error", error);
logger.debug("[SSEClient] Connection error", {
message: error.message,
});
if (this.options.autoReconnect && this.shouldReconnect()) {
await this.reconnect(requestOptions);
}
}
}
/**
* Disconnect from SSE endpoint
*/
disconnect() {
logger.debug(`[SSEClient] Disconnecting from ${this.url}`);
this.abortController?.abort();
this.state = "disconnected";
this.emit("disconnected");
}
/**
* Process the SSE stream
*/
async processStream(response) {
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
this.state = "disconnected";
this.emit("disconnected");
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);
if (data === "[DONE]") {
this.emit("done", { content: "" });
continue;
}
try {
const event = JSON.parse(data);
this.handleEvent(event);
}
catch {
// Ignore parse errors
}
}
else if (line.startsWith("event: ")) {
// Handle named events
const eventName = line.slice(7);
this.emit("event-type", eventName);
}
else if (line.startsWith("id: ")) {
// Handle event IDs
const eventId = line.slice(4);
this.emit("event-id", eventId);
}
else if (line.startsWith("retry: ")) {
// Handle retry directive
const retryMs = parseInt(line.slice(7), 10);
if (!isNaN(retryMs)) {
this.options.reconnectDelay = retryMs;
}
}
}
}
}
finally {
reader.releaseLock();
}
}
/**
* Handle a stream event
*/
handleEvent(event) {
this.emit("message", event);
switch (event.type) {
case "text":
if (event.content) {
this.emit("text", event.content);
}
break;
case "tool-call":
if (event.toolCall) {
this.emit("tool-call", event.toolCall);
}
break;
case "tool-result":
if (event.toolResult) {
this.emit("tool-result", event.toolResult);
}
break;
case "error":
if (event.error) {
this.emit("error", event.error);
}
break;
case "metadata":
if (event.metadata) {
this.emit("metadata", event.metadata);
}
break;
case "audio":
if (event.audio) {
this.emit("audio", event.audio);
}
break;
case "thinking":
if (event.thinking) {
this.emit("thinking", event.thinking);
}
break;
case "done":
this.emit("done", { content: "" });
break;
}
}
/**
* Check if should attempt reconnection
*/
shouldReconnect() {
return this.reconnectAttempts < (this.options.maxReconnectAttempts ?? 5);
}
/**
* Attempt reconnection
*/
async reconnect(requestOptions) {
this.state = "reconnecting";
this.reconnectAttempts++;
this.emit("reconnecting", this.reconnectAttempts);
const delayMs = (this.options.reconnectDelay ?? 1000) * this.reconnectAttempts;
logger.debug(`[SSEClient] Reconnecting to ${this.url} (attempt ${this.reconnectAttempts}, delay ${delayMs}ms)`);
await sleep(delayMs);
await this.connect(requestOptions);
}
/**
* Register event handler
*/
on(event, callback) {
let handlers = this.eventHandlers.get(event);
if (!handlers) {
handlers = new Set();
this.eventHandlers.set(event, handlers);
}
handlers.add(callback);
}
/**
* Remove event handler
*/
off(event, callback) {
this.eventHandlers.get(event)?.delete(callback);
}
/**
* Emit event
*/
emit(event, ...args) {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.forEach((handler) => handler(...args));
}
}
/**
* Get current connection state
*/
getState() {
return this.state;
}
/**
* Create async iterator for events
*
* @example
* ```typescript
* for await (const event of sse.events({ body: { prompt: 'Hello' } })) {
* console.log(event);
* }
* ```
*/
async *events(requestOptions = {}) {
const events = [];
let done = false;
let error = null;
let resolver = null;
const onMessage = (event) => {
events.push(event);
resolver?.();
};
const onDone = () => {
done = true;
resolver?.();
};
const onError = (err) => {
error = err;
resolver?.();
};
this.on("message", onMessage);
this.on("done", onDone);
this.on("error", onError);
try {
// Start connection in background
this.connect(requestOptions).catch((err) => {
error = err;
resolver?.();
});
while (!done && !error) {
if (events.length > 0) {
const nextEvent = events.shift();
if (!nextEvent) {
continue;
}
yield nextEvent;
}
else {
await new Promise((resolve) => {
resolver = resolve;
});
}
}
// Yield remaining events
while (events.length > 0) {
const nextEvent = events.shift();
if (!nextEvent) {
continue;
}
yield nextEvent;
}
if (error) {
throw error;
}
}
finally {
this.off("message", onMessage);
this.off("done", onDone);
this.off("error", onError);
}
}
}
// =============================================================================
// WebSocket Streaming Client
// =============================================================================
/**
* WebSocket Streaming Client
*
* Provides WebSocket-based streaming with automatic reconnection,
* heartbeat, and message handling.
*
* @example
* ```typescript
* const ws = new WebSocketStreamingClient({
* url: 'wss://api.example.com/ws',
* autoReconnect: true,
* });
*
* ws.on('message', (data) => console.log(data));
*
* await ws.connect();
* ws.send({ type: 'chat', content: 'Hello' });
* ```
*/
export class WebSocketStreamingClient {
options;
ws = null;
state = "disconnected";
reconnectAttempts = 0;
heartbeatInterval = null;
eventHandlers = new Map();
constructor(options) {
this.options = {
autoReconnect: options.autoReconnect ?? true,
reconnectInterval: options.reconnectInterval ?? 5000,
maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
heartbeatInterval: options.heartbeatInterval ?? 30000,
...options,
};
}
/**
* Connect to WebSocket server
*/
async connect() {
return withClientSpan({
name: "neurolink.client.streaming.ws.connect",
tracer: tracers.http,
attributes: { "http.url": this.options.url },
}, async () => this._connectWS());
}
async _connectWS() {
if (typeof WebSocket === "undefined") {
throw new Error("WebSocket is not available. Please use a polyfill for non-browser environments.");
}
if (this.state === "connected" || this.state === "connecting") {
return;
}
logger.debug(`[WebSocketClient] Connecting to ${this.options.url}`);
return new Promise((resolve, reject) => {
this.state = "connecting";
try {
this.ws = new WebSocket(this.options.url, this.options.protocols);
this.ws.onopen = () => {
this.state = "connected";
this.reconnectAttempts = 0;
this.startHeartbeat();
this.emit("connected");
logger.debug(`[WebSocketClient] Connected to ${this.options.url}`);
resolve();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleMessage(data);
}
catch {
this.emit("raw-message", event.data);
}
};
this.ws.onclose = (event) => {
this.state = "disconnected";
this.stopHeartbeat();
this.emit("disconnected", { code: event.code, reason: event.reason });
logger.debug("[WebSocketClient] Disconnected", {
code: event.code,
reason: event.reason,
});
if (this.options.autoReconnect && !event.wasClean) {
this.attemptReconnect();
}
};
this.ws.onerror = (event) => {
this.emit("error", event);
logger.debug("[WebSocketClient] Connection error");
if (this.state === "connecting") {
reject(new Error("WebSocket connection failed"));
}
};
}
catch (error) {
this.state = "disconnected";
logger.debug("[WebSocketClient] Connection error", {
message: error.message,
});
reject(error);
}
});
}
/**
* Disconnect from WebSocket server
*/
disconnect() {
logger.debug(`[WebSocketClient] Disconnecting from ${this.options.url}`);
this.stopHeartbeat();
if (this.ws) {
this.ws.close(1000, "Client disconnect");
this.ws = null;
}
this.state = "disconnected";
}
/**
* Send message to server
*/
send(data) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
else {
throw new Error("WebSocket is not connected");
}
}
/**
* Send message and wait for response
*/
async request(data, timeout = 30000) {
return new Promise((resolve, reject) => {
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const timeoutId = setTimeout(() => {
this.off("response", responseHandler);
reject(new Error(`Request timed out after ${timeout}ms`));
}, timeout);
const responseHandler = (data) => {
const response = data;
if (response.requestId === requestId) {
clearTimeout(timeoutId);
this.off("response", responseHandler);
resolve(response.data);
}
};
this.on("response", responseHandler);
this.send({ ...data, requestId });
});
}
/**
* Handle incoming message
*/
handleMessage(data) {
this.emit("message", data);
// Handle typed messages
if (typeof data === "object" && data !== null && "type" in data) {
const typedData = data;
this.emit(typedData.type, typedData);
}
}
/**
* Start heartbeat
*/
startHeartbeat() {
if (!this.options.heartbeatInterval) {
return;
}
this.heartbeatInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: "ping", timestamp: Date.now() });
}
}, this.options.heartbeatInterval);
}
/**
* Stop heartbeat
*/
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
/**
* Attempt reconnection
*/
async attemptReconnect() {
if (this.reconnectAttempts >= (this.options.maxReconnectAttempts ?? 10) ||
this.state === "reconnecting") {
this.emit("reconnect-failed", this.reconnectAttempts);
logger.debug("[WebSocketClient] Max reconnect attempts reached", {
attempts: this.reconnectAttempts,
});
return;
}
this.state = "reconnecting";
this.reconnectAttempts++;
this.emit("reconnecting", this.reconnectAttempts);
const delayMs = (this.options.reconnectInterval ?? 5000) *
Math.min(this.reconnectAttempts, 5);
logger.debug(`[WebSocketClient] Reconnecting (attempt ${this.reconnectAttempts}, delay ${delayMs}ms)`);
await sleep(delayMs);
try {
await this.connect();
}
catch {
// Reconnection failed, will be retried on next disconnect
}
}
/**
* Register event handler
*/
on(event, callback) {
let handlers = this.eventHandlers.get(event);
if (!handlers) {
handlers = new Set();
this.eventHandlers.set(event, handlers);
}
handlers.add(callback);
}
/**
* Remove event handler
*/
off(event, callback) {
this.eventHandlers.get(event)?.delete(callback);
}
/**
* Emit event
*/
emit(event, ...args) {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.forEach((handler) => handler(args.length === 1 ? args[0] : args));
}
}
/**
* Get current connection state
*/
getState() {
return this.state;
}
/**
* Create async iterator for messages
*/
async *messages() {
const messageQueue = [];
let resolver = null;
let disconnected = false;
const onMessage = (data) => {
messageQueue.push(data);
resolver?.();
};
const onDisconnect = () => {
disconnected = true;
resolver?.();
};
this.on("message", onMessage);
this.on("disconnected", onDisconnect);
try {
while (!disconnected) {
if (messageQueue.length > 0) {
const nextMessage = messageQueue.shift();
if (nextMessage === undefined) {
continue;
}
yield nextMessage;
}
else {
await new Promise((resolve) => {
resolver = resolve;
});
}
}
// Yield remaining messages
while (messageQueue.length > 0) {
const nextMessage = messageQueue.shift();
if (nextMessage === undefined) {
continue;
}
yield nextMessage;
}
}
finally {
this.off("message", onMessage);
this.off("disconnected", onDisconnect);
}
}
}
// =============================================================================
// Streaming Client Factory
// =============================================================================
/**
* Streaming Client Factory
*
* Creates streaming clients for real-time communication with NeuroLink API.
*
* @example SSE streaming
* ```typescript
* const client = createStreamingClient({
* baseUrl: 'https://api.example.com',
* apiKey: 'your-key',
* transport: 'sse',
* });
*
* const result = await client.stream({
* input: { text: 'Hello' },
* callbacks: {
* onText: (text) => console.log(text),
* },
* });
* ```
*
* @example WebSocket streaming
* ```typescript
* const client = createStreamingClient({
* baseUrl: 'https://api.example.com',
* apiKey: 'your-key',
* transport: 'websocket',
* });
*
* await client.connect();
* const result = await client.stream({
* input: { text: 'Hello' },
* });
* ```
*/
export function createStreamingClient(config) {
const { baseUrl, apiKey, token, headers = {}, transport = "sse" } = config;
const authHeaders = {
...headers,
};
if (apiKey) {
authHeaders["X-API-Key"] = apiKey;
}
if (token) {
authHeaders["Authorization"] = `Bearer ${token}`;
}
if (transport === "websocket") {
const wsUrl = baseUrl.replace(/^http/, "ws") + "/ws";
const wsClient = new WebSocketStreamingClient({
url: wsUrl,
autoReconnect: true,
});
return {
connect: () => wsClient.connect(),
disconnect: () => wsClient.disconnect(),
stream: async (options) => {
const { callbacks, ...requestOptions } = options;
return new Promise((resolve, reject) => {
let fullContent = "";
const toolCalls = [];
const toolResults = [];
const messageHandler = (data) => {
const event = data;
switch (event.type) {
case "text":
if (event.content) {
fullContent += event.content;
callbacks?.onText?.(event.content);
}
break;
case "tool-call":
if (event.toolCall) {
toolCalls.push(event.toolCall);
callbacks?.onToolCall?.(event.toolCall);
}
break;
case "tool-result":
if (event.toolResult) {
toolResults.push(event.toolResult);
callbacks?.onToolResult?.(event.toolResult);
}
break;
case "error":
if (event.error) {
callbacks?.onError?.(event.error);
reject(event.error);
}
break;
case "done": {
wsClient.off("message", messageHandler);
const result = {
content: fullContent,
toolCalls,
toolResults,
};
callbacks?.onDone?.(result);
resolve(result);
break;
}
}
};
wsClient.on("message", messageHandler);
wsClient.send({
type: "stream",
...requestOptions,
});
});
},
send: (data) => wsClient.send(data),
on: (event, callback) => wsClient.on(event, callback),
off: (event, callback) => wsClient.off(event, callback),
getState: () => wsClient.getState(),
};
}
// SSE transport — track connection state across stream calls
let sseState = "disconnected";
return {
connect: () => {
sseState = "connected";
return Promise.resolve();
},
disconnect: () => {
sseState = "disconnected";
},
stream: async (options) => {
const { callbacks, ...requestOptions } = options;
const sseUrl = `${baseUrl}/api/stream`;
const sse = new SSEClient(sseUrl, {
headers: authHeaders,
});
sseState = "connecting";
return new Promise((resolve, reject) => {
let fullContent = "";
const toolCalls = [];
const toolResults = [];
sse.on("connected", () => {
sseState = "connected";
});
sse.on("text", (text) => {
fullContent += text;
callbacks?.onText?.(text);
});
sse.on("tool-call", (toolCall) => {
toolCalls.push(toolCall);
callbacks?.onToolCall?.(toolCall);
});
sse.on("tool-result", (toolResult) => {
toolResults.push(toolResult);
callbacks?.onToolResult?.(toolResult);
});
sse.on("metadata", (metadata) => {
callbacks?.onMetadata?.(metadata);
});
sse.on("audio", (audio) => {
callbacks?.onAudio?.(audio);
});
sse.on("thinking", (thinking) => {
callbacks?.onThinking?.(thinking);
});
sse.on("error", (error) => {
sseState = "disconnected";
callbacks?.onError?.(error);
reject(error);
});
sse.on("done", () => {
sseState = "disconnected";
const result = {
content: fullContent,
toolCalls,
toolResults,
};
callbacks?.onDone?.(result);
resolve(result);
});
sse.on("disconnected", () => {
sseState = "disconnected";
});
sse.connect({ body: requestOptions });
});
},
send: () => {
throw new Error("SSE transport does not support sending messages");
},
on: () => { },
off: () => { },
getState: () => sseState,
};
}
// =============================================================================
// Async Stream Utilities
// =============================================================================
/**
* Create an async iterable from streaming response
*
* @example
* ```typescript
* const stream = createAsyncStream(fetch('/api/stream', { method: 'POST' }));
*
* for await (const event of stream) {
* console.log(event);
* }
* ```
*/
export async function* createAsyncStream(responsePromise) {
const response = await responsePromise;
if (!response.ok) {
const error = await response.json().catch(() => ({
code: "STREAM_ERROR",
message: `HTTP ${response.status}`,
status: response.status,
}));
throw error;
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let buffer = "";
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);
if (data === "[DONE]") {
return;
}
try {
const event = JSON.parse(data);
yield event;
}
catch {
// Ignore parse errors
}
}
}
}
}
finally {
reader.releaseLock();
}
}
/**
* Collect streaming events into a single result
*
* @example
* ```typescript
* const result = await collectStream(
* createAsyncStream(fetch('/api/stream', { method: 'POST' }))
* );
* console.log(result.content);
* ```
*/
export async function collectStream(stream) {
let content = "";
const toolCalls = [];
const toolResults = [];
let finishReason;
let usage;
let metadata;
for await (const event of stream) {
switch (event.type) {
case "text":
if (event.content) {
content += event.content;
}
break;
case "tool-call":
if (event.toolCall) {
toolCalls.push(event.toolCall);
}
break;
case "tool-result":
if (event.toolResult) {
toolResults.push(event.toolResult);
}
break;
case "metadata":
metadata = { ...metadata, ...event.metadata };
if (event.metadata?.finishReason) {
finishReason = event.metadata.finishReason;
}
if (event.metadata?.usage) {
usage = event.metadata.usage;
}
break;
}
}
return {
content,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
finishReason,
usage,
metadata,
};
}
// Types are exported at their definition sites above and via imports
//# sourceMappingURL=streamingClient.js.map