UNPKG

@elefunc/fetcheventsource

Version:

FetchEventSource - combines the full power of fetch() with EventSource streaming. Supports ALL HTTP methods, request bodies, headers, and fetch options.

177 lines (146 loc) 8.21 kB
/** * FetchEventSource - fetch() × EventSource * * Combines the full power of fetch() with EventSource streaming. * Supports ALL HTTP methods, request bodies, headers, and fetch options. * * Usage: * new FetchEventSource(url) // Returns standard EventSource (GET) * new FetchEventSource(url, fetchOptions) // Returns FetchEventSource with full fetch() API */ // Maximum data size per message (50MB) to protect against servers that never send message delimiters const MAX_DATA_SIZE = 50 * 1024 * 1024; export class FetchEventSource extends EventTarget { #onopen = null; #onmessage = null; #onerror = null; #readyState = null; #url = null; #withCredentials = false; #dispatchError = (message, error = new Error(message)) => this.dispatchEvent(new ErrorEvent('error', { message, error })); constructor(url, { method, headers, body, signal: externalSignal, ...options } = {}) { if (!(method ?? headers ?? body)) return new EventSource(url, options); super(); const { CONNECTING, OPEN, CLOSED } = FetchEventSource; this.#readyState = CONNECTING; this.#url = url; this.#withCredentials = !!options.withCredentials; // State for automatic retry let reader = null; let explicitlyClosed = false; let retryTimeout = null; let retryDelay = 5000; // 5 s default like native EventSource let lastEventId = ''; let controller = null; // Abort controller – refreshed on every (re)connect const originalBody = body; // Preserve original body reference this.close = () => { // Initialize close method to match EventSource behavior explicitlyClosed = true; this.#readyState = CLOSED; // Set immediately to prevent race conditions if (retryTimeout) clearTimeout(retryTimeout); reader?.cancel?.().catch(() => {}); // Cancel reader if it exists controller?.abort?.(); }; const scheduleRetry = () => { // Helper to set up retry with proper abort handling if (externalSignal && !externalSignal.aborted) { const abortHandler = () => { if (retryTimeout) { clearTimeout(retryTimeout); retryTimeout = null; } this.#readyState = CLOSED; }; externalSignal.addEventListener('abort', abortHandler, { once: true }); retryTimeout = setTimeout(() => { externalSignal.removeEventListener('abort', abortHandler); connect(); }, retryDelay); } else retryTimeout = setTimeout(connect, retryDelay); // No external signal or already aborted - simple retry }; const connect = async () => { controller = new AbortController(); // Fresh controller for this attempt // Use AbortSignal.any to combine external and internal signals const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal; // Re‑clone body when possible (Blob/Response/FormData etc.) const clonedBody = originalBody?.clone?.() ?? originalBody; const h = new Headers(headers); h.set('Cache-Control', 'no-store'); //html.spec.whatwg.org/multipage/server-sent-events.html#:~:text=cache%20mode h.set('Accept', 'text/event-stream'); if (lastEventId) h.set('Last-Event-ID', lastEventId); //html.spec.whatwg.org/multipage/server-sent-events.html#last-event-id const init = { credentials: options.withCredentials ? 'include' : 'same-origin', ...options, ...(method && { method }), // Use method if provided ...(originalBody && { body: clonedBody }), headers: h, signal }; try { const response = await fetch(url, init); const closeWithError = (msg, err) => (this.#readyState = CLOSED, this.#dispatchError(msg, err)); if (!response.ok || response.status === 204) return closeWithError(`HTTP ${response.status}: ${response.statusText}`); const ct = response.headers.get('Content-Type')?.split(';', 1)[0]?.trim?.()?.toLowerCase?.(); if (ct !== 'text/event-stream') return closeWithError(`Invalid content type '${ct}'`, new TypeError(`Invalid content type '${ct}'`)); const origin = URL.parse(response.url)?.origin; // Get origin from response URL reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); this.#readyState = OPEN; this.dispatchEvent(new Event('open')); let buffer = '', event = 'message', data = '', skipMessage = false; while (this.readyState === OPEN) { const { done, value } = await reader.read(); if (done) break; const lines = (buffer + value).split(/\r\n|\r|\n/); buffer = lines.pop(); lines.forEach(l => { if (!l) { // blank line → dispatch if (data && !skipMessage) this.dispatchEvent(new MessageEvent(event, { data, lastEventId, origin })); // Reset for next message event = 'message'; data = ''; skipMessage = false; return; } if (l[0] === ':') return; // comment → ignore const [field, ...rest] = l.split(':'); const val = rest.join(':').replace(/^ /, ''); switch (field) { case 'event': event = val || 'message'; break; case 'data': if (!skipMessage) { const newDataSize = data.length + (data ? 1 : 0) + val.length; if (newDataSize > MAX_DATA_SIZE) { // protect against rogue servers this.#dispatchError(`Message data exceeds maximum size of ${MAX_DATA_SIZE} bytes`); skipMessage = true; data = ''; } else data += (data ? '\n' : '') + val; } break; case 'id': if (!/[\u0000\n\r]/.test(val)) lastEventId = val; break; /* ⚠️ */ //html.spec.whatwg.org/multipage/server-sent-events.html#the-last-event-id-header case 'retry': if (/^[0-9]+$/.test(val)) retryDelay = parseInt(val, 10); break; /* ⚠️ only ASCII digits */ //html.spec.whatwg.org/multipage/server-sent-events.html#:~:text=only%20ASCII%20digits } }); } //html.spec.whatwg.org/multipage/server-sent-events.html#:~:text=the%20end%20of%20the%20stream%20is%20not%20enough%20to%20trigger%20the%20dispatch%20of%20the%20last%20event // ⚠️ the end of the stream is not enough to trigger the dispatch of the last event // Connection ended – trigger reconnect if not explicitly closed if (!explicitlyClosed) { this.#readyState = CONNECTING; this.#dispatchError('Connection closed'); scheduleRetry(); } } catch (e) { this.#dispatchError(e.message || 'Connection error', e); if (explicitlyClosed || externalSignal?.aborted) { this.#readyState = CLOSED; return; } // no retry this.#readyState = CONNECTING; scheduleRetry(); } }; connect(); } get readyState() { return this.#readyState; } get url() { return this.#url; } get withCredentials() { return this.#withCredentials; } #createEventHandler = (e, o, n) => (this.removeEventListener(e, o), this.addEventListener(e, n), n); get onopen() { return this.#onopen; } set onopen(handler) { this.#onopen = this.#createEventHandler('open', this.#onopen, handler); } get onmessage() { return this.#onmessage; } set onmessage(handler) { this.#onmessage = this.#createEventHandler('message', this.#onmessage, handler); } get onerror() { return this.#onerror; } set onerror(handler) { this.#onerror = this.#createEventHandler('error', this.#onerror, handler); } } // Define read‑only static constants to match EventSource behavior ['CONNECTING', 'OPEN', 'CLOSED'].map((p, i) => Object.defineProperty(FetchEventSource, p, { value: i, enumerable: true })); //developer.mozilla.org/en-US/docs/Web/API/URL/parse_static # Baseline September 2024 URL.parse ??= (...A) => URL.canParse ? (URL.canParse(...A) ? new URL(...A) : null) : (()=>{ try { return new URL(...A); } catch { } return null; })();