diff --git a/cli/tests/node_compat/test/common/index.js b/cli/tests/node_compat/test/common/index.js index f398108cde9eab..986b8ea1a5af71 100644 --- a/cli/tests/node_compat/test/common/index.js +++ b/cli/tests/node_compat/test/common/index.js @@ -37,6 +37,7 @@ let knownGlobals = [ crypto, Deno, dispatchEvent, + EventSource, fetch, getParent, global, diff --git a/ext/fetch/26_fetch.js b/ext/fetch/26_fetch.js index f1c771dc043a19..d77180e85671c1 100644 --- a/ext/fetch/26_fetch.js +++ b/ext/fetch/26_fetch.js @@ -594,4 +594,4 @@ function handleWasmStreaming(source, rid) { } } -export { fetch, handleWasmStreaming }; +export { fetch, handleWasmStreaming, mainFetch }; diff --git a/ext/fetch/27_eventsource.js b/ext/fetch/27_eventsource.js new file mode 100644 index 00000000000000..6e35bb693ea956 --- /dev/null +++ b/ext/fetch/27_eventsource.js @@ -0,0 +1,379 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +/// + +const core = globalThis.Deno.core; + +import * as webidl from "ext:deno_webidl/00_webidl.js"; +import { URL } from "ext:deno_url/00_url.js"; +import DOMException from "ext:deno_web/01_dom_exception.js"; +import { + defineEventHandler, + EventTarget, + setIsTrusted, +} from "ext:deno_web/02_event.js"; +import { TransformStream } from "ext:deno_web/06_streams.js"; +import { TextDecoderStream } from "ext:deno_web/08_text_encoding.js"; +import { getLocationHref } from "ext:deno_web/12_location.js"; +import { newInnerRequest } from "ext:deno_fetch/23_request.js"; +import { mainFetch } from "ext:deno_fetch/26_fetch.js"; + +const primordials = globalThis.__bootstrap.primordials; +const { + ArrayPrototypeFind, + Number, + NumberIsFinite, + NumberIsNaN, + ObjectDefineProperties, + Promise, + StringPrototypeEndsWith, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeStartsWith, + StringPrototypeToLowerCase, + Symbol, +} = primordials; + +// Copied from https://github.com/denoland/deno_std/blob/e0753abe0c8602552862a568348c046996709521/streams/text_line_stream.ts#L20-L74 +export class TextLineStream extends TransformStream { + #allowCR; + #buf = ""; + + constructor(options) { + super({ + transform: (chunk, controller) => this.#handle(chunk, controller), + flush: (controller) => { + if (this.#buf.length > 0) { + if ( + this.#allowCR && + this.#buf[this.#buf.length - 1] === "\r" + ) controller.enqueue(StringPrototypeSlice(this.#buf, 0, -1)); + else controller.enqueue(this.#buf); + } + }, + }); + this.#allowCR = options?.allowCR ?? false; + } + + #handle(chunk, controller) { + chunk = this.#buf + chunk; + + for (;;) { + const lfIndex = StringPrototypeIndexOf(chunk, "\n"); + + if (this.#allowCR) { + const crIndex = StringPrototypeIndexOf(chunk, "\r"); + + if ( + crIndex !== -1 && crIndex !== (chunk.length - 1) && + (lfIndex === -1 || (lfIndex - 1) > crIndex) + ) { + controller.enqueue(StringPrototypeSlice(chunk, 0, crIndex)); + chunk = StringPrototypeSlice(chunk, crIndex + 1); + continue; + } + } + + if (lfIndex !== -1) { + let crOrLfIndex = lfIndex; + if (chunk[lfIndex - 1] === "\r") { + crOrLfIndex--; + } + controller.enqueue(StringPrototypeSlice(chunk, 0, crOrLfIndex)); + chunk = StringPrototypeSlice(chunk, lfIndex + 1); + continue; + } + + break; + } + + this.#buf = chunk; + } +} + +const CONNECTING = 0; +const OPEN = 1; +const CLOSED = 2; + +const _url = Symbol("[[url]]"); +const _withCredentials = Symbol("[[withCredentials]]"); +const _readyState = Symbol("[[readyState]]"); +const _reconnectionTime = Symbol("[[reconnectionTime]]"); +const _lastEventID = Symbol("[[lastEventID]]"); +const _abortController = Symbol("[[abortController]]"); +const _loop = Symbol("[[loop]]"); + +class EventSource extends EventTarget { + /** @type {AbortController} */ + [_abortController] = new AbortController(); + + /** @type {number} */ + [_reconnectionTime] = 5000; + + /** @type {string} */ + [_lastEventID] = ""; + + /** @type {number} */ + [_readyState] = CONNECTING; + get readyState() { + webidl.assertBranded(this, EventSourcePrototype); + return this[_readyState]; + } + + get CONNECTING() { + webidl.assertBranded(this, EventSourcePrototype); + return CONNECTING; + } + get OPEN() { + webidl.assertBranded(this, EventSourcePrototype); + return OPEN; + } + get CLOSED() { + webidl.assertBranded(this, EventSourcePrototype); + return CLOSED; + } + + /** @type {string} */ + [_url]; + get url() { + webidl.assertBranded(this, EventSourcePrototype); + return this[_url]; + } + + /** @type {boolean} */ + [_withCredentials]; + get withCredentials() { + webidl.assertBranded(this, EventSourcePrototype); + return this[_withCredentials]; + } + + constructor(url, eventSourceInitDict = {}) { + super(); + this[webidl.brand] = webidl.brand; + const prefix = "Failed to construct 'EventSource'"; + webidl.requiredArguments(arguments.length, 1, { + prefix, + }); + url = webidl.converters.USVString(url, { + prefix, + context: "Argument 1", + }); + eventSourceInitDict = webidl.converters.EventSourceInit( + eventSourceInitDict, + { + prefix, + context: "Argument 2", + }, + ); + + try { + url = new URL(url, getLocationHref()).href; + } catch (e) { + throw new DOMException(e.message, "SyntaxError"); + } + + this[_url] = url; + this[_withCredentials] = eventSourceInitDict.withCredentials; + + this[_loop](); + } + + close() { + webidl.assertBranded(this, EventSourcePrototype); + this[_abortController].abort(); + this[_readyState] = CLOSED; + } + + async [_loop]() { + let lastEventIDValue = ""; + while (this[_readyState] !== CLOSED) { + const lastEventIDValueCopy = lastEventIDValue; + lastEventIDValue = ""; + const req = newInnerRequest( + "GET", + this[_url], + () => + lastEventIDValueCopy === "" + ? [ + ["accept", "text/event-stream"], + ] + : [ + ["accept", "text/event-stream"], + [ + "Last-Event-Id", + core.ops.op_utf8_to_byte_string(lastEventIDValueCopy), + ], + ], + null, + false, + ); + /** @type {InnerResponse} */ + const res = await mainFetch(req, true, this[_abortController].signal); + + const contentType = ArrayPrototypeFind( + res.headerList, + (header) => StringPrototypeToLowerCase(header[0]) === "content-type", + ); + if (res.type === "error") { + if (res.aborted) { + this[_readyState] = CLOSED; + this.dispatchEvent(new Event("error")); + break; + } else { + if (this[_readyState] === CLOSED) { + this[_abortController].abort(); + break; + } + this[_readyState] = CONNECTING; + this.dispatchEvent(new Event("error")); + await new Promise((res) => setTimeout(res, this[_reconnectionTime])); + if (this[_readyState] !== CONNECTING) { + continue; + } + + if (this[_lastEventID] !== "") { + lastEventIDValue = this[_lastEventID]; + } + continue; + } + } else if ( + res.status !== 200 || + !StringPrototypeIncludes( + contentType?.[1].toLowerCase(), + "text/event-stream", + ) + ) { + this[_readyState] = CLOSED; + this.dispatchEvent(new Event("error")); + break; + } + + if (this[_readyState] !== CLOSED) { + this[_readyState] = OPEN; + this.dispatchEvent(new Event("open")); + + let data = ""; + let eventType = ""; + let lastEventID = this[_lastEventID]; + + for await ( + // deno-lint-ignore prefer-primordials + const chunk of res.body.stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream({ allowCR: true })) + ) { + if (chunk === "") { + this[_lastEventID] = lastEventID; + if (data === "") { + eventType = ""; + continue; + } + if (StringPrototypeEndsWith(data, "\n")) { + data = StringPrototypeSlice(data, 0, -1); + } + const event = new MessageEvent(eventType || "message", { + data, + origin: res.url(), + lastEventId: this[_lastEventID], + }); + setIsTrusted(event, true); + data = ""; + eventType = ""; + if (this[_readyState] !== CLOSED) { + this.dispatchEvent(event); + } + } else if (StringPrototypeStartsWith(chunk, ":")) { + continue; + } else { + let field = chunk; + let value = ""; + if (StringPrototypeIncludes(chunk, ":")) { + ({ 0: field, 1: value } = StringPrototypeSplit(chunk, ":")); + if (StringPrototypeStartsWith(value, " ")) { + value = StringPrototypeSlice(value, 1); + } + } + + switch (field) { + case "event": { + eventType = value; + break; + } + case "data": { + data += value + "\n"; + break; + } + case "id": { + if (!StringPrototypeIncludes(value, "\0")) { + lastEventID = value; + } + break; + } + case "retry": { + const reconnectionTime = Number(value); + if ( + !NumberIsNaN(reconnectionTime) && + NumberIsFinite(reconnectionTime) + ) { + this[_reconnectionTime] = reconnectionTime; + } + break; + } + } + } + + if (this[_abortController].signal.aborted) { + break; + } + } + if (this[_readyState] === CLOSED) { + this[_abortController].abort(); + break; + } + this[_readyState] = CONNECTING; + this.dispatchEvent(new Event("error")); + await new Promise((res) => setTimeout(res, this[_reconnectionTime])); + if (this[_readyState] !== CONNECTING) { + continue; + } + + if (this[_lastEventID] !== "") { + lastEventIDValue = this[_lastEventID]; + } + } + } + } +} + +const EventSourcePrototype = EventSource.prototype; + +ObjectDefineProperties(EventSource, { + CONNECTING: { + value: 0, + }, + OPEN: { + value: 1, + }, + CLOSED: { + value: 2, + }, +}); + +defineEventHandler(EventSource.prototype, "open"); +defineEventHandler(EventSource.prototype, "message"); +defineEventHandler(EventSource.prototype, "error"); + +webidl.converters.EventSourceInit = webidl.createDictionaryConverter( + "EventSourceInit", + [ + { + key: "withCredentials", + defaultValue: false, + converter: webidl.converters.boolean, + }, + ], +); + +export { EventSource }; diff --git a/ext/fetch/lib.rs b/ext/fetch/lib.rs index 589e6ebd8e0e02..797c5e2cd549ef 100644 --- a/ext/fetch/lib.rs +++ b/ext/fetch/lib.rs @@ -112,6 +112,7 @@ deno_core::extension!(deno_fetch, op_fetch, op_fetch_send, op_fetch_response_upgrade, + op_utf8_to_byte_string, op_fetch_custom_client, ], esm = [ @@ -121,7 +122,8 @@ deno_core::extension!(deno_fetch, "22_http_client.js", "23_request.js", "23_response.js", - "26_fetch.js" + "26_fetch.js", + "27_eventsource.js" ], options = { options: Options, @@ -969,3 +971,11 @@ pub fn create_http_client( builder.build().map_err(|e| e.into()) } + +#[op2] +#[serde] +pub fn op_utf8_to_byte_string( + #[string] input: String, +) -> Result { + Ok(input.into()) +} diff --git a/runtime/js/98_global_scope.js b/runtime/js/98_global_scope.js index c916ef819f7944..6c650da10aea63 100644 --- a/runtime/js/98_global_scope.js +++ b/runtime/js/98_global_scope.js @@ -32,6 +32,7 @@ import * as formData from "ext:deno_fetch/21_formdata.js"; import * as request from "ext:deno_fetch/23_request.js"; import * as response from "ext:deno_fetch/23_response.js"; import * as fetch from "ext:deno_fetch/26_fetch.js"; +import * as eventSource from "ext:deno_fetch/27_eventsource.js"; import * as messagePort from "ext:deno_web/13_message_port.js"; import * as webidl from "ext:deno_webidl/00_webidl.js"; import DOMException from "ext:deno_web/01_dom_exception.js"; @@ -129,6 +130,7 @@ const windowOrWorkerGlobalScope = { Crypto: util.nonEnumerable(crypto.Crypto), SubtleCrypto: util.nonEnumerable(crypto.SubtleCrypto), fetch: util.writable(fetch.fetch), + EventSource: util.writable(eventSource.EventSource), performance: util.writable(performance.performance), reportError: util.writable(event.reportError), setInterval: util.writable(timers.setInterval), diff --git a/tools/wpt/expectation.json b/tools/wpt/expectation.json index b6443f531caab5..517ea494babbe4 100644 --- a/tools/wpt/expectation.json +++ b/tools/wpt/expectation.json @@ -8229,7 +8229,6 @@ ], "expected-self-properties.worker.html": [ "existence of XMLHttpRequest", - "existence of EventSource", "existence of SharedWorker" ], "unexpected-self-properties.worker.html": true @@ -8320,7 +8319,6 @@ "The CanvasPath interface object should be exposed.", "The TextMetrics interface object should be exposed.", "The Path2D interface object should be exposed.", - "The EventSource interface object should be exposed.", "The XMLHttpRequestEventTarget interface object should be exposed.", "The XMLHttpRequestUpload interface object should be exposed.", "The XMLHttpRequest interface object should be exposed.", @@ -10661,5 +10659,91 @@ "media": { "media-sniff.window.html": false } + }, + "eventsource": { + "dedicated-worker": { + "eventsource-eventtarget.worker.html": true, + "eventsource-constructor-no-new.any.html": true, + "eventsource-constructor-no-new.any.worker.html": true + }, + "event-data.any.html": true, + "event-data.any.worker.html": true, + "eventsource-constructor-empty-url.any.html": true, + "eventsource-constructor-empty-url.any.worker.html": true, + "eventsource-constructor-url-bogus.any.html": true, + "eventsource-constructor-url-bogus.any.worker.html": true, + "eventsource-eventtarget.any.html": true, + "eventsource-eventtarget.any.worker.html": true, + "eventsource-onmessage-trusted.any.html": true, + "eventsource-onmessage-trusted.any.worker.html": true, + "eventsource-onmessage.any.html": true, + "eventsource-onmessage.any.worker.html": true, + "eventsource-onopen.any.html": true, + "eventsource-onopen.any.worker.html": true, + "eventsource-prototype.any.html": true, + "eventsource-prototype.any.worker.html": true, + "eventsource-request-cancellation.window.any.html": false, + "eventsource-request-cancellation.window.any.worker.html": false, + "eventsource-url.any.html": true, + "eventsource-url.any.worker.html": true, + "format-bom-2.any.html": true, + "format-bom-2.any.worker.html": true, + "format-bom.any.html": true, + "format-bom.any.worker.html": true, + "format-comments.any.html": true, + "format-comments.any.worker.html": true, + "format-data-before-final-empty-line.any.html": true, + "format-data-before-final-empty-line.any.worker.html": true, + "format-field-data.any.html": true, + "format-field-data.any.worker.html": true, + "format-field-event-empty.any.html": true, + "format-field-event-empty.any.worker.html": true, + "format-field-event.any.html": true, + "format-field-event.any.worker.html": true, + "format-field-id-2.any.html": true, + "format-field-id-2.any.worker.html": true, + "format-field-id-3.window.html": true, + "format-field-id-null.window.html": true, + "format-field-id.any.html": true, + "format-field-id.any.worker.html": true, + "format-field-parsing.any.html": true, + "format-field-parsing.any.worker.html": true, + "format-field-retry-bogus.any.html": true, + "format-field-retry-bogus.any.worker.html": true, + "format-field-retry-empty.any.html": true, + "format-field-retry-empty.any.worker.html": true, + "format-field-retry.any.html": true, + "format-field-retry.any.worker.html": true, + "format-field-unknown.any.html": true, + "format-field-unknown.any.worker.html": true, + "format-leading-space.any.html": true, + "format-leading-space.any.worker.html": true, + "format-mime-bogus.any.html": true, + "format-mime-bogus.any.worker.html": true, + "format-mime-trailing-semicolon.any.html": true, + "format-mime-trailing-semicolon.any.worker.html": true, + "format-mime-valid-bogus.any.html": true, + "format-mime-valid-bogus.any.worker.html": true, + "format-newlines.any.html": true, + "format-newlines.any.worker.html": true, + "format-null-character.any.html": true, + "format-null-character.any.worker.html": true, + "format-utf-8.any.html": true, + "format-utf-8.any.worker.html": true, + "request-accept.any.html": true, + "request-accept.any.worker.html": true, + "request-cache-control.any.html": false, + "request-cache-control.any.worker.html": false, + "request-credentials.window.any.html": false, + "request-credentials.window.any.worker.html": false, + "request-redirect.window.any.html": false, + "request-redirect.window.any.worker.html": false, + "eventsource-close.window.html": false, + "eventsource-constructor-document-domain.window.html": false, + "eventsource-constructor-non-same-origin.window.html": false, + "eventsource-constructor-stringify.window.html": false, + "eventsource-cross-origin.window.html": false, + "eventsource-reconnect.window.html": false, + "request-status-error.window.html": false } -} \ No newline at end of file +} diff --git a/tools/wpt/runner.ts b/tools/wpt/runner.ts index fb39ddfa49099f..472449712f6e1c 100644 --- a/tools/wpt/runner.ts +++ b/tools/wpt/runner.ts @@ -186,7 +186,10 @@ async function generateBundle(location: URL): Promise { if (title) { const url = new URL(`#${inlineScriptCount}`, location); inlineScriptCount++; - scriptContents.push([url.href, `globalThis.META_TITLE="${title}"`]); + scriptContents.push([ + url.href, + `globalThis.META_TITLE=${JSON.stringify(title)}`, + ]); } for (const script of scripts) { const src = script.getAttribute("src");