eventsource
Version:
WhatWG/W3C compliant EventSource client for Node.js and browsers
523 lines • 19.3 kB
JavaScript
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