import { getCurrentInstance, getCurrentScope, onMounted, onScopeDispose, ref, shallowRef, type Ref, } from 'vue' import { tryUseSettingsStore } from '../stores/settings' export type EventStreamTransport = ( input: RequestInfo | URL, init?: RequestInit, ) => Promise export interface EventStreamMessage { event: string data: TData id?: string retry?: number raw: string } export interface EventStreamOptions { transport?: EventStreamTransport headers?: HeadersInit credentials?: RequestCredentials immediate?: boolean /** * Reconnect after a dropped stream. Left unset, it follows the platform's * `wsAutoReconnect` setting, defaulting to `true` without a settings store. */ reconnect?: boolean /** * Delay before the first reconnect attempt. Left unset, it follows the * platform's `wsReconnectInterval` setting, defaulting to 2000 ms without a * settings store. A server-sent `retry:` field still overrides it. */ reconnectDelayMs?: number parseJson?: boolean shouldReconnect?: (cause?: unknown) => boolean onOpen?: () => void onMessage?: (message: EventStreamMessage) => void onError?: (cause: unknown) => void onClose?: (cause?: unknown) => void errorLabel?: string } export interface UseEventStreamReturn { isConnected: Ref isConnecting: Ref error: Ref lastMessage: Ref | null> lastEventId: Ref start: () => void stop: () => void } interface ParsedEventStreamBlock { message?: EventStreamMessage id?: string retry?: number } class EventStreamResponseError extends Error { constructor(readonly response: Response, label: string) { super(`${label} failed with HTTP ${response.status}`) this.name = 'EventStreamResponseError' } } /** Parse one complete SSE block without owning connection policy. */ export function parseEventStreamBlock( block: string, parseJson: boolean, ): ParsedEventStreamBlock | null { let event = 'message' let id: string | undefined let retry: number | undefined const dataLines: string[] = [] for (const line of block.split(/\r\n|\r|\n/)) { if (!line || line.startsWith(':')) continue const separator = line.indexOf(':') const field = separator === -1 ? line : line.slice(0, separator) const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '') if (field === 'event') event = value || 'message' if (field === 'data') dataLines.push(value) if (field === 'id' && !value.includes('\0')) id = value if (field === 'retry' && /^\d+$/.test(value)) retry = Number(value) } if (!dataLines.length && id === undefined && retry === undefined) return null const data = dataLines.join('\n') return { message: dataLines.length ? { event, data: (parseJson && data ? JSON.parse(data) : data) as TData, id, retry, raw: block, } : undefined, id, retry, } } /** Shared fetch-based SSE lifecycle with cancellation and reconnect safety. */ export function useEventStream( url: string | (() => string), options: EventStreamOptions = {}, ): UseEventStreamReturn { const isConnected = ref(false) const isConnecting = ref(false) const error = ref(null) const lastMessage = shallowRef | null>(null) const lastEventId = ref(null) let controller: AbortController | null = null let reader: ReadableStreamDefaultReader | null = null let reconnectTimer: ReturnType | null = null const settings = tryUseSettingsStore() const reconnectEnabled = options.reconnect ?? settings?.wsAutoReconnect ?? true let reconnectDelayMs = options.reconnectDelayMs ?? settings?.wsReconnectInterval ?? 2_000 let stopped = true let generation = 0 function clearReconnectTimer(): void { if (reconnectTimer === null) return clearTimeout(reconnectTimer) reconnectTimer = null } function ownsConnection( connectionGeneration: number, connectionController: AbortController, ): boolean { return !stopped && generation === connectionGeneration && controller === connectionController } function scheduleReconnect(connectionGeneration: number, cause?: unknown): void { if (stopped || generation !== connectionGeneration) return if (!reconnectEnabled || options.shouldReconnect?.(cause) === false) { stopped = true return } clearReconnectTimer() reconnectTimer = setTimeout(() => { reconnectTimer = null if (stopped || generation !== connectionGeneration) return void connect(connectionGeneration) }, reconnectDelayMs) } function dispatchBlock( block: string, connectionGeneration: number, connectionController: AbortController, ): void { const parsed = parseEventStreamBlock(block, options.parseJson ?? false) if (!parsed || !ownsConnection(connectionGeneration, connectionController)) return if (parsed.id !== undefined) { lastEventId.value = parsed.id if (!ownsConnection(connectionGeneration, connectionController)) return } if (parsed.retry !== undefined) reconnectDelayMs = parsed.retry const message = parsed.message if (!message || !ownsConnection(connectionGeneration, connectionController)) return lastMessage.value = message if (!ownsConnection(connectionGeneration, connectionController)) return options.onMessage?.(message) } async function consume( body: ReadableStream, connectionGeneration: number, connectionController: AbortController, ): Promise { const connectionReader = body.getReader() reader = connectionReader const decoder = new TextDecoder() let buffer = '' try { for (;;) { const { value, done } = await connectionReader.read() if (!ownsConnection(connectionGeneration, connectionController)) return buffer += decoder.decode(value, { stream: !done }) let boundary = /(?:\r\n|\r|\n){2}/.exec(buffer) while (boundary?.index !== undefined) { const block = buffer.slice(0, boundary.index) buffer = buffer.slice(boundary.index + boundary[0].length) dispatchBlock(block, connectionGeneration, connectionController) if (!ownsConnection(connectionGeneration, connectionController)) return boundary = /(?:\r\n|\r|\n){2}/.exec(buffer) } if (!done) continue if (buffer) dispatchBlock(buffer, connectionGeneration, connectionController) return } } finally { if (reader === connectionReader) reader = null connectionReader.releaseLock() } } async function connect(connectionGeneration: number): Promise { if (stopped || generation !== connectionGeneration) return controller?.abort() const connectionController = new AbortController() controller = connectionController isConnecting.value = true if (!ownsConnection(connectionGeneration, connectionController)) return error.value = null if (!ownsConnection(connectionGeneration, connectionController)) return let cause: unknown try { const headers = new Headers(options.headers) headers.set('Accept', 'text/event-stream') if (lastEventId.value && !headers.has('Last-Event-ID')) { headers.set('Last-Event-ID', lastEventId.value) } const transport = options.transport ?? globalThis.fetch?.bind(globalThis) if (!transport) throw new Error('[MINT SDK] fetch is required for event streams.') const response = await transport(typeof url === 'function' ? url() : url, { method: 'GET', headers, credentials: options.credentials ?? 'same-origin', signal: connectionController.signal, }) if (!ownsConnection(connectionGeneration, connectionController)) return if (!response.ok) { throw new EventStreamResponseError(response, options.errorLabel ?? 'Event stream') } if (!response.body) { throw new Error(`${options.errorLabel ?? 'Event stream'} response did not include a readable body.`) } isConnected.value = true if (!ownsConnection(connectionGeneration, connectionController)) return isConnecting.value = false if (!ownsConnection(connectionGeneration, connectionController)) return options.onOpen?.() if (!ownsConnection(connectionGeneration, connectionController)) return await consume(response.body, connectionGeneration, connectionController) } catch (errorCause) { cause = errorCause if ( ownsConnection(connectionGeneration, connectionController) && !isAbortError(errorCause) ) { error.value = errorCause instanceof Error ? errorCause.message : `${options.errorLabel ?? 'Event stream'} failed` if (ownsConnection(connectionGeneration, connectionController)) { options.onError?.(errorCause) } } } finally { if (ownsConnection(connectionGeneration, connectionController)) { isConnecting.value = false if (!ownsConnection(connectionGeneration, connectionController)) return isConnected.value = false if (!ownsConnection(connectionGeneration, connectionController)) return controller = null options.onClose?.(cause) if (!stopped && generation === connectionGeneration) { scheduleReconnect(connectionGeneration, cause) } } } } function start(): void { if (!stopped) return stopped = false clearReconnectTimer() generation += 1 void connect(generation) } function stop(): void { if (stopped) return stopped = true generation += 1 clearReconnectTimer() controller?.abort() controller = null if (reader) void reader.cancel().catch(() => undefined) reader = null isConnecting.value = false isConnected.value = false } const currentInstance = getCurrentInstance() if (currentInstance && options.immediate !== false && typeof window === 'undefined') { onMounted(start) } if (getCurrentScope()) onScopeDispose(stop) if (options.immediate !== false && (typeof window !== 'undefined' || !currentInstance)) { start() } return { isConnected, isConnecting, error, lastMessage, lastEventId, start, stop, } } function isAbortError(cause: unknown): boolean { return cause instanceof DOMException && cause.name === 'AbortError' }