eventsource.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. 'use strict'
  2. const { pipeline } = require('node:stream')
  3. const { fetching } = require('../fetch')
  4. const { makeRequest } = require('../fetch/request')
  5. const { webidl } = require('../fetch/webidl')
  6. const { EventSourceStream } = require('./eventsource-stream')
  7. const { parseMIMEType } = require('../fetch/data-url')
  8. const { createFastMessageEvent } = require('../websocket/events')
  9. const { isNetworkError } = require('../fetch/response')
  10. const { delay } = require('./util')
  11. const { kEnumerableProperty } = require('../../core/util')
  12. const { environmentSettingsObject } = require('../fetch/util')
  13. let experimentalWarned = false
  14. /**
  15. * A reconnection time, in milliseconds. This must initially be an implementation-defined value,
  16. * probably in the region of a few seconds.
  17. *
  18. * In Comparison:
  19. * - Chrome uses 3000ms.
  20. * - Deno uses 5000ms.
  21. *
  22. * @type {3000}
  23. */
  24. const defaultReconnectionTime = 3000
  25. /**
  26. * The readyState attribute represents the state of the connection.
  27. * @enum
  28. * @readonly
  29. * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#dom-eventsource-readystate-dev
  30. */
  31. /**
  32. * The connection has not yet been established, or it was closed and the user
  33. * agent is reconnecting.
  34. * @type {0}
  35. */
  36. const CONNECTING = 0
  37. /**
  38. * The user agent has an open connection and is dispatching events as it
  39. * receives them.
  40. * @type {1}
  41. */
  42. const OPEN = 1
  43. /**
  44. * The connection is not open, and the user agent is not trying to reconnect.
  45. * @type {2}
  46. */
  47. const CLOSED = 2
  48. /**
  49. * Requests for the element will have their mode set to "cors" and their credentials mode set to "same-origin".
  50. * @type {'anonymous'}
  51. */
  52. const ANONYMOUS = 'anonymous'
  53. /**
  54. * Requests for the element will have their mode set to "cors" and their credentials mode set to "include".
  55. * @type {'use-credentials'}
  56. */
  57. const USE_CREDENTIALS = 'use-credentials'
  58. /**
  59. * The EventSource interface is used to receive server-sent events. It
  60. * connects to a server over HTTP and receives events in text/event-stream
  61. * format without closing the connection.
  62. * @extends {EventTarget}
  63. * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
  64. * @api public
  65. */
  66. class EventSource extends EventTarget {
  67. #events = {
  68. open: null,
  69. error: null,
  70. message: null
  71. }
  72. #url = null
  73. #withCredentials = false
  74. #readyState = CONNECTING
  75. #request = null
  76. #controller = null
  77. #dispatcher
  78. /**
  79. * @type {import('./eventsource-stream').eventSourceSettings}
  80. */
  81. #state
  82. /**
  83. * Creates a new EventSource object.
  84. * @param {string} url
  85. * @param {EventSourceInit} [eventSourceInitDict]
  86. * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface
  87. */
  88. constructor (url, eventSourceInitDict = {}) {
  89. // 1. Let ev be a new EventSource object.
  90. super()
  91. webidl.util.markAsUncloneable(this)
  92. const prefix = 'EventSource constructor'
  93. webidl.argumentLengthCheck(arguments, 1, prefix)
  94. if (!experimentalWarned) {
  95. experimentalWarned = true
  96. process.emitWarning('EventSource is experimental, expect them to change at any time.', {
  97. code: 'UNDICI-ES'
  98. })
  99. }
  100. url = webidl.converters.USVString(url, prefix, 'url')
  101. eventSourceInitDict = webidl.converters.EventSourceInitDict(eventSourceInitDict, prefix, 'eventSourceInitDict')
  102. this.#dispatcher = eventSourceInitDict.dispatcher
  103. this.#state = {
  104. lastEventId: '',
  105. reconnectionTime: defaultReconnectionTime
  106. }
  107. // 2. Let settings be ev's relevant settings object.
  108. // https://html.spec.whatwg.org/multipage/webappapis.html#environment-settings-object
  109. const settings = environmentSettingsObject
  110. let urlRecord
  111. try {
  112. // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings.
  113. urlRecord = new URL(url, settings.settingsObject.baseUrl)
  114. this.#state.origin = urlRecord.origin
  115. } catch (e) {
  116. // 4. If urlRecord is failure, then throw a "SyntaxError" DOMException.
  117. throw new DOMException(e, 'SyntaxError')
  118. }
  119. // 5. Set ev's url to urlRecord.
  120. this.#url = urlRecord.href
  121. // 6. Let corsAttributeState be Anonymous.
  122. let corsAttributeState = ANONYMOUS
  123. // 7. If the value of eventSourceInitDict's withCredentials member is true,
  124. // then set corsAttributeState to Use Credentials and set ev's
  125. // withCredentials attribute to true.
  126. if (eventSourceInitDict.withCredentials) {
  127. corsAttributeState = USE_CREDENTIALS
  128. this.#withCredentials = true
  129. }
  130. // 8. Let request be the result of creating a potential-CORS request given
  131. // urlRecord, the empty string, and corsAttributeState.
  132. const initRequest = {
  133. redirect: 'follow',
  134. keepalive: true,
  135. // @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes
  136. mode: 'cors',
  137. credentials: corsAttributeState === 'anonymous'
  138. ? 'same-origin'
  139. : 'omit',
  140. referrer: 'no-referrer'
  141. }
  142. // 9. Set request's client to settings.
  143. initRequest.client = environmentSettingsObject.settingsObject
  144. // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list.
  145. initRequest.headersList = [['accept', { name: 'accept', value: 'text/event-stream' }]]
  146. // 11. Set request's cache mode to "no-store".
  147. initRequest.cache = 'no-store'
  148. // 12. Set request's initiator type to "other".
  149. initRequest.initiator = 'other'
  150. initRequest.urlList = [new URL(this.#url)]
  151. // 13. Set ev's request to request.
  152. this.#request = makeRequest(initRequest)
  153. this.#connect()
  154. }
  155. /**
  156. * Returns the state of this EventSource object's connection. It can have the
  157. * values described below.
  158. * @returns {0|1|2}
  159. * @readonly
  160. */
  161. get readyState () {
  162. return this.#readyState
  163. }
  164. /**
  165. * Returns the URL providing the event stream.
  166. * @readonly
  167. * @returns {string}
  168. */
  169. get url () {
  170. return this.#url
  171. }
  172. /**
  173. * Returns a boolean indicating whether the EventSource object was
  174. * instantiated with CORS credentials set (true), or not (false, the default).
  175. */
  176. get withCredentials () {
  177. return this.#withCredentials
  178. }
  179. #connect () {
  180. if (this.#readyState === CLOSED) return
  181. this.#readyState = CONNECTING
  182. const fetchParams = {
  183. request: this.#request,
  184. dispatcher: this.#dispatcher
  185. }
  186. // 14. Let processEventSourceEndOfBody given response res be the following step: if res is not a network error, then reestablish the connection.
  187. const processEventSourceEndOfBody = (response) => {
  188. if (isNetworkError(response)) {
  189. this.dispatchEvent(new Event('error'))
  190. this.close()
  191. }
  192. this.#reconnect()
  193. }
  194. // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody...
  195. fetchParams.processResponseEndOfBody = processEventSourceEndOfBody
  196. // and processResponse set to the following steps given response res:
  197. fetchParams.processResponse = (response) => {
  198. // 1. If res is an aborted network error, then fail the connection.
  199. if (isNetworkError(response)) {
  200. // 1. When a user agent is to fail the connection, the user agent
  201. // must queue a task which, if the readyState attribute is set to a
  202. // value other than CLOSED, sets the readyState attribute to CLOSED
  203. // and fires an event named error at the EventSource object. Once the
  204. // user agent has failed the connection, it does not attempt to
  205. // reconnect.
  206. if (response.aborted) {
  207. this.close()
  208. this.dispatchEvent(new Event('error'))
  209. return
  210. // 2. Otherwise, if res is a network error, then reestablish the
  211. // connection, unless the user agent knows that to be futile, in
  212. // which case the user agent may fail the connection.
  213. } else {
  214. this.#reconnect()
  215. return
  216. }
  217. }
  218. // 3. Otherwise, if res's status is not 200, or if res's `Content-Type`
  219. // is not `text/event-stream`, then fail the connection.
  220. const contentType = response.headersList.get('content-type', true)
  221. const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure'
  222. const contentTypeValid = mimeType !== 'failure' && mimeType.essence === 'text/event-stream'
  223. if (
  224. response.status !== 200 ||
  225. contentTypeValid === false
  226. ) {
  227. this.close()
  228. this.dispatchEvent(new Event('error'))
  229. return
  230. }
  231. // 4. Otherwise, announce the connection and interpret res's body
  232. // line by line.
  233. // When a user agent is to announce the connection, the user agent
  234. // must queue a task which, if the readyState attribute is set to a
  235. // value other than CLOSED, sets the readyState attribute to OPEN
  236. // and fires an event named open at the EventSource object.
  237. // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
  238. this.#readyState = OPEN
  239. this.dispatchEvent(new Event('open'))
  240. // If redirected to a different origin, set the origin to the new origin.
  241. this.#state.origin = response.urlList[response.urlList.length - 1].origin
  242. const eventSourceStream = new EventSourceStream({
  243. eventSourceSettings: this.#state,
  244. push: (event) => {
  245. this.dispatchEvent(createFastMessageEvent(
  246. event.type,
  247. event.options
  248. ))
  249. }
  250. })
  251. pipeline(response.body.stream,
  252. eventSourceStream,
  253. (error) => {
  254. if (
  255. error?.aborted === false
  256. ) {
  257. this.close()
  258. this.dispatchEvent(new Event('error'))
  259. }
  260. })
  261. }
  262. this.#controller = fetching(fetchParams)
  263. }
  264. /**
  265. * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
  266. * @returns {Promise<void>}
  267. */
  268. async #reconnect () {
  269. // When a user agent is to reestablish the connection, the user agent must
  270. // run the following steps. These steps are run in parallel, not as part of
  271. // a task. (The tasks that it queues, of course, are run like normal tasks
  272. // and not themselves in parallel.)
  273. // 1. Queue a task to run the following steps:
  274. // 1. If the readyState attribute is set to CLOSED, abort the task.
  275. if (this.#readyState === CLOSED) return
  276. // 2. Set the readyState attribute to CONNECTING.
  277. this.#readyState = CONNECTING
  278. // 3. Fire an event named error at the EventSource object.
  279. this.dispatchEvent(new Event('error'))
  280. // 2. Wait a delay equal to the reconnection time of the event source.
  281. await delay(this.#state.reconnectionTime)
  282. // 5. Queue a task to run the following steps:
  283. // 1. If the EventSource object's readyState attribute is not set to
  284. // CONNECTING, then return.
  285. if (this.#readyState !== CONNECTING) return
  286. // 2. Let request be the EventSource object's request.
  287. // 3. If the EventSource object's last event ID string is not the empty
  288. // string, then:
  289. // 1. Let lastEventIDValue be the EventSource object's last event ID
  290. // string, encoded as UTF-8.
  291. // 2. Set (`Last-Event-ID`, lastEventIDValue) in request's header
  292. // list.
  293. if (this.#state.lastEventId.length) {
  294. this.#request.headersList.set('last-event-id', this.#state.lastEventId, true)
  295. }
  296. // 4. Fetch request and process the response obtained in this fashion, if any, as described earlier in this section.
  297. this.#connect()
  298. }
  299. /**
  300. * Closes the connection, if any, and sets the readyState attribute to
  301. * CLOSED.
  302. */
  303. close () {
  304. webidl.brandCheck(this, EventSource)
  305. if (this.#readyState === CLOSED) return
  306. this.#readyState = CLOSED
  307. this.#controller.abort()
  308. this.#request = null
  309. }
  310. get onopen () {
  311. return this.#events.open
  312. }
  313. set onopen (fn) {
  314. if (this.#events.open) {
  315. this.removeEventListener('open', this.#events.open)
  316. }
  317. if (typeof fn === 'function') {
  318. this.#events.open = fn
  319. this.addEventListener('open', fn)
  320. } else {
  321. this.#events.open = null
  322. }
  323. }
  324. get onmessage () {
  325. return this.#events.message
  326. }
  327. set onmessage (fn) {
  328. if (this.#events.message) {
  329. this.removeEventListener('message', this.#events.message)
  330. }
  331. if (typeof fn === 'function') {
  332. this.#events.message = fn
  333. this.addEventListener('message', fn)
  334. } else {
  335. this.#events.message = null
  336. }
  337. }
  338. get onerror () {
  339. return this.#events.error
  340. }
  341. set onerror (fn) {
  342. if (this.#events.error) {
  343. this.removeEventListener('error', this.#events.error)
  344. }
  345. if (typeof fn === 'function') {
  346. this.#events.error = fn
  347. this.addEventListener('error', fn)
  348. } else {
  349. this.#events.error = null
  350. }
  351. }
  352. }
  353. const constantsPropertyDescriptors = {
  354. CONNECTING: {
  355. __proto__: null,
  356. configurable: false,
  357. enumerable: true,
  358. value: CONNECTING,
  359. writable: false
  360. },
  361. OPEN: {
  362. __proto__: null,
  363. configurable: false,
  364. enumerable: true,
  365. value: OPEN,
  366. writable: false
  367. },
  368. CLOSED: {
  369. __proto__: null,
  370. configurable: false,
  371. enumerable: true,
  372. value: CLOSED,
  373. writable: false
  374. }
  375. }
  376. Object.defineProperties(EventSource, constantsPropertyDescriptors)
  377. Object.defineProperties(EventSource.prototype, constantsPropertyDescriptors)
  378. Object.defineProperties(EventSource.prototype, {
  379. close: kEnumerableProperty,
  380. onerror: kEnumerableProperty,
  381. onmessage: kEnumerableProperty,
  382. onopen: kEnumerableProperty,
  383. readyState: kEnumerableProperty,
  384. url: kEnumerableProperty,
  385. withCredentials: kEnumerableProperty
  386. })
  387. webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([
  388. {
  389. key: 'withCredentials',
  390. converter: webidl.converters.boolean,
  391. defaultValue: () => false
  392. },
  393. {
  394. key: 'dispatcher', // undici only
  395. converter: webidl.converters.any
  396. }
  397. ])
  398. module.exports = {
  399. EventSource,
  400. defaultReconnectionTime
  401. }