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 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") diff --git a/examples/eventsource.js b/examples/eventsource.js new file mode 100644 index 00000000000..a9cb323dc86 --- /dev/null +++ b/examples/eventsource.js @@ -0,0 +1,20 @@ +'use strict' + +const { randomBytes } = require('crypto') +const { EventSource } = require('../') + +async function main () { + 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() diff --git a/index.js b/index.js index 2594140d09e..bf46fc08d98 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/eventsource') + +module.exports.EventSource = EventSource diff --git a/lib/eventsource/eventsource-stream.js b/lib/eventsource/eventsource-stream.js new file mode 100644 index 00000000000..3117318df10 --- /dev/null +++ b/lib/eventsource/eventsource-stream.js @@ -0,0 +1,398 @@ +'use strict' +const { Transform } = require('node:stream') +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 + +/** + * @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 eventSourceSettings + * @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 {eventSourceSettings} + */ + 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 {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.eventSourceSettings || {} + 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 + } + + // 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 { + 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 + + // The buffer only contains one byte so we need to wait for more data + callback() + return + case 2: + // 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: + // 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: + // 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 + } + } + + while (this.pos < this.buffer.length) { + // 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 + } + this.crlfCheck = false + } + + 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.subarray(this.pos + 1) + this.pos = 0 + if ( + this.event.data !== undefined || this.event.event || this.event.id || this.event.retry) { + this.processEvent(this.event) + } + this.clearEvent() + continue + } + // 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.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++ + } + + callback() + } + + /** + * @param {Buffer} line + * @param {EventStreamEvent} event + */ + parseLine (line, 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 starts with a U+003A COLON character (:) + // Ignore the line. + const colonPosition = line.indexOf(COLON) + if (colonPosition === 0) { + return + } + + 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. + // 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 + // 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 + } + // 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 + // 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 + 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': + if (value.length > 0) { + event[field] = value + } + break + } + } + + /** + * @param {EventSourceStreamEvent} event + */ + processEvent (event) { + if (event.retry && isASCIINumber(event.retry)) { + this.state.reconnectionTime = parseInt(event.retry, 10) + } + + if (event.id && isValidLastEventId(event.id)) { + this.state.lastEventId = event.id + } + + // 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 () { + this.event = { + data: undefined, + event: undefined, + id: undefined, + retry: undefined + } + } +} + +module.exports = { + EventSourceStream +} diff --git a/lib/eventsource/eventsource.js b/lib/eventsource/eventsource.js new file mode 100644 index 00000000000..d940f236f93 --- /dev/null +++ b/lib/eventsource/eventsource.js @@ -0,0 +1,473 @@ +'use strict' + +const { setTimeout } = require('node:timers/promises') +const { pipeline } = require('node:stream') +const { fetching } = require('../fetch') +const { makeRequest } = require('../fetch/request') +const { getGlobalOrigin } = require('../fetch/global') +const { webidl } = require('../fetch/webidl') +const { EventSourceStream } = require('./eventsource-stream') +const { parseMIMEType } = require('../fetch/dataURL') +const { MessageEvent } = require('../websocket/events') +const { isNetworkError } = require('../fetch/response') +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 + * should include credentials. + */ + +/** + * The EventSource interface is used to receive server-sent events. It + * connects to a server over HTTP and receives events in text/event-stream + * format without closing the connection. + * @extends {EventTarget} + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events + * @api public + */ +class EventSource extends EventTarget { + #events = { + open: null, + error: null, + message: null + } + + #url = null + #withCredentials = false + + #readyState = CONNECTING + + #request = null + #controller = null + + /** + * @type {object} + * @property {string} lastEventId + * @property {number} reconnectionTime + * @property {any} reconnectionTimer + */ + #settings = null + + /** + * Creates a new EventSource object. + * @param {string} url + * @param {EventSourceInit} [eventSourceInitDict] + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface + */ + constructor (url, eventSourceInitDict = {}) { + // 1. Let ev be a new EventSource object. + super() + + webidl.argumentLengthCheck(arguments, 1, { header: 'EventSource constructor' }) + + if (!experimentalWarned) { + experimentalWarned = true + process.emitWarning('EventSource is experimental, expect them to change at any time.', { + code: 'UNDICI-ES' + }) + } + + url = webidl.converters.USVString(url) + eventSourceInitDict = webidl.converters.EventSourceInitDict(eventSourceInitDict) + + // 2. Let settings be ev's relevant settings object. + // https://html.spec.whatwg.org/multipage/webappapis.html#environment-settings-object + this.#settings = { + origin: getGlobalOrigin(), + policyContainer: { + referrerPolicy: 'no-referrer' + }, + lastEventId: '', + reconnectionTime: defaultReconnectionTime + } + + let urlRecord + + try { + // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings. + urlRecord = new URL(url, this.#settings.origin) + this.#settings.origin = urlRecord.origin + } catch (e) { + // 4. If urlRecord is failure, then throw a "SyntaxError" DOMException. + throw new DOMException(e, 'SyntaxError') + } + + // 5. Set ev's url to urlRecord. + this.#url = urlRecord.href + + // 6. Let corsAttributeState be Anonymous. + let corsAttributeState = ANONYMOUS + + // 7. If the value of eventSourceInitDict's withCredentials member is true, + // then set corsAttributeState to Use Credentials and set ev's + // withCredentials attribute to true. + if (eventSourceInitDict.withCredentials) { + corsAttributeState = USE_CREDENTIALS + this.#withCredentials = true + } + + // 8. Let request be the result of creating a potential-CORS request given + // urlRecord, the empty string, and corsAttributeState. + const initRequest = { + redirect: 'follow', + keepalive: true, + // @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes + mode: 'cors', + credentials: corsAttributeState === 'anonymous' + ? 'same-origin' + : 'omit', + referrer: 'no-referrer' + } + + // 9. Set request's client to settings. + initRequest.client = this.#settings + + // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list. + initRequest.headersList = [['accept', { name: 'accept', value: 'text/event-stream' }]] + + // 11. Set request's cache mode to "no-store". + initRequest.cache = 'no-store' + + // 12. Set request's initiator type to "other". + initRequest.initiator = 'other' + + initRequest.urlList = [new URL(this.#url)] + + // 13. Set ev's request to request. + this.#request = makeRequest(initRequest) + + this.#connect() + } + + /** + * Returns the state of this EventSource object's connection. It can have the + * values described below. + * @returns {0|1|2} + * @readonly + */ + get readyState () { + return this.#readyState + } + + /** + * Returns the URL providing the event stream. + * @readonly + * @returns {string} + */ + get url () { + return this.#url + } + + /** + * Returns a boolean indicating whether the EventSource object was + * instantiated with CORS credentials set (true), or not (false, the default). + */ + get withCredentials () { + return this.#withCredentials + } + + #connect () { + if (this.#readyState === CLOSED) return + + this.#readyState = CONNECTING + + const fetchParam = { + request: this.#request + } + + // 14. Let processEventSourceEndOfBody given response res be the following step: if res is not a network error, then reestablish the connection. + const processEventSourceEndOfBody = (response) => { + if (isNetworkError(response)) { + this.dispatchEvent(new Event('error')) + this.close() + } + + this.#reconnect() + } + + // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody... + fetchParam.processResponseEndOfBody = processEventSourceEndOfBody + + // and processResponse set to the following steps given response res: + fetchParam.processResponse = (response) => { + // 1. If res is an aborted network error, then fail the connection. + + if (isNetworkError(response)) { + // 1. When a user agent is to fail the connection, the user agent + // must queue a task which, if the readyState attribute is set to a + // value other than CLOSED, sets the readyState attribute to CLOSED + // and fires an event named error at the EventSource object. Once the + // user agent has failed the connection, it does not attempt to + // reconnect. + if (response.aborted) { + this.close() + this.dispatchEvent(new Event('error')) + return + // 2. Otherwise, if res is a network error, then reestablish the + // connection, unless the user agent knows that to be futile, in + // which case the user agent may fail the connection. + } else { + this.#reconnect() + return + } + } + + // 3. Otherwise, if res's status is not 200, or if res's `Content-Type` + // is not `text/event-stream`, then fail the connection. + const contentType = response.headersList.get('content-type', true) + const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' + const contentTypeValid = mimeType !== 'failure' && mimeType.essence === 'text/event-stream' + if ( + response.status !== 200 || + contentTypeValid === false + ) { + this.close() + this.dispatchEvent(new Event('error')) + return + } + + // 4. Otherwise, announce the connection and interpret res's body + // line by line. + + // When a user agent is to announce the connection, the user agent + // must queue a task which, if the readyState attribute is set to a + // value other than CLOSED, sets the readyState attribute to OPEN + // and fires an event named open at the EventSource object. + // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model + this.#readyState = OPEN + this.dispatchEvent(new Event('open')) + + // If redirected to a different origin, set the origin to the new origin. + this.#settings.origin = response.urlList[response.urlList.length - 1].origin + + const eventSourceStream = new EventSourceStream({ + eventSourceSettings: this.#settings, + push: (event) => { + this.dispatchEvent(new MessageEvent( + event.type, + event.options + )) + } + }) + + pipeline(response.body.stream, + eventSourceStream, + (error) => { + if ( + error?.aborted === false + ) { + this.close() + this.dispatchEvent(new Event('error')) + } + }) + } + + this.#controller = fetching({ + ...fetchParam, + dispatcher: getGlobalDispatcher() + }) + } + + /** + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model + * @returns {Promise} + */ + async #reconnect () { + // When a user agent is to reestablish the connection, the user agent must + // run the following steps. These steps are run in parallel, not as part of + // a task. (The tasks that it queues, of course, are run like normal tasks + // and not themselves in parallel.) + + // 1. Queue a task to run the following steps: + + // 1. If the readyState attribute is set to CLOSED, abort the task. + if (this.#readyState === CLOSED) return + + // 2. Set the readyState attribute to CONNECTING. + this.#readyState = CONNECTING + + // 3. Fire an event named error at the EventSource object. + this.dispatchEvent(new Event('error')) + + // 2. Wait a delay equal to the reconnection time of the event source. + await setTimeout(this.#settings.reconnectionTime, { ref: false }) + + // 5. Queue a task to run the following steps: + + // 1. If the EventSource object's readyState attribute is not set to + // CONNECTING, then return. + if (this.#readyState !== CONNECTING) return + + // 2. Let request be the EventSource object's request. + // 3. If the EventSource object's last event ID string is not the empty + // string, then: + // 1. Let lastEventIDValue be the EventSource object's last event ID + // string, encoded as UTF-8. + // 2. Set (`Last-Event-ID`, lastEventIDValue) in request's header + // list. + if (this.#settings.lastEventId !== '') { + this.#request.headersList.set('last-event-id', this.#settings.lastEventId, true) + } + + // 4. Fetch request and process the response obtained in this fashion, if any, as described earlier in this section. + this.#connect() + } + + /** + * Closes the connection, if any, and sets the readyState attribute to + * CLOSED. + */ + close () { + webidl.brandCheck(this, EventSource) + + if (this.#readyState === CLOSED) return + this.#readyState = CLOSED + clearTimeout(this.#settings.reconnectionTimer) + this.#controller.abort() + + if (this.#request) { + this.#request = null + } + } + + get onopen () { + return this.#events.open + } + + set onopen (fn) { + if (this.#events.open) { + this.removeEventListener('open', this.#events.open) + } + + if (typeof fn === 'function') { + this.#events.open = fn + this.addEventListener('open', fn) + } else { + this.#events.open = null + } + } + + get onmessage () { + return this.#events.message + } + + set onmessage (fn) { + if (this.#events.message) { + this.removeEventListener('message', this.#events.message) + } + + if (typeof fn === 'function') { + this.#events.message = fn + this.addEventListener('message', fn) + } else { + this.#events.message = null + } + } + + get onerror () { + return this.#events.error + } + + set onerror (fn) { + if (this.#events.error) { + this.removeEventListener('error', this.#events.error) + } + + if (typeof fn === 'function') { + this.#events.error = fn + this.addEventListener('error', fn) + } else { + this.#events.error = null + } + } +} + +const constantsPropertyDescriptors = { + CONNECTING: { + __proto__: null, + configurable: false, + enumerable: true, + value: CONNECTING, + writable: false + }, + OPEN: { + __proto__: null, + configurable: false, + enumerable: true, + value: OPEN, + writable: false + }, + CLOSED: { + __proto__: null, + configurable: false, + enumerable: true, + value: CLOSED, + writable: false + } +} + +Object.defineProperties(EventSource, constantsPropertyDescriptors) +Object.defineProperties(EventSource.prototype, constantsPropertyDescriptors) + +webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ + { key: 'withCredentials', converter: webidl.converters.boolean, defaultValue: false } +]) + +module.exports = { + EventSource, + defaultReconnectionTime +} diff --git a/lib/eventsource/util.js b/lib/eventsource/util.js new file mode 100644 index 00000000000..a87cc834eca --- /dev/null +++ b/lib/eventsource/util.js @@ -0,0 +1,29 @@ +'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 value.indexOf('\u0000') === -1 +} + +/** + * Checks if the given value is a base 10 digit. + * @param {string} 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 + } + return true +} + +module.exports = { + isValidLastEventId, + isASCIINumber +} diff --git a/lib/fetch/index.js b/lib/fetch/index.js index f4e84d0908e..75939e18599 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 diff --git a/lib/fetch/response.js b/lib/fetch/response.js index 5be1f438a12..29d3ac44c8a 100644 --- a/lib/fetch/response.js +++ b/lib/fetch/response.js @@ -364,6 +364,16 @@ 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 + ) +} + function makeFilteredResponse (response, state) { state = { internalResponse: response, @@ -572,6 +582,7 @@ webidl.converters.ResponseInit = webidl.dictionaryConverter([ ]) module.exports = { + isNetworkError, makeNetworkError, makeResponse, makeAppropriateNetworkError, diff --git a/package.json b/package.json index 292580cc44f..9690c9c6f23 100644 --- a/package.json +++ b/package.json @@ -75,9 +75,10 @@ "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\"", "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", @@ -86,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", 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-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-connect.js b/test/eventsource/eventsource-connect.js new file mode 100644 index 00000000000..f5f81b1a549 --- /dev/null +++ b/test/eventsource/eventsource-connect.js @@ -0,0 +1,184 @@ +'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 - 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 - 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() + } + }) +}) 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-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-message.js b/test/eventsource/eventsource-message.js new file mode 100644 index 00000000000..8b76bdc6b26 --- /dev/null +++ b/test/eventsource/eventsource-message.js @@ -0,0 +1,365 @@ +'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 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, + 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-reconnect.js b/test/eventsource/eventsource-reconnect.js new file mode 100644 index 00000000000..3499b8b3702 --- /dev/null +++ b/test/eventsource/eventsource-reconnect.js @@ -0,0 +1,155 @@ +'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, defaultReconnectionTime } = 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() + } + } + + 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() + } + } + + 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() + } + } + + 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() + } + } + + await finishedPromise.promise + }) +}) diff --git a/test/eventsource/eventsource-redirecting.js b/test/eventsource/eventsource-redirecting.js new file mode 100644 index 00000000000..1e8a31ae310 --- /dev/null +++ b/test/eventsource/eventsource-redirecting.js @@ -0,0 +1,119 @@ +'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}/redirect`) + 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(eventSourceInstance.url, `http://localhost:${port}/redirect`) + 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() + } + }) + + 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') + } + }) +}) 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') + } + }) + }) +}) 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-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js new file mode 100644 index 00000000000..6ef6dd8eca3 --- /dev/null +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -0,0 +1,281 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') + +describe('EventSourceStream - parseLine', () => { + const defaultEventSourceSettings = { + origin: 'example.com', + 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: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + 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) + }) + + 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) + }) + + 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/eventsource/eventsource-stream-process-event.js b/test/eventsource/eventsource-stream-process-event.js new file mode 100644 index 00000000000..aa106e15f1c --- /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 defaultEventSourceSettings = { + origin: 'example.com', + reconnectionTime: 1000 + } + + test('Should set the defined origin as the origin of the MessageEvent', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + 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) + }) + + stream.on('error', (error) => { + assert.fail(error) + }) + + stream.processEvent({}) + }) + + test('Should set reconnectionTime to 4000 if event contains retry field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.processEvent({ + retry: '4000' + }) + + assert.strictEqual(stream.state.reconnectionTime, 4000) + }) + + test('Dispatches a MessageEvent with data', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + 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) + }) + + 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({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + 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) + }) + + stream.processEvent({ + id: '1234' + }) + }) + + test('Dispatches a MessageEvent with lastEventId, reusing the persisted', () => { + // lastEventId + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings, + lastEventId: '1234' + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + 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) + }) + + stream.processEvent({}) + }) + + test('Dispatches a MessageEvent with type custom, when event contains type field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'custom') + 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) + }) + + stream.processEvent({ + event: 'custom' + }) + }) +}) diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js new file mode 100644 index 00000000000..69a04821e26 --- /dev/null +++ b/test/eventsource/eventsource-stream.js @@ -0,0 +1,298 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/eventsource/eventsource-stream') + +describe('EventSourceStream', () => { + test('ignore empty chunks', () => { + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.fail() + } + stream.write(Buffer.alloc(0)) + }) + + 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 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') + + 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 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() + + 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, '') + 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++) { + 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]])) + } + }) + + 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) + }) + + 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 new file mode 100644 index 00000000000..9ea9673f9e7 --- /dev/null +++ b/test/eventsource/eventsource.js @@ -0,0 +1,14 @@ +'use strict' + +const assert = require('node:assert') +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) + }) + test('Throw DOMException if URL is invalid', () => { + assert.throws(() => new EventSource('http:'), { message: /Invalid URL/ }) + }) +}) diff --git a/test/eventsource/util.js b/test/eventsource/util.js new file mode 100644 index 00000000000..e976731557e --- /dev/null +++ b/test/eventsource/util.js @@ -0,0 +1,18 @@ +'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('…'), true) +}) + +test('isASCIINumber', () => { + assert.strictEqual(isASCIINumber('123'), true) + assert.strictEqual(isASCIINumber(''), false) + assert.strictEqual(isASCIINumber('123a'), false) +}) diff --git a/test/wpt/runner/worker.mjs b/test/wpt/runner/worker.mjs index 961c1343a66..37c5dc6a105 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' import { webcrypto } from 'node:crypto' const { initScripts, meta, test, url, path } = workerData @@ -90,6 +92,10 @@ Object.defineProperties(globalThis, { CacheStorage: { ...globalPropertyDescriptors, value: CacheStorage + }, + EventSource: { + ...globalPropertyDescriptors, + value: EventSource } }) @@ -122,7 +128,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/server/server.mjs b/test/wpt/server/server.mjs index 9a8bad4b83a..6d664ff185a 100644 --- a/test/wpt/server/server.mjs +++ b/test/wpt/server/server.mjs @@ -406,6 +406,37 @@ 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.write(message + newline + '\n') + + setTimeout(() => { + res.end() + }, sleep) + + 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()) 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..b7bfaa4be5d --- /dev/null +++ b/test/wpt/status/eventsource.status.json @@ -0,0 +1,6 @@ +{ + "eventsource-onmessage-trusted.any.js": { + "note": "An Event created in userspace can not be set to trusted.", + "skip": true + } +} \ No newline at end of file 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 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/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' 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,