| |
| import { STREAM_STALL_TIMEOUT_MS } from "../config/runtimeConfig.js"; |
| import { dbg, isDebugEnabled } from "./debugLog.js"; |
|
|
| |
| function getTimeString() { |
| return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function createStreamController({ onDisconnect, onError, log, provider, model } = {}) { |
| const abortController = new AbortController(); |
| const startTime = Date.now(); |
| let disconnected = false; |
| let abortTimeout = null; |
|
|
| const logStream = (status) => { |
| const duration = Date.now() - startTime; |
| const p = provider?.toUpperCase() || "UNKNOWN"; |
| console.log(`[${getTimeString()}] 🌊 [STREAM] ${p} | ${model || "unknown"} | ${duration}ms | ${status}`); |
| }; |
|
|
| return { |
| signal: abortController.signal, |
| startTime, |
|
|
| isConnected: () => !disconnected, |
|
|
| |
| handleDisconnect: (reason = "client_closed") => { |
| if (disconnected) return; |
| disconnected = true; |
|
|
| logStream(`disconnect: ${reason}`); |
| dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`); |
|
|
| |
| abortTimeout = setTimeout(() => { |
| abortController.abort(); |
| }, 500); |
|
|
| onDisconnect?.({ reason, duration: Date.now() - startTime }); |
| }, |
|
|
| |
| handleComplete: () => { |
| if (disconnected) return; |
| disconnected = true; |
|
|
| logStream("complete"); |
|
|
| if (abortTimeout) { |
| clearTimeout(abortTimeout); |
| abortTimeout = null; |
| } |
| }, |
|
|
| |
| handleError: (error) => { |
| if (disconnected) return; |
| disconnected = true; |
|
|
| if (abortTimeout) { |
| clearTimeout(abortTimeout); |
| abortTimeout = null; |
| } |
|
|
| if (error.name === "AbortError") { |
| logStream("aborted"); |
| return; |
| } |
|
|
| logStream(`error: ${error.message}`); |
| onError?.(error); |
| }, |
|
|
| abort: () => abortController.abort() |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function createDisconnectAwareStream(transformStream, streamController, onAbortTerminal = null) { |
| const reader = transformStream.readable.getReader(); |
| const writer = transformStream.writable.getWriter(); |
| let terminalEmitted = false; |
|
|
| |
| const emitTerminal = (controller) => { |
| if (terminalEmitted || !onAbortTerminal) return; |
| terminalEmitted = true; |
| try { |
| const bytes = onAbortTerminal(); |
| if (bytes) controller.enqueue(bytes); |
| } catch { } |
| }; |
|
|
| return new ReadableStream({ |
| async pull(controller) { |
| if (!streamController.isConnected()) { |
| emitTerminal(controller); |
| controller.close(); |
| return; |
| } |
|
|
| try { |
| const { done, value } = await reader.read(); |
|
|
| if (done) { |
| streamController.handleComplete(); |
| controller.close(); |
| return; |
| } |
| controller.enqueue(value); |
| } catch (error) { |
| const wasConnected = streamController.isConnected(); |
| |
| const msg0 = error?.message || ""; |
| const isControllerClosed = msg0.includes("already closed") || msg0.includes("Invalid state"); |
| if (!isControllerClosed) streamController.handleError(error); |
| reader.cancel().catch(() => {}); |
| writer.abort().catch(() => {}); |
|
|
| |
| const msg = error?.message || ""; |
| const code = error?.code || error?.cause?.code || ""; |
| const isNetworkClose = |
| error.name === "AbortError" || |
| msg.includes("aborted") || |
| msg.includes("socket hang up") || |
| msg.includes("ECONNRESET") || |
| msg.includes("ETIMEDOUT") || |
| msg.includes("EPIPE") || |
| code === "ECONNRESET" || |
| code === "ETIMEDOUT" || |
| code === "EPIPE" || |
| code === "UND_ERR_SOCKET"; |
|
|
| |
| |
| try { |
| if (!wasConnected || isNetworkClose || onAbortTerminal) { |
| emitTerminal(controller); |
| controller.close(); |
| } else { |
| controller.error(error); |
| } |
| } catch (e) { } |
| } |
| }, |
|
|
| cancel(reason) { |
| streamController.handleDisconnect(reason || "cancelled"); |
| reader.cancel(); |
| writer.abort(); |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal = null, stallTimeoutMs = STREAM_STALL_TIMEOUT_MS) { |
| let stallTimer = null; |
| let chunkCount = 0; |
| let totalBytes = 0; |
| let lastChunkAt = Date.now(); |
| const t0 = Date.now(); |
| const tag = "STREAM"; |
| const clearStall = () => { |
| if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; } |
| }; |
| const armStall = () => { |
| clearStall(); |
| stallTimer = setTimeout(() => { |
| stallTimer = null; |
| dbg(tag, `STALL TIMEOUT ${stallTimeoutMs}ms | chunks=${chunkCount} | bytes=${totalBytes} | sinceLast=${Date.now() - lastChunkAt}ms`); |
| streamController.handleError?.(new Error("stream stall timeout")); |
| streamController.abort?.(); |
| }, stallTimeoutMs); |
| }; |
|
|
| |
| |
| |
| const wrappedController = { |
| signal: streamController.signal, |
| startTime: streamController.startTime, |
| isConnected: () => streamController.isConnected(), |
| handleComplete: () => { dbg(tag, `complete | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleComplete(); }, |
| handleError: (e) => { dbg(tag, `error: ${e?.message} | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleError(e); }, |
| handleDisconnect: (r) => { dbg(tag, `disconnect: ${r} | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleDisconnect(r); }, |
| abort: () => { clearStall(); streamController.abort(); } |
| }; |
|
|
| armStall(); |
| dbg(tag, `pipe start | stallTimeout=${stallTimeoutMs}ms`); |
|
|
| const upstreamTap = new TransformStream({ |
| transform(chunk, controller) { |
| chunkCount++; |
| const sz = chunk?.byteLength || chunk?.length || 0; |
| totalBytes += sz; |
| const now = Date.now(); |
| const gap = now - lastChunkAt; |
| lastChunkAt = now; |
| if (isDebugEnabled && (chunkCount <= 5 || chunkCount % 20 === 0 || gap > 5000)) { |
| dbg(tag, `chunk #${chunkCount} | size=${sz}B | gap=${gap}ms | total=${totalBytes}B`); |
| } |
| armStall(); |
| controller.enqueue(chunk); |
| }, |
| flush() { dbg(tag, `upstream EOF | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); } |
| }); |
|
|
| const transformedBody = providerResponse.body |
| .pipeThrough(upstreamTap) |
| .pipeThrough(transformStream); |
|
|
| return createDisconnectAwareStream( |
| { readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } }, |
| wrappedController, |
| onAbortTerminal |
| ); |
| } |
|
|
|
|