From bace6e2ae0841447f2a469c46ddfb491f537c675 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 11 Jan 2024 04:10:10 +0100 Subject: [PATCH 01/60] feat: implement eventsource --- lib/eventsource/constants.js | 78 +++++++ lib/eventsource/eventsource-stream.js | 225 ++++++++++++++++++ lib/eventsource/eventsource.js | 312 +++++++++++++++++++++++++ lib/eventsource/symbols.js | 6 + lib/eventsource/util.js | 30 +++ package.json | 1 + test/eventsource/eventsource-stream.js | 301 ++++++++++++++++++++++++ test/eventsource/eventsource.js | 164 +++++++++++++ test/eventsource/util.js | 20 ++ 9 files changed, 1137 insertions(+) create mode 100644 lib/eventsource/constants.js create mode 100644 lib/eventsource/eventsource-stream.js create mode 100644 lib/eventsource/eventsource.js create mode 100644 lib/eventsource/symbols.js create mode 100644 lib/eventsource/util.js create mode 100644 test/eventsource/eventsource-stream.js create mode 100644 test/eventsource/eventsource.js create mode 100644 test/eventsource/util.js diff --git a/lib/eventsource/constants.js b/lib/eventsource/constants.js new file mode 100644 index 00000000000..2f00448299b --- /dev/null +++ b/lib/eventsource/constants.js @@ -0,0 +1,78 @@ +'use strict' + +/** + * The event stream format's MIME type is text/event-stream. + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream + */ +const mimeType = 'text/event-stream' + +/** + * 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. + */ +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 + +/** + * @type {number[]} BOM + */ +const BOM = [0xEF, 0xBB, 0xBF] +/** + * @type {10} LF + */ +const LF = 0x0A +/** + * @type {13} CR + */ +const CR = 0x0D +/** + * @type {58} COLON + */ +const COLON = 0x3A +/** + * @type {32} SPACE + */ +const SPACE = 0x20 + +module.exports = { + mimeType, + defaultReconnectionTime, + CONNECTING, + OPEN, + CLOSED, + BOM, + LF, + CR, + COLON, + SPACE +} diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js new file mode 100644 index 00000000000..2c846524d72 --- /dev/null +++ b/lib/eventsource/eventsource-stream.js @@ -0,0 +1,225 @@ +'use strict' +const { Transform } = require('node:stream') +const { MessageEvent } = require('../websocket/events') +const { BOM, CR, LF, COLON, SPACE } = require('./constants') +const { isASCIINumber, isValidLastEventId } = require('./util') + +/** + * @typedef {object} EventSourceStreamEvent + * @type {object} + * @property {string} [event] The event type. + * @property {string} [data] The data of the message. + * @property {string} [id] A unique ID for the event. + * @property {string} [retry] The reconnection time, in milliseconds. + */ + +/** + * @typedef EventSourceState + * @type {object} + * @property {string} lastEventId The last event ID received from the server. + * @property {string} origin The origin of the event source. + * @property {number} reconnectionTime The reconnection time, in milliseconds. + */ + +class EventSourceStream extends Transform { + /** + * @type {EventSourceState} + */ + state = null + + /** + * Leading byte-order-mark check. + * @type {boolean} + */ + checkBOM = true + + /** + * @type {boolean} + */ + crlfCheck = false + + /** + * @type {boolean} + */ + eventEndCheck = false + + /** + * @type {Buffer} + */ + buffer = null + + pos = 0 + + event = { + data: undefined, + event: undefined, + id: undefined, + retry: undefined + } + + /** + * @param {object} options + * @param {EventSourceState} options.eventSourceState + * @param {Function} [options.push] + */ + constructor (options = {}) { + options.readableObjectMode = true + super(options) + this.state = options.eventSourceState + if (options.push) { + this.push = options.push + } + } + + /** + * @param {Buffer} chunk + * @param {string} _encoding + * @param {Function} callback + * @returns {void} + */ + _transform (chunk, _encoding, callback) { + if (chunk.length === 0) { + callback() + return + } + this.buffer = this.buffer ? Buffer.concat([this.buffer, chunk]) : chunk + + // Strip leading byte-order-mark if any + if (this.checkBOM) { + switch (this.buffer.length) { + case 1: + if (this.buffer[0] === BOM[0]) { + callback() + return + } + this.checkBOM = false + break + case 2: + if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { + callback() + return + } + this.checkBOM = false + break + case 3: + if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { + this.buffer = this.buffer.slice(3) + this.checkBOM = false + callback() + return + } + this.checkBOM = false + break + default: + if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { + this.buffer = this.buffer.slice(3) + } + this.checkBOM = false + break + } + } + + while (this.pos < this.buffer.length) { + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { + if (this.eventEndCheck) { + this.eventEndCheck = false + this.processEvent(this.event) + this.event = { + data: undefined, + event: undefined, + id: undefined, + retry: undefined + } + this.buffer = this.buffer.slice(1) + continue + } + if (this.buffer[0] === COLON) { + this.buffer = this.buffer.slice(1) + continue + } + this.parseLine(this.buffer.slice(0, this.pos), this.event) + + // Remove the processed line from the buffer + this.buffer = this.buffer.slice(this.pos + 1) + // Reset the position + this.pos = 0 + this.eventEndCheck = true + continue + } + this.pos++ + } + + callback() + } + + /** + * @param {Buffer} line + * @param {EventSourceStreamEvent} event + */ + parseLine (line, event) { + if (line.length === 0) { + return + } + const fieldNameEnd = line.indexOf(COLON) + let fieldValueStart + + if (fieldNameEnd === -1) { + return + // fieldNameEnd = line.length; + // fieldValueStart = line.length; + } + fieldValueStart = fieldNameEnd + 1 + if (line[fieldValueStart] === SPACE) { + fieldValueStart += 1 + } + + const fieldValueSize = line.length - fieldValueStart + const fieldName = line.slice(0, fieldNameEnd).toString('utf8') + switch (fieldName) { + case 'data': + event.data = line.slice(fieldValueStart, fieldValueStart + fieldValueSize).toString('utf8') + break + case 'event': + event.event = line.slice(fieldValueStart, fieldValueStart + fieldValueSize).toString('utf8') + break + case 'id': + event.id = line.slice(fieldValueStart, fieldValueStart + fieldValueSize).toString('utf8') + break + case 'retry': + event.retry = line.slice(fieldValueStart, fieldValueStart + fieldValueSize).toString('utf8') + break + } + } + + /** + * @param {EventSourceStreamEvent} event + */ + processEvent (event) { + if (event.retry) { + if (isASCIINumber(event.retry)) { + this.state.reconnectionTime = parseInt(event.retry, 10) + } + } + const { + id, + data = null, + event: type = 'message' + } = event + + if (id && isValidLastEventId(id)) { + this.state.lastEventId = id + } + + this.push( + new MessageEvent(type, { + data, + lastEventId: this.state.lastEventId, + origin: this.state.origin + }) + ) + } +} + +module.exports = { + EventSourceStream +} diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js new file mode 100644 index 00000000000..dbefa536d4c --- /dev/null +++ b/lib/eventsource/eventsource.js @@ -0,0 +1,312 @@ +'use strict' + +const { pipeline } = require('node:stream') +const { + kEvents, + kState +} = require('./symbols') +const { webidl } = require('../fetch/webidl') +const { CONNECTING, OPEN, CLOSED, mimeType, defaultReconnectionTime } = require('./constants') +const { EventSourceStream } = require('./eventsource-stream') + +/** + * @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://developer.mozilla.org/en-US/docs/Web/API/EventSource + * @api public + */ +class EventSource extends EventTarget { + #url = null + #withCredentials = false + #readyState = CONNECTING + #lastEventId = '' + #connection = null + #reconnectionTimer = null + #controller = new AbortController() + + /** + * Creates a new EventSource object. + * @param {string} url + * @param {EventSourceInit} [eventSourceInitDict] + */ + constructor (url, eventSourceInitDict) { + super() + + if (arguments.length === 0) { + throw new TypeError('url') + } + + this[kState] = { + lastEventId: '', + origin: '', + reconnectionTime: defaultReconnectionTime + } + + this[kEvents] = { + message: null, + error: null, + open: null + } + + this.#url = `${url}` + this[kState].origin = new URL(this.#url).origin + + if (eventSourceInitDict) { + if (eventSourceInitDict.withCredentials) { + this.#withCredentials = eventSourceInitDict.withCredentials + } + } + + 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 + } + + async #connect () { + this.#readyState = CONNECTING + this.#connection = null + + /** + * @type {RequestInit} + */ + const options = { + method: 'GET', + redirect: 'manual', + keepalive: true, + headers: { + Accept: mimeType, + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }, + signal: this.#controller.signal + } + + if (this.#lastEventId) { + options.headers['Last-Event-ID'] = this.#lastEventId + } + + options.credentials = this.#withCredentials ? 'include' : 'omit' + + try { + this.#connection = await fetch(this.#url, options) + + // Handle HTTP redirects + // https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events-intro + switch (this.#connection.status) { + // Redirecting status codes + case 301: // 301 Moved Permanently + case 302: // 302 Found + case 307: // 307 Temporary Redirect + case 308: // 308 Permanent Redirect + if (!this.#connection.headers.has('Location')) { + this.close() + this.dispatchEvent(new Event('error')) + return + } + this.#url = new URL(this.#connection.headers.get('Location'), new URL(this.#url).origin).href + this[kState].origin = new URL(this.#url).origin + this.#connect() + return + case 204: // 204 No Content + // Clients will reconnect if the connection is closed; a client can be told to stop reconnecting + // using the HTTP 204 No Content response code. + this.close() + this.dispatchEvent(new Event('error')) + return + case 200: + if (this.#connection.headers.get('Content-Type') !== mimeType) { + this.close() + this.dispatchEvent(new Event('error')) + return + } + break + default: + this.close() + this.dispatchEvent(new Event('error')) + return + } + + if (this.#connection === null) { + this.close() + this.dispatchEvent(new Event('error')) + return + } + + const self = this + + pipeline(this.#connection.body, + new EventSourceStream({ + eventSourceState: this[kState], + push: function push (chunk) { + self.dispatchEvent(chunk) + } + }), + (err) => { + if (err) { + this.dispatchEvent(new Event('error')) + this.close() + } + }) + + this.dispatchEvent(new Event('open')) + this.#readyState = OPEN + } catch (error) { + if (error.name === 'AbortError') { + return + } + this.dispatchEvent(new Event('error')) + + // Always set to CONNECTING as the readyState could be OPEN + this.#readyState = CONNECTING + this.#connection = null + + this.#reconnectionTimer = setTimeout(() => { + this.#connect() + }, this[kState].reconnectionTime) + } + } + + /** + * Closes the connection, if any, and sets the readyState attribute to + * CLOSED. + */ + close () { + webidl.brandCheck(this, EventSource) + + if (this.#readyState === CLOSED) return + clearTimeout(this.#reconnectionTimer) + this.#controller.abort() + if (this.#connection) { + this.#connection = null + } + this.#readyState = CLOSED + } + + get onopen () { + webidl.brandCheck(this, EventSource) + + return this[kEvents].open + } + + set onopen (fn) { + webidl.brandCheck(this, EventSource) + + if (this[kEvents].open) { + this.removeEventListener('open', this[kEvents].open) + } + + if (typeof fn === 'function') { + this[kEvents].open = fn + this.addEventListener('open', fn) + } else { + this[kEvents].open = null + } + } + + get onmessage () { + webidl.brandCheck(this, EventSource) + + return this[kEvents].message + } + + set onmessage (fn) { + webidl.brandCheck(this, EventSource) + + if (this[kEvents].message) { + this.removeEventListener('message', this[kEvents].message) + } + + if (typeof fn === 'function') { + this[kEvents].message = fn + this.addEventListener('message', fn) + } else { + this[kEvents].message = null + } + } + + get onerror () { + webidl.brandCheck(this, EventSource) + + return this[kEvents].error + } + + set onerror (fn) { + webidl.brandCheck(this, EventSource) + + if (this[kEvents].error) { + this.removeEventListener('error', this[kEvents].error) + } + + if (typeof fn === 'function') { + this[kEvents].error = fn + this.addEventListener('error', fn) + } else { + this[kEvents].error = null + } + } +} + +Object.defineProperties(EventSource, { + 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 + } +}) + +EventSource.prototype.CONNECTING = CONNECTING +EventSource.prototype.OPEN = OPEN +EventSource.prototype.CLOSED = CLOSED + +module.exports = { + EventSource +} diff --git a/lib/eventsource/symbols.js b/lib/eventsource/symbols.js new file mode 100644 index 00000000000..6dbccaf106e --- /dev/null +++ b/lib/eventsource/symbols.js @@ -0,0 +1,6 @@ +'use strict' + +module.exports = { + kEvents: Symbol('kEvents'), + kState: Symbol('kState') +} diff --git a/lib/eventsource/util.js b/lib/eventsource/util.js new file mode 100644 index 00000000000..85d9a46b4c6 --- /dev/null +++ b/lib/eventsource/util.js @@ -0,0 +1,30 @@ +'use strict' + +/** + * Checks if the given value is a valid LastEventId. + * @param {string} value + * @returns {boolean} + */ +function isValidLastEventId (value) { + // LastEventId should not contain U+0000 NULL + return ( + typeof value === 'string' && (value.indexOf('\u0000') === -1) + ) +} + +/** + * Checks if the given value is a base 10 digit. + * @param {string} value + * @returns {boolean} + */ +function isASCIINumber (value) { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) < 0x30 || value.charCodeAt(i) > 0x39) return false + } + return true +} + +module.exports = { + isValidLastEventId, + isASCIINumber +} diff --git a/package.json b/package.json index 43d11d25389..6f9653d9bfc 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "test": "node scripts/generate-pem && npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:wpt && npm run test:websocket && npm run test:jest && npm run test:typescript && npm run test:node-test", "test:cookies": "borp --coverage -p \"test/cookie/*.js\"", "test:node-fetch": "mocha --exit test/node-fetch", + "test:eventsource": "npm run build:node && borp --expose-gc --coverage -p \"test/eventsource/*.js\"", "test:fetch": "npm run build:node && borp --expose-gc --coverage -p \"test/fetch/*.js\" && borp --coverage -p \"test/webidl/*.js\"", "test:jest": "jest", "test:tap": "tap test/*.js", diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js new file mode 100644 index 00000000000..0dd50ff51ac --- /dev/null +++ b/test/eventsource/eventsource-stream.js @@ -0,0 +1,301 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') +const { MessageEvent } = require('../../lib/websocket/events') + +describe('EventSourceStream - processEvent', () => { + const defaultEventSourceState = { + origin: 'example.com', + reconnectionTime: 1000 + } + + test('Should set the defined origin as the origin of the MessageEvent', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.on('data', (event) => { + assert.strictEqual(event instanceof MessageEvent, true) + assert.strictEqual(event.data, null) + assert.strictEqual(event.lastEventId, '') + assert.strictEqual(event.type, 'message') + assert.strictEqual(stream.state.reconnectionTime, 1000) + assert.strictEqual(event.origin, 'example.com') + }) + + stream.processEvent({}) + }) + + test('Should set reconnectionTime to 4000 if event contains retry field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.processEvent({ + retry: '4000' + }) + + assert.strictEqual(stream.state.reconnectionTime, 4000) + }) + + test('Dispatches a MessageEvent with data', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.on('data', (event) => { + assert.strictEqual(event instanceof MessageEvent, true) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.lastEventId, '') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({ + data: 'Hello' + }) + }) + + test('Dispatches a MessageEvent with lastEventId, when event contains id field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.on('data', (event) => { + assert.strictEqual(event instanceof MessageEvent, true) + assert.strictEqual(event.data, null) + assert.strictEqual(event.lastEventId, '1234') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({ + id: '1234' + }) + }) + + test('Dispatches a MessageEvent with lastEventId, reusing the persisted', () => { + // lastEventId + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState, + lastEventId: '1234' + } + }) + + stream.on('data', (event) => { + assert.strictEqual(event instanceof MessageEvent, true) + assert.strictEqual(event.data, null) + assert.strictEqual(event.lastEventId, '1234') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({}) + }) + + test('Dispatches a MessageEvent with type custom, when event contains type field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.on('data', (event) => { + assert.strictEqual(event instanceof MessageEvent, true) + assert.strictEqual(event.data, null) + assert.strictEqual(event.lastEventId, '') + assert.strictEqual(event.type, 'custom') + assert.strictEqual(event.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({ + event: 'custom' + }) + }) +}) + +describe('EventSourceStream - parseLine', () => { + const defaultEventSourceState = { + origin: 'example.com', + reconnectionTime: 1000 + } + + test('Should set the data field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: Hello', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set retry field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('retry: 1000', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, '1000') + }) + + test('Should set id field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('id: 1234', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, '1234') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set id field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('event: custom', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, 'custom') + assert.strictEqual(event.retry, undefined) + }) +}) + +describe('EventSourceStream', () => { + test('Remove BOM from the beginning of the stream.', () => { + const content = Buffer.from('\uFEFFdata: Hello\n\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Simple event with data field.', () => { + const content = Buffer.from('data: Hello\n\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should ignore comments', () => { + const content = Buffer.from(':data: Hello\n\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.fail('Should not be called') + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should fire two events.', () => { + // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + const content = Buffer.from('data\n\ndata\ndata\n\ndata:', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should fire two identical events.', () => { + // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + const content = Buffer.from('data:test\n\ndata: test\n\n', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'test') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) +}) diff --git a/test/eventsource/eventsource.js b/test/eventsource/eventsource.js new file mode 100644 index 00000000000..630aba987cf --- /dev/null +++ b/test/eventsource/eventsource.js @@ -0,0 +1,164 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - constructor', () => { + test('Not providing url argument should throw', () => { + assert.throws(() => new EventSource(), TypeError) + }) +}) + +describe('EventSource - eventhandler idl', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'dummy') + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + let done = 0 + const eventhandlerIdl = ['onmessage', 'onerror', 'onopen'] + + eventhandlerIdl.forEach((type) => { + test(`Should properly configure the ${type} eventhandler idl`, () => { + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + + // Eventsource eventhandler idl is by default null, + assert.strictEqual(eventSourceInstance[type], null) + + // The eventhandler idl is by default not enumerable. + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(eventSourceInstance, type), false) + + // The eventhandler idl ignores non-functions. + eventSourceInstance[type] = 7 + assert.strictEqual(EventSource[type], undefined) + + // The eventhandler idl accepts functions. + function fn () { } + eventSourceInstance[type] = fn + assert.strictEqual(eventSourceInstance[type], fn) + + eventSourceInstance.close() + done++ + + if (done === eventhandlerIdl.length) server.close() + }) + }) +}) + +describe('EventSource - constants', () => { + [ + ['CONNECTING', 0], + ['OPEN', 1], + ['CLOSED', 2] + ].forEach((config) => { + test(`Should expose the ${config[0]} constant`, () => { + const [constant, value] = config + + // EventSource exposes the constant. + assert.strictEqual(Object.hasOwn(EventSource, constant), true) + + // The value is properly set. + assert.strictEqual(EventSource[constant], value) + + // The constant is enumerable. + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(EventSource, constant), true) + + // The constant is not writable. + try { + EventSource[constant] = 666 + } catch (e) { + assert.strictEqual(e instanceof TypeError, true) + } + // The constant is not configurable. + try { + delete EventSource[constant] + } catch (e) { + assert.strictEqual(e instanceof TypeError, true) + } + assert.strictEqual(EventSource[constant], value) + }) + }) +}) + +describe('EventSource - redirecting', () => { + [301, 302, 307, 308].forEach((statusCode) => { + test(`Should redirect on ${statusCode} status code`, async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(statusCode, undefined, { Location: '/target' }) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(200, 'dummy', { 'Content-Type': 'text/event-stream' }) + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) + eventSourceInstance.close() + server.close() + } + }) + }) +}) + +describe('EventSource - stop redirecting on 204 status code', async () => { + test('Stop trying to connect when getting a 204 response', async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(301, undefined, { Location: '/target' }) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(204, 'OK') + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = () => { + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) + assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) + server.close() + } + }) +}) + +describe('EventSource - Location header', () => { + test('Throw when missing a Location header', async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(301, undefined) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(204, 'OK') + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = () => { + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) + assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) + server.close() + } + }) +}) diff --git a/test/eventsource/util.js b/test/eventsource/util.js new file mode 100644 index 00000000000..5bec5609bab --- /dev/null +++ b/test/eventsource/util.js @@ -0,0 +1,20 @@ +'use strict' + +const assert = require('node:assert') +const { test } = require('node:test') +const { isASCIINumber, isValidLastEventId } = require('../../lib/eventsource/util') + +test('isValidLastEventId', () => { + assert.strictEqual(isValidLastEventId('valid'), true) + assert.strictEqual(isValidLastEventId('in\u0000valid'), false) + assert.strictEqual(isValidLastEventId('in\x00valid'), false) + + assert.strictEqual(isValidLastEventId(null), false) + assert.strictEqual(isValidLastEventId(undefined), false) + assert.strictEqual(isValidLastEventId(7), false) +}) + +test('isASCIINumber', () => { + assert.strictEqual(isASCIINumber('123'), true) + assert.strictEqual(isASCIINumber('123a'), false) +}) From cb1db294cc8df5e8c11298e1d5bc5c8ceb2b9900 Mon Sep 17 00:00:00 2001 From: Khafra Date: Wed, 10 Jan 2024 22:45:59 -0500 Subject: [PATCH 02/60] add wpts --- test/wpt/runner/worker.mjs | 8 ++- test/wpt/start-eventsource.mjs | 26 +++++++ test/wpt/status/eventsource.status.json | 1 + test/wpt/tests/eventsource/META.yml | 5 ++ test/wpt/tests/eventsource/README.md | 4 ++ .../dedicated-worker/eventsource-close.htm | 24 +++++++ .../dedicated-worker/eventsource-close.js | 9 +++ .../dedicated-worker/eventsource-close2.htm | 23 ++++++ .../dedicated-worker/eventsource-close2.js | 3 + .../eventsource-constructor-no-new.any.js | 7 ++ ...ventsource-constructor-non-same-origin.htm | 34 +++++++++ ...eventsource-constructor-non-same-origin.js | 10 +++ .../eventsource-constructor-url-bogus.js | 7 ++ .../eventsource-eventtarget.worker.js | 11 +++ .../dedicated-worker/eventsource-onmesage.js | 9 +++ .../eventsource-onmessage.htm | 24 +++++++ .../dedicated-worker/eventsource-onopen.htm | 27 +++++++ .../dedicated-worker/eventsource-onopen.js | 9 +++ .../eventsource-prototype.htm | 25 +++++++ .../dedicated-worker/eventsource-prototype.js | 8 +++ .../dedicated-worker/eventsource-url.htm | 25 +++++++ .../dedicated-worker/eventsource-url.js | 7 ++ test/wpt/tests/eventsource/event-data.any.js | 21 ++++++ .../eventsource/eventsource-close.window.js | 70 +++++++++++++++++++ ...urce-constructor-document-domain.window.js | 18 +++++ .../eventsource-constructor-empty-url.any.js | 6 ++ ...urce-constructor-non-same-origin.window.js | 21 ++++++ ...ventsource-constructor-stringify.window.js | 28 ++++++++ .../eventsource-constructor-url-bogus.any.js | 8 +++ ...entsource-constructor-url-multi-window.htm | 37 ++++++++++ .../eventsource-cross-origin.window.js | 51 ++++++++++++++ .../eventsource-eventtarget.any.js | 16 +++++ .../eventsource-onmessage-realm.htm | 25 +++++++ .../eventsource-onmessage-trusted.any.js | 12 ++++ .../eventsource/eventsource-onmessage.any.js | 14 ++++ .../eventsource/eventsource-onopen.any.js | 17 +++++ .../eventsource/eventsource-prototype.any.js | 10 +++ .../eventsource-reconnect.window.js | 47 +++++++++++++ ...tsource-request-cancellation.any.window.js | 21 ++++++ .../tests/eventsource/eventsource-url.any.js | 8 +++ .../wpt/tests/eventsource/format-bom-2.any.js | 24 +++++++ test/wpt/tests/eventsource/format-bom.any.js | 24 +++++++ .../tests/eventsource/format-comments.any.js | 16 +++++ ...format-data-before-final-empty-line.any.js | 17 +++++ .../eventsource/format-field-data.any.js | 23 ++++++ .../format-field-event-empty.any.js | 13 ++++ .../eventsource/format-field-event.any.js | 15 ++++ .../eventsource/format-field-id-2.any.js | 25 +++++++ .../eventsource/format-field-id-3.window.js | 56 +++++++++++++++ .../format-field-id-null.window.js | 25 +++++++ .../tests/eventsource/format-field-id.any.js | 21 ++++++ .../eventsource/format-field-parsing.any.js | 14 ++++ .../format-field-retry-bogus.any.js | 19 +++++ .../format-field-retry-empty.any.js | 13 ++++ .../eventsource/format-field-retry.any.js | 21 ++++++ .../eventsource/format-field-unknown.any.js | 13 ++++ .../eventsource/format-leading-space.any.js | 14 ++++ .../eventsource/format-mime-bogus.any.js | 25 +++++++ .../format-mime-trailing-semicolon.any.js | 20 ++++++ .../format-mime-valid-bogus.any.js | 24 +++++++ .../tests/eventsource/format-newlines.any.js | 13 ++++ .../eventsource/format-null-character.any.js | 17 +++++ .../wpt/tests/eventsource/format-utf-8.any.js | 12 ++++ .../tests/eventsource/request-accept.any.js | 13 ++++ .../eventsource/request-cache-control.any.js | 35 ++++++++++ .../request-credentials.any.window.js | 37 ++++++++++ .../request-redirect.any.window.js | 24 +++++++ .../request-status-error.window.js | 27 +++++++ .../eventsource/resources/accept.event_stream | 2 + .../resources/cache-control.event_stream | 2 + .../eventsource/resources/cors-cookie.py | 31 ++++++++ test/wpt/tests/eventsource/resources/cors.py | 36 ++++++++++ .../resources/eventsource-onmessage-realm.htm | 2 + test/wpt/tests/eventsource/resources/init.htm | 9 +++ .../eventsource/resources/last-event-id.py | 9 +++ .../eventsource/resources/last-event-id2.py | 23 ++++++ .../tests/eventsource/resources/message.py | 14 ++++ .../tests/eventsource/resources/message2.py | 33 +++++++++ .../eventsource/resources/reconnect-fail.py | 24 +++++++ .../eventsource/resources/status-error.py | 15 ++++ .../eventsource/resources/status-reconnect.py | 21 ++++++ .../shared-worker/eventsource-close.htm | 24 +++++++ .../shared-worker/eventsource-close.js | 12 ++++ ...ventsource-constructor-non-same-origin.htm | 34 +++++++++ ...eventsource-constructor-non-same-origin.js | 13 ++++ .../eventsource-constructor-url-bogus.js | 10 +++ .../shared-worker/eventsource-eventtarget.htm | 24 +++++++ .../shared-worker/eventsource-eventtarget.js | 13 ++++ .../shared-worker/eventsource-onmesage.js | 12 ++++ .../shared-worker/eventsource-onmessage.htm | 24 +++++++ .../shared-worker/eventsource-onopen.htm | 27 +++++++ .../shared-worker/eventsource-onopen.js | 12 ++++ .../shared-worker/eventsource-prototype.htm | 25 +++++++ .../shared-worker/eventsource-prototype.js | 11 +++ .../shared-worker/eventsource-url.htm | 25 +++++++ .../shared-worker/eventsource-url.js | 10 +++ 96 files changed, 1832 insertions(+), 1 deletion(-) create mode 100644 test/wpt/start-eventsource.mjs create mode 100644 test/wpt/status/eventsource.status.json create mode 100644 test/wpt/tests/eventsource/META.yml create mode 100644 test/wpt/tests/eventsource/README.md create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-close.htm create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-close.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-close2.htm create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-close2.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-no-new.any.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.htm create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-url-bogus.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-eventtarget.worker.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-onmesage.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-onmessage.htm create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-onopen.htm create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-onopen.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-prototype.htm create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-prototype.js create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-url.htm create mode 100644 test/wpt/tests/eventsource/dedicated-worker/eventsource-url.js create mode 100644 test/wpt/tests/eventsource/event-data.any.js create mode 100644 test/wpt/tests/eventsource/eventsource-close.window.js create mode 100644 test/wpt/tests/eventsource/eventsource-constructor-document-domain.window.js create mode 100644 test/wpt/tests/eventsource/eventsource-constructor-empty-url.any.js create mode 100644 test/wpt/tests/eventsource/eventsource-constructor-non-same-origin.window.js create mode 100644 test/wpt/tests/eventsource/eventsource-constructor-stringify.window.js create mode 100644 test/wpt/tests/eventsource/eventsource-constructor-url-bogus.any.js create mode 100644 test/wpt/tests/eventsource/eventsource-constructor-url-multi-window.htm create mode 100644 test/wpt/tests/eventsource/eventsource-cross-origin.window.js create mode 100644 test/wpt/tests/eventsource/eventsource-eventtarget.any.js create mode 100644 test/wpt/tests/eventsource/eventsource-onmessage-realm.htm create mode 100644 test/wpt/tests/eventsource/eventsource-onmessage-trusted.any.js create mode 100644 test/wpt/tests/eventsource/eventsource-onmessage.any.js create mode 100644 test/wpt/tests/eventsource/eventsource-onopen.any.js create mode 100644 test/wpt/tests/eventsource/eventsource-prototype.any.js create mode 100644 test/wpt/tests/eventsource/eventsource-reconnect.window.js create mode 100644 test/wpt/tests/eventsource/eventsource-request-cancellation.any.window.js create mode 100644 test/wpt/tests/eventsource/eventsource-url.any.js create mode 100644 test/wpt/tests/eventsource/format-bom-2.any.js create mode 100644 test/wpt/tests/eventsource/format-bom.any.js create mode 100644 test/wpt/tests/eventsource/format-comments.any.js create mode 100644 test/wpt/tests/eventsource/format-data-before-final-empty-line.any.js create mode 100644 test/wpt/tests/eventsource/format-field-data.any.js create mode 100644 test/wpt/tests/eventsource/format-field-event-empty.any.js create mode 100644 test/wpt/tests/eventsource/format-field-event.any.js create mode 100644 test/wpt/tests/eventsource/format-field-id-2.any.js create mode 100644 test/wpt/tests/eventsource/format-field-id-3.window.js create mode 100644 test/wpt/tests/eventsource/format-field-id-null.window.js create mode 100644 test/wpt/tests/eventsource/format-field-id.any.js create mode 100644 test/wpt/tests/eventsource/format-field-parsing.any.js create mode 100644 test/wpt/tests/eventsource/format-field-retry-bogus.any.js create mode 100644 test/wpt/tests/eventsource/format-field-retry-empty.any.js create mode 100644 test/wpt/tests/eventsource/format-field-retry.any.js create mode 100644 test/wpt/tests/eventsource/format-field-unknown.any.js create mode 100644 test/wpt/tests/eventsource/format-leading-space.any.js create mode 100644 test/wpt/tests/eventsource/format-mime-bogus.any.js create mode 100644 test/wpt/tests/eventsource/format-mime-trailing-semicolon.any.js create mode 100644 test/wpt/tests/eventsource/format-mime-valid-bogus.any.js create mode 100644 test/wpt/tests/eventsource/format-newlines.any.js create mode 100644 test/wpt/tests/eventsource/format-null-character.any.js create mode 100644 test/wpt/tests/eventsource/format-utf-8.any.js create mode 100644 test/wpt/tests/eventsource/request-accept.any.js create mode 100644 test/wpt/tests/eventsource/request-cache-control.any.js create mode 100644 test/wpt/tests/eventsource/request-credentials.any.window.js create mode 100644 test/wpt/tests/eventsource/request-redirect.any.window.js create mode 100644 test/wpt/tests/eventsource/request-status-error.window.js create mode 100644 test/wpt/tests/eventsource/resources/accept.event_stream create mode 100644 test/wpt/tests/eventsource/resources/cache-control.event_stream create mode 100644 test/wpt/tests/eventsource/resources/cors-cookie.py create mode 100644 test/wpt/tests/eventsource/resources/cors.py create mode 100644 test/wpt/tests/eventsource/resources/eventsource-onmessage-realm.htm create mode 100644 test/wpt/tests/eventsource/resources/init.htm create mode 100644 test/wpt/tests/eventsource/resources/last-event-id.py create mode 100644 test/wpt/tests/eventsource/resources/last-event-id2.py create mode 100644 test/wpt/tests/eventsource/resources/message.py create mode 100644 test/wpt/tests/eventsource/resources/message2.py create mode 100644 test/wpt/tests/eventsource/resources/reconnect-fail.py create mode 100644 test/wpt/tests/eventsource/resources/status-error.py create mode 100644 test/wpt/tests/eventsource/resources/status-reconnect.py create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-close.htm create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-close.js create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-constructor-non-same-origin.htm create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-constructor-non-same-origin.js create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-constructor-url-bogus.js create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-eventtarget.htm create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-eventtarget.js create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-onmesage.js create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-onmessage.htm create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-onopen.htm create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-onopen.js create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-prototype.htm create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-prototype.js create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-url.htm create mode 100644 test/wpt/tests/eventsource/shared-worker/eventsource-url.js diff --git a/test/wpt/runner/worker.mjs b/test/wpt/runner/worker.mjs index 90bfcf6dbde..f4ed9950021 100644 --- a/test/wpt/runner/worker.mjs +++ b/test/wpt/runner/worker.mjs @@ -12,6 +12,8 @@ import { WebSocket } from '../../../lib/websocket/websocket.js' import { Cache } from '../../../lib/cache/cache.js' import { CacheStorage } from '../../../lib/cache/cachestorage.js' import { kConstruct } from '../../../lib/cache/symbols.js' +// TODO(@KhafraDev): move this import once its added to index +import { EventSource } from '../../../lib/eventsource/eventsource.js' const { initScripts, meta, test, url, path } = workerData @@ -89,6 +91,10 @@ Object.defineProperties(globalThis, { CacheStorage: { ...globalPropertyDescriptors, value: CacheStorage + }, + EventSource: { + ...globalPropertyDescriptors, + value: EventSource } }) @@ -113,7 +119,7 @@ runInThisContext(` `) if (meta.title) { - runInThisContext(`globalThis.META_TITLE = "${meta.title}"`) + runInThisContext(`globalThis.META_TITLE = "${meta.title.replace(/"/g, '\\"')}"`) } const harness = readFileSync(join(basePath, '/resources/testharness.js'), 'utf-8') diff --git a/test/wpt/start-eventsource.mjs b/test/wpt/start-eventsource.mjs new file mode 100644 index 00000000000..44d7df30f83 --- /dev/null +++ b/test/wpt/start-eventsource.mjs @@ -0,0 +1,26 @@ +import { WPTRunner } from './runner/runner.mjs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { fork } from 'child_process' +import { on } from 'events' + +const serverPath = fileURLToPath(join(import.meta.url, '../server/server.mjs')) + +const child = fork(serverPath, [], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'] +}) + +child.on('exit', (code) => process.exit(code)) + +for await (const [message] of on(child, 'message')) { + if (message.server) { + const runner = new WPTRunner('eventsource', message.server) + runner.run() + + runner.once('completion', () => { + if (child.connected) { + child.send('shutdown') + } + }) + } +} diff --git a/test/wpt/status/eventsource.status.json b/test/wpt/status/eventsource.status.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/test/wpt/status/eventsource.status.json @@ -0,0 +1 @@ +{} diff --git a/test/wpt/tests/eventsource/META.yml b/test/wpt/tests/eventsource/META.yml new file mode 100644 index 00000000000..437da600931 --- /dev/null +++ b/test/wpt/tests/eventsource/META.yml @@ -0,0 +1,5 @@ +spec: https://html.spec.whatwg.org/multipage/server-sent-events.html +suggested_reviewers: + - odinho + - Yaffle + - annevk diff --git a/test/wpt/tests/eventsource/README.md b/test/wpt/tests/eventsource/README.md new file mode 100644 index 00000000000..e19a0ba6c74 --- /dev/null +++ b/test/wpt/tests/eventsource/README.md @@ -0,0 +1,4 @@ +These are the Server-sent events (`EventSource`) tests for the +[Server-sent events chapter of the HTML Standard](https://html.spec.whatwg.org/multipage/comms.html#server-sent-events). + +IDL tests are part of the `/html/dom/idlharness.*` resources. diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-close.htm b/test/wpt/tests/eventsource/dedicated-worker/eventsource-close.htm new file mode 100644 index 00000000000..f26aaaa4a90 --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-close.htm @@ -0,0 +1,24 @@ + + + + dedicated worker - EventSource: close() + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-close.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-close.js new file mode 100644 index 00000000000..875c9098bac --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-close.js @@ -0,0 +1,9 @@ +try { + var source = new EventSource("../resources/message.py") + source.onopen = function(e) { + this.close() + postMessage([true, this.readyState]) + } +} catch(e) { + postMessage([false, String(e)]) +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-close2.htm b/test/wpt/tests/eventsource/dedicated-worker/eventsource-close2.htm new file mode 100644 index 00000000000..34e07a2694e --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-close2.htm @@ -0,0 +1,23 @@ + + + + dedicated worker - EventSource created after: worker.close() + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-close2.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-close2.js new file mode 100644 index 00000000000..4a9cbd20b8a --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-close2.js @@ -0,0 +1,3 @@ +self.close() +var source = new EventSource("../resources/message.py") +postMessage(source.readyState) \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-no-new.any.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-no-new.any.js new file mode 100644 index 00000000000..48bc551130c --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-no-new.any.js @@ -0,0 +1,7 @@ +test(function() { + assert_throws_js(TypeError, + function() { + EventSource(''); + }, + "Calling EventSource constructor without 'new' must throw"); +}) diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.htm b/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.htm new file mode 100644 index 00000000000..b49d7ed609d --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.htm @@ -0,0 +1,34 @@ + + + + dedicated worker - EventSource: constructor (act as if there is a network error) + + + + + +
+ + + + diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.js new file mode 100644 index 00000000000..5ec25a0678c --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.js @@ -0,0 +1,10 @@ +try { + var url = decodeURIComponent(location.hash.substr(1)) + var source = new EventSource(url) + source.onerror = function(e) { + postMessage([true, this.readyState, 'data' in e]) + this.close(); + } +} catch(e) { + postMessage([false, String(e)]) +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-url-bogus.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-url-bogus.js new file mode 100644 index 00000000000..2a450a34631 --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-constructor-url-bogus.js @@ -0,0 +1,7 @@ +try { + var source = new EventSource("http://this is invalid/") + postMessage([false, 'no exception thrown']) + source.close() +} catch(e) { + postMessage([true, e.code]) +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-eventtarget.worker.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-eventtarget.worker.js new file mode 100644 index 00000000000..73b30556c49 --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-eventtarget.worker.js @@ -0,0 +1,11 @@ +importScripts("/resources/testharness.js"); + +async_test(function() { + var source = new EventSource("../resources/message.py") + source.addEventListener("message", this.step_func_done(function(e) { + assert_equals(e.data, 'data'); + source.close(); + }), false) +}, "dedicated worker - EventSource: addEventListener()"); + +done(); diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-onmesage.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-onmesage.js new file mode 100644 index 00000000000..9629f5e7936 --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-onmesage.js @@ -0,0 +1,9 @@ +try { + var source = new EventSource("../resources/message.py") + source.onmessage = function(e) { + postMessage([true, e.data]) + this.close() + } +} catch(e) { + postMessage([false, String(e)]) +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-onmessage.htm b/test/wpt/tests/eventsource/dedicated-worker/eventsource-onmessage.htm new file mode 100644 index 00000000000..c61855f5249 --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-onmessage.htm @@ -0,0 +1,24 @@ + + + + dedicated worker - EventSource: onmessage + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-onopen.htm b/test/wpt/tests/eventsource/dedicated-worker/eventsource-onopen.htm new file mode 100644 index 00000000000..010b0c66a8c --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-onopen.htm @@ -0,0 +1,27 @@ + + + + dedicated worker - EventSource: onopen (announcing the connection) + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-onopen.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-onopen.js new file mode 100644 index 00000000000..72a10532630 --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-onopen.js @@ -0,0 +1,9 @@ +try { + var source = new EventSource("../resources/message.py") + source.onopen = function(e) { + postMessage([true, source.readyState, 'data' in e, e.bubbles, e.cancelable]) + this.close() + } +} catch(e) { + postMessage([false, String(e)]) +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-prototype.htm b/test/wpt/tests/eventsource/dedicated-worker/eventsource-prototype.htm new file mode 100644 index 00000000000..5a5ac4ec2a7 --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-prototype.htm @@ -0,0 +1,25 @@ + + + + dedicated worker - EventSource: prototype et al + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-prototype.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-prototype.js new file mode 100644 index 00000000000..26993cb4efd --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-prototype.js @@ -0,0 +1,8 @@ +try { + EventSource.prototype.ReturnTrue = function() { return true } + var source = new EventSource("../resources/message.py") + postMessage([true, source.ReturnTrue(), 'EventSource' in self]) + source.close() +} catch(e) { + postMessage([false, String(e)]) +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-url.htm b/test/wpt/tests/eventsource/dedicated-worker/eventsource-url.htm new file mode 100644 index 00000000000..59e77cba57c --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-url.htm @@ -0,0 +1,25 @@ + + + + dedicated worker - EventSource: url + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/dedicated-worker/eventsource-url.js b/test/wpt/tests/eventsource/dedicated-worker/eventsource-url.js new file mode 100644 index 00000000000..7a3c8030d27 --- /dev/null +++ b/test/wpt/tests/eventsource/dedicated-worker/eventsource-url.js @@ -0,0 +1,7 @@ +try { + var source = new EventSource("../resources/message.py") + postMessage([true, source.url]) + source.close() +} catch(e) { + postMessage([false, String(e)]) +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/event-data.any.js b/test/wpt/tests/eventsource/event-data.any.js new file mode 100644 index 00000000000..12867694f85 --- /dev/null +++ b/test/wpt/tests/eventsource/event-data.any.js @@ -0,0 +1,21 @@ +// META: title=EventSource: lines and data parsing + + var test = async_test(); + test.step(function() { + var source = new EventSource("resources/message2.py"), + counter = 0; + source.onmessage = test.step_func(function(e) { + if(counter == 0) { + assert_equals(e.data,"msg\nmsg"); + } else if(counter == 1) { + assert_equals(e.data,""); + } else if(counter == 2) { + assert_equals(e.data,"end"); + source.close(); + test.done(); + } else { + assert_unreached(); + } + counter++; + }); + }); diff --git a/test/wpt/tests/eventsource/eventsource-close.window.js b/test/wpt/tests/eventsource/eventsource-close.window.js new file mode 100644 index 00000000000..e5693e6314b --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-close.window.js @@ -0,0 +1,70 @@ +// META: title=EventSource: close() + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py") + assert_equals(source.readyState, source.CONNECTING, "connecting readyState"); + source.onopen = this.step_func(function() { + assert_equals(source.readyState, source.OPEN, "open readyState"); + source.close() + assert_equals(source.readyState, source.CLOSED, "closed readyState"); + this.done() + }) + }) + + var test2 = async_test(document.title + ", test events"); + test2.step(function() { + var count = 0, reconnected = false, + source = new EventSource("resources/reconnect-fail.py?id=" + new Date().getTime()); + + source.onerror = this.step_func(function(e) { + assert_equals(e.type, 'error'); + switch(count) { + // reconnecting after first message + case 1: + assert_equals(source.readyState, source.CONNECTING, "reconnecting readyState"); + + reconnected = true; + break; + + // one more reconnect to get to the closing + case 2: + assert_equals(source.readyState, source.CONNECTING, "last reconnecting readyState"); + count++; + break; + + // close + case 3: + assert_equals(source.readyState, source.CLOSED, "closed readyState"); + + // give some time for errors to hit us + test2.step_timeout(function() { this.done(); }, 100); + break; + + default: + assert_unreached("Error handler with msg count " + count); + } + + }); + + source.onmessage = this.step_func(function(e) { + switch(count) { + case 0: + assert_true(!reconnected, "no error event run"); + assert_equals(e.data, "opened", "data"); + break; + + case 1: + assert_true(reconnected, "have reconnected"); + assert_equals(e.data, "reconnected", "data"); + break; + + default: + assert_unreached("Dunno what to do with message number " + count); + } + + count++; + }); + + }); + diff --git a/test/wpt/tests/eventsource/eventsource-constructor-document-domain.window.js b/test/wpt/tests/eventsource/eventsource-constructor-document-domain.window.js new file mode 100644 index 00000000000..defaee5b36e --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-constructor-document-domain.window.js @@ -0,0 +1,18 @@ +// META: title=EventSource: document.domain + + var test = async_test() + test.step(function() { + document.domain = document.domain + source = new EventSource("resources/message.py") + source.onopen = function(e) { + test.step(function() { + assert_equals(source.readyState, source.OPEN) + assert_false(e.hasOwnProperty('data')) + assert_false(e.bubbles) + assert_false(e.cancelable) + this.close() + }, this) + test.done() + } + }) + // Apart from document.domain equivalent to the onopen test. diff --git a/test/wpt/tests/eventsource/eventsource-constructor-empty-url.any.js b/test/wpt/tests/eventsource/eventsource-constructor-empty-url.any.js new file mode 100644 index 00000000000..850d854db4d --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-constructor-empty-url.any.js @@ -0,0 +1,6 @@ +// META: global=window,worker + +test(function() { + const source = new EventSource(""); + assert_equals(source.url, self.location.toString()); +}, "EventSource constructor with an empty url."); diff --git a/test/wpt/tests/eventsource/eventsource-constructor-non-same-origin.window.js b/test/wpt/tests/eventsource/eventsource-constructor-non-same-origin.window.js new file mode 100644 index 00000000000..bb32ed4b76e --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-constructor-non-same-origin.window.js @@ -0,0 +1,21 @@ +// META: title=EventSource: constructor (act as if there is a network error) + + function fetchFail(url) { + var test = async_test(document.title + " (" + url + ")") + test.step(function() { + var source = new EventSource(url) + source.onerror = function(e) { + test.step(function() { + assert_equals(source.readyState, source.CLOSED) + assert_false(e.hasOwnProperty('data')) + }) + test.done() + } + }) + } + fetchFail("ftp://example.not/") + fetchFail("about:blank") + fetchFail("mailto:whatwg@awesome.example") + fetchFail("javascript:alert('FAIL')") + // This tests "fails the connection" as well as making sure a simple + // event is dispatched and not a MessageEvent diff --git a/test/wpt/tests/eventsource/eventsource-constructor-stringify.window.js b/test/wpt/tests/eventsource/eventsource-constructor-stringify.window.js new file mode 100644 index 00000000000..ba14f90c6c6 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-constructor-stringify.window.js @@ -0,0 +1,28 @@ +// META: title=EventSource: stringify argument + + async_test(function (test) { + test.step(function() { + var source = new EventSource({toString:function(){return "resources/message.py";}}) + source.onopen = function(e) { + test.step(function() { + assert_false(e.hasOwnProperty('data')) + source.close() + test.done() + }) + } + }); + }, document.title + ', object'); + + test(function(){ + var source = new EventSource(1); + assert_regexp_match(source.url, /\/1$/); + }, document.title + ', 1'); + test(function(){ + var source = new EventSource(null); + assert_regexp_match(source.url, /\/null$/); + }, document.title + ', null'); + test(function(){ + var source = new EventSource(undefined); + assert_regexp_match(source.url, /\/undefined$/); + }, document.title + ', undefined'); + diff --git a/test/wpt/tests/eventsource/eventsource-constructor-url-bogus.any.js b/test/wpt/tests/eventsource/eventsource-constructor-url-bogus.any.js new file mode 100644 index 00000000000..53c3205e8a5 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-constructor-url-bogus.any.js @@ -0,0 +1,8 @@ +// META: global=window,worker +// META: title=EventSource: constructor (invalid URL) + +test(() => { + assert_throws_dom('SyntaxError', () => { new EventSource("http://this is invalid/"); }); +}); + +done(); diff --git a/test/wpt/tests/eventsource/eventsource-constructor-url-multi-window.htm b/test/wpt/tests/eventsource/eventsource-constructor-url-multi-window.htm new file mode 100644 index 00000000000..99fecb972c0 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-constructor-url-multi-window.htm @@ -0,0 +1,37 @@ + + + + EventSource: resolving URLs + + + + +
+ + + + diff --git a/test/wpt/tests/eventsource/eventsource-cross-origin.window.js b/test/wpt/tests/eventsource/eventsource-cross-origin.window.js new file mode 100644 index 00000000000..23bd27a7dce --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-cross-origin.window.js @@ -0,0 +1,51 @@ +// META: title=EventSource: cross-origin + + const crossdomain = location.href.replace('://', '://élève.').replace(/\/[^\/]*$/, '/'), + origin = location.origin.replace('://', '://xn--lve-6lad.'); + + + function doCORS(url, title) { + async_test(document.title + " " + title).step(function() { + var source = new EventSource(url, { withCredentials: true }) + source.onmessage = this.step_func_done(e => { + assert_equals(e.data, "data"); + assert_equals(e.origin, origin); + source.close(); + }) + }) + } + + doCORS(crossdomain + "resources/cors.py?run=message", + "basic use") + doCORS(crossdomain + "resources/cors.py?run=redirect&location=/eventsource/resources/cors.py?run=message", + "redirect use") + doCORS(crossdomain + "resources/cors.py?run=status-reconnect&status=200", + "redirect use recon") + + function failCORS(url, title) { + async_test(document.title + " " + title).step(function() { + var source = new EventSource(url) + source.onerror = this.step_func(function(e) { + assert_equals(source.readyState, source.CLOSED, 'readyState') + assert_false(e.hasOwnProperty('data')) + source.close() + this.done() + }) + + /* Shouldn't happen */ + source.onmessage = this.step_func(function(e) { + assert_unreached("shouldn't fire message event") + }) + source.onopen = this.step_func(function(e) { + assert_unreached("shouldn't fire open event") + }) + }) + } + + failCORS(crossdomain + "resources/cors.py?run=message&origin=http://example.org", + "allow-origin: http://example.org should fail") + failCORS(crossdomain + "resources/cors.py?run=message&origin=", + "allow-origin:'' should fail") + failCORS(crossdomain + "resources/message.py", + "No allow-origin should fail") + diff --git a/test/wpt/tests/eventsource/eventsource-eventtarget.any.js b/test/wpt/tests/eventsource/eventsource-eventtarget.any.js new file mode 100644 index 00000000000..b0d0017dd25 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-eventtarget.any.js @@ -0,0 +1,16 @@ +// META: title=EventSource: addEventListener() + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py") + source.addEventListener("message", listener, false) + }) + function listener(e) { + test.step(function() { + assert_equals("data", e.data) + this.close() + }, this) + test.done() + } + + diff --git a/test/wpt/tests/eventsource/eventsource-onmessage-realm.htm b/test/wpt/tests/eventsource/eventsource-onmessage-realm.htm new file mode 100644 index 00000000000..db2218b5168 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-onmessage-realm.htm @@ -0,0 +1,25 @@ + + +EventSource: message event Realm + + + + + + + diff --git a/test/wpt/tests/eventsource/eventsource-onmessage-trusted.any.js b/test/wpt/tests/eventsource/eventsource-onmessage-trusted.any.js new file mode 100644 index 00000000000..d0be4d03e8b --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-onmessage-trusted.any.js @@ -0,0 +1,12 @@ +// META: title=EventSource message events are trusted + +"use strict"; + +async_test(t => { + const source = new EventSource("resources/message.py"); + + source.onmessage = t.step_func_done(e => { + source.close(); + assert_equals(e.isTrusted, true); + }); +}, "EventSource message events are trusted"); diff --git a/test/wpt/tests/eventsource/eventsource-onmessage.any.js b/test/wpt/tests/eventsource/eventsource-onmessage.any.js new file mode 100644 index 00000000000..391fa4b1933 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-onmessage.any.js @@ -0,0 +1,14 @@ +// META: title=EventSource: onmessage + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py") + source.onmessage = function(e) { + test.step(function() { + assert_equals("data", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/eventsource-onopen.any.js b/test/wpt/tests/eventsource/eventsource-onopen.any.js new file mode 100644 index 00000000000..3977cb176e0 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-onopen.any.js @@ -0,0 +1,17 @@ +// META: title=EventSource: onopen (announcing the connection) + + var test = async_test() + test.step(function() { + source = new EventSource("resources/message.py") + source.onopen = function(e) { + test.step(function() { + assert_equals(source.readyState, source.OPEN) + assert_false(e.hasOwnProperty('data')) + assert_false(e.bubbles) + assert_false(e.cancelable) + this.close() + }, this) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/eventsource-prototype.any.js b/test/wpt/tests/eventsource/eventsource-prototype.any.js new file mode 100644 index 00000000000..b7aefb32f44 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-prototype.any.js @@ -0,0 +1,10 @@ +// META: title=EventSource: prototype et al + + test(function() { + EventSource.prototype.ReturnTrue = function() { return true } + var source = new EventSource("resources/message.py") + assert_true(source.ReturnTrue()) + assert_own_property(self, "EventSource") + source.close() + }) + diff --git a/test/wpt/tests/eventsource/eventsource-reconnect.window.js b/test/wpt/tests/eventsource/eventsource-reconnect.window.js new file mode 100644 index 00000000000..551fbdc88b2 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-reconnect.window.js @@ -0,0 +1,47 @@ +// META: title=EventSource: reconnection + + function doReconn(url, title) { + var test = async_test(document.title + " " + title) + test.step(function() { + var source = new EventSource(url) + source.onmessage = test.step_func(function(e) { + assert_equals(e.data, "data") + source.close() + test.done() + }) + }) + } + + doReconn("resources/status-reconnect.py?status=200", + "200") + + + var t = async_test(document.title + ", test reconnection events"); + t.step(function() { + var opened = false, reconnected = false, + source = new EventSource("resources/status-reconnect.py?status=200&ok_first&id=2"); + + source.onerror = t.step_func(function(e) { + assert_equals(e.type, 'error'); + assert_equals(source.readyState, source.CONNECTING, "readyState"); + assert_true(opened, "connection is opened earlier"); + + reconnected = true; + }); + + source.onmessage = t.step_func(function(e) { + if (!opened) { + opened = true; + assert_false(reconnected, "have reconnected before first message"); + assert_equals(e.data, "ok"); + } + else { + assert_true(reconnected, "Got reconnection event"); + assert_equals(e.data, "data"); + source.close() + t.done() + } + }); + }); + + diff --git a/test/wpt/tests/eventsource/eventsource-request-cancellation.any.window.js b/test/wpt/tests/eventsource/eventsource-request-cancellation.any.window.js new file mode 100644 index 00000000000..1cee9b742ea --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-request-cancellation.any.window.js @@ -0,0 +1,21 @@ +// META: title=EventSource: request cancellation + + var t = async_test(); + onload = t.step_func(function() { + var url = "resources/message.py?sleep=1000&message=" + encodeURIComponent("retry:1000\ndata:abc\n\n"); + var es = new EventSource(url); + es.onerror = t.step_func(function() { + assert_equals(es.readyState, EventSource.CLOSED) + t.step_timeout(function () { + assert_equals(es.readyState, EventSource.CLOSED, + "After stopping the eventsource readyState should be CLOSED") + t.done(); + }, 1000); + }); + + t.step_timeout(function() { + window.stop() + es.onopen = t.unreached_func("Got open event"); + es.onmessage = t.unreached_func("Got message after closing source"); + }, 0); + }); diff --git a/test/wpt/tests/eventsource/eventsource-url.any.js b/test/wpt/tests/eventsource/eventsource-url.any.js new file mode 100644 index 00000000000..92207ea78a1 --- /dev/null +++ b/test/wpt/tests/eventsource/eventsource-url.any.js @@ -0,0 +1,8 @@ +// META: title=EventSource: url + + test(function() { + var url = "resources/message.py", + source = new EventSource(url) + assert_equals(source.url.substr(-(url.length)), url) + source.close() + }) diff --git a/test/wpt/tests/eventsource/format-bom-2.any.js b/test/wpt/tests/eventsource/format-bom-2.any.js new file mode 100644 index 00000000000..8b7be8402c0 --- /dev/null +++ b/test/wpt/tests/eventsource/format-bom-2.any.js @@ -0,0 +1,24 @@ +// META: title=EventSource: Double BOM + + var test = async_test(), + hasbeenone = false, + hasbeentwo = false + test.step(function() { + var source = new EventSource("resources/message.py?message=%EF%BB%BF%EF%BB%BFdata%3A1%0A%0Adata%3A2%0A%0Adata%3A3") + source.addEventListener("message", listener, false) + }) + function listener(e) { + test.step(function() { + if(e.data == "1") + hasbeenone = true + if(e.data == "2") + hasbeentwo = true + if(e.data == "3") { + assert_false(hasbeenone) + assert_true(hasbeentwo) + this.close() + test.done() + } + }, this) + } + diff --git a/test/wpt/tests/eventsource/format-bom.any.js b/test/wpt/tests/eventsource/format-bom.any.js new file mode 100644 index 00000000000..05d1abd18b1 --- /dev/null +++ b/test/wpt/tests/eventsource/format-bom.any.js @@ -0,0 +1,24 @@ +// META: title=EventSource: BOM + + var test = async_test(), + hasbeenone = false, + hasbeentwo = false + test.step(function() { + var source = new EventSource("resources/message.py?message=%EF%BB%BFdata%3A1%0A%0A%EF%BB%BFdata%3A2%0A%0Adata%3A3") + source.addEventListener("message", listener, false) + }) + function listener(e) { + test.step(function() { + if(e.data == "1") + hasbeenone = true + if(e.data == "2") + hasbeentwo = true + if(e.data == "3") { + assert_true(hasbeenone) + assert_false(hasbeentwo) + this.close() + test.done() + } + }, this) + } + diff --git a/test/wpt/tests/eventsource/format-comments.any.js b/test/wpt/tests/eventsource/format-comments.any.js new file mode 100644 index 00000000000..186e4714ba3 --- /dev/null +++ b/test/wpt/tests/eventsource/format-comments.any.js @@ -0,0 +1,16 @@ +// META: title=EventSource: comment fest + + var test = async_test() + test.step(function() { + var longstring = (new Array(2*1024+1)).join("x"), // cannot make the string too long; causes timeout + message = encodeURI("data:1\r:\0\n:\r\ndata:2\n:" + longstring + "\rdata:3\n:data:fail\r:" + longstring + "\ndata:4\n"), + source = new EventSource("resources/message.py?message=" + message + "&newline=none") + source.onmessage = function(e) { + test.step(function() { + assert_equals("1\n2\n3\n4", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-data-before-final-empty-line.any.js b/test/wpt/tests/eventsource/format-data-before-final-empty-line.any.js new file mode 100644 index 00000000000..5a4d84d28d3 --- /dev/null +++ b/test/wpt/tests/eventsource/format-data-before-final-empty-line.any.js @@ -0,0 +1,17 @@ +// META: title=EventSource: a data before final empty line + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?newline=none&message=" + encodeURIComponent("retry:1000\ndata:test1\n\nid:test\ndata:test2")) + var count = 0; + source.onmessage = function(e) { + if (++count === 2) { + test.step(function() { + assert_equals(e.lastEventId, "", "lastEventId") + assert_equals(e.data, "test1", "data") + source.close() + }) + test.done() + } + } + }) diff --git a/test/wpt/tests/eventsource/format-field-data.any.js b/test/wpt/tests/eventsource/format-field-data.any.js new file mode 100644 index 00000000000..bea9be17424 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-data.any.js @@ -0,0 +1,23 @@ +// META: title=EventSource: data field parsing + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3A%0A%0Adata%0Adata%0A%0Adata%3Atest"), + counter = 0 + source.onmessage = function(e) { + test.step(function() { + if(counter == 0) { + assert_equals("", e.data) + } else if(counter == 1) { + assert_equals("\n", e.data) + } else if(counter == 2) { + assert_equals("test", e.data) + source.close() + test.done() + } else { + assert_unreached() + } + counter++ + }) + } + }) diff --git a/test/wpt/tests/eventsource/format-field-event-empty.any.js b/test/wpt/tests/eventsource/format-field-event-empty.any.js new file mode 100644 index 00000000000..ada8e5725fe --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-event-empty.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: empty "event" field + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=event%3A%20%0Adata%3Adata") + source.onmessage = function(e) { + test.step(function() { + assert_equals("data", e.data) + this.close() + }, this) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-field-event.any.js b/test/wpt/tests/eventsource/format-field-event.any.js new file mode 100644 index 00000000000..0c7d1fc2662 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-event.any.js @@ -0,0 +1,15 @@ +// META: title=EventSource: custom event name + var test = async_test(), + dispatchedtest = false + test.step(function() { + var source = new EventSource("resources/message.py?message=event%3Atest%0Adata%3Ax%0A%0Adata%3Ax") + source.addEventListener("test", function() { test.step(function() { dispatchedtest = true }) }, false) + source.onmessage = function() { + test.step(function() { + assert_true(dispatchedtest) + this.close() + }, this) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-field-id-2.any.js b/test/wpt/tests/eventsource/format-field-id-2.any.js new file mode 100644 index 00000000000..9933f46b875 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-id-2.any.js @@ -0,0 +1,25 @@ +// META: title=EventSource: Last-Event-ID (2) + var test = async_test() + test.step(function() { + var source = new EventSource("resources/last-event-id.py"), + counter = 0 + source.onmessage = function(e) { + test.step(function() { + if(e.data == "hello" && counter == 0) { + counter++ + assert_equals(e.lastEventId, "…") + } else if(counter == 1) { + counter++ + assert_equals("…", e.data) + assert_equals("…", e.lastEventId) + } else if(counter == 2) { + counter++ + assert_equals("…", e.data) + assert_equals("…", e.lastEventId) + source.close() + test.done() + } else + assert_unreached() + }) + } + }) diff --git a/test/wpt/tests/eventsource/format-field-id-3.window.js b/test/wpt/tests/eventsource/format-field-id-3.window.js new file mode 100644 index 00000000000..3766fbf7bb1 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-id-3.window.js @@ -0,0 +1,56 @@ +const ID_PERSISTS = 1, +ID_RESETS_1 = 2, +ID_RESETS_2 = 3; + +async_test(testPersist, "EventSource: lastEventId persists"); +async_test(testReset(ID_RESETS_1), "EventSource: lastEventId resets"); +async_test(testReset(ID_RESETS_2), "EventSource: lastEventId resets (id without colon)"); + +function testPersist(t) { + const source = new EventSource("resources/last-event-id2.py?type=" + ID_PERSISTS); + let counter = 0; + t.add_cleanup(() => source.close()); + source.onmessage = t.step_func(e => { + counter++; + if (counter === 1) { + assert_equals(e.lastEventId, "1"); + assert_equals(e.data, "1"); + } else if (counter === 2) { + assert_equals(e.lastEventId, "1"); + assert_equals(e.data, "2"); + } else if (counter === 3) { + assert_equals(e.lastEventId, "2"); + assert_equals(e.data, "3"); + } else if (counter === 4) { + assert_equals(e.lastEventId, "2"); + assert_equals(e.data, "4"); + t.done(); + } else { + assert_unreached(); + } + }); +} + +function testReset(type) { + return function (t) { + const source = new EventSource("resources/last-event-id2.py?type=" + type); + let counter = 0; + t.add_cleanup(() => source.close()); + source.onmessage = t.step_func(e => { + counter++; + if (counter === 1) { + assert_equals(e.lastEventId, "1"); + assert_equals(e.data, "1"); + } else if (counter === 2) { + assert_equals(e.lastEventId, ""); + assert_equals(e.data, "2"); + } else if (counter === 3) { + assert_equals(e.lastEventId, ""); + assert_equals(e.data, "3"); + t.done(); + } else { + assert_unreached(); + } + }); + } +} diff --git a/test/wpt/tests/eventsource/format-field-id-null.window.js b/test/wpt/tests/eventsource/format-field-id-null.window.js new file mode 100644 index 00000000000..6d564dde0f2 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-id-null.window.js @@ -0,0 +1,25 @@ +[ + "\u0000\u0000", + "x\u0000", + "\u0000x", + "x\u0000x", + " \u0000" +].forEach(idValue => { + const encodedIdValue = encodeURIComponent(idValue); + async_test(t => { + const source = new EventSource("resources/last-event-id.py?idvalue=" + encodedIdValue); + t.add_cleanup(() => source.close()); + let seenhello = false; + source.onmessage = t.step_func(e => { + if (e.data == "hello" && !seenhello) { + seenhello = true; + assert_equals(e.lastEventId, ""); + } else if(seenhello) { + assert_equals(e.data, "hello"); + assert_equals(e.lastEventId, ""); + t.done(); + } else + assert_unreached(); + }); + }, "EventSource: id field set to " + encodedIdValue); +}); diff --git a/test/wpt/tests/eventsource/format-field-id.any.js b/test/wpt/tests/eventsource/format-field-id.any.js new file mode 100644 index 00000000000..26f1aea7091 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-id.any.js @@ -0,0 +1,21 @@ +// META: title=EventSource: Last-Event-ID + var test = async_test() + test.step(function() { + var source = new EventSource("resources/last-event-id.py"), + seenhello = false + source.onmessage = function(e) { + test.step(function() { + if(e.data == "hello" && !seenhello) { + seenhello = true + assert_equals(e.lastEventId, "…") + } else if(seenhello) { + assert_equals("…", e.data) + assert_equals("…", e.lastEventId) + source.close() + test.done() + } else + assert_unreached() + }) + } + }) + diff --git a/test/wpt/tests/eventsource/format-field-parsing.any.js b/test/wpt/tests/eventsource/format-field-parsing.any.js new file mode 100644 index 00000000000..9b05187153a --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-parsing.any.js @@ -0,0 +1,14 @@ +// META: title=EventSource: field parsing + var test = async_test() + test.step(function() { + var message = encodeURI("data:\0\ndata: 2\rData:1\ndata\0:2\ndata:1\r\0data:4\nda-ta:3\rdata_5\ndata:3\rdata:\r\n data:32\ndata:4\n"), + source = new EventSource("resources/message.py?message=" + message + "&newline=none") + source.onmessage = function(e) { + test.step(function() { + assert_equals(e.data, "\0\n 2\n1\n3\n\n4") + source.close() + }) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-field-retry-bogus.any.js b/test/wpt/tests/eventsource/format-field-retry-bogus.any.js new file mode 100644 index 00000000000..86d9b9ea409 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-retry-bogus.any.js @@ -0,0 +1,19 @@ +// META: title=EventSource: "retry" field (bogus) + var test = async_test() + test.step(function() { + var timeoutms = 3000, + source = new EventSource("resources/message.py?message=retry%3A3000%0Aretry%3A1000x%0Adata%3Ax"), + opened = 0 + source.onopen = function() { + test.step(function() { + if(opened == 0) + opened = new Date().getTime() + else { + var diff = (new Date().getTime()) - opened + assert_true(Math.abs(1 - diff / timeoutms) < 0.25) // allow 25% difference + this.close(); + test.done() + } + }, this) + } + }) diff --git a/test/wpt/tests/eventsource/format-field-retry-empty.any.js b/test/wpt/tests/eventsource/format-field-retry-empty.any.js new file mode 100644 index 00000000000..e7d5e76a134 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-retry-empty.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: empty retry field + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=retry%0Adata%3Atest") + source.onmessage = function(e) { + test.step(function() { + assert_equals("test", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-field-retry.any.js b/test/wpt/tests/eventsource/format-field-retry.any.js new file mode 100644 index 00000000000..819241dbd40 --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-retry.any.js @@ -0,0 +1,21 @@ +// META: title=EventSource: "retry" field + var test = async_test(); + test.step(function() { + var timeoutms = 3000, + timeoutstr = "03000", // 1536 in octal, but should be 3000 + source = new EventSource("resources/message.py?message=retry%3A" + timeoutstr + "%0Adata%3Ax"), + opened = 0 + source.onopen = function() { + test.step(function() { + if(opened == 0) + opened = new Date().getTime() + else { + var diff = (new Date().getTime()) - opened + assert_true(Math.abs(1 - diff / timeoutms) < 0.25) // allow 25% difference + this.close(); + test.done() + } + }, this) + } + }) + diff --git a/test/wpt/tests/eventsource/format-field-unknown.any.js b/test/wpt/tests/eventsource/format-field-unknown.any.js new file mode 100644 index 00000000000..f702ed8565d --- /dev/null +++ b/test/wpt/tests/eventsource/format-field-unknown.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: unknown fields and parsing fun + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3Atest%0A%20data%0Adata%0Afoobar%3Axxx%0Ajustsometext%0A%3Athisisacommentyay%0Adata%3Atest") + source.onmessage = function(e) { + test.step(function() { + assert_equals("test\n\ntest", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-leading-space.any.js b/test/wpt/tests/eventsource/format-leading-space.any.js new file mode 100644 index 00000000000..0ddfd9b32bb --- /dev/null +++ b/test/wpt/tests/eventsource/format-leading-space.any.js @@ -0,0 +1,14 @@ +// META: title=EventSource: leading space + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3A%09test%0Ddata%3A%20%0Adata%3Atest") + source.onmessage = function(e) { + test.step(function() { + assert_equals("\ttest\n\ntest", e.data) + source.close() + }) + test.done() + } + }) + // also used a CR as newline once + diff --git a/test/wpt/tests/eventsource/format-mime-bogus.any.js b/test/wpt/tests/eventsource/format-mime-bogus.any.js new file mode 100644 index 00000000000..18c7c7d4a49 --- /dev/null +++ b/test/wpt/tests/eventsource/format-mime-bogus.any.js @@ -0,0 +1,25 @@ +// META: title=EventSource: bogus MIME type + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?mime=x%20bogus") + source.onmessage = function() { + test.step(function() { + assert_unreached() + source.close() + }) + test.done() + } + source.onerror = function(e) { + test.step(function() { + assert_equals(this.readyState, this.CLOSED) + assert_false(e.hasOwnProperty('data')) + assert_false(e.bubbles) + assert_false(e.cancelable) + this.close() + }, this) + test.done() + } + }) + // This tests "fails the connection" as well as making sure a simple + // event is dispatched and not a MessageEvent + diff --git a/test/wpt/tests/eventsource/format-mime-trailing-semicolon.any.js b/test/wpt/tests/eventsource/format-mime-trailing-semicolon.any.js new file mode 100644 index 00000000000..55a314bf524 --- /dev/null +++ b/test/wpt/tests/eventsource/format-mime-trailing-semicolon.any.js @@ -0,0 +1,20 @@ +// META: title=EventSource: MIME type with trailing ; + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?mime=text/event-stream%3B") + source.onopen = function() { + test.step(function() { + assert_equals(source.readyState, source.OPEN) + source.close() + }) + test.done() + } + source.onerror = function() { + test.step(function() { + assert_unreached() + source.close() + }) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-mime-valid-bogus.any.js b/test/wpt/tests/eventsource/format-mime-valid-bogus.any.js new file mode 100644 index 00000000000..355ba6c524f --- /dev/null +++ b/test/wpt/tests/eventsource/format-mime-valid-bogus.any.js @@ -0,0 +1,24 @@ +// META: title=EventSource: incorrect valid MIME type + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?mime=text/x-bogus") + source.onmessage = function() { + test.step(function() { + assert_unreached() + source.close() + }) + test.done() + } + source.onerror = function(e) { + test.step(function() { + assert_equals(source.readyState, source.CLOSED) + assert_false(e.hasOwnProperty('data')) + assert_false(e.bubbles) + assert_false(e.cancelable) + }) + test.done() + } + }) + // This tests "fails the connection" as well as making sure a simple + // event is dispatched and not a MessageEvent + diff --git a/test/wpt/tests/eventsource/format-newlines.any.js b/test/wpt/tests/eventsource/format-newlines.any.js new file mode 100644 index 00000000000..0768171d333 --- /dev/null +++ b/test/wpt/tests/eventsource/format-newlines.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: newline fest + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3Atest%0D%0Adata%0Adata%3Atest%0D%0A%0D&newline=none") + source.onmessage = function(e) { + test.step(function() { + assert_equals("test\n\ntest", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-null-character.any.js b/test/wpt/tests/eventsource/format-null-character.any.js new file mode 100644 index 00000000000..943628d2c02 --- /dev/null +++ b/test/wpt/tests/eventsource/format-null-character.any.js @@ -0,0 +1,17 @@ +// META: title=EventSource: null character in response + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3A%00%0A%0A") + source.onmessage = function(e) { + test.step(function() { + assert_equals("\x00", e.data) + source.close() + }, this) + test.done() + } + source.onerror = function() { + test.step(function() { assert_unreached() }) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/format-utf-8.any.js b/test/wpt/tests/eventsource/format-utf-8.any.js new file mode 100644 index 00000000000..7976abfb55d --- /dev/null +++ b/test/wpt/tests/eventsource/format-utf-8.any.js @@ -0,0 +1,12 @@ +// META: title=EventSource always UTF-8 +async_test().step(function() { + var source = new EventSource("resources/message.py?mime=text/event-stream%3bcharset=windows-1252&message=data%3Aok%E2%80%A6") + source.onmessage = this.step_func(function(e) { + assert_equals('ok…', e.data, 'decoded data') + source.close() + this.done() + }) + source.onerror = this.step_func(function() { + assert_unreached("Got error event") + }) +}) diff --git a/test/wpt/tests/eventsource/request-accept.any.js b/test/wpt/tests/eventsource/request-accept.any.js new file mode 100644 index 00000000000..2e181735564 --- /dev/null +++ b/test/wpt/tests/eventsource/request-accept.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: Accept header + var test = async_test() + test.step(function() { + var source = new EventSource("resources/accept.event_stream?pipe=sub") + source.onmessage = function(e) { + test.step(function() { + assert_equals(e.data, "text/event-stream") + this.close() + }, this) + test.done() + } + }) + diff --git a/test/wpt/tests/eventsource/request-cache-control.any.js b/test/wpt/tests/eventsource/request-cache-control.any.js new file mode 100644 index 00000000000..95b71d7a589 --- /dev/null +++ b/test/wpt/tests/eventsource/request-cache-control.any.js @@ -0,0 +1,35 @@ +// META: title=EventSource: Cache-Control + var crossdomain = location.href + .replace('://', '://www2.') + .replace(/\/[^\/]*$/, '/') + + // running it twice to check whether it stays consistent + function cacheTest(url) { + var test = async_test(url + "1") + // Recursive test. This avoids test that timeout + var test2 = async_test(url + "2") + test.step(function() { + var source = new EventSource(url) + source.onmessage = function(e) { + test.step(function() { + assert_equals(e.data, "no-cache") + this.close() + test2.step(function() { + var source2 = new EventSource(url) + source2.onmessage = function(e) { + test2.step(function() { + assert_equals(e.data, "no-cache") + this.close() + }, this) + test2.done() + } + }) + }, this) + test.done() + } + }) + } + + cacheTest("resources/cache-control.event_stream?pipe=sub") + cacheTest(crossdomain + "resources/cors.py?run=cache-control") + diff --git a/test/wpt/tests/eventsource/request-credentials.any.window.js b/test/wpt/tests/eventsource/request-credentials.any.window.js new file mode 100644 index 00000000000..d7c554aa4a2 --- /dev/null +++ b/test/wpt/tests/eventsource/request-credentials.any.window.js @@ -0,0 +1,37 @@ +// META: title=EventSource: credentials + var crossdomain = location.href + .replace('://', '://www2.') + .replace(/\/[^\/]*$/, '/') + + function testCookie(desc, success, props, id) { + var test = async_test(document.title + ': credentials ' + desc) + test.step(function() { + var source = new EventSource(crossdomain + "resources/cors-cookie.py?ident=" + id, props) + + source.onmessage = test.step_func(function(e) { + if(e.data.indexOf("first") == 0) { + assert_equals(e.data, "first NO_COOKIE", "cookie status") + } + else if(e.data.indexOf("second") == 0) { + if (success) + assert_equals(e.data, "second COOKIE", "cookie status") + else + assert_equals(e.data, "second NO_COOKIE", "cookie status") + + source.close() + test.done() + } + else { + assert_unreached("unrecognized data returned: " + e.data) + source.close() + test.done() + } + }) + }) + } + + testCookie('enabled', true, { withCredentials: true }, '1_' + new Date().getTime()) + testCookie('disabled', false, { withCredentials: false }, '2_' + new Date().getTime()) + testCookie('default', false, { }, '3_' + new Date().getTime()) + + diff --git a/test/wpt/tests/eventsource/request-redirect.any.window.js b/test/wpt/tests/eventsource/request-redirect.any.window.js new file mode 100644 index 00000000000..3788dd84502 --- /dev/null +++ b/test/wpt/tests/eventsource/request-redirect.any.window.js @@ -0,0 +1,24 @@ +// META: title=EventSource: redirect + function redirectTest(status) { + var test = async_test(document.title + " (" + status +")") + test.step(function() { + var source = new EventSource("/common/redirect.py?location=/eventsource/resources/message.py&status=" + status) + source.onopen = function() { + test.step(function() { + assert_equals(this.readyState, this.OPEN) + this.close() + }, this) + test.done() + } + source.onerror = function() { + test.step(function() { assert_unreached() }) + test.done() + } + }) + } + + redirectTest("301") + redirectTest("302") + redirectTest("303") + redirectTest("307") + diff --git a/test/wpt/tests/eventsource/request-status-error.window.js b/test/wpt/tests/eventsource/request-status-error.window.js new file mode 100644 index 00000000000..8632d8e8c6b --- /dev/null +++ b/test/wpt/tests/eventsource/request-status-error.window.js @@ -0,0 +1,27 @@ +// META: title=EventSource: incorrect HTTP status code + function statusTest(status) { + var test = async_test(document.title + " (" + status +")") + test.step(function() { + var source = new EventSource("resources/status-error.py?status=" + status) + source.onmessage = function() { + test.step(function() { + assert_unreached() + }) + test.done() + } + source.onerror = function() { + test.step(function() { + assert_equals(this.readyState, this.CLOSED) + }, this) + test.done() + } + }) + } + statusTest("204") + statusTest("205") + statusTest("210") + statusTest("299") + statusTest("404") + statusTest("410") + statusTest("503") + diff --git a/test/wpt/tests/eventsource/resources/accept.event_stream b/test/wpt/tests/eventsource/resources/accept.event_stream new file mode 100644 index 00000000000..24da5482678 --- /dev/null +++ b/test/wpt/tests/eventsource/resources/accept.event_stream @@ -0,0 +1,2 @@ +data: {{headers[accept]}} + diff --git a/test/wpt/tests/eventsource/resources/cache-control.event_stream b/test/wpt/tests/eventsource/resources/cache-control.event_stream new file mode 100644 index 00000000000..aa9f2d6c090 --- /dev/null +++ b/test/wpt/tests/eventsource/resources/cache-control.event_stream @@ -0,0 +1,2 @@ +data: {{headers[cache-control]}} + diff --git a/test/wpt/tests/eventsource/resources/cors-cookie.py b/test/wpt/tests/eventsource/resources/cors-cookie.py new file mode 100644 index 00000000000..9eaab9b95a0 --- /dev/null +++ b/test/wpt/tests/eventsource/resources/cors-cookie.py @@ -0,0 +1,31 @@ +from datetime import datetime + +def main(request, response): + last_event_id = request.headers.get(b"Last-Event-Id", b"") + ident = request.GET.first(b'ident', b"test") + cookie = b"COOKIE" if ident in request.cookies else b"NO_COOKIE" + origin = request.GET.first(b'origin', request.headers[b"origin"]) + credentials = request.GET.first(b'credentials', b'true') + + headers = [] + + if origin != b'none': + headers.append((b"Access-Control-Allow-Origin", origin)); + + if credentials != b'none': + headers.append((b"Access-Control-Allow-Credentials", credentials)); + + if last_event_id == b'': + headers.append((b"Content-Type", b"text/event-stream")) + response.set_cookie(ident, b"COOKIE") + data = b"id: 1\nretry: 200\ndata: first %s\n\n" % cookie + elif last_event_id == b'1': + headers.append((b"Content-Type", b"text/event-stream")) + long_long_time_ago = datetime.now().replace(year=2001, month=7, day=27) + response.set_cookie(ident, b"COOKIE", expires=long_long_time_ago) + data = b"id: 2\ndata: second %s\n\n" % cookie + else: + headers.append((b"Content-Type", b"stop")) + data = b"data: " + last_event_id + cookie + b"\n\n"; + + return headers, data diff --git a/test/wpt/tests/eventsource/resources/cors.py b/test/wpt/tests/eventsource/resources/cors.py new file mode 100644 index 00000000000..6ed31f2cd7d --- /dev/null +++ b/test/wpt/tests/eventsource/resources/cors.py @@ -0,0 +1,36 @@ +import os +from wptserve import pipes + +from wptserve.utils import isomorphic_decode + +def run_other(request, response, path): + #This is a terrible hack + environ = {u"__file__": path} + exec(compile(open(path, u"r").read(), path, u'exec'), environ, environ) + rv = environ[u"main"](request, response) + return rv + +def main(request, response): + origin = request.GET.first(b"origin", request.headers[b"origin"]) + credentials = request.GET.first(b"credentials", b"true") + + response.headers.update([(b"Access-Control-Allow-Origin", origin), + (b"Access-Control-Allow-Credentials", credentials)]) + + handler = request.GET.first(b'run') + if handler in [b"status-reconnect", + b"message", + b"redirect", + b"cache-control"]: + if handler == b"cache-control": + response.headers.set(b"Content-Type", b"text/event-stream") + rv = open(os.path.join(request.doc_root, u"eventsource", u"resources", u"cache-control.event_stream"), u"r").read() + response.content = rv + pipes.sub(request, response) + return + elif handler == b"redirect": + return run_other(request, response, os.path.join(request.doc_root, u"common", u"redirect.py")) + else: + return run_other(request, response, os.path.join(os.path.dirname(isomorphic_decode(__file__)), isomorphic_decode(handler) + u".py")) + else: + return diff --git a/test/wpt/tests/eventsource/resources/eventsource-onmessage-realm.htm b/test/wpt/tests/eventsource/resources/eventsource-onmessage-realm.htm new file mode 100644 index 00000000000..63e6d012b4d --- /dev/null +++ b/test/wpt/tests/eventsource/resources/eventsource-onmessage-realm.htm @@ -0,0 +1,2 @@ + +This page is just used to grab an EventSource constructor diff --git a/test/wpt/tests/eventsource/resources/init.htm b/test/wpt/tests/eventsource/resources/init.htm new file mode 100644 index 00000000000..7c56d88800d --- /dev/null +++ b/test/wpt/tests/eventsource/resources/init.htm @@ -0,0 +1,9 @@ + + + + support init file + + + + + diff --git a/test/wpt/tests/eventsource/resources/last-event-id.py b/test/wpt/tests/eventsource/resources/last-event-id.py new file mode 100644 index 00000000000..a2cb7264457 --- /dev/null +++ b/test/wpt/tests/eventsource/resources/last-event-id.py @@ -0,0 +1,9 @@ +def main(request, response): + response.headers.set(b"Content-Type", b"text/event-stream") + + last_event_id = request.headers.get(b"Last-Event-ID", b"") + if last_event_id: + return b"data: " + last_event_id + b"\n\n" + else: + idvalue = request.GET.first(b"idvalue", u"\u2026".encode("utf-8")) + return b"id: " + idvalue + b"\nretry: 200\ndata: hello\n\n" diff --git a/test/wpt/tests/eventsource/resources/last-event-id2.py b/test/wpt/tests/eventsource/resources/last-event-id2.py new file mode 100644 index 00000000000..4f133d707d1 --- /dev/null +++ b/test/wpt/tests/eventsource/resources/last-event-id2.py @@ -0,0 +1,23 @@ +ID_PERSISTS = 1 +ID_RESETS_1 = 2 +ID_RESETS_2 = 3 + +def main(request, response): + response.headers.set(b"Content-Type", b"text/event-stream") + try: + test_type = int(request.GET.first(b"type", ID_PERSISTS)) + except: + test_type = ID_PERSISTS + + if test_type == ID_PERSISTS: + return b"id: 1\ndata: 1\n\ndata: 2\n\nid: 2\ndata:3\n\ndata:4\n\n" + + elif test_type == ID_RESETS_1: + return b"id: 1\ndata: 1\n\nid:\ndata:2\n\ndata:3\n\n" + + # empty id field without colon character (:) should also reset + elif test_type == ID_RESETS_2: + return b"id: 1\ndata: 1\n\nid\ndata:2\n\ndata:3\n\n" + + else: + return b"data: invalid_test\n\n" diff --git a/test/wpt/tests/eventsource/resources/message.py b/test/wpt/tests/eventsource/resources/message.py new file mode 100644 index 00000000000..468564f4df0 --- /dev/null +++ b/test/wpt/tests/eventsource/resources/message.py @@ -0,0 +1,14 @@ +import time + +def main(request, response): + mime = request.GET.first(b"mime", b"text/event-stream") + message = request.GET.first(b"message", b"data: data"); + newline = b"" if request.GET.first(b"newline", None) == b"none" else b"\n\n"; + sleep = int(request.GET.first(b"sleep", b"0")) + + headers = [(b"Content-Type", mime)] + body = message + newline + b"\n" + if sleep != 0: + time.sleep(sleep/1000) + + return headers, body diff --git a/test/wpt/tests/eventsource/resources/message2.py b/test/wpt/tests/eventsource/resources/message2.py new file mode 100644 index 00000000000..8515e7b25eb --- /dev/null +++ b/test/wpt/tests/eventsource/resources/message2.py @@ -0,0 +1,33 @@ +import time + +def main(request, response): + response.headers.set(b'Content-Type', b'text/event-stream') + response.headers.set(b'Cache-Control', b'no-cache') + + response.write_status_headers() + + while True: + response.writer.write(u"data:msg") + response.writer.write(u"\n") + response.writer.write(u"data: msg") + response.writer.write(u"\n\n") + + response.writer.write(u":") + response.writer.write(u"\n") + + response.writer.write(u"falsefield:msg") + response.writer.write(u"\n\n") + + response.writer.write(u"falsefield:msg") + response.writer.write(u"\n") + + response.writer.write(u"Data:data") + response.writer.write(u"\n\n") + + response.writer.write(u"data") + response.writer.write(u"\n\n") + + response.writer.write(u"data:end") + response.writer.write(u"\n\n") + + time.sleep(2) diff --git a/test/wpt/tests/eventsource/resources/reconnect-fail.py b/test/wpt/tests/eventsource/resources/reconnect-fail.py new file mode 100644 index 00000000000..12b07700cd0 --- /dev/null +++ b/test/wpt/tests/eventsource/resources/reconnect-fail.py @@ -0,0 +1,24 @@ +def main(request, response): + name = b"recon_fail_" + request.GET.first(b"id") + + headers = [(b"Content-Type", b"text/event-stream")] + cookie = request.cookies.first(name, None) + state = cookie.value if cookie is not None else None + + if state == b'opened': + status = (200, b"RECONNECT") + response.set_cookie(name, b"reconnected"); + body = b"data: reconnected\n\n"; + + elif state == b'reconnected': + status = (204, b"NO CONTENT (CLOSE)") + response.delete_cookie(name); + body = b"data: closed\n\n" # Will never get through + + else: + status = (200, b"OPEN"); + response.set_cookie(name, b"opened"); + body = b"retry: 2\ndata: opened\n\n"; + + return status, headers, body + diff --git a/test/wpt/tests/eventsource/resources/status-error.py b/test/wpt/tests/eventsource/resources/status-error.py new file mode 100644 index 00000000000..ed5687b6c2b --- /dev/null +++ b/test/wpt/tests/eventsource/resources/status-error.py @@ -0,0 +1,15 @@ +def main(request, response): + status = (request.GET.first(b"status", b"404"), b"HAHAHAHA") + headers = [(b"Content-Type", b"text/event-stream")] + + # According to RFC7231, HTTP responses bearing status code 204 or 205 must + # not specify a body. The expected browser behavior for this condition is not + # currently defined--see the following for further discussion: + # + # https://github.com/web-platform-tests/wpt/pull/5227 + if status[0] in [b"204", b"205"]: + body = b"" + else: + body = b"data: data\n\n" + + return status, headers, body diff --git a/test/wpt/tests/eventsource/resources/status-reconnect.py b/test/wpt/tests/eventsource/resources/status-reconnect.py new file mode 100644 index 00000000000..a59f751fc36 --- /dev/null +++ b/test/wpt/tests/eventsource/resources/status-reconnect.py @@ -0,0 +1,21 @@ +def main(request, response): + status_code = request.GET.first(b"status", b"204") + name = request.GET.first(b"id", status_code) + + headers = [(b"Content-Type", b"text/event-stream")] + + cookie_name = b"request" + name + + if request.cookies.first(cookie_name, b"") == status_code: + status = 200 + response.delete_cookie(cookie_name) + body = b"data: data\n\n" + else: + response.set_cookie(cookie_name, status_code); + status = (int(status_code), b"TEST") + body = b"retry: 2\n" + if b"ok_first" in request.GET: + body += b"data: ok\n\n" + + return status, headers, body + diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-close.htm b/test/wpt/tests/eventsource/shared-worker/eventsource-close.htm new file mode 100644 index 00000000000..30fbc309ab6 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-close.htm @@ -0,0 +1,24 @@ + + + + shared worker - EventSource: close() + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-close.js b/test/wpt/tests/eventsource/shared-worker/eventsource-close.js new file mode 100644 index 00000000000..8d160b605f2 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-close.js @@ -0,0 +1,12 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + source.onopen = function(e) { + this.close() + port.postMessage([true, this.readyState]) + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-non-same-origin.htm b/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-non-same-origin.htm new file mode 100644 index 00000000000..690cde36002 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-non-same-origin.htm @@ -0,0 +1,34 @@ + + + + shared worker - EventSource: constructor (act as if there is a network error) + + + + + +
+ + + + diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-non-same-origin.js b/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-non-same-origin.js new file mode 100644 index 00000000000..a68dc5b0b7d --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-non-same-origin.js @@ -0,0 +1,13 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var url = decodeURIComponent(location.hash.substr(1)) + var source = new EventSource(url) + source.onerror = function(e) { + port.postMessage([true, this.readyState, 'data' in e]) + this.close(); + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-url-bogus.js b/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-url-bogus.js new file mode 100644 index 00000000000..80847357b55 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-constructor-url-bogus.js @@ -0,0 +1,10 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("http://this is invalid/") + port.postMessage([false, 'no exception thrown']) + source.close() +} catch(e) { + port.postMessage([true, e.code]) +} +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-eventtarget.htm b/test/wpt/tests/eventsource/shared-worker/eventsource-eventtarget.htm new file mode 100644 index 00000000000..f25509dfd4a --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-eventtarget.htm @@ -0,0 +1,24 @@ + + + + shared worker - EventSource: addEventListener() + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-eventtarget.js b/test/wpt/tests/eventsource/shared-worker/eventsource-eventtarget.js new file mode 100644 index 00000000000..761165118ac --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-eventtarget.js @@ -0,0 +1,13 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + source.addEventListener("message", listener, false) + function listener(e) { + port.postMessage([true, e.data]) + this.close() + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-onmesage.js b/test/wpt/tests/eventsource/shared-worker/eventsource-onmesage.js new file mode 100644 index 00000000000..f5e2c898df0 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-onmesage.js @@ -0,0 +1,12 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + source.onmessage = function(e) { + port.postMessage([true, e.data]) + this.close() + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-onmessage.htm b/test/wpt/tests/eventsource/shared-worker/eventsource-onmessage.htm new file mode 100644 index 00000000000..bcd6093454d --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-onmessage.htm @@ -0,0 +1,24 @@ + + + + shared worker - EventSource: onmessage + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-onopen.htm b/test/wpt/tests/eventsource/shared-worker/eventsource-onopen.htm new file mode 100644 index 00000000000..752a6e449f9 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-onopen.htm @@ -0,0 +1,27 @@ + + + + shared worker - EventSource: onopen (announcing the connection) + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-onopen.js b/test/wpt/tests/eventsource/shared-worker/eventsource-onopen.js new file mode 100644 index 00000000000..6dc9424a213 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-onopen.js @@ -0,0 +1,12 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + source.onopen = function(e) { + port.postMessage([true, source.readyState, 'data' in e, e.bubbles, e.cancelable]) + this.close() + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-prototype.htm b/test/wpt/tests/eventsource/shared-worker/eventsource-prototype.htm new file mode 100644 index 00000000000..16c932a3384 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-prototype.htm @@ -0,0 +1,25 @@ + + + + shared worker - EventSource: prototype et al + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-prototype.js b/test/wpt/tests/eventsource/shared-worker/eventsource-prototype.js new file mode 100644 index 00000000000..f4c809a9b3e --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-prototype.js @@ -0,0 +1,11 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + EventSource.prototype.ReturnTrue = function() { return true } + var source = new EventSource("../resources/message.py") + port.postMessage([true, source.ReturnTrue(), 'EventSource' in self]) + source.close() +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-url.htm b/test/wpt/tests/eventsource/shared-worker/eventsource-url.htm new file mode 100644 index 00000000000..a1c9ca8455f --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-url.htm @@ -0,0 +1,25 @@ + + + + shared worker - EventSource: url + + + + +
+ + + \ No newline at end of file diff --git a/test/wpt/tests/eventsource/shared-worker/eventsource-url.js b/test/wpt/tests/eventsource/shared-worker/eventsource-url.js new file mode 100644 index 00000000000..491dbac3332 --- /dev/null +++ b/test/wpt/tests/eventsource/shared-worker/eventsource-url.js @@ -0,0 +1,10 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + port.postMessage([true, source.url]) + source.close() +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file From 926438eb72f5a18f7c9bc5c81927a28d7e74882f Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 11 Jan 2024 05:01:21 +0100 Subject: [PATCH 03/60] make partially work wpts --- lib/eventsource/eventsource.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index dbefa536d4c..d2f2fe14fa4 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -5,6 +5,7 @@ const { kEvents, kState } = require('./symbols') +const { getGlobalOrigin } = require('../fetch/global') const { webidl } = require('../fetch/webidl') const { CONNECTING, OPEN, CLOSED, mimeType, defaultReconnectionTime } = require('./constants') const { EventSourceStream } = require('./eventsource-stream') @@ -40,10 +41,24 @@ class EventSource extends EventTarget { constructor (url, eventSourceInitDict) { super() - if (arguments.length === 0) { - throw new TypeError('url') + webidl.argumentLengthCheck(arguments, 1, { header: 'EventSource constructor' }) + + // 1. Let baseURL be this's relevant settings object's API base URL. + const baseURL = getGlobalOrigin() + + // 2. Let urlRecord be the result of applying the URL parser to url with baseURL. + let urlRecord + + try { + urlRecord = new URL(url, baseURL) + } catch (e) { + // 3. If urlRecord is failure, then throw a "SyntaxError" DOMException. + throw new DOMException(e, 'SyntaxError') } + // 4. Set this's url to urlRecord. + this.#url = urlRecord.href + this[kState] = { lastEventId: '', origin: '', @@ -56,7 +71,6 @@ class EventSource extends EventTarget { open: null } - this.#url = `${url}` this[kState].origin = new URL(this.#url).origin if (eventSourceInitDict) { From 1c7fcb29be104b2d2784ea6f15c8331974598e03 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 12 Jan 2024 01:37:45 +0100 Subject: [PATCH 04/60] fix some --- lib/eventsource/eventsource.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index d2f2fe14fa4..4bb182b4dd0 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -71,7 +71,7 @@ class EventSource extends EventTarget { open: null } - this[kState].origin = new URL(this.#url).origin + this[kState].origin = urlRecord.origin if (eventSourceInitDict) { if (eventSourceInitDict.withCredentials) { @@ -110,6 +110,8 @@ class EventSource extends EventTarget { } async #connect () { + if (this.#readyState === CLOSED) return + this.#readyState = CONNECTING this.#connection = null @@ -179,14 +181,10 @@ class EventSource extends EventTarget { return } - const self = this - pipeline(this.#connection.body, new EventSourceStream({ eventSourceState: this[kState], - push: function push (chunk) { - self.dispatchEvent(chunk) - } + push: this.dispatchEvent }), (err) => { if (err) { @@ -209,7 +207,7 @@ class EventSource extends EventTarget { this.#reconnectionTimer = setTimeout(() => { this.#connect() - }, this[kState].reconnectionTime) + }, this[kState].reconnectionTime).unref() } } From 2e6966318d73c06a1edcb3e508a14c49491ef049 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 12 Jan 2024 07:08:48 +0100 Subject: [PATCH 05/60] restructure, use ErrorEvent --- lib/eventsource/constants.js | 7 ---- lib/eventsource/eventsource-stream.js | 10 +++--- lib/eventsource/eventsource.js | 48 ++++++++++++++++++-------- test/eventsource/eventsource-stream.js | 41 +++++++++++----------- 4 files changed, 58 insertions(+), 48 deletions(-) diff --git a/lib/eventsource/constants.js b/lib/eventsource/constants.js index 2f00448299b..699a9b40f34 100644 --- a/lib/eventsource/constants.js +++ b/lib/eventsource/constants.js @@ -1,11 +1,5 @@ 'use strict' -/** - * The event stream format's MIME type is text/event-stream. - * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream - */ -const mimeType = 'text/event-stream' - /** * A reconnection time, in milliseconds. This must initially be an implementation-defined value, * probably in the region of a few seconds. @@ -65,7 +59,6 @@ const COLON = 0x3A const SPACE = 0x20 module.exports = { - mimeType, defaultReconnectionTime, CONNECTING, OPEN, diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 2c846524d72..d6cd10c3fb8 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -1,6 +1,5 @@ 'use strict' const { Transform } = require('node:stream') -const { MessageEvent } = require('../websocket/events') const { BOM, CR, LF, COLON, SPACE } = require('./constants') const { isASCIINumber, isValidLastEventId } = require('./util') @@ -210,13 +209,14 @@ class EventSourceStream extends Transform { this.state.lastEventId = id } - this.push( - new MessageEvent(type, { + this.push({ + type, + payload: { data, lastEventId: this.state.lastEventId, origin: this.state.origin - }) - ) + } + }) } } diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 4bb182b4dd0..3ab292e6f04 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -5,10 +5,13 @@ const { kEvents, kState } = require('./symbols') +const { fetch } = require('../fetch') const { getGlobalOrigin } = require('../fetch/global') const { webidl } = require('../fetch/webidl') const { CONNECTING, OPEN, CLOSED, mimeType, defaultReconnectionTime } = require('./constants') const { EventSourceStream } = require('./eventsource-stream') +const { parseMIMEType } = require('../fetch/dataURL') +const { MessageEvent, ErrorEvent } = require('../websocket/events') /** * @typedef {object} EventSourceInit @@ -149,7 +152,7 @@ class EventSource extends EventTarget { case 308: // 308 Permanent Redirect if (!this.#connection.headers.has('Location')) { this.close() - this.dispatchEvent(new Event('error')) + this.dispatchEvent(new ErrorEvent('error', { message: 'Missing Location header' })) return } this.#url = new URL(this.#connection.headers.get('Location'), new URL(this.#url).origin).href @@ -160,35 +163,50 @@ class EventSource extends EventTarget { // Clients will reconnect if the connection is closed; a client can be told to stop reconnecting // using the HTTP 204 No Content response code. this.close() - this.dispatchEvent(new Event('error')) + this.dispatchEvent(new ErrorEvent('error', { message: 'Closing connection as 204 No Content was received' })) return - case 200: - if (this.#connection.headers.get('Content-Type') !== mimeType) { + case 200: { // 200 OK + const contentType = this.#connection.headers.get('content-type', true) + const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' + + /** + * The event stream format's MIME type is text/event-stream. + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream + */ + if (mimeType === 'failure' || mimeType.essence !== 'text/event-stream') { this.close() - this.dispatchEvent(new Event('error')) + this.dispatchEvent(new ErrorEvent('error', { message: 'Content-Type is not text/event-stream' })) return } break + } default: this.close() - this.dispatchEvent(new Event('error')) + this.dispatchEvent(new ErrorEvent('error', { message: 'Unsupported status code' })) return } if (this.#connection === null) { this.close() - this.dispatchEvent(new Event('error')) + this.dispatchEvent(new ErrorEvent('error', { message: 'Could not establish connection' })) return } + const eventSourceStream = new EventSourceStream({ + eventSourceState: this[kState], + push: (eventPayload) => { + this.dispatchEvent('message', new MessageEvent( + eventPayload.type, + eventPayload.payload + )) + } + }) + pipeline(this.#connection.body, - new EventSourceStream({ - eventSourceState: this[kState], - push: this.dispatchEvent - }), - (err) => { - if (err) { - this.dispatchEvent(new Event('error')) + eventSourceStream, + (error) => { + if (error) { + this.dispatchEvent(new ErrorEvent('error', { error })) this.close() } }) @@ -199,7 +217,7 @@ class EventSource extends EventTarget { if (error.name === 'AbortError') { return } - this.dispatchEvent(new Event('error')) + this.dispatchEvent(new ErrorEvent('error', { error })) // Always set to CONNECTING as the readyState could be OPEN this.#readyState = CONNECTING diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js index 0dd50ff51ac..bad97c60cf1 100644 --- a/test/eventsource/eventsource-stream.js +++ b/test/eventsource/eventsource-stream.js @@ -3,7 +3,6 @@ const assert = require('node:assert') const { test, describe } = require('node:test') const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') -const { MessageEvent } = require('../../lib/websocket/events') describe('EventSourceStream - processEvent', () => { const defaultEventSourceState = { @@ -19,12 +18,12 @@ describe('EventSourceStream - processEvent', () => { }) stream.on('data', (event) => { - assert.strictEqual(event instanceof MessageEvent, true) - assert.strictEqual(event.data, null) - assert.strictEqual(event.lastEventId, '') + assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'message') + assert.strictEqual(event.payload.data, null) + assert.strictEqual(event.payload.lastEventId, undefined) + assert.strictEqual(event.payload.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) - assert.strictEqual(event.origin, 'example.com') }) stream.processEvent({}) @@ -52,11 +51,11 @@ describe('EventSourceStream - processEvent', () => { }) stream.on('data', (event) => { - assert.strictEqual(event instanceof MessageEvent, true) - assert.strictEqual(event.data, 'Hello') - assert.strictEqual(event.lastEventId, '') + assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'message') - assert.strictEqual(event.origin, 'example.com') + assert.strictEqual(event.payload.data, 'Hello') + assert.strictEqual(event.payload.lastEventId, undefined) + assert.strictEqual(event.payload.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) @@ -73,11 +72,11 @@ describe('EventSourceStream - processEvent', () => { }) stream.on('data', (event) => { - assert.strictEqual(event instanceof MessageEvent, true) - assert.strictEqual(event.data, null) - assert.strictEqual(event.lastEventId, '1234') + assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'message') - assert.strictEqual(event.origin, 'example.com') + assert.strictEqual(event.payload.data, null) + assert.strictEqual(event.payload.lastEventId, '1234') + assert.strictEqual(event.payload.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) @@ -96,11 +95,11 @@ describe('EventSourceStream - processEvent', () => { }) stream.on('data', (event) => { - assert.strictEqual(event instanceof MessageEvent, true) - assert.strictEqual(event.data, null) - assert.strictEqual(event.lastEventId, '1234') + assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'message') - assert.strictEqual(event.origin, 'example.com') + assert.strictEqual(event.payload.data, null) + assert.strictEqual(event.payload.lastEventId, '1234') + assert.strictEqual(event.payload.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) @@ -115,11 +114,11 @@ describe('EventSourceStream - processEvent', () => { }) stream.on('data', (event) => { - assert.strictEqual(event instanceof MessageEvent, true) - assert.strictEqual(event.data, null) - assert.strictEqual(event.lastEventId, '') + assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'custom') - assert.strictEqual(event.origin, 'example.com') + assert.strictEqual(event.payload.data, null) + assert.strictEqual(event.payload.lastEventId, undefined) + assert.strictEqual(event.payload.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) From 79e7419ee0135badae2f3579e932da228114e9a5 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 12 Jan 2024 14:22:34 +0100 Subject: [PATCH 06/60] fix --- lib/eventsource/eventsource.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 3ab292e6f04..ea7735544da 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -150,22 +150,34 @@ class EventSource extends EventTarget { case 302: // 302 Found case 307: // 307 Temporary Redirect case 308: // 308 Permanent Redirect + { if (!this.#connection.headers.has('Location')) { this.close() this.dispatchEvent(new ErrorEvent('error', { message: 'Missing Location header' })) return } - this.#url = new URL(this.#connection.headers.get('Location'), new URL(this.#url).origin).href - this[kState].origin = new URL(this.#url).origin + + let urlRecord + try { + urlRecord = new URL(this.#connection.headers.get('Location'), this[kState].origin) + } catch (e) { + // If urlRecord is failure, then throw a "SyntaxError" DOMException. + throw new DOMException(e, 'SyntaxError') + } + this.#url = urlRecord.href + this[kState].origin = urlRecord.origin this.#connect() return + } case 204: // 204 No Content + { // Clients will reconnect if the connection is closed; a client can be told to stop reconnecting // using the HTTP 204 No Content response code. this.close() this.dispatchEvent(new ErrorEvent('error', { message: 'Closing connection as 204 No Content was received' })) - return - case 200: { // 200 OK + return } + case 200: // 200 OK + { const contentType = this.#connection.headers.get('content-type', true) const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' @@ -181,9 +193,11 @@ class EventSource extends EventTarget { break } default: + { this.close() this.dispatchEvent(new ErrorEvent('error', { message: 'Unsupported status code' })) return + } } if (this.#connection === null) { From 54b234a0274b5e5b70e087ca766638938320f220 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 12 Jan 2024 15:11:48 +0100 Subject: [PATCH 07/60] restructure, create distinct OpenEvent --- lib/eventsource/events.js | 31 +++++++++++++++++++++++++++++++ lib/eventsource/eventsource.js | 6 +++--- lib/websocket/events.js | 1 + 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 lib/eventsource/events.js diff --git a/lib/eventsource/events.js b/lib/eventsource/events.js new file mode 100644 index 00000000000..74147c46d74 --- /dev/null +++ b/lib/eventsource/events.js @@ -0,0 +1,31 @@ +'use strict' + +const { webidl } = require('../fetch/webidl') +const { + MessageEvent, + ErrorEvent, + eventInit +} = require('../websocket/events') + +class OpenEvent extends Event { + constructor (type, eventInitDict = {}) { + try { + webidl.argumentLengthCheck(arguments, 1, { header: 'OpenEvent constructor' }) + } catch (e) { + console.log(e) + } + + type = webidl.converters.DOMString(type) + eventInitDict = webidl.converters.OpenEventInit(eventInitDict) + + super(type, eventInitDict) + } +} + +webidl.converters.OpenEventInit = webidl.dictionaryConverter(eventInit) + +module.exports = { + ErrorEvent, + MessageEvent, + OpenEvent +} diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index ea7735544da..7c0008f5b3f 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -11,7 +11,7 @@ const { webidl } = require('../fetch/webidl') const { CONNECTING, OPEN, CLOSED, mimeType, defaultReconnectionTime } = require('./constants') const { EventSourceStream } = require('./eventsource-stream') const { parseMIMEType } = require('../fetch/dataURL') -const { MessageEvent, ErrorEvent } = require('../websocket/events') +const { MessageEvent, OpenEvent, ErrorEvent } = require('./events') /** * @typedef {object} EventSourceInit @@ -209,7 +209,7 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceState: this[kState], push: (eventPayload) => { - this.dispatchEvent('message', new MessageEvent( + this.dispatchEvent(new MessageEvent( eventPayload.type, eventPayload.payload )) @@ -225,7 +225,7 @@ class EventSource extends EventTarget { } }) - this.dispatchEvent(new Event('open')) + this.dispatchEvent(new OpenEvent('open')) this.#readyState = OPEN } catch (error) { if (error.name === 'AbortError') { diff --git a/lib/websocket/events.js b/lib/websocket/events.js index 621a2263b7d..cfea412ba50 100644 --- a/lib/websocket/events.js +++ b/lib/websocket/events.js @@ -297,6 +297,7 @@ webidl.converters.ErrorEventInit = webidl.dictionaryConverter([ ]) module.exports = { + eventInit, MessageEvent, CloseEvent, ErrorEvent From 3337d7d8cc58ad20322c4580b561ac253edde2f8 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 12 Jan 2024 15:26:10 +0100 Subject: [PATCH 08/60] add experimental warning, transform inputs --- lib/eventsource/events.js | 6 +----- lib/eventsource/eventsource.js | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/eventsource/events.js b/lib/eventsource/events.js index 74147c46d74..7a5ee6f4a84 100644 --- a/lib/eventsource/events.js +++ b/lib/eventsource/events.js @@ -9,11 +9,7 @@ const { class OpenEvent extends Event { constructor (type, eventInitDict = {}) { - try { - webidl.argumentLengthCheck(arguments, 1, { header: 'OpenEvent constructor' }) - } catch (e) { - console.log(e) - } + webidl.argumentLengthCheck(arguments, 1, { header: 'OpenEvent constructor' }) type = webidl.converters.DOMString(type) eventInitDict = webidl.converters.OpenEventInit(eventInitDict) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 7c0008f5b3f..b07a982d2fd 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -13,6 +13,8 @@ const { EventSourceStream } = require('./eventsource-stream') const { parseMIMEType } = require('../fetch/dataURL') const { MessageEvent, OpenEvent, ErrorEvent } = require('./events') +let experimentalWarned = false + /** * @typedef {object} EventSourceInit * @property {boolean} [withCredentials] indicates whether the request @@ -41,11 +43,21 @@ class EventSource extends EventTarget { * @param {string} url * @param {EventSourceInit} [eventSourceInitDict] */ - constructor (url, eventSourceInitDict) { + constructor (url, eventSourceInitDict = {}) { 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) + // 1. Let baseURL be this's relevant settings object's API base URL. const baseURL = getGlobalOrigin() @@ -351,6 +363,10 @@ EventSource.prototype.CONNECTING = CONNECTING EventSource.prototype.OPEN = OPEN EventSource.prototype.CLOSED = CLOSED +webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ + { key: 'withCredentials', converter: webidl.converters.boolean, defaultValue: false } +]) + module.exports = { EventSource } From 82214e0573c6f4e40bf07798cb13d2bcf7797e4f Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 15 Jan 2024 03:25:15 +0100 Subject: [PATCH 09/60] add-types --- types/eventsource.d.ts | 61 ++++++++++++++++++++++++++++++++++++++++++ types/websocket.d.ts | 23 +++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 types/eventsource.d.ts diff --git a/types/eventsource.d.ts b/types/eventsource.d.ts new file mode 100644 index 00000000000..0615bf45e90 --- /dev/null +++ b/types/eventsource.d.ts @@ -0,0 +1,61 @@ +import { MessageEvent, ErrorEvent } from './websocket' + +import { + EventTarget, + Event, + EventListenerOptions, + AddEventListenerOptions, + EventListenerOrEventListenerObject +} from './patch' + +interface EventSourceEventMap { + error: ErrorEvent + message: MessageEvent + open: Event +} + +interface EventSource extends EventTarget { + close(): void + readonly CLOSED: 2 + readonly CONNECTING: 0 + readonly OPEN: 1 + onerror: (this: EventSource, ev: ErrorEvent) => any + onmessage: (this: EventSource, ev: MessageEvent) => any + onopen: (this: EventSource, ev: Event) => any + readonly readyState: 0 | 1 | 2 + readonly url: string + readonly withCredentials: boolean + + addEventListener( + type: K, + listener: (this: EventSource, ev: EventSourceEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void + removeEventListener( + type: K, + listener: (this: EventSource, ev: EventSourceEventMap[K]) => any, + options?: boolean | EventListenerOptions + ): void + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void +} + +export declare const EventSource: { + prototype: EventSource + new (url: string | URL, init: EventSourceInit): EventSource + readonly CLOSED: 2 + readonly CONNECTING: 0 + readonly OPEN: 1 +} + +interface EventSourceInit { + withCredentials?: boolean +} diff --git a/types/websocket.d.ts b/types/websocket.d.ts index 15a357d36d5..d1be45235d4 100644 --- a/types/websocket.d.ts +++ b/types/websocket.d.ts @@ -17,7 +17,7 @@ export type BinaryType = 'blob' | 'arraybuffer' interface WebSocketEventMap { close: CloseEvent - error: Event + error: ErrorEvent message: MessageEvent open: Event } @@ -124,6 +124,27 @@ export declare const MessageEvent: { new(type: string, eventInitDict?: MessageEventInit): MessageEvent } +interface ErrorEventInit extends EventInit { + message?: string + filename?: string + lineno?: number + colno?: number + error?: any +} + +interface ErrorEvent extends Event { + readonly message: string + readonly filename: string + readonly lineno: number + readonly colno: number + readonly error: any +} + +export declare const ErrorEvent: { + prototype: ErrorEvent + new (type: string, eventInitDict?: ErrorEventInit): ErrorEvent +} + interface WebSocketInit { protocols?: string | string[], dispatcher?: Dispatcher, From 56ec56f05653e338d6f5a65401d9a72c3979f215 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 15 Jan 2024 15:50:24 +0100 Subject: [PATCH 10/60] restructure --- examples/eventsource.js | 17 ++ index.js | 4 + lib/eventsource/constants.js | 16 +- lib/eventsource/eventsource.js | 372 ----------------------------- lib/eventsource/index.js | 408 ++++++++++++++++++++++++++++++++ lib/eventsource/symbols.js | 6 - lib/fetch/index.js | 2 +- lib/fetch/request.js | 4 +- lib/fetch/response.js | 14 ++ test/eventsource/eventsource.js | 17 +- types/index.d.ts | 1 + 11 files changed, 475 insertions(+), 386 deletions(-) create mode 100644 examples/eventsource.js delete mode 100644 lib/eventsource/eventsource.js create mode 100644 lib/eventsource/index.js delete mode 100644 lib/eventsource/symbols.js diff --git a/examples/eventsource.js b/examples/eventsource.js new file mode 100644 index 00000000000..b54663f5315 --- /dev/null +++ b/examples/eventsource.js @@ -0,0 +1,17 @@ +'use strict' + +const { EventSource } = require('../') + +async function main () { + const ev = new EventSource('https://smee.io/wcGp009TievZCLT') + ev.onmessage = (event) => { + console.log(event) + } + ev.onerror = event => { + console.log(event) + } + ev.onopen = event => { + console.log(event) + } +} +main() diff --git a/index.js b/index.js index 2594140d09e..d0e1ce1ecc6 100644 --- a/index.js +++ b/index.js @@ -149,3 +149,7 @@ module.exports.MockClient = MockClient module.exports.MockPool = MockPool module.exports.MockAgent = MockAgent module.exports.mockErrors = mockErrors + +const { EventSource } = require('./lib/eventsource') + +module.exports.EventSource = EventSource diff --git a/lib/eventsource/constants.js b/lib/eventsource/constants.js index 699a9b40f34..47ee45e285a 100644 --- a/lib/eventsource/constants.js +++ b/lib/eventsource/constants.js @@ -37,6 +37,18 @@ const OPEN = 1 */ 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' + /** * @type {number[]} BOM */ @@ -67,5 +79,7 @@ module.exports = { LF, CR, COLON, - SPACE + SPACE, + ANONYMOUS, + USE_CREDENTIALS } diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js deleted file mode 100644 index b07a982d2fd..00000000000 --- a/lib/eventsource/eventsource.js +++ /dev/null @@ -1,372 +0,0 @@ -'use strict' - -const { pipeline } = require('node:stream') -const { - kEvents, - kState -} = require('./symbols') -const { fetch } = require('../fetch') -const { getGlobalOrigin } = require('../fetch/global') -const { webidl } = require('../fetch/webidl') -const { CONNECTING, OPEN, CLOSED, mimeType, defaultReconnectionTime } = require('./constants') -const { EventSourceStream } = require('./eventsource-stream') -const { parseMIMEType } = require('../fetch/dataURL') -const { MessageEvent, OpenEvent, ErrorEvent } = require('./events') - -let experimentalWarned = false - -/** - * @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://developer.mozilla.org/en-US/docs/Web/API/EventSource - * @api public - */ -class EventSource extends EventTarget { - #url = null - #withCredentials = false - #readyState = CONNECTING - #lastEventId = '' - #connection = null - #reconnectionTimer = null - #controller = new AbortController() - - /** - * Creates a new EventSource object. - * @param {string} url - * @param {EventSourceInit} [eventSourceInitDict] - */ - constructor (url, eventSourceInitDict = {}) { - 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) - - // 1. Let baseURL be this's relevant settings object's API base URL. - const baseURL = getGlobalOrigin() - - // 2. Let urlRecord be the result of applying the URL parser to url with baseURL. - let urlRecord - - try { - urlRecord = new URL(url, baseURL) - } catch (e) { - // 3. If urlRecord is failure, then throw a "SyntaxError" DOMException. - throw new DOMException(e, 'SyntaxError') - } - - // 4. Set this's url to urlRecord. - this.#url = urlRecord.href - - this[kState] = { - lastEventId: '', - origin: '', - reconnectionTime: defaultReconnectionTime - } - - this[kEvents] = { - message: null, - error: null, - open: null - } - - this[kState].origin = urlRecord.origin - - if (eventSourceInitDict) { - if (eventSourceInitDict.withCredentials) { - this.#withCredentials = eventSourceInitDict.withCredentials - } - } - - 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 - } - - async #connect () { - if (this.#readyState === CLOSED) return - - this.#readyState = CONNECTING - this.#connection = null - - /** - * @type {RequestInit} - */ - const options = { - method: 'GET', - redirect: 'manual', - keepalive: true, - headers: { - Accept: mimeType, - 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - }, - signal: this.#controller.signal - } - - if (this.#lastEventId) { - options.headers['Last-Event-ID'] = this.#lastEventId - } - - options.credentials = this.#withCredentials ? 'include' : 'omit' - - try { - this.#connection = await fetch(this.#url, options) - - // Handle HTTP redirects - // https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events-intro - switch (this.#connection.status) { - // Redirecting status codes - case 301: // 301 Moved Permanently - case 302: // 302 Found - case 307: // 307 Temporary Redirect - case 308: // 308 Permanent Redirect - { - if (!this.#connection.headers.has('Location')) { - this.close() - this.dispatchEvent(new ErrorEvent('error', { message: 'Missing Location header' })) - return - } - - let urlRecord - try { - urlRecord = new URL(this.#connection.headers.get('Location'), this[kState].origin) - } catch (e) { - // If urlRecord is failure, then throw a "SyntaxError" DOMException. - throw new DOMException(e, 'SyntaxError') - } - this.#url = urlRecord.href - this[kState].origin = urlRecord.origin - this.#connect() - return - } - case 204: // 204 No Content - { - // Clients will reconnect if the connection is closed; a client can be told to stop reconnecting - // using the HTTP 204 No Content response code. - this.close() - this.dispatchEvent(new ErrorEvent('error', { message: 'Closing connection as 204 No Content was received' })) - return } - case 200: // 200 OK - { - const contentType = this.#connection.headers.get('content-type', true) - const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' - - /** - * The event stream format's MIME type is text/event-stream. - * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream - */ - if (mimeType === 'failure' || mimeType.essence !== 'text/event-stream') { - this.close() - this.dispatchEvent(new ErrorEvent('error', { message: 'Content-Type is not text/event-stream' })) - return - } - break - } - default: - { - this.close() - this.dispatchEvent(new ErrorEvent('error', { message: 'Unsupported status code' })) - return - } - } - - if (this.#connection === null) { - this.close() - this.dispatchEvent(new ErrorEvent('error', { message: 'Could not establish connection' })) - return - } - - const eventSourceStream = new EventSourceStream({ - eventSourceState: this[kState], - push: (eventPayload) => { - this.dispatchEvent(new MessageEvent( - eventPayload.type, - eventPayload.payload - )) - } - }) - - pipeline(this.#connection.body, - eventSourceStream, - (error) => { - if (error) { - this.dispatchEvent(new ErrorEvent('error', { error })) - this.close() - } - }) - - this.dispatchEvent(new OpenEvent('open')) - this.#readyState = OPEN - } catch (error) { - if (error.name === 'AbortError') { - return - } - this.dispatchEvent(new ErrorEvent('error', { error })) - - // Always set to CONNECTING as the readyState could be OPEN - this.#readyState = CONNECTING - this.#connection = null - - this.#reconnectionTimer = setTimeout(() => { - this.#connect() - }, this[kState].reconnectionTime).unref() - } - } - - /** - * Closes the connection, if any, and sets the readyState attribute to - * CLOSED. - */ - close () { - webidl.brandCheck(this, EventSource) - - if (this.#readyState === CLOSED) return - clearTimeout(this.#reconnectionTimer) - this.#controller.abort() - if (this.#connection) { - this.#connection = null - } - this.#readyState = CLOSED - } - - get onopen () { - webidl.brandCheck(this, EventSource) - - return this[kEvents].open - } - - set onopen (fn) { - webidl.brandCheck(this, EventSource) - - if (this[kEvents].open) { - this.removeEventListener('open', this[kEvents].open) - } - - if (typeof fn === 'function') { - this[kEvents].open = fn - this.addEventListener('open', fn) - } else { - this[kEvents].open = null - } - } - - get onmessage () { - webidl.brandCheck(this, EventSource) - - return this[kEvents].message - } - - set onmessage (fn) { - webidl.brandCheck(this, EventSource) - - if (this[kEvents].message) { - this.removeEventListener('message', this[kEvents].message) - } - - if (typeof fn === 'function') { - this[kEvents].message = fn - this.addEventListener('message', fn) - } else { - this[kEvents].message = null - } - } - - get onerror () { - webidl.brandCheck(this, EventSource) - - return this[kEvents].error - } - - set onerror (fn) { - webidl.brandCheck(this, EventSource) - - if (this[kEvents].error) { - this.removeEventListener('error', this[kEvents].error) - } - - if (typeof fn === 'function') { - this[kEvents].error = fn - this.addEventListener('error', fn) - } else { - this[kEvents].error = null - } - } -} - -Object.defineProperties(EventSource, { - 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 - } -}) - -EventSource.prototype.CONNECTING = CONNECTING -EventSource.prototype.OPEN = OPEN -EventSource.prototype.CLOSED = CLOSED - -webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ - { key: 'withCredentials', converter: webidl.converters.boolean, defaultValue: false } -]) - -module.exports = { - EventSource -} diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js new file mode 100644 index 00000000000..3c9dbd8cef3 --- /dev/null +++ b/lib/eventsource/index.js @@ -0,0 +1,408 @@ +'use strict' + +const { pipeline } = require('node:stream') +const { fetching, finalizeAndReportTiming } = require('../fetch') +const { HeadersList } = require('../fetch/headers') +const { Request } = require('../fetch/request') +const { getGlobalOrigin } = require('../fetch/global') +const { webidl } = require('../fetch/webidl') +const { CONNECTING, OPEN, CLOSED, defaultReconnectionTime, ANONYMOUS, USE_CREDENTIALS } = require('./constants') +const { EventSourceStream } = require('./eventsource-stream') +const { parseMIMEType } = require('../fetch/dataURL') +const { MessageEvent, OpenEvent, ErrorEvent } = require('./events') +const { isNetworkError } = require('../fetch/response') +const { getGlobalDispatcher } = require('../global') + +let experimentalWarned = false + +/** + * @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 + #lastEventId = '' + #reconnectionTimer = null + #request = null + #controller = new AbortController() + #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. + this.#settings = { + lastEventId: '', + origin: '', + reconnectionTime: defaultReconnectionTime + } + + // 1. Let baseURL be this's relevant settings object's API base URL. + const baseURL = getGlobalOrigin() + + // 2. Let urlRecord be the result of applying the URL parser to url with baseURL. + let urlRecord + + try { + // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings. + urlRecord = new URL(url, baseURL) + } 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', + signal: this.#controller.signal, + referrer: 'no-referrer', + referrerPolicy: 'no-referrer' + } + + // 9. Set request's client to settings. + + // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list. + initRequest.headers = new HeadersList() + initRequest.headers.set('accept', 'text/event-stream', true) + + // 11. Set request's cache mode to "no-store". + initRequest.cache = 'no-store' + + // 12. Set request's initiator type to "other". + initRequest.initiator = 'other' + + // 13. Set ev's request to request. + this.#request = new Request(this.#url, initRequest) + + this.#request.headersList = initRequest.headers + this.#request.urlList = this.#request.urlList || [new URL(this.#url)] + + this.#events = { + message: null, + error: null, + open: null + } + + this.#settings.origin = urlRecord.origin + + 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 options = { + request: this.#request, + dispatcher: getGlobalDispatcher() + } + + // 12. Set request's initiator type to "other". + options.processResponseEndOfBody = (response) => + finalizeAndReportTiming(response, 'other') + + if (this.#lastEventId) { + options.request.headers.set('last-event-id', this.#lastEventId, true) + } + + // 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 ErrorEvent('error', { error: response.error })) + this.close() + } + this.#connect() + } + + // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody... + options.processResponseEndOfBody = processEventSourceEndOfBody + + // and processResponse set to the following steps given response res: + + options.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 ErrorEvent('error', { error: response.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, [...], then fail the + // connection. + } else if (response.status !== 200) { + let message = `Unexpected status code: ${response.status}` + + if (response.status === 204) { + message = 'No content' + } + this.close() + this.dispatchEvent(new ErrorEvent('error', { message })) + return + } + + // 3. Otherwise, [...] 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' + if (mimeType === 'failure' || mimeType.essence !== 'text/event-stream') { + this.close() + this.dispatchEvent(new ErrorEvent('error', { error: 'Invalid content-type' })) + 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 OpenEvent('open', {})) + + const eventSourceStream = new EventSourceStream({ + eventSourceState: this.#settings, + push: (eventPayload) => { + this.dispatchEvent(new MessageEvent( + eventPayload.type, + eventPayload.payload + )) + } + }) + + pipeline(response.body.stream, + eventSourceStream, + (error) => { + if (error) { + this.close() + this.dispatchEvent(new ErrorEvent('error', { error })) + } + }) + } + + fetching(options) + } + + #reconnect () { + // TODO: implement reestablish + this.dispatchEvent(new ErrorEvent('error')) + this.close() + } + + /** + * 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.#reconnectionTimer) + this.#controller.abort() + + if (this.#request) { + this.#request = null + } + } + + get onopen () { + webidl.brandCheck(this, EventSource) + + return this.#events.open + } + + set onopen (fn) { + webidl.brandCheck(this, EventSource) + + 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 () { + webidl.brandCheck(this, EventSource) + + return this.#events.message + } + + set onmessage (fn) { + webidl.brandCheck(this, EventSource) + + 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 () { + webidl.brandCheck(this, EventSource) + + return this.#events.error + } + + set onerror (fn) { + webidl.brandCheck(this, EventSource) + + 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 + } + } +} + +Object.defineProperties(EventSource, { + 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 + } +}) + +EventSource.prototype.CONNECTING = CONNECTING +EventSource.prototype.OPEN = OPEN +EventSource.prototype.CLOSED = CLOSED + +webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ + { key: 'withCredentials', converter: webidl.converters.boolean, defaultValue: false } +]) + +module.exports = { + EventSource +} diff --git a/lib/eventsource/symbols.js b/lib/eventsource/symbols.js deleted file mode 100644 index 6dbccaf106e..00000000000 --- a/lib/eventsource/symbols.js +++ /dev/null @@ -1,6 +0,0 @@ -'use strict' - -module.exports = { - kEvents: Symbol('kEvents'), - kState: Symbol('kState') -} diff --git a/lib/fetch/index.js b/lib/fetch/index.js index f4e84d0908e..161e631f51b 100644 --- a/lib/fetch/index.js +++ b/lib/fetch/index.js @@ -555,7 +555,7 @@ async function mainFetch (fetchParams, recursive = false) { // 8. If request’s referrer is not "no-referrer", then set request’s // referrer to the result of invoking determine request’s referrer. - if (request.referrer !== 'no-referrer') { + if (request[kState].referrer !== 'no-referrer') { request.referrer = determineRequestsReferrer(request) } diff --git a/lib/fetch/request.js b/lib/fetch/request.js index f5522486c1d..b6f9f50399f 100644 --- a/lib/fetch/request.js +++ b/lib/fetch/request.js @@ -220,7 +220,7 @@ class Request { const referrer = init.referrer // 2. If referrer is the empty string, then set request’s referrer to "no-referrer". - if (referrer === '') { + if (referrer === '' || referrer === 'no-referrer') { request.referrer = 'no-referrer' } else { // 1. Let parsedReferrer be the result of parsing referrer with @@ -823,7 +823,7 @@ function makeRequest (init) { ? new HeadersList(init.headersList) : new HeadersList() } - request.url = request.urlList[0] + request.url = request.urlList[request.urlList.length - 1] return request } diff --git a/lib/fetch/response.js b/lib/fetch/response.js index 5be1f438a12..6366e446dc7 100644 --- a/lib/fetch/response.js +++ b/lib/fetch/response.js @@ -364,6 +364,19 @@ function makeNetworkError (reason) { }) } +// @see https://fetch.spec.whatwg.org/#concept-network-error +function isNetworkError (response) { + return ( + // A network error is a response whose type is "error", + response.type === 'error' && + // status is 0 + response.status === 0 && + 'error' in response && ( + 'aborted' in response ? typeof response.aborted === 'boolean' : true + ) + ) +} + function makeFilteredResponse (response, state) { state = { internalResponse: response, @@ -572,6 +585,7 @@ webidl.converters.ResponseInit = webidl.dictionaryConverter([ ]) module.exports = { + isNetworkError, makeNetworkError, makeResponse, makeAppropriateNetworkError, diff --git a/test/eventsource/eventsource.js b/test/eventsource/eventsource.js index 630aba987cf..aece3cf2897 100644 --- a/test/eventsource/eventsource.js +++ b/test/eventsource/eventsource.js @@ -4,7 +4,7 @@ const assert = require('node:assert') const events = require('node:events') const http = require('node:http') const { test, describe } = require('node:test') -const { EventSource } = require('../../lib/eventsource/eventsource') +const { EventSource } = require('../../lib/eventsource') describe('EventSource - constructor', () => { test('Not providing url argument should throw', () => { @@ -101,11 +101,15 @@ describe('EventSource - redirecting', () => { server.listen(0) await events.once(server, 'listening') + const port = server.address().port const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = (e) => { + assert.fail('Should not have errored') + } eventSourceInstance.onopen = () => { - assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) + // assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) eventSourceInstance.close() server.close() } @@ -130,11 +134,16 @@ describe('EventSource - stop redirecting on 204 status code', async () => { const port = server.address().port const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) - eventSourceInstance.onerror = () => { - assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) + eventSourceInstance.onerror = (event) => { + assert.strictEqual(event.message, 'No content') + // TODO: fetching does not set the url properly? + // assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) server.close() } + eventSourceInstance.onopen = () => { + assert.fail('Should not have opened') + } }) }) diff --git a/types/index.d.ts b/types/index.d.ts index 8b35475219b..63e5c32bcef 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -19,6 +19,7 @@ import { request, pipeline, stream, connect, upgrade } from './api' export * from './util' export * from './cookies' +export * from './eventsource' export * from './fetch' export * from './file' export * from './filereader' From 8c76a8aab0161058ccf5f525906008aa5f597718 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 15 Jan 2024 16:25:37 +0100 Subject: [PATCH 11/60] add TODO for comment --- lib/fetch/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/fetch/index.js b/lib/fetch/index.js index 161e631f51b..2f9a89605ed 100644 --- a/lib/fetch/index.js +++ b/lib/fetch/index.js @@ -1361,6 +1361,7 @@ function httpRedirectFetch (fetchParams, response) { // 18. Append locationURL to request’s URL list. request.urlList.push(locationURL) + // TODO: not enough? // 19. Invoke set request’s referrer policy on redirect on request and // actualResponse. From 755b2d6fdfb0fe757e46de807a5e4c187fa3391b Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 15 Jan 2024 22:22:30 +0100 Subject: [PATCH 12/60] use mainFetch --- lib/eventsource/index.js | 31 ++++++++++++++++++------------- lib/fetch/index.js | 3 ++- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js index 3c9dbd8cef3..6e7a85761f3 100644 --- a/lib/eventsource/index.js +++ b/lib/eventsource/index.js @@ -1,9 +1,9 @@ 'use strict' const { pipeline } = require('node:stream') -const { fetching, finalizeAndReportTiming } = require('../fetch') +const { mainFetch, Fetch, finalizeAndReportTiming } = require('../fetch') const { HeadersList } = require('../fetch/headers') -const { Request } = require('../fetch/request') +const { makeRequest } = require('../fetch/request') const { getGlobalOrigin } = require('../fetch/global') const { webidl } = require('../fetch/webidl') const { CONNECTING, OPEN, CLOSED, defaultReconnectionTime, ANONYMOUS, USE_CREDENTIALS } = require('./constants') @@ -12,6 +12,7 @@ const { parseMIMEType } = require('../fetch/dataURL') const { MessageEvent, OpenEvent, ErrorEvent } = require('./events') const { isNetworkError } = require('../fetch/response') const { getGlobalDispatcher } = require('../global') +const { createOpaqueTimingInfo } = require('../fetch/util') let experimentalWarned = false @@ -129,11 +130,12 @@ class EventSource extends EventTarget { // 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 = new Request(this.#url, initRequest) + this.#request = makeRequest(initRequest) this.#request.headersList = initRequest.headers - this.#request.urlList = this.#request.urlList || [new URL(this.#url)] this.#events = { message: null, @@ -173,22 +175,21 @@ class EventSource extends EventTarget { return this.#withCredentials } - #connect () { + async #connect () { if (this.#readyState === CLOSED) return this.#readyState = CONNECTING - const options = { - request: this.#request, - dispatcher: getGlobalDispatcher() + const fetchParam = { + request: this.#request } // 12. Set request's initiator type to "other". - options.processResponseEndOfBody = (response) => + fetchParam.processResponseEndOfBody = (response) => finalizeAndReportTiming(response, 'other') if (this.#lastEventId) { - options.request.headers.set('last-event-id', this.#lastEventId, true) + fetchParam.request.headers.set('last-event-id', this.#lastEventId, true) } // 14. Let processEventSourceEndOfBody given response res be the following step: if res is not a network error, then reestablish the connection. @@ -201,11 +202,11 @@ class EventSource extends EventTarget { } // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody... - options.processResponseEndOfBody = processEventSourceEndOfBody + fetchParam.processResponseEndOfBody = processEventSourceEndOfBody // and processResponse set to the following steps given response res: - options.processResponse = (response) => { + fetchParam.processResponse = (response) => { // 1. If res is an aborted network error, then fail the connection. if (isNetworkError(response)) { @@ -281,7 +282,11 @@ class EventSource extends EventTarget { }) } - fetching(options) + fetchParam.timingInfo = createOpaqueTimingInfo({}) + + fetchParam.controller = new Fetch(getGlobalDispatcher()) + + await mainFetch(fetchParam) } #reconnect () { diff --git a/lib/fetch/index.js b/lib/fetch/index.js index 2f9a89605ed..230ac2dc39f 100644 --- a/lib/fetch/index.js +++ b/lib/fetch/index.js @@ -555,7 +555,7 @@ async function mainFetch (fetchParams, recursive = false) { // 8. If request’s referrer is not "no-referrer", then set request’s // referrer to the result of invoking determine request’s referrer. - if (request[kState].referrer !== 'no-referrer') { + if (request.referrer !== 'no-referrer') { request.referrer = determineRequestsReferrer(request) } @@ -2280,5 +2280,6 @@ module.exports = { fetch, Fetch, fetching, + mainFetch, finalizeAndReportTiming } From 4e7eeb5bf2eec9f2280e8bff2c903dde516f0232 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 15 Jan 2024 22:26:50 +0100 Subject: [PATCH 13/60] k --- lib/fetch/request.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/fetch/request.js b/lib/fetch/request.js index b6f9f50399f..f5522486c1d 100644 --- a/lib/fetch/request.js +++ b/lib/fetch/request.js @@ -220,7 +220,7 @@ class Request { const referrer = init.referrer // 2. If referrer is the empty string, then set request’s referrer to "no-referrer". - if (referrer === '' || referrer === 'no-referrer') { + if (referrer === '') { request.referrer = 'no-referrer' } else { // 1. Let parsedReferrer be the result of parsing referrer with @@ -823,7 +823,7 @@ function makeRequest (init) { ? new HeadersList(init.headersList) : new HeadersList() } - request.url = request.urlList[request.urlList.length - 1] + request.url = request.urlList[0] return request } From a46bb20dd2d84dfda366af059ede0ef66d119445 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 15 Jan 2024 23:06:48 +0100 Subject: [PATCH 14/60] make it terminatable --- examples/eventsource.js | 4 ++++ lib/eventsource/index.js | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/eventsource.js b/examples/eventsource.js index b54663f5315..5e83a4a3d8b 100644 --- a/examples/eventsource.js +++ b/examples/eventsource.js @@ -13,5 +13,9 @@ async function main () { ev.onopen = event => { console.log(event) } + setTimeout(() => { + console.log('close') + ev.close() + }, 3000) } main() diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js index 6e7a85761f3..b87bb78e95e 100644 --- a/lib/eventsource/index.js +++ b/lib/eventsource/index.js @@ -43,7 +43,7 @@ class EventSource extends EventTarget { #lastEventId = '' #reconnectionTimer = null #request = null - #controller = new AbortController() + #controller = null #settings = null /** @@ -113,7 +113,6 @@ class EventSource extends EventTarget { credentials: corsAttributeState === 'anonymous' ? 'same-origin' : 'omit', - signal: this.#controller.signal, referrer: 'no-referrer', referrerPolicy: 'no-referrer' } @@ -198,6 +197,7 @@ class EventSource extends EventTarget { this.dispatchEvent(new ErrorEvent('error', { error: response.error })) this.close() } + this.#connect() } @@ -275,7 +275,10 @@ class EventSource extends EventTarget { pipeline(response.body.stream, eventSourceStream, (error) => { - if (error) { + if ( + error && + error.aborted === false + ) { this.close() this.dispatchEvent(new ErrorEvent('error', { error })) } @@ -286,6 +289,8 @@ class EventSource extends EventTarget { fetchParam.controller = new Fetch(getGlobalDispatcher()) + this.#controller = fetchParam.controller + await mainFetch(fetchParam) } From bb416e6ef58f5196be8dca2e699d2e431510166a Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 15 Jan 2024 23:47:40 +0100 Subject: [PATCH 15/60] fix --- lib/eventsource/index.js | 62 ++++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js index b87bb78e95e..1db75fe2eb8 100644 --- a/lib/eventsource/index.js +++ b/lib/eventsource/index.js @@ -1,5 +1,6 @@ 'use strict' +const { setTimeout } = require('node:timers/promises') const { pipeline } = require('node:stream') const { mainFetch, Fetch, finalizeAndReportTiming } = require('../fetch') const { HeadersList } = require('../fetch/headers') @@ -39,11 +40,19 @@ class EventSource extends EventTarget { #url = null #withCredentials = false + #readyState = CONNECTING - #lastEventId = '' - #reconnectionTimer = null + #request = null #controller = null + + /** + * @type {object} + * @property {string} lastEventId + * @property {string} origin + * @property {number} reconnectionTime + * @property {any} reconnectionTimer + */ #settings = null /** @@ -187,8 +196,8 @@ class EventSource extends EventTarget { fetchParam.processResponseEndOfBody = (response) => finalizeAndReportTiming(response, 'other') - if (this.#lastEventId) { - fetchParam.request.headers.set('last-event-id', this.#lastEventId, true) + if (this.#settings.lastEventId) { + fetchParam.request.headers.set('last-event-id', this.#settings.lastEventId, true) } // 14. Let processEventSourceEndOfBody given response res be the following step: if res is not a network error, then reestablish the connection. @@ -294,10 +303,45 @@ class EventSource extends EventTarget { await mainFetch(fetchParam) } - #reconnect () { - // TODO: implement reestablish - this.dispatchEvent(new ErrorEvent('error')) - this.close() + /** + * @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 ErrorEvent('error', { message: 'Reconnecting' })) + + // 2. Wait a delay equal to the reconnection time of the event source. + await setTimeout(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. + // 4. Fetch request and process the response obtained in this fashion, if any, as described earlier in this section. + this.#connect() } /** @@ -309,7 +353,7 @@ class EventSource extends EventTarget { if (this.#readyState === CLOSED) return this.#readyState = CLOSED - clearTimeout(this.#reconnectionTimer) + clearTimeout(this.#settings.reconnectionTimer) this.#controller.abort() if (this.#request) { From 36d974757ab062c72e07e361dd489bd20075d5a6 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 15 Jan 2024 23:50:50 +0100 Subject: [PATCH 16/60] fix --- lib/eventsource/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js index 1db75fe2eb8..47b29b69ada 100644 --- a/lib/eventsource/index.js +++ b/lib/eventsource/index.js @@ -257,7 +257,7 @@ class EventSource extends EventTarget { const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' if (mimeType === 'failure' || mimeType.essence !== 'text/event-stream') { this.close() - this.dispatchEvent(new ErrorEvent('error', { error: 'Invalid content-type' })) + this.dispatchEvent(new ErrorEvent('error', { message: 'Invalid content-type' })) return } // 4. Otherwise, announce the connection and interpret res's body From bdc793c35fd9064a48e0bf7e502d26e4a78a125d Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 15 Jan 2024 23:53:56 +0100 Subject: [PATCH 17/60] remove OpenEvent --- lib/eventsource/events.js | 27 --------------------------- lib/eventsource/index.js | 4 ++-- 2 files changed, 2 insertions(+), 29 deletions(-) delete mode 100644 lib/eventsource/events.js diff --git a/lib/eventsource/events.js b/lib/eventsource/events.js deleted file mode 100644 index 7a5ee6f4a84..00000000000 --- a/lib/eventsource/events.js +++ /dev/null @@ -1,27 +0,0 @@ -'use strict' - -const { webidl } = require('../fetch/webidl') -const { - MessageEvent, - ErrorEvent, - eventInit -} = require('../websocket/events') - -class OpenEvent extends Event { - constructor (type, eventInitDict = {}) { - webidl.argumentLengthCheck(arguments, 1, { header: 'OpenEvent constructor' }) - - type = webidl.converters.DOMString(type) - eventInitDict = webidl.converters.OpenEventInit(eventInitDict) - - super(type, eventInitDict) - } -} - -webidl.converters.OpenEventInit = webidl.dictionaryConverter(eventInit) - -module.exports = { - ErrorEvent, - MessageEvent, - OpenEvent -} diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js index 47b29b69ada..94da92e5fc5 100644 --- a/lib/eventsource/index.js +++ b/lib/eventsource/index.js @@ -10,7 +10,7 @@ const { webidl } = require('../fetch/webidl') const { CONNECTING, OPEN, CLOSED, defaultReconnectionTime, ANONYMOUS, USE_CREDENTIALS } = require('./constants') const { EventSourceStream } = require('./eventsource-stream') const { parseMIMEType } = require('../fetch/dataURL') -const { MessageEvent, OpenEvent, ErrorEvent } = require('./events') +const { MessageEvent, ErrorEvent } = require('../websocket/events') const { isNetworkError } = require('../fetch/response') const { getGlobalDispatcher } = require('../global') const { createOpaqueTimingInfo } = require('../fetch/util') @@ -269,7 +269,7 @@ class EventSource extends EventTarget { // 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 OpenEvent('open', {})) + this.dispatchEvent(new Event('open', {})) const eventSourceStream = new EventSourceStream({ eventSourceState: this.#settings, From 14e503ba210f97b36220b538bb50fd223906664f Mon Sep 17 00:00:00 2001 From: uzlopak Date: Tue, 16 Jan 2024 00:02:44 +0100 Subject: [PATCH 18/60] fix wpt runner --- test/wpt/runner/worker.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/wpt/runner/worker.mjs b/test/wpt/runner/worker.mjs index f4ed9950021..91f35d6b5e1 100644 --- a/test/wpt/runner/worker.mjs +++ b/test/wpt/runner/worker.mjs @@ -13,7 +13,7 @@ import { Cache } from '../../../lib/cache/cache.js' import { CacheStorage } from '../../../lib/cache/cachestorage.js' import { kConstruct } from '../../../lib/cache/symbols.js' // TODO(@KhafraDev): move this import once its added to index -import { EventSource } from '../../../lib/eventsource/eventsource.js' +import { EventSource } from '../../../lib/eventsource/index.js' const { initScripts, meta, test, url, path } = workerData @@ -119,7 +119,7 @@ runInThisContext(` `) if (meta.title) { - runInThisContext(`globalThis.META_TITLE = "${meta.title.replace(/"/g, '\\"')}"`) + runInThisContext(`globalThis.META_TITLE = "${meta.title.replace(/"/g, '\\"').replace(/`/g, '\\`')}"`) } const harness = readFileSync(join(basePath, '/resources/testharness.js'), 'utf-8') From c73c67d5b596d56c921f8bb581c1324c720a9738 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Tue, 16 Jan 2024 00:44:18 +0100 Subject: [PATCH 19/60] fix --- examples/eventsource.js | 4 ---- lib/eventsource/eventsource-stream.js | 10 ++++------ lib/eventsource/index.js | 24 +++++------------------- test/wpt/runner/worker.mjs | 2 +- 4 files changed, 10 insertions(+), 30 deletions(-) diff --git a/examples/eventsource.js b/examples/eventsource.js index 5e83a4a3d8b..b54663f5315 100644 --- a/examples/eventsource.js +++ b/examples/eventsource.js @@ -13,9 +13,5 @@ async function main () { ev.onopen = event => { console.log(event) } - setTimeout(() => { - console.log('close') - ev.close() - }, 3000) } main() diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index d6cd10c3fb8..1205935c501 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -139,7 +139,7 @@ class EventSourceStream extends Transform { this.parseLine(this.buffer.slice(0, this.pos), this.event) // Remove the processed line from the buffer - this.buffer = this.buffer.slice(this.pos + 1) + this.buffer = this.buffer.slice(this.pos) // Reset the position this.pos = 0 this.eventEndCheck = true @@ -159,15 +159,13 @@ class EventSourceStream extends Transform { if (line.length === 0) { return } - const fieldNameEnd = line.indexOf(COLON) - let fieldValueStart + const fieldNameEnd = line.indexOf(COLON) if (fieldNameEnd === -1) { return - // fieldNameEnd = line.length; - // fieldValueStart = line.length; } - fieldValueStart = fieldNameEnd + 1 + + let fieldValueStart = fieldNameEnd + 1 if (line[fieldValueStart] === SPACE) { fieldValueStart += 1 } diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js index 94da92e5fc5..cb1b0004c52 100644 --- a/lib/eventsource/index.js +++ b/lib/eventsource/index.js @@ -2,7 +2,7 @@ const { setTimeout } = require('node:timers/promises') const { pipeline } = require('node:stream') -const { mainFetch, Fetch, finalizeAndReportTiming } = require('../fetch') +const { mainFetch, Fetch } = require('../fetch') const { HeadersList } = require('../fetch/headers') const { makeRequest } = require('../fetch/request') const { getGlobalOrigin } = require('../fetch/global') @@ -49,7 +49,6 @@ class EventSource extends EventTarget { /** * @type {object} * @property {string} lastEventId - * @property {string} origin * @property {number} reconnectionTime * @property {any} reconnectionTimer */ @@ -80,7 +79,6 @@ class EventSource extends EventTarget { // 2. Let settings be ev's relevant settings object. this.#settings = { lastEventId: '', - origin: '', reconnectionTime: defaultReconnectionTime } @@ -145,14 +143,6 @@ class EventSource extends EventTarget { this.#request.headersList = initRequest.headers - this.#events = { - message: null, - error: null, - open: null - } - - this.#settings.origin = urlRecord.origin - this.#connect() } @@ -192,14 +182,6 @@ class EventSource extends EventTarget { request: this.#request } - // 12. Set request's initiator type to "other". - fetchParam.processResponseEndOfBody = (response) => - finalizeAndReportTiming(response, 'other') - - if (this.#settings.lastEventId) { - fetchParam.request.headers.set('last-event-id', this.#settings.lastEventId, true) - } - // 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)) { @@ -340,6 +322,10 @@ class EventSource extends EventTarget { // string, encoded as UTF-8. // 2. Set (`Last-Event-ID`, lastEventIDValue) in request's header // list. + if (this.#settings.lastEventId !== '') { + this.#request.headers.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() } diff --git a/test/wpt/runner/worker.mjs b/test/wpt/runner/worker.mjs index 91f35d6b5e1..d4eec69742d 100644 --- a/test/wpt/runner/worker.mjs +++ b/test/wpt/runner/worker.mjs @@ -119,7 +119,7 @@ runInThisContext(` `) if (meta.title) { - runInThisContext(`globalThis.META_TITLE = "${meta.title.replace(/"/g, '\\"').replace(/`/g, '\\`')}"`) + runInThisContext(`globalThis.META_TITLE = "${meta.title.replace(/"/g, '\\"')}"`) } const harness = readFileSync(join(basePath, '/resources/testharness.js'), 'utf-8') From 2c65c7597992f8127aaf13cfff418b25fc553321 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 16 Jan 2024 01:53:05 +0100 Subject: [PATCH 20/60] Apply suggestions from code review --- lib/fetch/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/fetch/index.js b/lib/fetch/index.js index 230ac2dc39f..d557df20cbd 100644 --- a/lib/fetch/index.js +++ b/lib/fetch/index.js @@ -1361,7 +1361,6 @@ function httpRedirectFetch (fetchParams, response) { // 18. Append locationURL to request’s URL list. request.urlList.push(locationURL) - // TODO: not enough? // 19. Invoke set request’s referrer policy on redirect on request and // actualResponse. From 7c38511d345fe97c08f75f0ff5d413b12e83618b Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 16 Jan 2024 05:45:01 +0100 Subject: [PATCH 21/60] Update lib/eventsource/index.js Co-authored-by: Khafra --- lib/eventsource/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js index cb1b0004c52..f3e3e31d0ad 100644 --- a/lib/eventsource/index.js +++ b/lib/eventsource/index.js @@ -307,7 +307,7 @@ class EventSource extends EventTarget { this.dispatchEvent(new ErrorEvent('error', { message: 'Reconnecting' })) // 2. Wait a delay equal to the reconnection time of the event source. - await setTimeout(this.#settings.reconnectionTime) + await setTimeout(this.#settings.reconnectionTime, { ref: false }) // 5. Queue a task to run the following steps: From d7a7dabd51eaa8d51f835fe9ad150b8710d3d982 Mon Sep 17 00:00:00 2001 From: Khafra Date: Tue, 16 Jan 2024 13:58:37 -0500 Subject: [PATCH 22/60] fetching --- lib/eventsource/index.js | 14 +++++--------- lib/fetch/index.js | 3 +++ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/eventsource/index.js b/lib/eventsource/index.js index f3e3e31d0ad..caa0b9b5d01 100644 --- a/lib/eventsource/index.js +++ b/lib/eventsource/index.js @@ -2,7 +2,7 @@ const { setTimeout } = require('node:timers/promises') const { pipeline } = require('node:stream') -const { mainFetch, Fetch } = require('../fetch') +const { fetching } = require('../fetch') const { HeadersList } = require('../fetch/headers') const { makeRequest } = require('../fetch/request') const { getGlobalOrigin } = require('../fetch/global') @@ -13,7 +13,6 @@ const { parseMIMEType } = require('../fetch/dataURL') const { MessageEvent, ErrorEvent } = require('../websocket/events') const { isNetworkError } = require('../fetch/response') const { getGlobalDispatcher } = require('../global') -const { createOpaqueTimingInfo } = require('../fetch/util') let experimentalWarned = false @@ -276,13 +275,10 @@ class EventSource extends EventTarget { }) } - fetchParam.timingInfo = createOpaqueTimingInfo({}) - - fetchParam.controller = new Fetch(getGlobalDispatcher()) - - this.#controller = fetchParam.controller - - await mainFetch(fetchParam) + this.#controller = fetching({ + ...fetchParam, + dispatcher: getGlobalDispatcher() + }) } /** diff --git a/lib/fetch/index.js b/lib/fetch/index.js index d557df20cbd..7ae66ad4957 100644 --- a/lib/fetch/index.js +++ b/lib/fetch/index.js @@ -374,6 +374,9 @@ function fetching ({ useParallelQueue = false, dispatcher // undici }) { + // This has bitten me in the ass more times than I'd like to admit. + assert(dispatcher) + // 1. Let taskDestination be null. let taskDestination = null From 1fa548b7763ffe6e0a1f4040231d2036d60a56c4 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 04:30:44 +0100 Subject: [PATCH 23/60] improve BOM check --- lib/eventsource/eventsource-stream.js | 62 +++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 1205935c501..4c4af7b1e46 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -81,38 +81,84 @@ class EventSourceStream extends Transform { callback() return } - this.buffer = this.buffer ? Buffer.concat([this.buffer, chunk]) : chunk - // Strip leading byte-order-mark if any + // We cache the chunk in the buffer, as the data might not be complete + // while processing it + if (this.buffer) { + this.buffer = Buffer.concat([this.buffer, chunk]) + } else { + this.buffer = chunk + } + + // Strip leading byte-order-mark if we opened the stream and started + // the processing of the incoming data if (this.checkBOM) { switch (this.buffer.length) { case 1: + // Check if the first byte is the same as the first byte of the BOM if (this.buffer[0] === BOM[0]) { + // If it is, we need to wait for more data callback() return } + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore this.checkBOM = false - break + + // The buffer only contains one byte so we need to wait for more data + callback() + return case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { + // Check if the first two bytes are the same as the first two bytes + // of the BOM + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] + ) { + // If it is, we need to wait for more data, because the third byte + // is needed to determine if it is the BOM or not callback() return } + + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore this.checkBOM = false break case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.slice(3) + // Check if the first three bytes are the same as the first three + // bytes of the BOM + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] && + this.buffer[2] === BOM[2] + ) { + // If it is, we can drop the buffered data, as it is only the BOM + this.buffer = Buffer.alloc(0) + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore this.checkBOM = false + + // Await more data callback() return } + // If it is not the BOM, we can start processing the data this.checkBOM = false break default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.slice(3) + // The buffer is longer than 3 bytes, so we can drop the BOM if it is + // present + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] && + this.buffer[2] === BOM[2] + ) { + // Remove the BOM from the buffer + this.buffer = this.buffer.subarray(3) } + + // Set the checkBOM flag to false as we don't need to check for the this.checkBOM = false break } From 03dc9265e47e239fca97fa6f35c1e1306889dbae Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 05:28:33 +0100 Subject: [PATCH 24/60] improve parseLine --- lib/eventsource/constants.js | 8 + lib/eventsource/eventsource-stream.js | 65 ++++-- .../eventsource-stream-parse-line.js | 202 ++++++++++++++++++ test/eventsource/eventsource-stream.js | 83 ------- 4 files changed, 254 insertions(+), 104 deletions(-) create mode 100644 test/eventsource/eventsource-stream-parse-line.js diff --git a/lib/eventsource/constants.js b/lib/eventsource/constants.js index 47ee45e285a..c02039eee33 100644 --- a/lib/eventsource/constants.js +++ b/lib/eventsource/constants.js @@ -70,6 +70,13 @@ const COLON = 0x3A */ const SPACE = 0x20 +const validMessageEventFieldNames = [ + 'data', + 'event', + 'id', + 'retry' +] + module.exports = { defaultReconnectionTime, CONNECTING, @@ -80,6 +87,7 @@ module.exports = { CR, COLON, SPACE, + validMessageEventFieldNames, ANONYMOUS, USE_CREDENTIALS } diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 4c4af7b1e46..c99ec06fba7 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -1,6 +1,13 @@ 'use strict' const { Transform } = require('node:stream') -const { BOM, CR, LF, COLON, SPACE } = require('./constants') +const { + BOM, + CR, + LF, + COLON, + SPACE, + validMessageEventFieldNames +} = require('./constants') const { isASCIINumber, isValidLastEventId } = require('./util') /** @@ -199,39 +206,55 @@ class EventSourceStream extends Transform { /** * @param {Buffer} line - * @param {EventSourceStreamEvent} event + * @param {EventStreamEvent} event */ parseLine (line, event) { + // If the line is empty, we can skip processing it as it does not modify + // the event if (line.length === 0) { return } - const fieldNameEnd = line.indexOf(COLON) - if (fieldNameEnd === -1) { + // If the line does not contain a colon, we can skip processing it as it + // wont have have a field name and value + // Potentially the data is invalid, but we just ignore it + const colonPosition = line.indexOf(COLON) + if (colonPosition === -1) { return } - let fieldValueStart = fieldNameEnd + 1 + // If the line starts with a colon, we can skip processing it as it is a + // comment + if (colonPosition === 0) { + return + } + + // The field name is the part of the line before the colon. Event streams + // are always decoded as UTF-8 + const fieldName = line.subarray(0, colonPosition).toString('utf8') + + // If the field name is not a valid field name, we can stop processing the + // line + if (!validMessageEventFieldNames.includes(fieldName)) { + return + } + + // We expect that the value starts after the colon. If there is a space + // after the colon, we ignore it + let fieldValueStart = colonPosition + 1 if (line[fieldValueStart] === SPACE) { - fieldValueStart += 1 + ++fieldValueStart } - const fieldValueSize = line.length - fieldValueStart - const fieldName = line.slice(0, fieldNameEnd).toString('utf8') - switch (fieldName) { - case 'data': - event.data = line.slice(fieldValueStart, fieldValueStart + fieldValueSize).toString('utf8') - break - case 'event': - event.event = line.slice(fieldValueStart, fieldValueStart + fieldValueSize).toString('utf8') - break - case 'id': - event.id = line.slice(fieldValueStart, fieldValueStart + fieldValueSize).toString('utf8') - break - case 'retry': - event.retry = line.slice(fieldValueStart, fieldValueStart + fieldValueSize).toString('utf8') - break + // If the value starts after the colon, but the line ends, we can stop + // processing the line as it is only an empty string + if (fieldValueStart === line.length) { + event[fieldName] = '' } + + // Modify the event with the field name and value. The value is also + // decoded as UTF-8 + event[fieldName] = line.subarray(fieldValueStart).toString('utf8') } /** diff --git a/test/eventsource/eventsource-stream-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js new file mode 100644 index 00000000000..53a9dffd024 --- /dev/null +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -0,0 +1,202 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') + +describe('EventSourceStream - parseLine', () => { + const defaultEventSourceState = { + origin: 'example.com', + reconnectionTime: 1000 + } + + test('Should set the data field with empty string if not containing data', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data:', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, '') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set the data field with empty string if not containing data (containing space after colon)', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: ', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, '') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set the data field with a string containing space if having more than one space after colon', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: ', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, ' ') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set value properly, even if the line contains multiple colons', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: : ', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, ': ') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set the data field when containing data', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: Hello', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should ignore comments', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from(':comment', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 0) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set retry field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('retry: 1000', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, '1000') + }) + + test('Should set id field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('id: 1234', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, '1234') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set id field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('event: custom', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, 'custom') + assert.strictEqual(event.retry, undefined) + }) + + test('Should ignore invalid field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + const event = {} + + stream.parseLine(Buffer.from('comment: invalid', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 0) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) +}) diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js index bad97c60cf1..d3337e6e010 100644 --- a/test/eventsource/eventsource-stream.js +++ b/test/eventsource/eventsource-stream.js @@ -128,89 +128,6 @@ describe('EventSourceStream - processEvent', () => { }) }) -describe('EventSourceStream - parseLine', () => { - const defaultEventSourceState = { - origin: 'example.com', - reconnectionTime: 1000 - } - - test('Should set the data field', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - const event = {} - - stream.parseLine(Buffer.from('data: Hello', 'utf8'), event) - - assert.strictEqual(typeof event, 'object') - assert.strictEqual(Object.keys(event).length, 1) - assert.strictEqual(event.data, 'Hello') - assert.strictEqual(event.id, undefined) - assert.strictEqual(event.event, undefined) - assert.strictEqual(event.retry, undefined) - }) - - test('Should set retry field', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - const event = {} - - stream.parseLine(Buffer.from('retry: 1000', 'utf8'), event) - - assert.strictEqual(typeof event, 'object') - assert.strictEqual(Object.keys(event).length, 1) - assert.strictEqual(event.data, undefined) - assert.strictEqual(event.id, undefined) - assert.strictEqual(event.event, undefined) - assert.strictEqual(event.retry, '1000') - }) - - test('Should set id field', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - const event = {} - - stream.parseLine(Buffer.from('id: 1234', 'utf8'), event) - - assert.strictEqual(typeof event, 'object') - assert.strictEqual(Object.keys(event).length, 1) - assert.strictEqual(event.data, undefined) - assert.strictEqual(event.id, '1234') - assert.strictEqual(event.event, undefined) - assert.strictEqual(event.retry, undefined) - }) - - test('Should set id field', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - const event = {} - - stream.parseLine(Buffer.from('event: custom', 'utf8'), event) - - assert.strictEqual(typeof event, 'object') - assert.strictEqual(Object.keys(event).length, 1) - assert.strictEqual(event.data, undefined) - assert.strictEqual(event.id, undefined) - assert.strictEqual(event.event, 'custom') - assert.strictEqual(event.retry, undefined) - }) -}) - describe('EventSourceStream', () => { test('Remove BOM from the beginning of the stream.', () => { const content = Buffer.from('\uFEFFdata: Hello\n\n', 'utf8') From e04194c9a4435788100d2141dddb83bac6cdfe47 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 05:31:57 +0100 Subject: [PATCH 25/60] rename back index.js to eventsource.js --- index.js | 2 +- lib/eventsource/{index.js => eventsource.js} | 0 test/eventsource/eventsource.js | 2 +- test/wpt/runner/worker.mjs | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename lib/eventsource/{index.js => eventsource.js} (100%) diff --git a/index.js b/index.js index d0e1ce1ecc6..bf46fc08d98 100644 --- a/index.js +++ b/index.js @@ -150,6 +150,6 @@ module.exports.MockPool = MockPool module.exports.MockAgent = MockAgent module.exports.mockErrors = mockErrors -const { EventSource } = require('./lib/eventsource') +const { EventSource } = require('./lib/eventsource/eventsource') module.exports.EventSource = EventSource diff --git a/lib/eventsource/index.js b/lib/eventsource/eventsource.js similarity index 100% rename from lib/eventsource/index.js rename to lib/eventsource/eventsource.js diff --git a/test/eventsource/eventsource.js b/test/eventsource/eventsource.js index aece3cf2897..b584aae4522 100644 --- a/test/eventsource/eventsource.js +++ b/test/eventsource/eventsource.js @@ -4,7 +4,7 @@ const assert = require('node:assert') const events = require('node:events') const http = require('node:http') const { test, describe } = require('node:test') -const { EventSource } = require('../../lib/eventsource') +const { EventSource } = require('../../lib/eventsource/eventsource') describe('EventSource - constructor', () => { test('Not providing url argument should throw', () => { diff --git a/test/wpt/runner/worker.mjs b/test/wpt/runner/worker.mjs index f03370dbc7e..37c5dc6a105 100644 --- a/test/wpt/runner/worker.mjs +++ b/test/wpt/runner/worker.mjs @@ -13,7 +13,7 @@ import { Cache } from '../../../lib/cache/cache.js' import { CacheStorage } from '../../../lib/cache/cachestorage.js' import { kConstruct } from '../../../lib/cache/symbols.js' // TODO(@KhafraDev): move this import once its added to index -import { EventSource } from '../../../lib/eventsource/index.js' +import { EventSource } from '../../../lib/eventsource/eventsource.js' import { webcrypto } from 'node:crypto' const { initScripts, meta, test, url, path } = workerData From 041c6489644245dc3b03b1d7c4baedfa4d6a203b Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 06:12:53 +0100 Subject: [PATCH 26/60] improve --- lib/eventsource/eventsource-stream.js | 2 +- test/eventsource/eventsource-stream-bom.js | 135 +++++++++++++++++ .../eventsource-stream-process-event.js | 137 +++++++++++++++++ test/eventsource/eventsource-stream.js | 139 +----------------- 4 files changed, 276 insertions(+), 137 deletions(-) create mode 100644 test/eventsource/eventsource-stream-bom.js create mode 100644 test/eventsource/eventsource-stream-process-event.js diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index c99ec06fba7..52f8365b7a0 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -71,7 +71,7 @@ class EventSourceStream extends Transform { constructor (options = {}) { options.readableObjectMode = true super(options) - this.state = options.eventSourceState + this.state = options.eventSourceState || {} if (options.push) { this.push = options.push } diff --git a/test/eventsource/eventsource-stream-bom.js b/test/eventsource/eventsource-stream-bom.js new file mode 100644 index 00000000000..4bcfd76064f --- /dev/null +++ b/test/eventsource/eventsource-stream-bom.js @@ -0,0 +1,135 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') + +describe('EventSourceStream - handle BOM', () => { + test('Remove BOM from the beginning of the stream. 1 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`\uFEFF${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Remove BOM from the beginning of the stream. 2 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`\uFEFF${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 2) { + stream.write(Buffer.from([content[i], content[i + 1]])) + } + }) + + test('Remove BOM from the beginning of the stream. 3 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`\uFEFF${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 3) { + stream.write(Buffer.from([content[i], content[i + 1], content[i + 2]])) + } + }) + + test('Remove BOM from the beginning of the stream. 4 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`\uFEFF${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 4) { + stream.write(Buffer.from([content[i], content[i + 1], content[i + 2], content[i + 3]])) + } + }) + + test('Not containing BOM from the beginning of the stream. 1 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 1) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Not containing BOM from the beginning of the stream. 2 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 2) { + stream.write(Buffer.from([content[i], content[i + 1]])) + } + }) + + test('Not containing BOM from the beginning of the stream. 3 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 3) { + stream.write(Buffer.from([content[i], content[i + 1], content[i + 2]])) + } + }) + + test('Not containing BOM from the beginning of the stream. 4 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 4) { + stream.write(Buffer.from([content[i], content[i + 1], content[i + 2], content[i + 3]])) + } + }) +}) diff --git a/test/eventsource/eventsource-stream-process-event.js b/test/eventsource/eventsource-stream-process-event.js new file mode 100644 index 00000000000..dd3b6e4602c --- /dev/null +++ b/test/eventsource/eventsource-stream-process-event.js @@ -0,0 +1,137 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') + +describe('EventSourceStream - processEvent', () => { + const defaultEventSourceState = { + origin: 'example.com', + reconnectionTime: 1000 + } + + test('Should set the defined origin as the origin of the MessageEvent', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.payload.data, null) + assert.strictEqual(event.payload.lastEventId, undefined) + assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.on('error', (error) => { + assert.fail(error) + }) + + stream.processEvent({}) + }) + + test('Should set reconnectionTime to 4000 if event contains retry field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.processEvent({ + retry: '4000' + }) + + assert.strictEqual(stream.state.reconnectionTime, 4000) + }) + + test('Dispatches a MessageEvent with data', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.payload.data, 'Hello') + assert.strictEqual(event.payload.lastEventId, undefined) + assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.on('error', (error) => { + assert.fail(error) + }) + + stream.processEvent({ + data: 'Hello' + }) + }) + + test('Dispatches a MessageEvent with lastEventId, when event contains id field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.payload.data, null) + assert.strictEqual(event.payload.lastEventId, '1234') + assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({ + id: '1234' + }) + }) + + test('Dispatches a MessageEvent with lastEventId, reusing the persisted', () => { + // lastEventId + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState, + lastEventId: '1234' + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.payload.data, null) + assert.strictEqual(event.payload.lastEventId, '1234') + assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({}) + }) + + test('Dispatches a MessageEvent with type custom, when event contains type field', () => { + const stream = new EventSourceStream({ + eventSourceState: { + ...defaultEventSourceState + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'custom') + assert.strictEqual(event.payload.data, null) + assert.strictEqual(event.payload.lastEventId, undefined) + assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({ + event: 'custom' + }) + }) +}) diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js index d3337e6e010..c10a5cecc38 100644 --- a/test/eventsource/eventsource-stream.js +++ b/test/eventsource/eventsource-stream.js @@ -4,147 +4,14 @@ const assert = require('node:assert') const { test, describe } = require('node:test') const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') -describe('EventSourceStream - processEvent', () => { - const defaultEventSourceState = { - origin: 'example.com', - reconnectionTime: 1000 - } - - test('Should set the defined origin as the origin of the MessageEvent', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - stream.on('data', (event) => { - assert.strictEqual(typeof event, 'object') - assert.strictEqual(event.type, 'message') - assert.strictEqual(event.payload.data, null) - assert.strictEqual(event.payload.lastEventId, undefined) - assert.strictEqual(event.payload.origin, 'example.com') - assert.strictEqual(stream.state.reconnectionTime, 1000) - }) - - stream.processEvent({}) - }) - - test('Should set reconnectionTime to 4000 if event contains retry field', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - stream.processEvent({ - retry: '4000' - }) - - assert.strictEqual(stream.state.reconnectionTime, 4000) - }) - - test('Dispatches a MessageEvent with data', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - stream.on('data', (event) => { - assert.strictEqual(typeof event, 'object') - assert.strictEqual(event.type, 'message') - assert.strictEqual(event.payload.data, 'Hello') - assert.strictEqual(event.payload.lastEventId, undefined) - assert.strictEqual(event.payload.origin, 'example.com') - assert.strictEqual(stream.state.reconnectionTime, 1000) - }) - - stream.processEvent({ - data: 'Hello' - }) - }) - - test('Dispatches a MessageEvent with lastEventId, when event contains id field', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - stream.on('data', (event) => { - assert.strictEqual(typeof event, 'object') - assert.strictEqual(event.type, 'message') - assert.strictEqual(event.payload.data, null) - assert.strictEqual(event.payload.lastEventId, '1234') - assert.strictEqual(event.payload.origin, 'example.com') - assert.strictEqual(stream.state.reconnectionTime, 1000) - }) - - stream.processEvent({ - id: '1234' - }) - }) - - test('Dispatches a MessageEvent with lastEventId, reusing the persisted', () => { - // lastEventId - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState, - lastEventId: '1234' - } - }) - - stream.on('data', (event) => { - assert.strictEqual(typeof event, 'object') - assert.strictEqual(event.type, 'message') - assert.strictEqual(event.payload.data, null) - assert.strictEqual(event.payload.lastEventId, '1234') - assert.strictEqual(event.payload.origin, 'example.com') - assert.strictEqual(stream.state.reconnectionTime, 1000) - }) - - stream.processEvent({}) - }) - - test('Dispatches a MessageEvent with type custom, when event contains type field', () => { - const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState - } - }) - - stream.on('data', (event) => { - assert.strictEqual(typeof event, 'object') - assert.strictEqual(event.type, 'custom') - assert.strictEqual(event.payload.data, null) - assert.strictEqual(event.payload.lastEventId, undefined) - assert.strictEqual(event.payload.origin, 'example.com') - assert.strictEqual(stream.state.reconnectionTime, 1000) - }) - - stream.processEvent({ - event: 'custom' - }) - }) -}) - describe('EventSourceStream', () => { - test('Remove BOM from the beginning of the stream.', () => { - const content = Buffer.from('\uFEFFdata: Hello\n\n', 'utf8') - + test('ignore empty chunks', () => { const stream = new EventSourceStream() stream.processEvent = function (event) { - assert.strictEqual(typeof event, 'object') - assert.strictEqual(event.event, undefined) - assert.strictEqual(event.data, 'Hello') - assert.strictEqual(event.id, undefined) - assert.strictEqual(event.retry, undefined) - } - - for (let i = 0; i < content.length; i++) { - stream.write(Buffer.from([content[i]])) + assert.fail() } + stream.write(Buffer.alloc(0)) }) test('Simple event with data field.', () => { From 55e2729d1ad80e95adc1da4212e6bd0d449f021a Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 06:21:23 +0100 Subject: [PATCH 27/60] rename eventSourceState --- lib/eventsource/eventsource-stream.js | 12 ++++-- lib/eventsource/eventsource.js | 2 +- .../eventsource-stream-parse-line.js | 42 +++++++++---------- .../eventsource-stream-process-event.js | 26 ++++++------ 4 files changed, 43 insertions(+), 39 deletions(-) diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 52f8365b7a0..99c57ca2368 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -20,7 +20,7 @@ const { isASCIINumber, isValidLastEventId } = require('./util') */ /** - * @typedef EventSourceState + * @typedef eventSourceSettings * @type {object} * @property {string} lastEventId The last event ID received from the server. * @property {string} origin The origin of the event source. @@ -29,7 +29,7 @@ const { isASCIINumber, isValidLastEventId } = require('./util') class EventSourceStream extends Transform { /** - * @type {EventSourceState} + * @type {eventSourceSettings} */ state = null @@ -65,13 +65,17 @@ class EventSourceStream extends Transform { /** * @param {object} options - * @param {EventSourceState} options.eventSourceState + * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ constructor (options = {}) { + // Enable object mode as EventSourceStream emits objects of shape + // EventSourceStreamEvent options.readableObjectMode = true + super(options) - this.state = options.eventSourceState || {} + + this.state = options.eventSourceSettings || {} if (options.push) { this.push = options.push } diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index caa0b9b5d01..b39d7d0ff97 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -253,7 +253,7 @@ class EventSource extends EventTarget { this.dispatchEvent(new Event('open', {})) const eventSourceStream = new EventSourceStream({ - eventSourceState: this.#settings, + eventSourceSettings: this.#settings, push: (eventPayload) => { this.dispatchEvent(new MessageEvent( eventPayload.type, diff --git a/test/eventsource/eventsource-stream-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js index 53a9dffd024..01a77e56065 100644 --- a/test/eventsource/eventsource-stream-parse-line.js +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -5,15 +5,15 @@ const { test, describe } = require('node:test') const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') describe('EventSourceStream - parseLine', () => { - const defaultEventSourceState = { + const defaultEventSourceSettings = { origin: 'example.com', reconnectionTime: 1000 } test('Should set the data field with empty string if not containing data', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -31,8 +31,8 @@ describe('EventSourceStream - parseLine', () => { test('Should set the data field with empty string if not containing data (containing space after colon)', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -50,8 +50,8 @@ describe('EventSourceStream - parseLine', () => { test('Should set the data field with a string containing space if having more than one space after colon', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -69,8 +69,8 @@ describe('EventSourceStream - parseLine', () => { test('Should set value properly, even if the line contains multiple colons', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -88,8 +88,8 @@ describe('EventSourceStream - parseLine', () => { test('Should set the data field when containing data', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -107,8 +107,8 @@ describe('EventSourceStream - parseLine', () => { test('Should ignore comments', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -126,8 +126,8 @@ describe('EventSourceStream - parseLine', () => { test('Should set retry field', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -145,8 +145,8 @@ describe('EventSourceStream - parseLine', () => { test('Should set id field', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -164,8 +164,8 @@ describe('EventSourceStream - parseLine', () => { test('Should set id field', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -183,8 +183,8 @@ describe('EventSourceStream - parseLine', () => { test('Should ignore invalid field', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) diff --git a/test/eventsource/eventsource-stream-process-event.js b/test/eventsource/eventsource-stream-process-event.js index dd3b6e4602c..97e91cdb544 100644 --- a/test/eventsource/eventsource-stream-process-event.js +++ b/test/eventsource/eventsource-stream-process-event.js @@ -5,15 +5,15 @@ const { test, describe } = require('node:test') const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') describe('EventSourceStream - processEvent', () => { - const defaultEventSourceState = { + const defaultEventSourceSettings = { origin: 'example.com', reconnectionTime: 1000 } test('Should set the defined origin as the origin of the MessageEvent', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -35,8 +35,8 @@ describe('EventSourceStream - processEvent', () => { test('Should set reconnectionTime to 4000 if event contains retry field', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -49,8 +49,8 @@ describe('EventSourceStream - processEvent', () => { test('Dispatches a MessageEvent with data', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -74,8 +74,8 @@ describe('EventSourceStream - processEvent', () => { test('Dispatches a MessageEvent with lastEventId, when event contains id field', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) @@ -96,8 +96,8 @@ describe('EventSourceStream - processEvent', () => { test('Dispatches a MessageEvent with lastEventId, reusing the persisted', () => { // lastEventId const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState, + eventSourceSettings: { + ...defaultEventSourceSettings, lastEventId: '1234' } }) @@ -116,8 +116,8 @@ describe('EventSourceStream - processEvent', () => { test('Dispatches a MessageEvent with type custom, when event contains type field', () => { const stream = new EventSourceStream({ - eventSourceState: { - ...defaultEventSourceState + eventSourceSettings: { + ...defaultEventSourceSettings } }) From fc326394f66433f3e94655a21127de35d4f0e14d Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 07:42:27 +0100 Subject: [PATCH 28/60] fix --- lib/eventsource/eventsource-stream.js | 6 +----- lib/eventsource/eventsource.js | 12 ------------ .../eventsource-stream-parse-line.js | 19 +++++++++++++++++++ test/eventsource/eventsource-stream.js | 6 +++++- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 99c57ca2368..74e858ed932 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -189,14 +189,10 @@ class EventSourceStream extends Transform { this.buffer = this.buffer.slice(1) continue } - if (this.buffer[0] === COLON) { - this.buffer = this.buffer.slice(1) - continue - } this.parseLine(this.buffer.slice(0, this.pos), this.event) // Remove the processed line from the buffer - this.buffer = this.buffer.slice(this.pos) + this.buffer = this.buffer.slice(this.pos + 1) // Reset the position this.pos = 0 this.eventEndCheck = true diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index b39d7d0ff97..9b4c1d0151d 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -344,14 +344,10 @@ class EventSource extends EventTarget { } get onopen () { - webidl.brandCheck(this, EventSource) - return this.#events.open } set onopen (fn) { - webidl.brandCheck(this, EventSource) - if (this.#events.open) { this.removeEventListener('open', this.#events.open) } @@ -365,14 +361,10 @@ class EventSource extends EventTarget { } get onmessage () { - webidl.brandCheck(this, EventSource) - return this.#events.message } set onmessage (fn) { - webidl.brandCheck(this, EventSource) - if (this.#events.message) { this.removeEventListener('message', this.#events.message) } @@ -386,14 +378,10 @@ class EventSource extends EventTarget { } get onerror () { - webidl.brandCheck(this, EventSource) - return this.#events.error } set onerror (fn) { - webidl.brandCheck(this, EventSource) - if (this.#events.error) { this.removeEventListener('error', this.#events.error) } diff --git a/test/eventsource/eventsource-stream-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js index 01a77e56065..a3a948dd41c 100644 --- a/test/eventsource/eventsource-stream-parse-line.js +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -10,6 +10,25 @@ describe('EventSourceStream - parseLine', () => { reconnectionTime: 1000 } + test('Should push an unmodified event when line is empty', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 0) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + test('Should set the data field with empty string if not containing data', () => { const stream = new EventSourceStream({ eventSourceSettings: { diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js index c10a5cecc38..141d1768f51 100644 --- a/test/eventsource/eventsource-stream.js +++ b/test/eventsource/eventsource-stream.js @@ -38,7 +38,11 @@ describe('EventSourceStream', () => { const stream = new EventSourceStream() stream.processEvent = function (event) { - assert.fail('Should not be called') + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) } for (let i = 0; i < content.length; i++) { From 11f581430a8afe29a6c5f3a6a19af432d5463bc4 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 07:47:33 +0100 Subject: [PATCH 29/60] fix --- lib/eventsource/eventsource.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 9b4c1d0151d..d9d4c475c10 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -395,7 +395,7 @@ class EventSource extends EventTarget { } } -Object.defineProperties(EventSource, { +const constantsPropertyDescriptors = { CONNECTING: { __proto__: null, configurable: false, @@ -417,11 +417,10 @@ Object.defineProperties(EventSource, { value: CLOSED, writable: false } -}) +} -EventSource.prototype.CONNECTING = CONNECTING -EventSource.prototype.OPEN = OPEN -EventSource.prototype.CLOSED = CLOSED +Object.defineProperties(EventSource, constantsPropertyDescriptors) +Object.defineProperties(EventSource.prototype, constantsPropertyDescriptors) webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ { key: 'withCredentials', converter: webidl.converters.boolean, defaultValue: false } From 6ab5dea8d2ebd9292fecc64a8e5564351abcb0dd Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 07:56:40 +0100 Subject: [PATCH 30/60] fix --- lib/eventsource/eventsource.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index d9d4c475c10..b7227c8b84e 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -250,7 +250,7 @@ class EventSource extends EventTarget { // 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', {})) + this.dispatchEvent(new Event('open')) const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#settings, @@ -266,8 +266,7 @@ class EventSource extends EventTarget { eventSourceStream, (error) => { if ( - error && - error.aborted === false + error?.aborted === false ) { this.close() this.dispatchEvent(new ErrorEvent('error', { error })) From ba941ea0475096eed249bca6020bd4edc4630a55 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 08:03:58 +0100 Subject: [PATCH 31/60] fix --- lib/eventsource/eventsource.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index b7227c8b84e..aa7ba30f025 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -81,15 +81,11 @@ class EventSource extends EventTarget { reconnectionTime: defaultReconnectionTime } - // 1. Let baseURL be this's relevant settings object's API base URL. - const baseURL = getGlobalOrigin() - - // 2. Let urlRecord be the result of applying the URL parser to url with baseURL. let urlRecord try { // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings. - urlRecord = new URL(url, baseURL) + urlRecord = new URL(url, getGlobalOrigin()) } catch (e) { // 4. If urlRecord is failure, then throw a "SyntaxError" DOMException. throw new DOMException(e, 'SyntaxError') From f8dbc1a36ef7ba1ef35ac2ce14193f38ae978d39 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 08:34:18 +0100 Subject: [PATCH 32/60] add settings environment --- lib/eventsource/eventsource.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index aa7ba30f025..4f65e9d44b2 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -76,7 +76,15 @@ class EventSource extends EventTarget { 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 = { + baseUrl: getGlobalOrigin(), + get origin () { + return this.baseUrl?.origin + }, + policyContainer: { + referrerPolicy: 'no-referrer' + }, lastEventId: '', reconnectionTime: defaultReconnectionTime } @@ -85,7 +93,7 @@ class EventSource extends EventTarget { try { // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings. - urlRecord = new URL(url, getGlobalOrigin()) + urlRecord = new URL(url, this.#settings.origin) } catch (e) { // 4. If urlRecord is failure, then throw a "SyntaxError" DOMException. throw new DOMException(e, 'SyntaxError') @@ -115,11 +123,11 @@ class EventSource extends EventTarget { credentials: corsAttributeState === 'anonymous' ? 'same-origin' : 'omit', - referrer: 'no-referrer', - referrerPolicy: 'no-referrer' + 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.headers = new HeadersList() From b9fed125f020f429a07e6a728aab2564f4a07933 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 08:56:28 +0100 Subject: [PATCH 33/60] fix isNetworkError --- lib/fetch/response.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/fetch/response.js b/lib/fetch/response.js index 6366e446dc7..29d3ac44c8a 100644 --- a/lib/fetch/response.js +++ b/lib/fetch/response.js @@ -369,11 +369,8 @@ function isNetworkError (response) { return ( // A network error is a response whose type is "error", response.type === 'error' && - // status is 0 - response.status === 0 && - 'error' in response && ( - 'aborted' in response ? typeof response.aborted === 'boolean' : true - ) + // status is 0 + response.status === 0 ) } From 7a834fbb1dce6d30e17c465bdddc5dedac86e46a Mon Sep 17 00:00:00 2001 From: Khafra Date: Wed, 17 Jan 2024 10:16:23 -0500 Subject: [PATCH 34/60] add route, fix 2 tests --- lib/eventsource/eventsource.js | 13 +++++++++++-- test/wpt/server/server.mjs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 4f65e9d44b2..a1769d2bca5 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -80,7 +80,7 @@ class EventSource extends EventTarget { this.#settings = { baseUrl: getGlobalOrigin(), get origin () { - return this.baseUrl?.origin + return this.baseUrl?.href }, policyContainer: { referrerPolicy: 'no-referrer' @@ -238,13 +238,22 @@ class EventSource extends EventTarget { // `Content-Type` is not `text/event-stream`, then fail the // connection. - const contentType = response.headersList.get('content-type', true) + let contentType = response.headersList.get('content-type', true) + + if (contentType !== null) { + if (contentType.endsWith(';')) { + // spec compatibility + contentType = contentType.slice(0, -1) + } + } + const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' if (mimeType === 'failure' || mimeType.essence !== 'text/event-stream') { this.close() this.dispatchEvent(new ErrorEvent('error', { message: 'Invalid content-type' })) return } + // 4. Otherwise, announce the connection and interpret res's body // line by line. diff --git a/test/wpt/server/server.mjs b/test/wpt/server/server.mjs index 9a8bad4b83a..2c4a6fdca3f 100644 --- a/test/wpt/server/server.mjs +++ b/test/wpt/server/server.mjs @@ -406,6 +406,24 @@ const server = createServer(async (req, res) => { res.end('vary response') return } + case '/eventsource/resources/message.py': { + const mime = fullUrl.searchParams.get('mime') ?? 'text/event-stream' + const message = fullUrl.searchParams.get('message') ?? 'data: data' + const newline = fullUrl.searchParams.get('newline') === 'none' ? '' : '\n\n' + const sleep = parseInt(fullUrl.searchParams.get('sleep') ?? '0') + + res.setHeader('content-type', mime) + res.setHeader('test', 'wtf') + res.write(message + newline + '\n') + + if (sleep !== 0) { + setTimeout(() => { + res.end() + }, sleep) + } + + return + } default: { res.statusCode = 200 res.end(fullUrl.toString()) From ffaac56b5798a09b5237f92065d6d276dda2e869 Mon Sep 17 00:00:00 2001 From: Khafra Date: Wed, 17 Jan 2024 10:22:30 -0500 Subject: [PATCH 35/60] fixup --- test/wpt/server/server.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/wpt/server/server.mjs b/test/wpt/server/server.mjs index 2c4a6fdca3f..38b7bde804e 100644 --- a/test/wpt/server/server.mjs +++ b/test/wpt/server/server.mjs @@ -413,7 +413,6 @@ const server = createServer(async (req, res) => { const sleep = parseInt(fullUrl.searchParams.get('sleep') ?? '0') res.setHeader('content-type', mime) - res.setHeader('test', 'wtf') res.write(message + newline + '\n') if (sleep !== 0) { From 46360ddbfff4b227a3b01fd20c140bbfa5e95b72 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 16:28:18 +0100 Subject: [PATCH 36/60] improve CRLF processing, add tests --- lib/eventsource/eventsource-stream.js | 89 ++++++++-- lib/eventsource/eventsource.js | 2 +- test/eventsource/eventsource-attributes.js | 91 ++++++++++ test/eventsource/eventsource-connect.js | 144 ++++++++++++++++ test/eventsource/eventsource-constructor.js | 53 ++++++ test/eventsource/eventsource-reconnect.js | 176 ++++++++++++++++++++ test/eventsource/eventsource-redirecting.js | 89 ++++++++++ test/eventsource/eventsource-stream.js | 72 ++++++++ test/eventsource/eventsource.js | 152 ++++++++++++++++- 9 files changed, 853 insertions(+), 15 deletions(-) create mode 100644 test/eventsource/eventsource-attributes.js create mode 100644 test/eventsource/eventsource-connect.js create mode 100644 test/eventsource/eventsource-constructor.js create mode 100644 test/eventsource/eventsource-reconnect.js create mode 100644 test/eventsource/eventsource-redirecting.js diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 74e858ed932..c4f7f04cd49 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -176,28 +176,82 @@ class EventSourceStream extends Transform { } while (this.pos < this.buffer.length) { - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.eventEndCheck) { - this.eventEndCheck = false - this.processEvent(this.event) - this.event = { - data: undefined, - event: undefined, - id: undefined, - retry: undefined + // If the previous line ended with an end-of-line, we need to check + // if the next character is also an end-of-line. + if (this.eventEndCheck) { + // If the the current character is an end-of-line, then the event + // is finished and we can process it + + // If the previous line ended with a carriage return, we need to + // check if the current character is a line feed and remove it + // from the buffer. + if (this.crlfCheck) { + // If the current character is a line feed, we can remove it + // from the buffer and reset the crlfCheck flag + if (this.buffer[this.pos] === LF) { + this.buffer = this.buffer.subarray(this.pos + 1) + this.pos = 0 + this.crlfCheck = false + + // It is possible that the line feed is not the end of the + // event. We need to check if the next character is an + // end-of-line character to determine if the event is + // finished. We simply continue the loop to check the next + // character. + + // As we removed the line feed from the buffer and set the + // crlfCheck flag to false, we basically don't make any + // distinction between a line feed and a carriage return. + continue + } + } + + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { + // If the current character is a carriage return, we need to + // set the crlfCheck flag to true, as we need to check if the + // next character is a line feed so we can remove it from the + // buffer + if (this.buffer[this.pos] === CR) { + this.crlfCheck = true } - this.buffer = this.buffer.slice(1) + + this.buffer = this.buffer.subarray(this.pos + 1) + this.pos = 0 + this.processEvent(this.event) + this.clearEvent() continue } - this.parseLine(this.buffer.slice(0, this.pos), this.event) + // If the current character is not an end-of-line, then the event + // is not finished and we have to reset the eventEndCheck flag + this.eventEndCheck = false + continue + } + + // If the current character is an end-of-line, we can process the + // line + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { + // If the current character is a carriage return, we need to + // set the crlfCheck flag to true, as we need to check if the + // next character is a line feed + if (this.buffer[this.pos] === CR) { + this.crlfCheck = true + } + + // In any case, we can process the line as we reached an + // end-of-line character + this.parseLine(this.buffer.subarray(0, this.pos), this.event) // Remove the processed line from the buffer - this.buffer = this.buffer.slice(this.pos + 1) - // Reset the position + this.buffer = this.buffer.subarray(this.pos + 1) + // Reset the position as we removed the processed line from the buffer this.pos = 0 + // A line was processed and this could be the end of the event. We need + // to check if the next line is empty to determine if the event is + // finished. this.eventEndCheck = true continue } + this.pos++ } @@ -285,6 +339,15 @@ class EventSourceStream extends Transform { } }) } + + clearEvent () { + this.event = { + data: undefined, + event: undefined, + id: undefined, + retry: undefined + } + } } module.exports = { diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index a1769d2bca5..3e715504e58 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -192,7 +192,7 @@ class EventSource extends EventTarget { this.close() } - this.#connect() + this.#reconnect() } // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody... diff --git a/test/eventsource/eventsource-attributes.js b/test/eventsource/eventsource-attributes.js new file mode 100644 index 00000000000..41b01aff295 --- /dev/null +++ b/test/eventsource/eventsource-attributes.js @@ -0,0 +1,91 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - eventhandler idl', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'dummy') + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + let done = 0 + const eventhandlerIdl = ['onmessage', 'onerror', 'onopen'] + + eventhandlerIdl.forEach((type) => { + test(`Should properly configure the ${type} eventhandler idl`, () => { + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + + // Eventsource eventhandler idl is by default null, + assert.strictEqual(eventSourceInstance[type], null) + + // The eventhandler idl is by default not enumerable. + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(eventSourceInstance, type), false) + + // The eventhandler idl ignores non-functions. + eventSourceInstance[type] = 7 + assert.strictEqual(EventSource[type], undefined) + + // The eventhandler idl accepts functions. + function fn () { + assert.fail('Should not have called the eventhandler') + } + eventSourceInstance[type] = fn + assert.strictEqual(eventSourceInstance[type], fn) + + // The eventhandler idl can be set to another function. + function fn2 () { } + eventSourceInstance[type] = fn2 + assert.strictEqual(eventSourceInstance[type], fn2) + + // The eventhandler idl overrides the previous function. + eventSourceInstance.dispatchEvent(new Event(type)) + + eventSourceInstance.close() + done++ + + if (done === eventhandlerIdl.length) server.close() + }) + }) +}) + +describe('EventSource - constants', () => { + [ + ['CONNECTING', 0], + ['OPEN', 1], + ['CLOSED', 2] + ].forEach((config) => { + test(`Should expose the ${config[0]} constant`, () => { + const [constant, value] = config + + // EventSource exposes the constant. + assert.strictEqual(Object.hasOwn(EventSource, constant), true) + + // The value is properly set. + assert.strictEqual(EventSource[constant], value) + + // The constant is enumerable. + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(EventSource, constant), true) + + // The constant is not writable. + try { + EventSource[constant] = 666 + } catch (e) { + assert.strictEqual(e instanceof TypeError, true) + } + // The constant is not configurable. + try { + delete EventSource[constant] + } catch (e) { + assert.strictEqual(e instanceof TypeError, true) + } + assert.strictEqual(EventSource[constant], value) + }) + }) +}) diff --git a/test/eventsource/eventsource-connect.js b/test/eventsource/eventsource-connect.js new file mode 100644 index 00000000000..46ce6a7249b --- /dev/null +++ b/test/eventsource/eventsource-connect.js @@ -0,0 +1,144 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - withCredentials', () => { + test('withCredentials should be false by default', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.withCredentials, false) + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('withCredentials can be set to true', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`, { withCredentials: true }) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.withCredentials, true) + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) +}) + +describe('EventSource - sending correct request headers', () => { + test('should send request with connection keep-alive', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with sec-fetch-mode set to cors', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers['sec-fetch-mode'], 'cors') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with pragma and cache-control set to no-cache', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers['cache-control'], 'no-cache') + assert.strictEqual(req.headers.pragma, 'no-cache') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with accept text/event-stream', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.accept, 'text/event-stream') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) +}) diff --git a/test/eventsource/eventsource-constructor.js b/test/eventsource/eventsource-constructor.js new file mode 100644 index 00000000000..3640ba15467 --- /dev/null +++ b/test/eventsource/eventsource-constructor.js @@ -0,0 +1,53 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - withCredentials', () => { + test('withCredentials should be false by default', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.withCredentials, false) + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('withCredentials can be set to true', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`, { withCredentials: true }) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.withCredentials, true) + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) +}) diff --git a/test/eventsource/eventsource-reconnect.js b/test/eventsource/eventsource-reconnect.js new file mode 100644 index 00000000000..9af6ca9db22 --- /dev/null +++ b/test/eventsource/eventsource-reconnect.js @@ -0,0 +1,176 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { defaultReconnectionTime } = require('../../lib/eventsource/constants') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - reconnect', () => { + test('Should reconnect on connection close', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + + eventSourceInstance.onerror = (err) => { + if (err.message === 'Reconnecting') return + finishedPromise.reject(new Error('Should not have errored')) + } + + await finishedPromise.promise + }) + + test('Should reconnect on with reconnection timeout', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const start = Date.now() + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + assert.ok(Date.now() - start >= defaultReconnectionTime) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + + eventSourceInstance.onerror = (err) => { + if (err.message === 'Reconnecting') return + finishedPromise.reject(new Error('Should not have errored')) + } + + await finishedPromise.promise + }) + + test('Should reconnect on with modified reconnection timeout', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('retry: 100\n\n') + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const start = Date.now() + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + assert.ok(Date.now() - start >= 100) + assert.ok(Date.now() - start < 1000) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + + eventSourceInstance.onerror = (err) => { + if (err.message === 'Reconnecting') return + finishedPromise.reject(new Error('Should not have errored')) + } + + await finishedPromise.promise + }) + + test('Should reconnect and send lastEventId', async () => { + let requestCount = 0 + + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('id: 1337\n\n') + if (requestCount++ !== 0) { + assert.strictEqual(req.headers['last-event-id'], '1337') + } + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const start = Date.now() + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + assert.ok(Date.now() - start >= 3000) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + + eventSourceInstance.onerror = (err) => { + if (err.message === 'Reconnecting') return + finishedPromise.reject(new Error('Should not have errored')) + } + + await finishedPromise.promise + }) +}) diff --git a/test/eventsource/eventsource-redirecting.js b/test/eventsource/eventsource-redirecting.js new file mode 100644 index 00000000000..f0fbd062d4a --- /dev/null +++ b/test/eventsource/eventsource-redirecting.js @@ -0,0 +1,89 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - redirecting', () => { + [301, 302, 307, 308].forEach((statusCode) => { + test(`Should redirect on ${statusCode} status code`, async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(statusCode, undefined, { Location: '/target' }) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(200, 'dummy', { 'Content-Type': 'text/event-stream' }) + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = (e) => { + assert.fail('Should not have errored') + } + eventSourceInstance.onopen = () => { + // assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) + eventSourceInstance.close() + server.close() + } + }) + }) + + test('Stop trying to connect when getting a 204 response', async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(301, undefined, { Location: '/target' }) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(204, 'OK') + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = (event) => { + assert.strictEqual(event.message, 'No content') + // TODO: fetching does not set the url properly? + // assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) + assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) + server.close() + } + eventSourceInstance.onopen = () => { + assert.fail('Should not have opened') + } + }) + + test('Throw when missing a Location header', async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(301, undefined) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(204, 'OK') + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = () => { + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) + assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) + server.close() + } + }) +}) diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js index 141d1768f51..6f8b6010959 100644 --- a/test/eventsource/eventsource-stream.js +++ b/test/eventsource/eventsource-stream.js @@ -32,6 +32,78 @@ describe('EventSourceStream', () => { } }) + test('Should also process CR as EOL.', () => { + const content = Buffer.from('data: Hello\r\r', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should also process CRLF as EOL.', () => { + const content = Buffer.from('data: Hello\r\n\r\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should also process mixed CR and CRLF as EOL.', () => { + const content = Buffer.from('data: Hello\r\r\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should also process mixed LF and CRLF as EOL.', () => { + const content = Buffer.from('data: Hello\n\r\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + test('Should ignore comments', () => { const content = Buffer.from(':data: Hello\n\n', 'utf8') diff --git a/test/eventsource/eventsource.js b/test/eventsource/eventsource.js index b584aae4522..6f1b7010657 100644 --- a/test/eventsource/eventsource.js +++ b/test/eventsource/eventsource.js @@ -10,6 +10,146 @@ describe('EventSource - constructor', () => { test('Not providing url argument should throw', () => { assert.throws(() => new EventSource(), TypeError) }) + test('Throw DOMException if URL is invalid', () => { + assert.throws(() => new EventSource('http:'), { message: /Invalid URL/ }) + }) +}) + +describe('EventSource - withCredentials', () => { + test('withCredentials should be false by default', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.withCredentials, false) + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('withCredentials can be set to true', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`, { withCredentials: true }) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.withCredentials, true) + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) +}) + +describe('EventSource - sending correct request headers', () => { + test('should send request with connection keep-alive', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with sec-fetch-mode set to cors', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers['sec-fetch-mode'], 'cors') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with pragma and cache-control set to no-cache', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers['cache-control'], 'no-cache') + assert.strictEqual(req.headers.pragma, 'no-cache') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with accept text/event-stream', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.accept, 'text/event-stream') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) }) describe('EventSource - eventhandler idl', async () => { @@ -39,10 +179,20 @@ describe('EventSource - eventhandler idl', async () => { assert.strictEqual(EventSource[type], undefined) // The eventhandler idl accepts functions. - function fn () { } + function fn () { + assert.fail('Should not have called the eventhandler') + } eventSourceInstance[type] = fn assert.strictEqual(eventSourceInstance[type], fn) + // The eventhandler idl can be set to another function. + function fn2 () { } + eventSourceInstance[type] = fn2 + assert.strictEqual(eventSourceInstance[type], fn2) + + // The eventhandler idl overrides the previous function. + eventSourceInstance.dispatchEvent(new Event(type)) + eventSourceInstance.close() done++ From 05b0fa791882a1a589c4b940ebcf7ae18c9b13dc Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 21:25:35 +0100 Subject: [PATCH 37/60] more tests --- lib/eventsource/eventsource.js | 1 - test/eventsource/eventsource-connect.js | 86 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 3e715504e58..20face6e615 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -237,7 +237,6 @@ class EventSource extends EventTarget { // 3. Otherwise, [...] if res's // `Content-Type` is not `text/event-stream`, then fail the // connection. - let contentType = response.headersList.get('content-type', true) if (contentType !== null) { diff --git a/test/eventsource/eventsource-connect.js b/test/eventsource/eventsource-connect.js index 46ce6a7249b..96e75e781e5 100644 --- a/test/eventsource/eventsource-connect.js +++ b/test/eventsource/eventsource-connect.js @@ -142,3 +142,89 @@ describe('EventSource - sending correct request headers', () => { } }) }) + +describe('EventSource - received response must have content-type to be text/event-stream', () => { + test('should send request with accept text/event-stream', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with accept text/event-stream;', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream;' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should handle content-type text/event-stream;charset=UTF-8 properly', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream;charset=UTF-8' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should throw if content-type is text/html properly', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/html' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + assert.fail('Should not have opened') + } + + eventSourceInstance.onerror = () => { + eventSourceInstance.close() + server.close() + } + }) +}) From ef1999609b4708a08b04217343c2743553b324fe Mon Sep 17 00:00:00 2001 From: uzlopak Date: Wed, 17 Jan 2024 21:28:25 +0100 Subject: [PATCH 38/60] remove constants.js --- lib/eventsource/constants.js | 93 ----------------------- lib/eventsource/eventsource-stream.js | 36 +++++++-- lib/eventsource/eventsource.js | 55 +++++++++++++- test/eventsource/eventsource-reconnect.js | 3 +- 4 files changed, 82 insertions(+), 105 deletions(-) delete mode 100644 lib/eventsource/constants.js diff --git a/lib/eventsource/constants.js b/lib/eventsource/constants.js deleted file mode 100644 index c02039eee33..00000000000 --- a/lib/eventsource/constants.js +++ /dev/null @@ -1,93 +0,0 @@ -'use strict' - -/** - * 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. - */ -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' - -/** - * @type {number[]} BOM - */ -const BOM = [0xEF, 0xBB, 0xBF] -/** - * @type {10} LF - */ -const LF = 0x0A -/** - * @type {13} CR - */ -const CR = 0x0D -/** - * @type {58} COLON - */ -const COLON = 0x3A -/** - * @type {32} SPACE - */ -const SPACE = 0x20 - -const validMessageEventFieldNames = [ - 'data', - 'event', - 'id', - 'retry' -] - -module.exports = { - defaultReconnectionTime, - CONNECTING, - OPEN, - CLOSED, - BOM, - LF, - CR, - COLON, - SPACE, - validMessageEventFieldNames, - ANONYMOUS, - USE_CREDENTIALS -} diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index c4f7f04cd49..239c9b3866e 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -1,15 +1,35 @@ 'use strict' const { Transform } = require('node:stream') -const { - BOM, - CR, - LF, - COLON, - SPACE, - validMessageEventFieldNames -} = require('./constants') const { isASCIINumber, isValidLastEventId } = require('./util') +/** + * @type {number[]} BOM + */ +const BOM = [0xEF, 0xBB, 0xBF] +/** + * @type {10} LF + */ +const LF = 0x0A +/** + * @type {13} CR + */ +const CR = 0x0D +/** + * @type {58} COLON + */ +const COLON = 0x3A +/** + * @type {32} SPACE + */ +const SPACE = 0x20 + +const validMessageEventFieldNames = [ + 'data', + 'event', + 'id', + 'retry' +] + /** * @typedef {object} EventSourceStreamEvent * @type {object} diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 20face6e615..4221d2dfc58 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -7,7 +7,6 @@ const { HeadersList } = require('../fetch/headers') const { makeRequest } = require('../fetch/request') const { getGlobalOrigin } = require('../fetch/global') const { webidl } = require('../fetch/webidl') -const { CONNECTING, OPEN, CLOSED, defaultReconnectionTime, ANONYMOUS, USE_CREDENTIALS } = require('./constants') const { EventSourceStream } = require('./eventsource-stream') const { parseMIMEType } = require('../fetch/dataURL') const { MessageEvent, ErrorEvent } = require('../websocket/events') @@ -16,6 +15,57 @@ const { getGlobalDispatcher } = require('../global') 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 @@ -438,5 +488,6 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ ]) module.exports = { - EventSource + EventSource, + defaultReconnectionTime } diff --git a/test/eventsource/eventsource-reconnect.js b/test/eventsource/eventsource-reconnect.js index 9af6ca9db22..6e49ab2bc17 100644 --- a/test/eventsource/eventsource-reconnect.js +++ b/test/eventsource/eventsource-reconnect.js @@ -4,8 +4,7 @@ const assert = require('node:assert') const events = require('node:events') const http = require('node:http') const { test, describe } = require('node:test') -const { defaultReconnectionTime } = require('../../lib/eventsource/constants') -const { EventSource } = require('../../lib/eventsource/eventsource') +const { EventSource, defaultReconnectionTime } = require('../../lib/eventsource/eventsource') describe('EventSource - reconnect', () => { test('Should reconnect on connection close', async () => { From 9ea9156a2ea7f3d7eabcd69ba15eba777c5c91f3 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 18 Jan 2024 03:12:58 +0100 Subject: [PATCH 39/60] improve parseLine logic --- lib/eventsource/eventsource-stream.js | 85 ++--- lib/eventsource/eventsource.js | 2 +- test/eventsource/eventsource-connect.js | 46 --- .../eventsource-stream-parse-line.js | 3 +- .../eventsource-stream-process-event.js | 30 +- test/eventsource/eventsource-stream.js | 134 +++++++- test/eventsource/eventsource.js | 309 ------------------ 7 files changed, 191 insertions(+), 418 deletions(-) diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 239c9b3866e..69495331e30 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -23,13 +23,6 @@ const COLON = 0x3A */ const SPACE = 0x20 -const validMessageEventFieldNames = [ - 'data', - 'event', - 'id', - 'retry' -] - /** * @typedef {object} EventSourceStreamEvent * @type {object} @@ -224,6 +217,7 @@ class EventSourceStream extends Transform { // distinction between a line feed and a carriage return. continue } + this.crlfCheck = false } if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { @@ -237,7 +231,10 @@ class EventSourceStream extends Transform { this.buffer = this.buffer.subarray(this.pos + 1) this.pos = 0 - this.processEvent(this.event) + if ( + this.event.data !== undefined || this.event.event || this.event.id || this.event.retry) { + this.processEvent(this.event) + } this.clearEvent() continue } @@ -283,52 +280,58 @@ class EventSourceStream extends Transform { * @param {EventStreamEvent} event */ parseLine (line, event) { - // If the line is empty, we can skip processing it as it does not modify - // the event + // If the line is empty (a blank line) + // Dispatch the event, as defined below. + // This will be handled in the _transform method if (line.length === 0) { return } - // If the line does not contain a colon, we can skip processing it as it - // wont have have a field name and value - // Potentially the data is invalid, but we just ignore it + // If the line starts with a U+003A COLON character (:) + // Ignore the line. const colonPosition = line.indexOf(COLON) - if (colonPosition === -1) { - return - } - - // If the line starts with a colon, we can skip processing it as it is a - // comment if (colonPosition === 0) { return } - // The field name is the part of the line before the colon. Event streams - // are always decoded as UTF-8 - const fieldName = line.subarray(0, colonPosition).toString('utf8') - - // If the field name is not a valid field name, we can stop processing the - // line - if (!validMessageEventFieldNames.includes(fieldName)) { - return - } - - // We expect that the value starts after the colon. If there is a space - // after the colon, we ignore it - let fieldValueStart = colonPosition + 1 - if (line[fieldValueStart] === SPACE) { - ++fieldValueStart - } + let field = '' + let value = '' + + // If the line contains a U+003A COLON character (:) + if (colonPosition !== -1) { + // Collect the characters on the line before the first U+003A COLON + // character (:), and let field be that string. + field = line.subarray(0, colonPosition).toString('utf8') + + // Collect the characters on the line after the first U+003A COLON + // character (:), and let value be that string. + // If value starts with a U+0020 SPACE character, remove it from value. + let valueStart = colonPosition + 1 + if (line[valueStart] === SPACE) { + ++valueStart + } + value = line.subarray(valueStart).toString('utf8') - // If the value starts after the colon, but the line ends, we can stop - // processing the line as it is only an empty string - if (fieldValueStart === line.length) { - event[fieldName] = '' + // Otherwise, the string is not empty but does not contain a U+003A COLON + // character (:) + } else { + // Process the field using the steps described below, using the whole + // line as the field name, and the empty string as the field value. + field = line.toString('utf8') + value = '' } // Modify the event with the field name and value. The value is also // decoded as UTF-8 - event[fieldName] = line.subarray(fieldValueStart).toString('utf8') + if (field === 'data') { + if (event[field] === undefined) { + event[field] = value + } else { + event[field] += '\n' + value + } + } else { + event[field] = value + } } /** @@ -352,7 +355,7 @@ class EventSourceStream extends Transform { this.push({ type, - payload: { + options: { data, lastEventId: this.state.lastEventId, origin: this.state.origin diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 4221d2dfc58..b334272da47 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -319,7 +319,7 @@ class EventSource extends EventTarget { push: (eventPayload) => { this.dispatchEvent(new MessageEvent( eventPayload.type, - eventPayload.payload + eventPayload.options )) } }) diff --git a/test/eventsource/eventsource-connect.js b/test/eventsource/eventsource-connect.js index 96e75e781e5..f5f81b1a549 100644 --- a/test/eventsource/eventsource-connect.js +++ b/test/eventsource/eventsource-connect.js @@ -6,52 +6,6 @@ const http = require('node:http') const { test, describe } = require('node:test') const { EventSource } = require('../../lib/eventsource/eventsource') -describe('EventSource - withCredentials', () => { - test('withCredentials should be false by default', async () => { - const server = http.createServer((req, res) => { - res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) - res.end() - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}`) - eventSourceInstance.onopen = () => { - assert.strictEqual(eventSourceInstance.withCredentials, false) - eventSourceInstance.close() - server.close() - } - - eventSourceInstance.onerror = () => { - assert.fail('Should not have errored') - } - }) - - test('withCredentials can be set to true', async () => { - const server = http.createServer((req, res) => { - res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) - res.end() - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}`, { withCredentials: true }) - eventSourceInstance.onopen = () => { - assert.strictEqual(eventSourceInstance.withCredentials, true) - eventSourceInstance.close() - server.close() - } - - eventSourceInstance.onerror = () => { - assert.fail('Should not have errored') - } - }) -}) - describe('EventSource - sending correct request headers', () => { test('should send request with connection keep-alive', async () => { const server = http.createServer((req, res) => { diff --git a/test/eventsource/eventsource-stream-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js index a3a948dd41c..ccb1da1e228 100644 --- a/test/eventsource/eventsource-stream-parse-line.js +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -212,8 +212,9 @@ describe('EventSourceStream - parseLine', () => { stream.parseLine(Buffer.from('comment: invalid', 'utf8'), event) assert.strictEqual(typeof event, 'object') - assert.strictEqual(Object.keys(event).length, 0) + assert.strictEqual(Object.keys(event).length, 1) assert.strictEqual(event.data, undefined) + assert.strictEqual(event.comment, 'invalid') assert.strictEqual(event.id, undefined) assert.strictEqual(event.event, undefined) assert.strictEqual(event.retry, undefined) diff --git a/test/eventsource/eventsource-stream-process-event.js b/test/eventsource/eventsource-stream-process-event.js index 97e91cdb544..aa106e15f1c 100644 --- a/test/eventsource/eventsource-stream-process-event.js +++ b/test/eventsource/eventsource-stream-process-event.js @@ -20,9 +20,9 @@ describe('EventSourceStream - processEvent', () => { stream.on('data', (event) => { assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'message') - assert.strictEqual(event.payload.data, null) - assert.strictEqual(event.payload.lastEventId, undefined) - assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(event.options.data, null) + assert.strictEqual(event.options.lastEventId, undefined) + assert.strictEqual(event.options.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) @@ -57,9 +57,9 @@ describe('EventSourceStream - processEvent', () => { stream.on('data', (event) => { assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'message') - assert.strictEqual(event.payload.data, 'Hello') - assert.strictEqual(event.payload.lastEventId, undefined) - assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(event.options.data, 'Hello') + assert.strictEqual(event.options.lastEventId, undefined) + assert.strictEqual(event.options.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) @@ -82,9 +82,9 @@ describe('EventSourceStream - processEvent', () => { stream.on('data', (event) => { assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'message') - assert.strictEqual(event.payload.data, null) - assert.strictEqual(event.payload.lastEventId, '1234') - assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(event.options.data, null) + assert.strictEqual(event.options.lastEventId, '1234') + assert.strictEqual(event.options.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) @@ -105,9 +105,9 @@ describe('EventSourceStream - processEvent', () => { stream.on('data', (event) => { assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'message') - assert.strictEqual(event.payload.data, null) - assert.strictEqual(event.payload.lastEventId, '1234') - assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(event.options.data, null) + assert.strictEqual(event.options.lastEventId, '1234') + assert.strictEqual(event.options.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) @@ -124,9 +124,9 @@ describe('EventSourceStream - processEvent', () => { stream.on('data', (event) => { assert.strictEqual(typeof event, 'object') assert.strictEqual(event.type, 'custom') - assert.strictEqual(event.payload.data, null) - assert.strictEqual(event.payload.lastEventId, undefined) - assert.strictEqual(event.payload.origin, 'example.com') + assert.strictEqual(event.options.data, null) + assert.strictEqual(event.options.lastEventId, undefined) + assert.strictEqual(event.options.origin, 'example.com') assert.strictEqual(stream.state.reconnectionTime, 1000) }) diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js index 6f8b6010959..8b0f464ade0 100644 --- a/test/eventsource/eventsource-stream.js +++ b/test/eventsource/eventsource-stream.js @@ -127,12 +127,26 @@ describe('EventSourceStream', () => { const content = Buffer.from('data\n\ndata\ndata\n\ndata:', 'utf8') const stream = new EventSourceStream() + let count = 0 stream.processEvent = function (event) { - assert.strictEqual(typeof event, 'object') - assert.strictEqual(event.event, undefined) - assert.strictEqual(event.data, undefined) - assert.strictEqual(event.id, undefined) - assert.strictEqual(event.retry, undefined) + switch (count) { + case 0: { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, '') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + break + } + case 1: { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, '\n') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + } + count++ } for (let i = 0; i < content.length; i++) { @@ -157,4 +171,114 @@ describe('EventSourceStream', () => { stream.write(Buffer.from([content[i]])) } }) + + test('ignores empty comments', () => { + const content = Buffer.from('data: Hello\n\n:\n\ndata: World\n\n', 'utf8') + const stream = new EventSourceStream() + + let count = 0 + + stream.processEvent = function (event) { + switch (count) { + case 0: { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + break + } + case 1: { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'World') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + break + } + default: { + assert.fail() + } + } + count++ + } + + stream.write(content) + }) + + test('comment fest', () => { + const longstring = new Array(2 * 1024 + 1).join('x') + const content = Buffer.from(`data:1\r:\0\n:\r\ndata:2\n:${longstring}\rdata:3\n:data:fail\r:${longstring}\ndata:4\n\n`, 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, '1\n2\n3\n4') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + stream.write(content) + }) + + test('comment fest', () => { + const content = Buffer.from('data:\n\ndata\ndata\n\ndata:test\n\n', 'utf8') + const stream = new EventSourceStream() + + let count = 0 + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + switch (count) { + case 0: { + assert.strictEqual(event.data, '') + break + } + case 1: { + assert.strictEqual(event.data, '\n') + break + } + case 2: { + assert.strictEqual(event.data, 'test') + break + } + default: { + assert.fail() + } + } + count++ + } + stream.write(content) + }) + + test('newline test', () => { + const content = Buffer.from('data:test\r\ndata\ndata:test\r\n\r\n', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + assert.strictEqual(event.data, 'test\n\ntest') + } + stream.write(content) + }) + + test('newline test', () => { + const content = Buffer.from('data:test\n data\ndata\nfoobar:xxx\njustsometext\n:thisisacommentyay\ndata:test\n\n', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + assert.strictEqual(event.data, 'test\n\ntest') + } + stream.write(content) + }) }) diff --git a/test/eventsource/eventsource.js b/test/eventsource/eventsource.js index 6f1b7010657..9ea9673f9e7 100644 --- a/test/eventsource/eventsource.js +++ b/test/eventsource/eventsource.js @@ -1,8 +1,6 @@ 'use strict' const assert = require('node:assert') -const events = require('node:events') -const http = require('node:http') const { test, describe } = require('node:test') const { EventSource } = require('../../lib/eventsource/eventsource') @@ -14,310 +12,3 @@ describe('EventSource - constructor', () => { assert.throws(() => new EventSource('http:'), { message: /Invalid URL/ }) }) }) - -describe('EventSource - withCredentials', () => { - test('withCredentials should be false by default', async () => { - const server = http.createServer((req, res) => { - res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) - res.end() - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}`) - eventSourceInstance.onopen = () => { - assert.strictEqual(eventSourceInstance.withCredentials, false) - eventSourceInstance.close() - server.close() - } - - eventSourceInstance.onerror = () => { - assert.fail('Should not have errored') - } - }) - - test('withCredentials can be set to true', async () => { - const server = http.createServer((req, res) => { - res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) - res.end() - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}`, { withCredentials: true }) - eventSourceInstance.onopen = () => { - assert.strictEqual(eventSourceInstance.withCredentials, true) - eventSourceInstance.close() - server.close() - } - - eventSourceInstance.onerror = () => { - assert.fail('Should not have errored') - } - }) -}) - -describe('EventSource - sending correct request headers', () => { - test('should send request with connection keep-alive', async () => { - const server = http.createServer((req, res) => { - assert.strictEqual(req.headers.connection, 'keep-alive') - res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) - res.end() - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}`) - eventSourceInstance.onopen = () => { - eventSourceInstance.close() - server.close() - } - - eventSourceInstance.onerror = () => { - assert.fail('Should not have errored') - } - }) - - test('should send request with sec-fetch-mode set to cors', async () => { - const server = http.createServer((req, res) => { - assert.strictEqual(req.headers['sec-fetch-mode'], 'cors') - res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) - res.end() - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}`) - eventSourceInstance.onopen = () => { - eventSourceInstance.close() - server.close() - } - - eventSourceInstance.onerror = () => { - assert.fail('Should not have errored') - } - }) - - test('should send request with pragma and cache-control set to no-cache', async () => { - const server = http.createServer((req, res) => { - assert.strictEqual(req.headers['cache-control'], 'no-cache') - assert.strictEqual(req.headers.pragma, 'no-cache') - res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) - res.end() - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}`) - eventSourceInstance.onopen = () => { - eventSourceInstance.close() - server.close() - } - - eventSourceInstance.onerror = () => { - assert.fail('Should not have errored') - } - }) - - test('should send request with accept text/event-stream', async () => { - const server = http.createServer((req, res) => { - assert.strictEqual(req.headers.accept, 'text/event-stream') - res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) - res.end() - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}`) - eventSourceInstance.onopen = () => { - eventSourceInstance.close() - server.close() - } - - eventSourceInstance.onerror = () => { - assert.fail('Should not have errored') - } - }) -}) - -describe('EventSource - eventhandler idl', async () => { - const server = http.createServer((req, res) => { - res.writeHead(200, 'dummy') - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - let done = 0 - const eventhandlerIdl = ['onmessage', 'onerror', 'onopen'] - - eventhandlerIdl.forEach((type) => { - test(`Should properly configure the ${type} eventhandler idl`, () => { - const eventSourceInstance = new EventSource(`http://localhost:${port}`) - - // Eventsource eventhandler idl is by default null, - assert.strictEqual(eventSourceInstance[type], null) - - // The eventhandler idl is by default not enumerable. - assert.strictEqual(Object.prototype.propertyIsEnumerable.call(eventSourceInstance, type), false) - - // The eventhandler idl ignores non-functions. - eventSourceInstance[type] = 7 - assert.strictEqual(EventSource[type], undefined) - - // The eventhandler idl accepts functions. - function fn () { - assert.fail('Should not have called the eventhandler') - } - eventSourceInstance[type] = fn - assert.strictEqual(eventSourceInstance[type], fn) - - // The eventhandler idl can be set to another function. - function fn2 () { } - eventSourceInstance[type] = fn2 - assert.strictEqual(eventSourceInstance[type], fn2) - - // The eventhandler idl overrides the previous function. - eventSourceInstance.dispatchEvent(new Event(type)) - - eventSourceInstance.close() - done++ - - if (done === eventhandlerIdl.length) server.close() - }) - }) -}) - -describe('EventSource - constants', () => { - [ - ['CONNECTING', 0], - ['OPEN', 1], - ['CLOSED', 2] - ].forEach((config) => { - test(`Should expose the ${config[0]} constant`, () => { - const [constant, value] = config - - // EventSource exposes the constant. - assert.strictEqual(Object.hasOwn(EventSource, constant), true) - - // The value is properly set. - assert.strictEqual(EventSource[constant], value) - - // The constant is enumerable. - assert.strictEqual(Object.prototype.propertyIsEnumerable.call(EventSource, constant), true) - - // The constant is not writable. - try { - EventSource[constant] = 666 - } catch (e) { - assert.strictEqual(e instanceof TypeError, true) - } - // The constant is not configurable. - try { - delete EventSource[constant] - } catch (e) { - assert.strictEqual(e instanceof TypeError, true) - } - assert.strictEqual(EventSource[constant], value) - }) - }) -}) - -describe('EventSource - redirecting', () => { - [301, 302, 307, 308].forEach((statusCode) => { - test(`Should redirect on ${statusCode} status code`, async () => { - const server = http.createServer((req, res) => { - if (res.req.url === '/redirect') { - res.writeHead(statusCode, undefined, { Location: '/target' }) - res.end() - } else if (res.req.url === '/target') { - res.writeHead(200, 'dummy', { 'Content-Type': 'text/event-stream' }) - res.end() - } - }) - - server.listen(0) - await events.once(server, 'listening') - - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) - eventSourceInstance.onerror = (e) => { - assert.fail('Should not have errored') - } - eventSourceInstance.onopen = () => { - // assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) - eventSourceInstance.close() - server.close() - } - }) - }) -}) - -describe('EventSource - stop redirecting on 204 status code', async () => { - test('Stop trying to connect when getting a 204 response', async () => { - const server = http.createServer((req, res) => { - if (res.req.url === '/redirect') { - res.writeHead(301, undefined, { Location: '/target' }) - res.end() - } else if (res.req.url === '/target') { - res.writeHead(204, 'OK') - res.end() - } - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) - eventSourceInstance.onerror = (event) => { - assert.strictEqual(event.message, 'No content') - // TODO: fetching does not set the url properly? - // assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) - assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) - server.close() - } - eventSourceInstance.onopen = () => { - assert.fail('Should not have opened') - } - }) -}) - -describe('EventSource - Location header', () => { - test('Throw when missing a Location header', async () => { - const server = http.createServer((req, res) => { - if (res.req.url === '/redirect') { - res.writeHead(301, undefined) - res.end() - } else if (res.req.url === '/target') { - res.writeHead(204, 'OK') - res.end() - } - }) - - server.listen(0) - await events.once(server, 'listening') - const port = server.address().port - - const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) - eventSourceInstance.onerror = () => { - assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) - assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) - server.close() - } - }) -}) From 1a2d50827eb129487aba76f28a6d1f48d0d45087 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 18 Jan 2024 03:25:14 +0100 Subject: [PATCH 40/60] rename --- lib/eventsource/eventsource.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index b334272da47..ea6fe4246f3 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -316,10 +316,10 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#settings, - push: (eventPayload) => { + push: (event) => { this.dispatchEvent(new MessageEvent( - eventPayload.type, - eventPayload.options + event.type, + event.options )) } }) From 6d8a2e9f2e9aedf1116bcd9fd4235b370ff9eb4b Mon Sep 17 00:00:00 2001 From: Khafra Date: Wed, 17 Jan 2024 22:44:25 -0500 Subject: [PATCH 41/60] fixup --- lib/eventsource/eventsource.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index ea6fe4246f3..5f762e76bf8 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -3,7 +3,6 @@ const { setTimeout } = require('node:timers/promises') const { pipeline } = require('node:stream') const { fetching } = require('../fetch') -const { HeadersList } = require('../fetch/headers') const { makeRequest } = require('../fetch/request') const { getGlobalOrigin } = require('../fetch/global') const { webidl } = require('../fetch/webidl') @@ -180,8 +179,7 @@ class EventSource extends EventTarget { initRequest.client = this.#settings // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list. - initRequest.headers = new HeadersList() - initRequest.headers.set('accept', 'text/event-stream', true) + initRequest.headersList = [['accept', { name: 'accept', value: 'text/event-stream' }]] // 11. Set request's cache mode to "no-store". initRequest.cache = 'no-store' @@ -194,8 +192,6 @@ class EventSource extends EventTarget { // 13. Set ev's request to request. this.#request = makeRequest(initRequest) - this.#request.headersList = initRequest.headers - this.#connect() } From 1531c8d43745c02c109bf0b139479827a3dda2ee Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 18 Jan 2024 05:28:34 +0100 Subject: [PATCH 42/60] add ignored tests of wpt --- test/wpt/status/eventsource.status.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/test/wpt/status/eventsource.status.json b/test/wpt/status/eventsource.status.json index 0967ef424bc..da94b4c7f58 100644 --- a/test/wpt/status/eventsource.status.json +++ b/test/wpt/status/eventsource.status.json @@ -1 +1,22 @@ -{} +{ + "eventsource-onmessage-trusted.any.js": { + "note": "An Event created in userspace can not be set to trusted.", + "skip": true + }, + "format-data-before-final-empty-line.any.js": { + "note": "To be investigated.", + "skip": true + }, + "format-field-retry.any.js": { + "note": "To be investigated.", + "skip": true + }, + "format-field-event-empty.any.js": { + "note": "To be investigated.", + "skip": true + }, + "format-field-retry-bogus.any.js": { + "note": "To be investigated.", + "skip": true + } +} \ No newline at end of file From 7b32778068be8234e37405e6c3c9e31acf0bbc16 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 18 Jan 2024 06:16:01 +0100 Subject: [PATCH 43/60] better --- lib/eventsource/eventsource.js | 2 +- test/eventsource/eventsource-stream.js | 14 ++++++++++++++ test/eventsource/util.js | 1 + test/wpt/server/server.mjs | 16 ++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 5f762e76bf8..0ec0fc275cd 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -376,7 +376,7 @@ class EventSource extends EventTarget { // 2. Set (`Last-Event-ID`, lastEventIDValue) in request's header // list. if (this.#settings.lastEventId !== '') { - this.#request.headers.set('last-event-id', this.#settings.lastEventId, true) + 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. diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js index 8b0f464ade0..69a04821e26 100644 --- a/test/eventsource/eventsource-stream.js +++ b/test/eventsource/eventsource-stream.js @@ -281,4 +281,18 @@ describe('EventSourceStream', () => { } stream.write(content) }) + + test('newline test', () => { + const content = Buffer.from('data:test\n data\ndata\nfoobar:xxx\njustsometext\n:thisisacommentyay\ndata:test\n\n', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + assert.strictEqual(event.data, 'test\n\ntest') + } + stream.write(content) + }) }) diff --git a/test/eventsource/util.js b/test/eventsource/util.js index 5bec5609bab..07d60d11f9d 100644 --- a/test/eventsource/util.js +++ b/test/eventsource/util.js @@ -8,6 +8,7 @@ test('isValidLastEventId', () => { assert.strictEqual(isValidLastEventId('valid'), true) assert.strictEqual(isValidLastEventId('in\u0000valid'), false) assert.strictEqual(isValidLastEventId('in\x00valid'), false) + assert.strictEqual(isValidLastEventId('…'), true) assert.strictEqual(isValidLastEventId(null), false) assert.strictEqual(isValidLastEventId(undefined), false) diff --git a/test/wpt/server/server.mjs b/test/wpt/server/server.mjs index 38b7bde804e..59a955a274e 100644 --- a/test/wpt/server/server.mjs +++ b/test/wpt/server/server.mjs @@ -423,6 +423,22 @@ const server = createServer(async (req, res) => { return } + case '/eventsource/resources/last-event-id.py': { + const lastEventId = req.headers['Last-Event-ID'] ?? '' + const idValue = fullUrl.searchParams.get('idvalue') ?? '\u2026' + + res.setHeader('content-type', 'text/event-stream') + + if (lastEventId) { + res.write(`data: ${lastEventId}\n\n`) + res.end() + } else { + res.write(`id: ${idValue}\nretry: 200\ndata: hello\n\n`) + res.end() + } + + return + } default: { res.statusCode = 200 res.end(fullUrl.toString()) From 4b7ad3a80a83b13c3c578ec268bde1d11f8ed2c1 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 18 Jan 2024 06:40:38 +0100 Subject: [PATCH 44/60] fix more --- lib/eventsource/eventsource-stream.js | 31 +++++++++----- lib/eventsource/util.js | 1 + .../eventsource-stream-parse-line.js | 40 +++++++++++++++++++ test/eventsource/util.js | 1 + 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 69495331e30..6a8d87a8edf 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -312,8 +312,8 @@ class EventSourceStream extends Transform { } value = line.subarray(valueStart).toString('utf8') - // Otherwise, the string is not empty but does not contain a U+003A COLON - // character (:) + // Otherwise, the string is not empty but does not contain a U+003A COLON + // character (:) } else { // Process the field using the steps described below, using the whole // line as the field name, and the empty string as the field value. @@ -323,14 +323,27 @@ class EventSourceStream extends Transform { // Modify the event with the field name and value. The value is also // decoded as UTF-8 - if (field === 'data') { - if (event[field] === undefined) { + switch (field) { + case 'data': + if (event[field] === undefined) { + event[field] = value + } else { + event[field] += '\n' + value + } + break + case 'retry': + if (isASCIINumber(value)) { + event[field] = value + } + break + case 'id': + if (isValidLastEventId(value)) { + event[field] = value + } + break + case 'event': + default: event[field] = value - } else { - event[field] += '\n' + value - } - } else { - event[field] = value } } diff --git a/lib/eventsource/util.js b/lib/eventsource/util.js index 85d9a46b4c6..8cf38505988 100644 --- a/lib/eventsource/util.js +++ b/lib/eventsource/util.js @@ -18,6 +18,7 @@ function isValidLastEventId (value) { * @returns {boolean} */ function isASCIINumber (value) { + if (value.length === 0) return false for (let i = 0; i < value.length; i++) { if (value.charCodeAt(i) < 0x30 || value.charCodeAt(i) > 0x39) return false } diff --git a/test/eventsource/eventsource-stream-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js index ccb1da1e228..4a0d3f652f5 100644 --- a/test/eventsource/eventsource-stream-parse-line.js +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -219,4 +219,44 @@ describe('EventSourceStream - parseLine', () => { assert.strictEqual(event.event, undefined) assert.strictEqual(event.retry, undefined) }) + + test('bogus retry', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + 'retry:3000\nretry:1000x\ndata:x'.split('\n').forEach((line) => { + stream.parseLine(Buffer.from(line, 'utf8'), event) + }) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 2) + assert.strictEqual(event.data, 'x') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, '3000') + }) + + test('bogus id', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + 'id:3000\nid:30\x000\ndata:x'.split('\n').forEach((line) => { + stream.parseLine(Buffer.from(line, 'utf8'), event) + }) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 2) + assert.strictEqual(event.data, 'x') + assert.strictEqual(event.id, '3000') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) }) diff --git a/test/eventsource/util.js b/test/eventsource/util.js index 07d60d11f9d..e0e84523459 100644 --- a/test/eventsource/util.js +++ b/test/eventsource/util.js @@ -17,5 +17,6 @@ test('isValidLastEventId', () => { test('isASCIINumber', () => { assert.strictEqual(isASCIINumber('123'), true) + assert.strictEqual(isASCIINumber(''), false) assert.strictEqual(isASCIINumber('123a'), false) }) From 67fef73b66da196d5f884258f50dbd02967abfe3 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 18 Jan 2024 13:17:09 +0100 Subject: [PATCH 45/60] add docs --- docs/api/EventSource.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/api/EventSource.md diff --git a/docs/api/EventSource.md b/docs/api/EventSource.md new file mode 100644 index 00000000000..27b146785b9 --- /dev/null +++ b/docs/api/EventSource.md @@ -0,0 +1,21 @@ +# EventSource + +Undici exposes a WHATWG spec-compliant implementation of [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) +for [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events). + +## Instantiating EventSource + +Undici exports a EventSource class. You can instantiate the EventSource as +follows: + +```mjs +import { EventSource } from 'undici' + +const evenSource = new EventSource('http://localhost:3000') +evenSource.onmessage = (event) => { + console.log(event.data) +} +``` + +More information about the EventSource API can be found on +[MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventSource). \ No newline at end of file From 104da43a426708ba601064800c8d5a44034b4b1d Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 19 Jan 2024 04:04:40 +0100 Subject: [PATCH 46/60] fix setting origin on message event --- lib/eventsource/eventsource.js | 9 ++--- test/eventsource/eventsource-redirecting.js | 37 +++++++++++++++++++-- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 0ec0fc275cd..fb4714b31a9 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -127,10 +127,7 @@ class EventSource extends EventTarget { // 2. Let settings be ev's relevant settings object. // https://html.spec.whatwg.org/multipage/webappapis.html#environment-settings-object this.#settings = { - baseUrl: getGlobalOrigin(), - get origin () { - return this.baseUrl?.href - }, + origin: getGlobalOrigin(), policyContainer: { referrerPolicy: 'no-referrer' }, @@ -143,6 +140,7 @@ class EventSource extends EventTarget { 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') @@ -310,6 +308,9 @@ class EventSource extends EventTarget { 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) => { diff --git a/test/eventsource/eventsource-redirecting.js b/test/eventsource/eventsource-redirecting.js index f0fbd062d4a..b0a0a2a8c39 100644 --- a/test/eventsource/eventsource-redirecting.js +++ b/test/eventsource/eventsource-redirecting.js @@ -29,7 +29,7 @@ describe('EventSource - redirecting', () => { assert.fail('Should not have errored') } eventSourceInstance.onopen = () => { - // assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) eventSourceInstance.close() server.close() } @@ -54,8 +54,7 @@ describe('EventSource - redirecting', () => { const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) eventSourceInstance.onerror = (event) => { assert.strictEqual(event.message, 'No content') - // TODO: fetching does not set the url properly? - // assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/target`) + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) server.close() } @@ -86,4 +85,36 @@ describe('EventSource - redirecting', () => { server.close() } }) + + test('Should set origin attribute of messages after redirecting', async () => { + const targetServer = http.createServer((req, res) => { + if (res.req.url === '/target') { + res.writeHead(200, undefined, { 'Content-Type': 'text/event-stream' }) + res.write('event: message\ndata: test\n\n') + } + }) + targetServer.listen(0) + await events.once(targetServer, 'listening') + const targetPort = targetServer.address().port + + const sourceServer = http.createServer((req, res) => { + res.writeHead(301, undefined, { Location: `http://127.0.0.1:${targetPort}/target` }) + res.end() + }) + sourceServer.listen(0) + await events.once(sourceServer, 'listening') + + const sourcePort = sourceServer.address().port + + const eventSourceInstance = new EventSource(`http://127.0.0.1:${sourcePort}/redirect`) + eventSourceInstance.onmessage = (event) => { + assert.strictEqual(event.origin, `http://127.0.0.1:${targetPort}`) + eventSourceInstance.close() + targetServer.close() + sourceServer.close() + } + eventSourceInstance.onerror = (e) => { + assert.fail('Should not have errored') + } + }) }) From 2ca2594c328b6048c1114977c1a60505603db956 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 19 Jan 2024 06:04:46 +0100 Subject: [PATCH 47/60] add more tests --- .../eventsource-constructor-stringify.js | 31 ++++++++++++++++ .../eventsource-request-status-error.js | 36 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 test/eventsource/eventsource-constructor-stringify.js create mode 100644 test/eventsource/eventsource-request-status-error.js diff --git a/test/eventsource/eventsource-constructor-stringify.js b/test/eventsource/eventsource-constructor-stringify.js new file mode 100644 index 00000000000..8e6fb7c2601 --- /dev/null +++ b/test/eventsource/eventsource-constructor-stringify.js @@ -0,0 +1,31 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - constructor stringify', () => { + test('should stringify argument', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource({ toString: function () { return `http://localhost:${port}` } }) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) +}) diff --git a/test/eventsource/eventsource-request-status-error.js b/test/eventsource/eventsource-request-status-error.js new file mode 100644 index 00000000000..a8775fde18a --- /dev/null +++ b/test/eventsource/eventsource-request-status-error.js @@ -0,0 +1,36 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - status error', () => { + [204, 205, 210, 299, 404, 410, 503].forEach((statusCode) => { + test(`Should error on ${statusCode} status code`, async () => { + const server = http.createServer((req, res) => { + res.writeHead(statusCode, 'dummy', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onerror = (e) => { + assert.strictEqual(this.readyState, this.CLOSED) + eventSourceInstance.close() + server.close() + } + eventSourceInstance.onmessage = () => { + assert.fail('Should not have received a message') + } + eventSourceInstance.onopen = () => { + assert.fail('Should not have opened') + } + }) + }) +}) From 98eae69b02304ab1405e2b71cf8dca50511702f8 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 19 Jan 2024 23:18:39 +0100 Subject: [PATCH 48/60] fix wpt tests --- lib/eventsource/eventsource-stream.js | 4 ++++ .../eventsource-stream-parse-line.js | 20 +++++++++++++++++++ test/wpt/server/server.mjs | 8 +++----- test/wpt/status/eventsource.status.json | 12 ----------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 6a8d87a8edf..9d003e38cbc 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -342,6 +342,10 @@ class EventSourceStream extends Transform { } break case 'event': + if (value.length > 0) { + event[field] = value + } + break default: event[field] = value } diff --git a/test/eventsource/eventsource-stream-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js index 4a0d3f652f5..9007342c9ca 100644 --- a/test/eventsource/eventsource-stream-parse-line.js +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -259,4 +259,24 @@ describe('EventSourceStream - parseLine', () => { assert.strictEqual(event.event, undefined) assert.strictEqual(event.retry, undefined) }) + + test('empty event', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + 'event: \ndata:data'.split('\n').forEach((line) => { + stream.parseLine(Buffer.from(line, 'utf8'), event) + }) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, 'data') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) }) diff --git a/test/wpt/server/server.mjs b/test/wpt/server/server.mjs index 59a955a274e..6d664ff185a 100644 --- a/test/wpt/server/server.mjs +++ b/test/wpt/server/server.mjs @@ -415,11 +415,9 @@ const server = createServer(async (req, res) => { res.setHeader('content-type', mime) res.write(message + newline + '\n') - if (sleep !== 0) { - setTimeout(() => { - res.end() - }, sleep) - } + setTimeout(() => { + res.end() + }, sleep) return } diff --git a/test/wpt/status/eventsource.status.json b/test/wpt/status/eventsource.status.json index da94b4c7f58..2db13cb2a65 100644 --- a/test/wpt/status/eventsource.status.json +++ b/test/wpt/status/eventsource.status.json @@ -3,20 +3,8 @@ "note": "An Event created in userspace can not be set to trusted.", "skip": true }, - "format-data-before-final-empty-line.any.js": { - "note": "To be investigated.", - "skip": true - }, - "format-field-retry.any.js": { - "note": "To be investigated.", - "skip": true - }, "format-field-event-empty.any.js": { "note": "To be investigated.", "skip": true - }, - "format-field-retry-bogus.any.js": { - "note": "To be investigated.", - "skip": true } } \ No newline at end of file From 49d40cc8782a42c3ad9c0e74eb430336163cf705 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Fri, 19 Jan 2024 23:21:09 +0100 Subject: [PATCH 49/60] add EventSource documentation to website sidebar --- docsify/sidebar.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docsify/sidebar.md b/docsify/sidebar.md index 04af3fd8d3f..e187c3080f6 100644 --- a/docsify/sidebar.md +++ b/docsify/sidebar.md @@ -10,6 +10,7 @@ * [ProxyAgent](/docs/api/ProxyAgent.md "Undici API - ProxyAgent") * [Connector](/docs/api/Connector.md "Custom connector") * [Errors](/docs/api/Errors.md "Undici API - Errors") + * [EventSource](/docs/api/EventSource.md "Undici API - EventSource") * [Fetch](/docs/api/Fetch.md "Undici API - Fetch") * [Cookies](/docs/api/Cookies.md "Undici API - Cookies") * [MockClient](/docs/api/MockClient.md "Undici API - MockClient") From 6d3f48a57d0c4fd386b91071e92d9f6e92991f4d Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 00:42:46 +0100 Subject: [PATCH 50/60] activate skipped wpt test --- test/wpt/status/eventsource.status.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/wpt/status/eventsource.status.json b/test/wpt/status/eventsource.status.json index 2db13cb2a65..b7bfaa4be5d 100644 --- a/test/wpt/status/eventsource.status.json +++ b/test/wpt/status/eventsource.status.json @@ -2,9 +2,5 @@ "eventsource-onmessage-trusted.any.js": { "note": "An Event created in userspace can not be set to trusted.", "skip": true - }, - "format-field-event-empty.any.js": { - "note": "To be investigated.", - "skip": true } } \ No newline at end of file From 8841ef12413902694c71a82c9ea7c228104790ef Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 03:55:01 +0100 Subject: [PATCH 51/60] fix some remarks --- lib/eventsource/eventsource.js | 2 +- lib/fetch/index.js | 1 - lib/websocket/events.js | 1 - package.json | 4 ++-- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index fb4714b31a9..b1155f0b10d 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -220,7 +220,7 @@ class EventSource extends EventTarget { return this.#withCredentials } - async #connect () { + #connect () { if (this.#readyState === CLOSED) return this.#readyState = CONNECTING diff --git a/lib/fetch/index.js b/lib/fetch/index.js index 7ae66ad4957..75939e18599 100644 --- a/lib/fetch/index.js +++ b/lib/fetch/index.js @@ -2282,6 +2282,5 @@ module.exports = { fetch, Fetch, fetching, - mainFetch, finalizeAndReportTiming } diff --git a/lib/websocket/events.js b/lib/websocket/events.js index cfea412ba50..621a2263b7d 100644 --- a/lib/websocket/events.js +++ b/lib/websocket/events.js @@ -297,7 +297,6 @@ webidl.converters.ErrorEventInit = webidl.dictionaryConverter([ ]) module.exports = { - eventInit, MessageEvent, CloseEvent, ErrorEvent diff --git a/package.json b/package.json index 07e027d29c5..bafd00daf2d 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "build:wasm": "node build/wasm.js --docker", "lint": "standard | snazzy", "lint:fix": "standard --fix | snazzy", - "test": "node scripts/generate-pem && npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:wpt && npm run test:websocket && npm run test:jest && npm run test:typescript && npm run test:node-test", + "test": "node scripts/generate-pem && npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:eventsource && npm run test:wpt && npm run test:websocket && npm run test:jest && npm run test:typescript && npm run test:node-test", "test:cookies": "borp --coverage -p \"test/cookie/*.js\"", "test:node-fetch": "mocha --exit test/node-fetch", "test:eventsource": "npm run build:node && borp --expose-gc --coverage -p \"test/eventsource/*.js\"", @@ -87,7 +87,7 @@ "test:tdd:node-test": "borp -p \"test/node-test/**/*.js\" -w", "test:typescript": "tsd && tsc --skipLibCheck test/imports/undici-import.ts", "test:websocket": "borp --coverage -p \"test/websocket/*.js\"", - "test:wpt": "node test/wpt/start-fetch.mjs && node test/wpt/start-FileAPI.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-websockets.mjs && node test/wpt/start-cacheStorage.mjs", + "test:wpt": "node test/wpt/start-fetch.mjs && node test/wpt/start-FileAPI.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-websockets.mjs && node test/wpt/start-cacheStorage.mjs && node test/wpt/start-eventsource.mjs", "coverage": "nyc --reporter=text --reporter=html npm run test", "coverage:ci": "nyc --reporter=lcov npm run test", "bench": "PORT=3042 concurrently -k -s first npm:bench:server npm:bench:run", From 15fea9319618c46cdc68c5164b9ae6b676f97aa4 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 03:59:02 +0100 Subject: [PATCH 52/60] simplify --- lib/eventsource/eventsource.js | 42 +++++++++++++++------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index b1155f0b10d..57e117b2da3 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -265,35 +265,29 @@ class EventSource extends EventTarget { this.#reconnect() return } - // 3. Otherwise, if res's status is not 200, [...], then fail the - // connection. - } else if (response.status !== 200) { - let message = `Unexpected status code: ${response.status}` - - if (response.status === 204) { - message = 'No content' - } - this.close() - this.dispatchEvent(new ErrorEvent('error', { message })) - return } - // 3. Otherwise, [...] if res's - // `Content-Type` is not `text/event-stream`, then fail the - // connection. - let contentType = response.headersList.get('content-type', true) - - if (contentType !== null) { - if (contentType.endsWith(';')) { - // spec compatibility - contentType = contentType.slice(0, -1) + // 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 + ) { + let message + + if (response.status !== 200) { + message = 'No content' + } else if (contentTypeValid === false) { + message = `Invalid content-type: ${contentType}` + } else { + message = `Invalid status code: ${response.status}` } - } - const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' - if (mimeType === 'failure' || mimeType.essence !== 'text/event-stream') { this.close() - this.dispatchEvent(new ErrorEvent('error', { message: 'Invalid content-type' })) + this.dispatchEvent(new ErrorEvent('error', { message })) return } From cb392efc9f61333dbea63fd55214ebded179a13d Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 04:00:57 +0100 Subject: [PATCH 53/60] remove newline --- lib/eventsource/eventsource.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index 57e117b2da3..e38e0df2802 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -243,7 +243,6 @@ class EventSource extends EventTarget { 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. From 920885e0fc0f18ed2bdf0d0d639861e8f5ecd995 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 04:09:54 +0100 Subject: [PATCH 54/60] remove usage of ErrorEvent --- lib/eventsource/eventsource.js | 22 ++++++--------------- test/eventsource/eventsource-reconnect.js | 20 ------------------- test/eventsource/eventsource-redirecting.js | 1 - 3 files changed, 6 insertions(+), 37 deletions(-) diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js index e38e0df2802..d940f236f93 100644 --- a/lib/eventsource/eventsource.js +++ b/lib/eventsource/eventsource.js @@ -8,7 +8,7 @@ const { getGlobalOrigin } = require('../fetch/global') const { webidl } = require('../fetch/webidl') const { EventSourceStream } = require('./eventsource-stream') const { parseMIMEType } = require('../fetch/dataURL') -const { MessageEvent, ErrorEvent } = require('../websocket/events') +const { MessageEvent } = require('../websocket/events') const { isNetworkError } = require('../fetch/response') const { getGlobalDispatcher } = require('../global') @@ -232,7 +232,7 @@ class EventSource extends EventTarget { // 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 ErrorEvent('error', { error: response.error })) + this.dispatchEvent(new Event('error')) this.close() } @@ -255,7 +255,7 @@ class EventSource extends EventTarget { // reconnect. if (response.aborted) { this.close() - this.dispatchEvent(new ErrorEvent('error', { error: response.error })) + 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 @@ -275,18 +275,8 @@ class EventSource extends EventTarget { response.status !== 200 || contentTypeValid === false ) { - let message - - if (response.status !== 200) { - message = 'No content' - } else if (contentTypeValid === false) { - message = `Invalid content-type: ${contentType}` - } else { - message = `Invalid status code: ${response.status}` - } - this.close() - this.dispatchEvent(new ErrorEvent('error', { message })) + this.dispatchEvent(new Event('error')) return } @@ -321,7 +311,7 @@ class EventSource extends EventTarget { error?.aborted === false ) { this.close() - this.dispatchEvent(new ErrorEvent('error', { error })) + this.dispatchEvent(new Event('error')) } }) } @@ -351,7 +341,7 @@ class EventSource extends EventTarget { this.#readyState = CONNECTING // 3. Fire an event named error at the EventSource object. - this.dispatchEvent(new ErrorEvent('error', { message: 'Reconnecting' })) + this.dispatchEvent(new Event('error')) // 2. Wait a delay equal to the reconnection time of the event source. await setTimeout(this.#settings.reconnectionTime, { ref: false }) diff --git a/test/eventsource/eventsource-reconnect.js b/test/eventsource/eventsource-reconnect.js index 6e49ab2bc17..3499b8b3702 100644 --- a/test/eventsource/eventsource-reconnect.js +++ b/test/eventsource/eventsource-reconnect.js @@ -37,11 +37,6 @@ describe('EventSource - reconnect', () => { } } - eventSourceInstance.onerror = (err) => { - if (err.message === 'Reconnecting') return - finishedPromise.reject(new Error('Should not have errored')) - } - await finishedPromise.promise }) @@ -77,11 +72,6 @@ describe('EventSource - reconnect', () => { } } - eventSourceInstance.onerror = (err) => { - if (err.message === 'Reconnecting') return - finishedPromise.reject(new Error('Should not have errored')) - } - await finishedPromise.promise }) @@ -119,11 +109,6 @@ describe('EventSource - reconnect', () => { } } - eventSourceInstance.onerror = (err) => { - if (err.message === 'Reconnecting') return - finishedPromise.reject(new Error('Should not have errored')) - } - await finishedPromise.promise }) @@ -165,11 +150,6 @@ describe('EventSource - reconnect', () => { } } - eventSourceInstance.onerror = (err) => { - if (err.message === 'Reconnecting') return - finishedPromise.reject(new Error('Should not have errored')) - } - await finishedPromise.promise }) }) diff --git a/test/eventsource/eventsource-redirecting.js b/test/eventsource/eventsource-redirecting.js index b0a0a2a8c39..1e8a31ae310 100644 --- a/test/eventsource/eventsource-redirecting.js +++ b/test/eventsource/eventsource-redirecting.js @@ -53,7 +53,6 @@ describe('EventSource - redirecting', () => { const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) eventSourceInstance.onerror = (event) => { - assert.strictEqual(event.message, 'No content') assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) server.close() From b643ddece9316ac77cadda439321f19a6125df01 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 05:46:09 +0100 Subject: [PATCH 55/60] harden --- lib/eventsource/eventsource-stream.js | 38 ++- test/eventsource/eventsource-message.js | 217 ++++++++++++++++++ .../eventsource-stream-parse-line.js | 3 +- 3 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 test/eventsource/eventsource-message.js diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 9d003e38cbc..949a7dc12f0 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -346,8 +346,6 @@ class EventSourceStream extends Transform { event[field] = value } break - default: - event[field] = value } } @@ -355,29 +353,25 @@ class EventSourceStream extends Transform { * @param {EventSourceStreamEvent} event */ processEvent (event) { - if (event.retry) { - if (isASCIINumber(event.retry)) { - this.state.reconnectionTime = parseInt(event.retry, 10) - } + if (event.retry && isASCIINumber(event.retry)) { + this.state.reconnectionTime = parseInt(event.retry, 10) } - const { - id, - data = null, - event: type = 'message' - } = event - - if (id && isValidLastEventId(id)) { - this.state.lastEventId = id + + if (event.id && isValidLastEventId(event.id)) { + this.state.lastEventId = event.id } - this.push({ - type, - options: { - data, - lastEventId: this.state.lastEventId, - origin: this.state.origin - } - }) + // only dispatch event, when data is provided + if (event.data !== undefined) { + this.push({ + type: event.event || 'message', + options: { + data: event.data, + lastEventId: this.state.lastEventId, + origin: this.state.origin + } + }) + } } clearEvent () { diff --git a/test/eventsource/eventsource-message.js b/test/eventsource/eventsource-message.js new file mode 100644 index 00000000000..7acd614478f --- /dev/null +++ b/test/eventsource/eventsource-message.js @@ -0,0 +1,217 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { setTimeout } = require('node:timers/promises') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - message', () => { + test('Should not emit a message if only retry field was sent', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('retry: 100\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const start = Date.now() + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + assert.ok(Date.now() - start >= 100) + assert.ok(Date.now() - start < 1000) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + eventSourceInstance.onmessage = () => { + finishedPromise.reject('Should not have received a message') + eventSourceInstance.close() + server.close() + } + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should not emit a message if no data is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:message\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + + eventSourceInstance.onmessage = () => { + finishedPromise.reject('Should not have received a message') + eventSourceInstance.close() + server.close() + } + + await setTimeout(500) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + + await finishedPromise.promise + }) + + test('Should emit a custom type message if data is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:custom\ndata:test\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('custom', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a message event if data is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data:test\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('message', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should not emit a custom type message if no data is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:custom\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + let reconnectionCount = 0 + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + if (++reconnectionCount === 2) { + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + } + eventSourceInstance.addEventListener('custom', () => { + finishedPromise.reject('Should not have received a message') + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) +}) diff --git a/test/eventsource/eventsource-stream-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js index 9007342c9ca..6ef6dd8eca3 100644 --- a/test/eventsource/eventsource-stream-parse-line.js +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -212,9 +212,8 @@ describe('EventSourceStream - parseLine', () => { stream.parseLine(Buffer.from('comment: invalid', 'utf8'), event) assert.strictEqual(typeof event, 'object') - assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(Object.keys(event).length, 0) assert.strictEqual(event.data, undefined) - assert.strictEqual(event.comment, 'invalid') assert.strictEqual(event.id, undefined) assert.strictEqual(event.event, undefined) assert.strictEqual(event.retry, undefined) From 7fd4a6489a1aaae5a8f2098d0f36ff1ec42eed79 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 06:44:08 +0100 Subject: [PATCH 56/60] more tests --- test/eventsource/eventsource-close.js | 59 ++++++++++ test/eventsource/eventsource-message.js | 148 ++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 test/eventsource/eventsource-close.js diff --git a/test/eventsource/eventsource-close.js b/test/eventsource/eventsource-close.js new file mode 100644 index 00000000000..7f88d00dc87 --- /dev/null +++ b/test/eventsource/eventsource-close.js @@ -0,0 +1,59 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { setTimeout } = require('node:timers/promises') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/eventsource/eventsource') + +describe('EventSource - close', () => { + test('should not emit error when closing the EventSource Instance', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data: hello\n\n') + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.close() + await setTimeout(1000, { ref: false }) + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should set readyState to CLOSED', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data: hello\n\n') + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.readyState, EventSource.OPEN) + eventSourceInstance.close() + assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + + await setTimeout(2000, { ref: false }) + server.close() + }) +}) diff --git a/test/eventsource/eventsource-message.js b/test/eventsource/eventsource-message.js index 7acd614478f..8b76bdc6b26 100644 --- a/test/eventsource/eventsource-message.js +++ b/test/eventsource/eventsource-message.js @@ -168,6 +168,154 @@ describe('EventSource - message', () => { await finishedPromise.promise }) + test('Should emit a message event if data as a field is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('message', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a custom message event if data is empty', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:custom\ndata:\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('custom', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a message event if data is empty', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data:\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('message', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a custom message event if data only as a field is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:custom\ndata\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('custom', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + test('Should not emit a custom type message if no data is provided', async () => { const finishedPromise = { promise: undefined, From 9592c470896afc7bf4277effe3fada1f2e4bf195 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 07:09:40 +0100 Subject: [PATCH 57/60] dont check for strings in isValidLastEventId --- lib/eventsource/util.js | 4 +--- test/eventsource/util.js | 4 ---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/eventsource/util.js b/lib/eventsource/util.js index 8cf38505988..a87cc834eca 100644 --- a/lib/eventsource/util.js +++ b/lib/eventsource/util.js @@ -7,9 +7,7 @@ */ function isValidLastEventId (value) { // LastEventId should not contain U+0000 NULL - return ( - typeof value === 'string' && (value.indexOf('\u0000') === -1) - ) + return value.indexOf('\u0000') === -1 } /** diff --git a/test/eventsource/util.js b/test/eventsource/util.js index e0e84523459..e976731557e 100644 --- a/test/eventsource/util.js +++ b/test/eventsource/util.js @@ -9,10 +9,6 @@ test('isValidLastEventId', () => { assert.strictEqual(isValidLastEventId('in\u0000valid'), false) assert.strictEqual(isValidLastEventId('in\x00valid'), false) assert.strictEqual(isValidLastEventId('…'), true) - - assert.strictEqual(isValidLastEventId(null), false) - assert.strictEqual(isValidLastEventId(undefined), false) - assert.strictEqual(isValidLastEventId(7), false) }) test('isASCIINumber', () => { From 3e912ec26554b8143235242a33d2a8b177f27df2 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 16:16:43 +0100 Subject: [PATCH 58/60] add TODOs --- lib/eventsource/eventsource-stream.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js index 949a7dc12f0..3117318df10 100644 --- a/lib/eventsource/eventsource-stream.js +++ b/lib/eventsource/eventsource-stream.js @@ -106,8 +106,11 @@ class EventSourceStream extends Transform { return } - // We cache the chunk in the buffer, as the data might not be complete - // while processing it + // Cache the chunk in the buffer, as the data might not be complete while + // processing it + // TODO: Investigate if there is a more performant way to handle + // incoming chunks + // see: https://github.com/nodejs/undici/issues/2630 if (this.buffer) { this.buffer = Buffer.concat([this.buffer, chunk]) } else { @@ -301,6 +304,9 @@ class EventSourceStream extends Transform { if (colonPosition !== -1) { // Collect the characters on the line before the first U+003A COLON // character (:), and let field be that string. + // TODO: Investigate if there is a more performant way to extract the + // field + // see: https://github.com/nodejs/undici/issues/2630 field = line.subarray(0, colonPosition).toString('utf8') // Collect the characters on the line after the first U+003A COLON @@ -310,6 +316,9 @@ class EventSourceStream extends Transform { if (line[valueStart] === SPACE) { ++valueStart } + // TODO: Investigate if there is a more performant way to extract the + // value + // see: https://github.com/nodejs/undici/issues/2630 value = line.subarray(valueStart).toString('utf8') // Otherwise, the string is not empty but does not contain a U+003A COLON From 4b18785b39f666d4bdd4b29745437f1cbef067f3 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sat, 20 Jan 2024 17:25:26 +0100 Subject: [PATCH 59/60] improve example for eventsource --- examples/eventsource.js | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/examples/eventsource.js b/examples/eventsource.js index b54663f5315..bae28cc20c4 100644 --- a/examples/eventsource.js +++ b/examples/eventsource.js @@ -1,17 +1,20 @@ 'use strict' +const { randomBytes } = require('crypto') const { EventSource } = require('../') async function main () { - const ev = new EventSource('https://smee.io/wcGp009TievZCLT') - ev.onmessage = (event) => { - console.log(event) - } - ev.onerror = event => { - console.log(event) - } - ev.onopen = event => { - console.log(event) - } + const url = `https://smee.io/${randomBytes(8).toString('base64url')}` + console.log(`Connecting to event source server ${url}}`) + const ev = new EventSource(url) + ev.onmessage = console.log + ev.onerror = console.log + ev.onopen = console.log + + // Special event of smee.io + ev.addEventListener('ready', console.log) + + // Ping event is sent every 30 seconds by smee.io + ev.addEventListener('ping', console.log) } main() From 42fbe9382f70269d15f4992ab8779f7b201a2aee Mon Sep 17 00:00:00 2001 From: uzlopak Date: Mon, 22 Jan 2024 20:29:43 +0100 Subject: [PATCH 60/60] trigger CI because node 21.6.1 got released --- examples/eventsource.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/eventsource.js b/examples/eventsource.js index bae28cc20c4..a9cb323dc86 100644 --- a/examples/eventsource.js +++ b/examples/eventsource.js @@ -5,7 +5,7 @@ const { EventSource } = require('../') async function main () { const url = `https://smee.io/${randomBytes(8).toString('base64url')}` - console.log(`Connecting to event source server ${url}}`) + console.log(`Connecting to event source server ${url}`) const ev = new EventSource(url) ev.onmessage = console.log ev.onerror = console.log