UNPKG

eventsource

Version:

WhatWG/W3C compliant EventSource client for Node.js and browsers

523 lines 19.3 kB
import { createParser, } from 'eventsource-parser'; import { ErrorEvent, flattenError, syntaxError } from './errors.js'; const DEFAULT_MAX_BUFFER_SIZE = 100 * 1024 * 1024; /** * Implementation of the `EventSource` interface. * * Intentionally not exported: TypeScript emits a `#private` brand into declaration files for any * class holding hard-private (`#`) fields, which makes the emitted type nominal. Exporting the * interface and const instead keeps the public type structural, while the implementation keeps * its actual, runtime-enforced private state. * * Public members are documented on the `EventSource` interface, which is what consumers see. * * @internal */ class EventSourceImpl extends EventTarget { static CONNECTING = 0; static OPEN = 1; static CLOSED = 2; CONNECTING = 0; OPEN = 1; CLOSED = 2; get readyState() { return this.#readyState; } get url() { return this.#url.href; } get withCredentials() { return this.#withCredentials; } get onerror() { return this.#onError; } set onerror(value) { if (this.#onError) { this.removeEventListener('error', this.#onError); } this.#onError = value; if (value) { this.addEventListener('error', value); } } get onmessage() { return this.#onMessage; } set onmessage(value) { if (this.#onMessage) { this.removeEventListener('message', this.#onMessage); } this.#onMessage = value; if (value) { this.addEventListener('message', value); } } get onopen() { return this.#onOpen; } set onopen(value) { if (this.#onOpen) { this.removeEventListener('open', this.#onOpen); } this.#onOpen = value; if (value) { this.addEventListener('open', value); } } addEventListener(type, listener, options) { const listen = listener; super.addEventListener(type, listen, options); } removeEventListener(type, listener, options) { const listen = listener; super.removeEventListener(type, listen, options); } constructor(url, eventSourceInitDict) { super(); try { if (url instanceof URL) { this.#url = url; } else if (typeof url === 'string') { this.#url = new URL(url, getBaseURL()); } else { throw new Error('Invalid URL'); } } catch { throw syntaxError('An invalid or illegal string was specified'); } this.#parser = createParser({ maxBufferSize: eventSourceInitDict?.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE, onEvent: this.#onEvent, onError: this.#onParseError, onId: this.#onIdChange, onRetry: this.#onRetryChange, }); this.#readyState = this.CONNECTING; this.#reconnectInterval = 3000; this.#fetch = eventSourceInitDict?.fetch ?? globalThis.fetch; this.#withCredentials = eventSourceInitDict?.withCredentials ?? false; this.#connect(); } close() { if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer); if (this.#readyState === this.CLOSED) return; if (this.#controller) this.#controller.abort(); this.#readyState = this.CLOSED; this.#controller = undefined; } // PRIVATES FOLLOW /** * Current connection state * * @internal */ #readyState; /** * Original URL used to connect. * * Note that this will stay the same even after a redirect. * * @internal */ #url; /** * The destination URL after a redirect. Is reset on reconnection. * * @internal */ #redirectUrl; /** * Whether to include credentials in the request * * @internal */ #withCredentials; /** * The fetch implementation to use * * @internal */ #fetch; /** * The reconnection time in milliseconds * * @internal */ #reconnectInterval; /** * Reference to an ongoing reconnect attempt, if any * * @internal */ #reconnectTimer; /** * The last event ID seen by the EventSource, which will be sent as `Last-Event-ID` in the * request headers on a reconnection attempt. * * @internal */ #lastEventId = null; /** * The AbortController instance used to abort the fetch request * * @internal */ #controller; /** * Instance of an EventSource parser (`eventsource-parser` npm module) * * @internal */ #parser; /** * Holds the current error handler, attached through `onerror` property directly. * Note that `addEventListener('error', …)` will not be stored here. * * @internal */ #onError = null; /** * Holds the current message handler, attached through `onmessage` property directly. * Note that `addEventListener('message', …)` will not be stored here. * * @internal */ #onMessage = null; /** * Holds the current open handler, attached through `onopen` property directly. * Note that `addEventListener('open', …)` will not be stored here. * * @internal */ #onOpen = null; /** * Connect to the given URL and start receiving events * * @internal */ #connect() { this.#readyState = this.CONNECTING; this.#controller = new AbortController(); // Browser tests are failing if we directly call `this.#fetch()`, thus the indirection. const fetch = this.#fetch; fetch(this.#url, this.#getRequestOptions()) .then(this.#onFetchResponse) .catch(this.#onFetchError); } /** * Handles the fetch response * * @param response - The Fetch(ish) response * @internal */ #onFetchResponse = async (response) => { this.#parser.reset(); const { body, redirected, status, headers } = response; // [spec] a client can be told to stop reconnecting using the HTTP 204 No Content response code. if (status === 204) { // We still need to emit an error event - this mirrors the browser behavior, // and without it there is no way to tell the user that the connection was closed. this.#failConnection('Server sent HTTP 204, not reconnecting', 204); this.close(); return; } // [spec] …Event stream requests can be redirected using HTTP 301 and 307 redirects as with // [spec] normal HTTP requests. // Spec does not say anything about other redirect codes (302, 308), but this seems an // unintended omission, rather than a feature. Browsers will happily redirect on other 3xxs's. if (redirected) { this.#redirectUrl = new URL(response.url); } else { this.#redirectUrl = undefined; } // [spec] if res's status is not 200, …, then fail the connection. if (status !== 200) { this.#failConnection(`Non-200 status code (${status})`, status); return; } // [spec] …or if res's `Content-Type` is not `text/event-stream`, then fail the connection. const contentType = headers.get('content-type') || ''; if (!contentType.startsWith('text/event-stream')) { this.#failConnection('Invalid content type, expected "text/event-stream"', status); return; } // [spec] …if the readyState attribute is set to a value other than CLOSED… if (this.#readyState === this.CLOSED) { return; } // [spec] …sets the readyState attribute to OPEN and fires an event // [spec] …named open at the EventSource object. this.#readyState = this.OPEN; const openEvent = new Event('open'); this.dispatchEvent(openEvent); // Ensure that the response stream is a web stream if (typeof body !== 'object' || !body || !('getReader' in body)) { this.#failConnection('Invalid response body, expected a web ReadableStream', status); this.close(); // This should only happen if `fetch` provided is "faulty" - don't reconnect return; } const decoder = new TextDecoder(); const reader = body.getReader(); let open = true; do { const { done, value } = await reader.read(); if (this.#readyState === this.CLOSED) { open = false; break; } if (value) { this.#parser.feed(decoder.decode(value, { stream: !done })); } if (!done) { continue; } open = false; this.#parser.reset(); this.#scheduleReconnect(); } while (open); }; /** * Handles rejected requests for the EventSource endpoint * * @param err - The error from `fetch()` * @internal */ #onFetchError = (err) => { this.#controller = undefined; // We expect abort errors when the user manually calls `close()` - ignore those if (err.name === 'AbortError' || err.type === 'aborted') { return; } this.#scheduleReconnect(flattenError(err)); }; /** * Get request options for the `fetch()` request * * @returns The request options * @internal */ #getRequestOptions() { const lastEvent = this.#lastEventId ? { 'Last-Event-ID': this.#lastEventId } : undefined; const init = { // [spec] Let `corsAttributeState` be `Anonymous`… // [spec] …will have their mode set to "cors"… mode: 'cors', redirect: 'follow', headers: { Accept: 'text/event-stream', ...lastEvent }, cache: 'no-store', signal: this.#controller?.signal, }; // Some environments crash if attempting to set `credentials` where it is not supported, // eg on Cloudflare Workers. To avoid this, we only set it in browser-like environments. if ('window' in globalThis) { // [spec] …and their credentials mode set to "same-origin" // [spec] …if the `withCredentials` attribute is `true`, set the credentials mode to "include"… init.credentials = this.withCredentials ? 'include' : 'same-origin'; } return init; } /** * Called by EventSourceParser when a blank line ends a block containing a valid `id` field. * This runs before `#onEvent` when the same block also contains data. * * @param value - The value of the `id` field * @internal */ #onIdChange = (value) => { this.#lastEventId = value; }; /** * Called by EventSourceParser instance when an event has successfully been parsed * and is ready to be processed. * * @param event - The parsed event * @internal */ #onEvent = (event) => { const origin = this.#redirectUrl ? this.#redirectUrl.origin : this.#url.origin; // [spec] The `lastEventId` attribute is the last event ID string of the event // source, i.e. the persisted buffer (`#lastEventId`) - not the current event's `id`. // The buffer is only updated by an explicit `id` field and must survive an // event that omits `id`. const lastEventId = this.#lastEventId ?? ''; const messageEvent = new MessageEvent(event.event || 'message', { data: event.data, origin, lastEventId, }); // workerd (Cloudflare Workers) accepts `data` but silently drops `origin` and `lastEventId` // from the constructor init, yielding `null` and `''`. Both are plain own properties there // rather than prototype getters, so they can be assigned after the fact. Elsewhere the // constructor honours them and this is skipped, so no runtime pays for it unnecessarily. if (messageEvent.origin !== origin) { defineEventProperty(messageEvent, 'origin', origin); } if (messageEvent.lastEventId !== lastEventId) { defineEventProperty(messageEvent, 'lastEventId', lastEventId); } // The `onmessage` property only triggers on messages without an `event` field, or ones that // explicitly set `message`. This is handled automatically: the event is dispatched with type // `event.event || 'message'`, and `onmessage` is registered as a `message` event listener. this.dispatchEvent(messageEvent); }; /** * Called by EventSourceParser instance when a new reconnection interval is received * from the EventSource endpoint. * * @param value - The new reconnection interval in milliseconds * @internal */ #onRetryChange = (value) => { this.#reconnectInterval = value; }; /** * Called by EventSourceParser instance when a parse error occurs. * * @param error - The parser error * @internal */ #onParseError = (error) => { if (error.type !== 'max-buffer-size-exceeded') { return; } this.close(); this.#failConnection(error.message); }; /** * Handles the process referred to in the EventSource specification as "failing a connection". * * @param error - The error causing the connection to fail * @param code - The HTTP status code, if available * @internal */ #failConnection(message, code) { // [spec] …if the readyState attribute is set to a value other than CLOSED, // [spec] sets the readyState attribute to CLOSED… if (this.#readyState !== this.CLOSED) { this.#readyState = this.CLOSED; } // [spec] …and fires an event named `error` at the `EventSource` object. // [spec] Once the user agent has failed the connection, it does not attempt to reconnect. // [spec] > Implementations are especially encouraged to report detailed information // [spec] > to their development consoles whenever an error event is fired, since little // [spec] > to no information can be made available in the events themselves. // Printing to console is not very programatically helpful, though, so we emit a custom event. const errorEvent = new ErrorEvent('error', { code, message }); this.dispatchEvent(errorEvent); } /** * Schedules a reconnection attempt against the EventSource endpoint. * * @param message - The error causing the connection to fail * @param code - The HTTP status code, if available * @internal */ #scheduleReconnect(message, code) { // [spec] If the readyState attribute is set to CLOSED, abort the task. if (this.#readyState === this.CLOSED) { return; } // [spec] Set the readyState attribute to CONNECTING. this.#readyState = this.CONNECTING; // [spec] Fire an event named `error` at the EventSource object. const errorEvent = new ErrorEvent('error', { code, message }); this.dispatchEvent(errorEvent); // [spec] Wait a delay equal to the reconnection time of the event source. const timer = setTimeout(this.#reconnect, this.#reconnectInterval); // In Node.js (and Bun), a pending timer keeps the event loop alive, preventing the // process from exiting while we wait to reconnect. `unref()` opts out of that. Browsers // and Deno return a numeric handle with no `unref()`, so only call it when available. if (typeof timer === 'object' && timer !== null && 'unref' in timer) { timer.unref(); } this.#reconnectTimer = timer; } /** * Reconnects to the EventSource endpoint after a disconnect/failure * * @internal */ #reconnect = () => { this.#reconnectTimer = undefined; // [spec] If the EventSource's readyState attribute is not set to CONNECTING, then return. if (this.#readyState !== this.CONNECTING) { return; } this.#connect(); }; } // The class is named `EventSourceImpl` to leave the `EventSource` name free for the interface and // the const below. Restore the public name, so neither `EventSource.name` nor inspected instances // (eg `console.log(eventSource)`) leak the implementation name. Object.defineProperty(EventSourceImpl, 'name', { value: 'EventSource' }); // Provides a way to detect that the EventSource implementation supports passing `fetch` // that can be used to customize the request, eg custom headers and similar. Object.defineProperty(EventSourceImpl, Symbol.for('eventsource.supports-fetch-override'), { value: true, writable: false, configurable: false, enumerable: false, }); /** * An `EventSource` instance opens a persistent connection to an HTTP server, which sends events * in `text/event-stream` format. The connection remains open until closed by calling `.close()`. * * @public * @example * ```js * const eventSource = new EventSource('https://example.com/stream') * eventSource.addEventListener('error', (error) => { * console.error(error) * }) * eventSource.addEventListener('message', (event) => { * console.log('Received message:', event.data) * }) * ``` */ export const EventSource = EventSourceImpl; /** * According to spec, when constructing a URL: * > 1. Let baseURL be environment's base URL, if environment is a Document object * > 2. Return the result of applying the URL parser to url, with baseURL. * * Thus we should use `document.baseURI` if available, since it can be set through a base tag. * * @returns The base URL, if available - otherwise `undefined` * @internal */ function getBaseURL() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const doc = 'document' in globalThis ? globalThis.document : undefined; return doc && typeof doc === 'object' && 'baseURI' in doc && typeof doc.baseURI === 'string' ? doc.baseURI : undefined; } /** * Assigns a `MessageEvent` property that the constructor's init dictionary was supposed to have * set, for runtimes that ignore it. * * Defined rather than assigned because on the runtimes that do implement the property as a * prototype getter, a plain assignment would throw in strict mode. `enumerable` and * `configurable` mirror how a spec-compliant implementation exposes it. * * @param event - The message event to define the property on * @param property - The property to define * @param value - The value the constructor should have set * @internal */ function defineEventProperty(event, property, value) { Object.defineProperty(event, property, { value, enumerable: true, configurable: true, }); } //# sourceMappingURL=EventSource.js.map