481 lines
14 KiB
JavaScript
481 lines
14 KiB
JavaScript
'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<void>}
|
|
*/
|
|
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
|
|
}
|