mirror of
https://github.com/denoland/deno.git
synced 2024-11-25 15:29:32 -05:00
fix(ext/fetch): make EventSource
more robust (#22493)
This PR fixes all unhandled rejections and resource leaks found while adding a test for #22368.
This commit is contained in:
parent
ae52b49dd6
commit
d263c632e3
2 changed files with 165 additions and 182 deletions
|
@ -169,7 +169,7 @@ async function mainFetch(req, recursive, terminator) {
|
||||||
try {
|
try {
|
||||||
resp = await opFetchSend(requestRid);
|
resp = await opFetchSend(requestRid);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (terminator.aborted) return;
|
if (terminator.aborted) return abortedNetworkError();
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
if (cancelHandleRid !== null) {
|
if (cancelHandleRid !== null) {
|
||||||
|
|
|
@ -11,7 +11,6 @@ const {
|
||||||
NumberIsNaN,
|
NumberIsNaN,
|
||||||
ObjectDefineProperties,
|
ObjectDefineProperties,
|
||||||
ObjectPrototypeIsPrototypeOf,
|
ObjectPrototypeIsPrototypeOf,
|
||||||
Promise,
|
|
||||||
StringPrototypeEndsWith,
|
StringPrototypeEndsWith,
|
||||||
StringPrototypeIncludes,
|
StringPrototypeIncludes,
|
||||||
StringPrototypeIndexOf,
|
StringPrototypeIndexOf,
|
||||||
|
@ -19,7 +18,6 @@ const {
|
||||||
StringPrototypeSplit,
|
StringPrototypeSplit,
|
||||||
StringPrototypeStartsWith,
|
StringPrototypeStartsWith,
|
||||||
StringPrototypeToLowerCase,
|
StringPrototypeToLowerCase,
|
||||||
Symbol,
|
|
||||||
SymbolFor,
|
SymbolFor,
|
||||||
} = primordials;
|
} = primordials;
|
||||||
|
|
||||||
|
@ -32,6 +30,7 @@ import {
|
||||||
EventTarget,
|
EventTarget,
|
||||||
setIsTrusted,
|
setIsTrusted,
|
||||||
} from "ext:deno_web/02_event.js";
|
} from "ext:deno_web/02_event.js";
|
||||||
|
import { clearTimeout, setTimeout } from "ext:deno_web/02_timers.js";
|
||||||
import { TransformStream } from "ext:deno_web/06_streams.js";
|
import { TransformStream } from "ext:deno_web/06_streams.js";
|
||||||
import { TextDecoderStream } from "ext:deno_web/08_text_encoding.js";
|
import { TextDecoderStream } from "ext:deno_web/08_text_encoding.js";
|
||||||
import { getLocationHref } from "ext:deno_web/12_location.js";
|
import { getLocationHref } from "ext:deno_web/12_location.js";
|
||||||
|
@ -99,29 +98,24 @@ const CONNECTING = 0;
|
||||||
const OPEN = 1;
|
const OPEN = 1;
|
||||||
const CLOSED = 2;
|
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 {
|
class EventSource extends EventTarget {
|
||||||
/** @type {AbortController} */
|
/** @type {AbortController} */
|
||||||
[_abortController] = new AbortController();
|
#abortController = new AbortController();
|
||||||
|
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
#reconnectionTimerId;
|
||||||
|
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
[_reconnectionTime] = 5000;
|
#reconnectionTime = 5000;
|
||||||
|
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
[_lastEventID] = "";
|
#lastEventId = "";
|
||||||
|
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
[_readyState] = CONNECTING;
|
#readyState = CONNECTING;
|
||||||
get readyState() {
|
get readyState() {
|
||||||
webidl.assertBranded(this, EventSourcePrototype);
|
webidl.assertBranded(this, EventSourcePrototype);
|
||||||
return this[_readyState];
|
return this.#readyState;
|
||||||
}
|
}
|
||||||
|
|
||||||
get CONNECTING() {
|
get CONNECTING() {
|
||||||
|
@ -138,36 +132,29 @@ class EventSource extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
[_url];
|
#url;
|
||||||
get url() {
|
get url() {
|
||||||
webidl.assertBranded(this, EventSourcePrototype);
|
webidl.assertBranded(this, EventSourcePrototype);
|
||||||
return this[_url];
|
return this.#url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
[_withCredentials];
|
#withCredentials;
|
||||||
get withCredentials() {
|
get withCredentials() {
|
||||||
webidl.assertBranded(this, EventSourcePrototype);
|
webidl.assertBranded(this, EventSourcePrototype);
|
||||||
return this[_withCredentials];
|
return this.#withCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(url, eventSourceInitDict = {}) {
|
constructor(url, eventSourceInitDict = {}) {
|
||||||
super();
|
super();
|
||||||
this[webidl.brand] = webidl.brand;
|
this[webidl.brand] = webidl.brand;
|
||||||
const prefix = "Failed to construct 'EventSource'";
|
const prefix = "Failed to construct 'EventSource'";
|
||||||
webidl.requiredArguments(arguments.length, 1, {
|
webidl.requiredArguments(arguments.length, 1, prefix);
|
||||||
prefix,
|
url = webidl.converters.USVString(url, prefix, "Argument 1");
|
||||||
});
|
|
||||||
url = webidl.converters.USVString(url, {
|
|
||||||
prefix,
|
|
||||||
context: "Argument 1",
|
|
||||||
});
|
|
||||||
eventSourceInitDict = webidl.converters.EventSourceInit(
|
eventSourceInitDict = webidl.converters.EventSourceInit(
|
||||||
eventSourceInitDict,
|
eventSourceInitDict,
|
||||||
{
|
|
||||||
prefix,
|
prefix,
|
||||||
context: "Argument 2",
|
"Argument 2",
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -176,90 +163,80 @@ class EventSource extends EventTarget {
|
||||||
throw new DOMException(e.message, "SyntaxError");
|
throw new DOMException(e.message, "SyntaxError");
|
||||||
}
|
}
|
||||||
|
|
||||||
this[_url] = url;
|
this.#url = url;
|
||||||
this[_withCredentials] = eventSourceInitDict.withCredentials;
|
this.#withCredentials = eventSourceInitDict.withCredentials;
|
||||||
|
|
||||||
this[_loop]();
|
this.#loop();
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
webidl.assertBranded(this, EventSourcePrototype);
|
webidl.assertBranded(this, EventSourcePrototype);
|
||||||
this[_abortController].abort();
|
this.#abortController.abort();
|
||||||
this[_readyState] = CLOSED;
|
this.#readyState = CLOSED;
|
||||||
|
clearTimeout(this.#reconnectionTimerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async [_loop]() {
|
async #loop() {
|
||||||
let lastEventIDValue = "";
|
const lastEventIdValue = this.#lastEventId;
|
||||||
while (this[_readyState] !== CLOSED) {
|
|
||||||
const lastEventIDValueCopy = lastEventIDValue;
|
|
||||||
lastEventIDValue = "";
|
|
||||||
const req = newInnerRequest(
|
const req = newInnerRequest(
|
||||||
"GET",
|
"GET",
|
||||||
this[_url],
|
this.#url,
|
||||||
() =>
|
() =>
|
||||||
lastEventIDValueCopy === ""
|
lastEventIdValue === ""
|
||||||
? [
|
? [
|
||||||
["accept", "text/event-stream"],
|
["accept", "text/event-stream"],
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
["accept", "text/event-stream"],
|
["accept", "text/event-stream"],
|
||||||
[
|
["Last-Event-Id", op_utf8_to_byte_string(lastEventIdValue)],
|
||||||
"Last-Event-Id",
|
|
||||||
op_utf8_to_byte_string(lastEventIDValueCopy),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
/** @type {InnerResponse} */
|
/** @type {InnerResponse} */
|
||||||
const res = await mainFetch(req, true, this[_abortController].signal);
|
let res;
|
||||||
|
try {
|
||||||
|
res = await mainFetch(req, true, this.#abortController.signal);
|
||||||
|
} catch {
|
||||||
|
this.#reestablishConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.aborted) {
|
||||||
|
this.#failConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.type === "error") {
|
||||||
|
this.#reestablishConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const contentType = ArrayPrototypeFind(
|
const contentType = ArrayPrototypeFind(
|
||||||
res.headerList,
|
res.headerList,
|
||||||
(header) => StringPrototypeToLowerCase(header[0]) === "content-type",
|
(header) => StringPrototypeToLowerCase(header[0]) === "content-type",
|
||||||
);
|
);
|
||||||
if (res.type === "error") {
|
if (
|
||||||
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 ||
|
res.status !== 200 ||
|
||||||
|
!contentType ||
|
||||||
!StringPrototypeIncludes(
|
!StringPrototypeIncludes(
|
||||||
contentType?.[1].toLowerCase(),
|
StringPrototypeToLowerCase(contentType[1]),
|
||||||
"text/event-stream",
|
"text/event-stream",
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this[_readyState] = CLOSED;
|
this.#failConnection();
|
||||||
this.dispatchEvent(new Event("error"));
|
return;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this[_readyState] !== CLOSED) {
|
if (this.#readyState === CLOSED) {
|
||||||
this[_readyState] = OPEN;
|
return;
|
||||||
|
}
|
||||||
|
this.#readyState = OPEN;
|
||||||
this.dispatchEvent(new Event("open"));
|
this.dispatchEvent(new Event("open"));
|
||||||
|
|
||||||
let data = "";
|
let data = "";
|
||||||
let eventType = "";
|
let eventType = "";
|
||||||
let lastEventID = this[_lastEventID];
|
let lastEventId = this.#lastEventId;
|
||||||
|
|
||||||
|
try {
|
||||||
for await (
|
for await (
|
||||||
// deno-lint-ignore prefer-primordials
|
// deno-lint-ignore prefer-primordials
|
||||||
const chunk of res.body.stream
|
const chunk of res.body.stream
|
||||||
|
@ -267,7 +244,7 @@ class EventSource extends EventTarget {
|
||||||
.pipeThrough(new TextLineStream({ allowCR: true }))
|
.pipeThrough(new TextLineStream({ allowCR: true }))
|
||||||
) {
|
) {
|
||||||
if (chunk === "") {
|
if (chunk === "") {
|
||||||
this[_lastEventID] = lastEventID;
|
this.#lastEventId = lastEventId;
|
||||||
if (data === "") {
|
if (data === "") {
|
||||||
eventType = "";
|
eventType = "";
|
||||||
continue;
|
continue;
|
||||||
|
@ -278,12 +255,12 @@ class EventSource extends EventTarget {
|
||||||
const event = new MessageEvent(eventType || "message", {
|
const event = new MessageEvent(eventType || "message", {
|
||||||
data,
|
data,
|
||||||
origin: res.url(),
|
origin: res.url(),
|
||||||
lastEventId: this[_lastEventID],
|
lastEventId: this.#lastEventId,
|
||||||
});
|
});
|
||||||
setIsTrusted(event, true);
|
setIsTrusted(event, true);
|
||||||
data = "";
|
data = "";
|
||||||
eventType = "";
|
eventType = "";
|
||||||
if (this[_readyState] !== CLOSED) {
|
if (this.#readyState !== CLOSED) {
|
||||||
this.dispatchEvent(event);
|
this.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
} else if (StringPrototypeStartsWith(chunk, ":")) {
|
} else if (StringPrototypeStartsWith(chunk, ":")) {
|
||||||
|
@ -309,7 +286,7 @@ class EventSource extends EventTarget {
|
||||||
}
|
}
|
||||||
case "id": {
|
case "id": {
|
||||||
if (!StringPrototypeIncludes(value, "\0")) {
|
if (!StringPrototypeIncludes(value, "\0")) {
|
||||||
lastEventID = value;
|
lastEventId = value;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -319,32 +296,38 @@ class EventSource extends EventTarget {
|
||||||
!NumberIsNaN(reconnectionTime) &&
|
!NumberIsNaN(reconnectionTime) &&
|
||||||
NumberIsFinite(reconnectionTime)
|
NumberIsFinite(reconnectionTime)
|
||||||
) {
|
) {
|
||||||
this[_reconnectionTime] = reconnectionTime;
|
this.#reconnectionTime = reconnectionTime;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The connection is reestablished below
|
||||||
|
}
|
||||||
|
|
||||||
if (this[_abortController].signal.aborted) {
|
this.#reestablishConnection();
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#reestablishConnection() {
|
||||||
|
if (this.#readyState === CLOSED) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (this[_readyState] === CLOSED) {
|
this.#readyState = CONNECTING;
|
||||||
this[_abortController].abort();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
this[_readyState] = CONNECTING;
|
|
||||||
this.dispatchEvent(new Event("error"));
|
this.dispatchEvent(new Event("error"));
|
||||||
await new Promise((res) => setTimeout(res, this[_reconnectionTime]));
|
this.#reconnectionTimerId = setTimeout(() => {
|
||||||
if (this[_readyState] !== CONNECTING) {
|
if (this.#readyState !== CONNECTING) {
|
||||||
continue;
|
return;
|
||||||
|
}
|
||||||
|
this.#loop();
|
||||||
|
}, this.#reconnectionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this[_lastEventID] !== "") {
|
#failConnection() {
|
||||||
lastEventIDValue = this[_lastEventID];
|
if (this.#readyState !== CLOSED) {
|
||||||
}
|
this.#readyState = CLOSED;
|
||||||
}
|
this.dispatchEvent(new Event("error"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue