'use strict' const { pipeline } = require('node:stream') const { fetching } = require('../fetch') const { makeRequest } = require('../fetch/request') const { getGlobalOrigin } = require('../fetch/global') const { webidl } = require('../fetch/webidl') const { EventSourceStream } = require('./eventsource-stream') const { parseMIMEType } = require('../fetch/data-url') const { MessageEvent } = require('../websocket/events') const { isNetworkError } = require('../fetch/response') const { delay } = require('./util') const { kEnumerableProperty } = require('../../core/util') let experimentalWarned = false /** * A reconnection time, in milliseconds. This must initially be an implementation-defined value, * probably in the region of a few seconds. * * In Comparison: * - Chrome uses 3000ms. * - Deno uses 5000ms. * * @type {3000} */ const defaultReconnectionTime = 3000 /** * The readyState attribute represents the state of the connection. * @enum * @readonly * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#dom-eventsource-readystate-dev */ /** * The connection has not yet been established, or it was closed and the user * agent is reconnecting. * @type {0} */ const CONNECTING = 0 /** * The user agent has an open connection and is dispatching events as it * receives them. * @type {1} */ const OPEN = 1 /** * The connection is not open, and the user agent is not trying to reconnect. * @type {2} */ const CLOSED = 2 /** * Requests for the element will have their mode set to "cors" and their credentials mode set to "same-origin". * @type {'anonymous'} */ const ANONYMOUS = 'anonymous' /** * Requests for the element will have their mode set to "cors" and their credentials mode set to "include". * @type {'use-credentials'} */ const USE_CREDENTIALS = 'use-credentials' /** * @typedef {object} EventSourceInit * @property {boolean} [withCredentials] indicates whether the request * should include credentials. */ /** * The EventSource interface is used to receive server-sent events. It * connects to a server over HTTP and receives events in text/event-stream * format without closing the connection. * @extends {EventTarget} * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events * @api public */ class EventSource extends EventTarget { #events = { open: null, error: null, message: null } #url = null #withCredentials = false #readyState = CONNECTING #request = null #controller = null /** * @type {object} * @property {string} lastEventId * @property {number} reconnectionTime * @property {any} reconnectionTimer */ #settings = null /** * Creates a new EventSource object. * @param {string} url * @param {EventSourceInit} [eventSourceInitDict] * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface */ constructor (url, eventSourceInitDict = {}) { // 1. Let ev be a new EventSource object. super() webidl.argumentLengthCheck(arguments, 1, { header: 'EventSource constructor' }) if (!experimentalWarned) { experimentalWarned = true process.emitWarning('EventSource is experimental, expect them to change at any time.', { code: 'UNDICI-ES' }) } url = webidl.converters.USVString(url) eventSourceInitDict = webidl.converters.EventSourceInitDict(eventSourceInitDict) // 2. Let settings be ev's relevant settings object. // https://html.spec.whatwg.org/multipage/webappapis.html#environment-settings-object this.#settings = { origin: getGlobalOrigin(), policyContainer: { referrerPolicy: 'no-referrer' }, lastEventId: '', reconnectionTime: defaultReconnectionTime } let urlRecord try { // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings. urlRecord = new URL(url, this.#settings.origin) this.#settings.origin = urlRecord.origin } catch (e) { // 4. If urlRecord is failure, then throw a "SyntaxError" DOMException. throw new DOMException(e, 'SyntaxError') } // 5. Set ev's url to urlRecord. this.#url = urlRecord.href // 6. Let corsAttributeState be Anonymous. let corsAttributeState = ANONYMOUS // 7. If the value of eventSourceInitDict's withCredentials member is true, // then set corsAttributeState to Use Credentials and set ev's // withCredentials attribute to true. if (eventSourceInitDict.withCredentials) { corsAttributeState = USE_CREDENTIALS this.#withCredentials = true } // 8. Let request be the result of creating a potential-CORS request given // urlRecord, the empty string, and corsAttributeState. const initRequest = { redirect: 'follow', keepalive: true, // @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes mode: 'cors', credentials: corsAttributeState === 'anonymous' ? 'same-origin' : 'omit', referrer: 'no-referrer' } // 9. Set request's client to settings. initRequest.client = this.#settings // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list. initRequest.headersList = [['accept', { name: 'accept', value: 'text/event-stream' }]] // 11. Set request's cache mode to "no-store". initRequest.cache = 'no-store' // 12. Set request's initiator type to "other". initRequest.initiator = 'other' initRequest.urlList = [new URL(this.#url)] // 13. Set ev's request to request. this.#request = makeRequest(initRequest) this.#connect() } /** * Returns the state of this EventSource object's connection. It can have the * values described below. * @returns {0|1|2} * @readonly */ get readyState () { return this.#readyState } /** * Returns the URL providing the event stream. * @readonly * @returns {string} */ get url () { return this.#url } /** * Returns a boolean indicating whether the EventSource object was * instantiated with CORS credentials set (true), or not (false, the default). */ get withCredentials () { return this.#withCredentials } #connect () { if (this.#readyState === CLOSED) return this.#readyState = CONNECTING const fetchParam = { request: this.#request } // 14. Let processEventSourceEndOfBody given response res be the following step: if res is not a network error, then reestablish the connection. const processEventSourceEndOfBody = (response) => { if (isNetworkError(response)) { this.dispatchEvent(new Event('error')) this.close() } this.#reconnect() } // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody... fetchParam.processResponseEndOfBody = processEventSourceEndOfBody // and processResponse set to the following steps given response res: fetchParam.processResponse = (response) => { // 1. If res is an aborted network error, then fail the connection. if (isNetworkError(response)) { // 1. When a user agent is to fail the connection, the user agent // must queue a task which, if the readyState attribute is set to a // value other than CLOSED, sets the readyState attribute to CLOSED // and fires an event named error at the EventSource object. Once the // user agent has failed the connection, it does not attempt to // reconnect. if (response.aborted) { this.close() this.dispatchEvent(new Event('error')) return // 2. Otherwise, if res is a network error, then reestablish the // connection, unless the user agent knows that to be futile, in // which case the user agent may fail the connection. } else { this.#reconnect() return } } // 3. Otherwise, if res's status is not 200, or if res's `Content-Type` // is not `text/event-stream`, then fail the connection. const contentType = response.headersList.get('content-type', true) const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' const contentTypeValid = mimeType !== 'failure' && mimeType.essence === 'text/event-stream' if ( response.status !== 200 || contentTypeValid === false ) { this.close() this.dispatchEvent(new Event('error')) return } // 4. Otherwise, announce the connection and interpret res's body // line by line. // When a user agent is to announce the connection, the user agent // must queue a task which, if the readyState attribute is set to a // value other than CLOSED, sets the readyState attribute to OPEN // and fires an event named open at the EventSource object. // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model this.#readyState = OPEN this.dispatchEvent(new Event('open')) // If redirected to a different origin, set the origin to the new origin. this.#settings.origin = response.urlList[response.urlList.length - 1].origin const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#settings, push: (event) => { this.dispatchEvent(new MessageEvent( event.type, event.options )) } }) pipeline(response.body.stream, eventSourceStream, (error) => { if ( error?.aborted === false ) { this.close() this.dispatchEvent(new Event('error')) } }) } this.#controller = fetching(fetchParam) } /** * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model * @returns {Promise} */ async #reconnect () { // When a user agent is to reestablish the connection, the user agent must // run the following steps. These steps are run in parallel, not as part of // a task. (The tasks that it queues, of course, are run like normal tasks // and not themselves in parallel.) // 1. Queue a task to run the following steps: // 1. If the readyState attribute is set to CLOSED, abort the task. if (this.#readyState === CLOSED) return // 2. Set the readyState attribute to CONNECTING. this.#readyState = CONNECTING // 3. Fire an event named error at the EventSource object. this.dispatchEvent(new Event('error')) // 2. Wait a delay equal to the reconnection time of the event source. await delay(this.#settings.reconnectionTime) // 5. Queue a task to run the following steps: // 1. If the EventSource object's readyState attribute is not set to // CONNECTING, then return. if (this.#readyState !== CONNECTING) return // 2. Let request be the EventSource object's request. // 3. If the EventSource object's last event ID string is not the empty // string, then: // 1. Let lastEventIDValue be the EventSource object's last event ID // string, encoded as UTF-8. // 2. Set (`Last-Event-ID`, lastEventIDValue) in request's header // list. if (this.#settings.lastEventId !== '') { this.#request.headersList.set('last-event-id', this.#settings.lastEventId, true) } // 4. Fetch request and process the response obtained in this fashion, if any, as described earlier in this section. this.#connect() } /** * Closes the connection, if any, and sets the readyState attribute to * CLOSED. */ close () { webidl.brandCheck(this, EventSource) if (this.#readyState === CLOSED) return this.#readyState = CLOSED clearTimeout(this.#settings.reconnectionTimer) this.#controller.abort() if (this.#request) { this.#request = null } } get onopen () { return this.#events.open } set onopen (fn) { if (this.#events.open) { this.removeEventListener('open', this.#events.open) } if (typeof fn === 'function') { this.#events.open = fn this.addEventListener('open', fn) } else { this.#events.open = null } } get onmessage () { return this.#events.message } set onmessage (fn) { if (this.#events.message) { this.removeEventListener('message', this.#events.message) } if (typeof fn === 'function') { this.#events.message = fn this.addEventListener('message', fn) } else { this.#events.message = null } } get onerror () { return this.#events.error } set onerror (fn) { if (this.#events.error) { this.removeEventListener('error', this.#events.error) } if (typeof fn === 'function') { this.#events.error = fn this.addEventListener('error', fn) } else { this.#events.error = null } } } const constantsPropertyDescriptors = { CONNECTING: { __proto__: null, configurable: false, enumerable: true, value: CONNECTING, writable: false }, OPEN: { __proto__: null, configurable: false, enumerable: true, value: OPEN, writable: false }, CLOSED: { __proto__: null, configurable: false, enumerable: true, value: CLOSED, writable: false } } Object.defineProperties(EventSource, constantsPropertyDescriptors) Object.defineProperties(EventSource.prototype, constantsPropertyDescriptors) Object.defineProperties(EventSource.prototype, { close: kEnumerableProperty, onerror: kEnumerableProperty, onmessage: kEnumerableProperty, onopen: kEnumerableProperty, readyState: kEnumerableProperty, url: kEnumerableProperty, withCredentials: kEnumerableProperty }) webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ { key: 'withCredentials', converter: webidl.converters.boolean, defaultValue: false } ]) module.exports = { EventSource, defaultReconnectionTime }