@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
JavaScript
/**
* 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; })();