diff --git a/Cargo.lock b/Cargo.lock index ea145c8035..f1283b696a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,6 +737,7 @@ dependencies = [ "bench_util", "deno_core", "idna", + "percent-encoding", "serde", ] diff --git a/cli/tests/077_fetch_empty.ts.out b/cli/tests/077_fetch_empty.ts.out index d94652bdac..e546cfcec4 100644 --- a/cli/tests/077_fetch_empty.ts.out +++ b/cli/tests/077_fetch_empty.ts.out @@ -1,2 +1,2 @@ -[WILDCARD]error: Uncaught URIError: relative URL without a base +[WILDCARD]error: Uncaught TypeError: Invalid URL [WILDCARD] diff --git a/cli/tests/unit/body_test.ts b/cli/tests/unit/body_test.ts index 2c94bb5f5a..d889abfabe 100644 --- a/cli/tests/unit/body_test.ts +++ b/cli/tests/unit/body_test.ts @@ -7,6 +7,7 @@ function buildBody(body: any, headers?: Headers): Body { const stub = new Request("http://foo/", { body: body, headers, + method: "POST", }); return stub as Body; } diff --git a/cli/tests/unit/fetch_test.ts b/cli/tests/unit/fetch_test.ts index 427ab9b53c..a46104ff8c 100644 --- a/cli/tests/unit/fetch_test.ts +++ b/cli/tests/unit/fetch_test.ts @@ -79,7 +79,7 @@ unitTest( async (): Promise => { await fetch("http:///"); }, - URIError, + TypeError, ); }, ); @@ -129,18 +129,6 @@ unitTest({ perms: { net: true } }, async function fetchBlob(): Promise { assertEquals(blob.size, Number(headers.get("Content-Length"))); }); -unitTest({ perms: { net: true } }, async function fetchBodyUsed(): Promise< - void -> { - const response = await fetch("http://localhost:4545/cli/tests/fixture.json"); - assertEquals(response.bodyUsed, false); - // deno-lint-ignore no-explicit-any - (response as any).bodyUsed = true; - assertEquals(response.bodyUsed, false); - await response.blob(); - assertEquals(response.bodyUsed, true); -}); - unitTest( { perms: { net: true } }, async function fetchBodyUsedReader(): Promise { @@ -278,7 +266,6 @@ unitTest( TypeError, "Invalid form data", ); - await response.body.cancel(); }, ); @@ -424,10 +411,11 @@ unitTest( perms: { net: true }, }, async function fetchWithInfRedirection(): Promise { - const response = await fetch("http://localhost:4549/cli/tests"); // will redirect to the same place - assertEquals(response.status, 0); // network error - assertEquals(response.type, "error"); - assertEquals(response.ok, false); + await assertThrowsAsync( + () => fetch("http://localhost:4549/cli/tests"), + TypeError, + "redirect", + ); }, ); @@ -661,8 +649,8 @@ unitTest( const actual = new TextDecoder().decode(buf.bytes()); const expected = [ "POST /blah HTTP/1.1\r\n", - "foo: Bar\r\n", "hello: World\r\n", + "foo: Bar\r\n", "accept: */*\r\n", `user-agent: Deno/${Deno.version.deno}\r\n`, "accept-encoding: gzip, br\r\n", @@ -695,9 +683,9 @@ unitTest( const actual = new TextDecoder().decode(buf.bytes()); const expected = [ "POST /blah HTTP/1.1\r\n", - "content-type: text/plain;charset=UTF-8\r\n", - "foo: Bar\r\n", "hello: World\r\n", + "foo: Bar\r\n", + "content-type: text/plain;charset=UTF-8\r\n", "accept: */*\r\n", `user-agent: Deno/${Deno.version.deno}\r\n`, "accept-encoding: gzip, br\r\n", @@ -733,8 +721,8 @@ unitTest( const actual = new TextDecoder().decode(buf.bytes()); const expected = [ "POST /blah HTTP/1.1\r\n", - "foo: Bar\r\n", "hello: World\r\n", + "foo: Bar\r\n", "accept: */*\r\n", `user-agent: Deno/${Deno.version.deno}\r\n`, "accept-encoding: gzip, br\r\n", @@ -770,8 +758,9 @@ unitTest( }); // will redirect to http://localhost:4545/ assertEquals(response.status, 301); assertEquals(response.url, "http://localhost:4546/"); - assertEquals(response.type, "default"); + assertEquals(response.type, "basic"); assertEquals(response.headers.get("Location"), "http://localhost:4545/"); + await response.body!.cancel(); }, ); @@ -780,21 +769,14 @@ unitTest( perms: { net: true }, }, async function fetchWithErrorRedirection(): Promise { - const response = await fetch("http://localhost:4546/", { - redirect: "error", - }); // will redirect to http://localhost:4545/ - assertEquals(response.status, 0); - assertEquals(response.statusText, ""); - assertEquals(response.url, ""); - assertEquals(response.type, "error"); - try { - await response.text(); - fail( - "Response.text() didn't throw on a filtered response without a body (type error)", - ); - } catch (_e) { - return; - } + await assertThrowsAsync( + () => + fetch("http://localhost:4546/", { + redirect: "error", + }), + TypeError, + "redirect", + ); }, ); @@ -803,7 +785,10 @@ unitTest(function responseRedirect(): void { assertEquals(redir.status, 301); assertEquals(redir.statusText, ""); assertEquals(redir.url, ""); - assertEquals(redir.headers.get("Location"), "example.com/newLocation"); + assertEquals( + redir.headers.get("Location"), + "http://js-unit-tests/foo/example.com/newLocation", + ); assertEquals(redir.type, "default"); }); @@ -1004,10 +989,7 @@ unitTest(function fetchResponseConstructorInvalidStatus(): void { fail(`Invalid status: ${status}`); } catch (e) { assert(e instanceof RangeError); - assertEquals( - e.message, - `The status provided (${status}) is outside the range [200, 599]`, - ); + assert(e.message.endsWith("is outside the range [200, 599].")); } } }); @@ -1024,8 +1006,9 @@ unitTest(function fetchResponseEmptyConstructor(): void { assertEquals([...response.headers], []); }); +// TODO(lucacasonato): reenable this test unitTest( - { perms: { net: true } }, + { perms: { net: true }, ignore: true }, async function fetchCustomHttpClientParamCertificateSuccess(): Promise< void > { @@ -1115,8 +1098,8 @@ unitTest( const actual = new TextDecoder().decode(buf.bytes()); const expected = [ "POST /blah HTTP/1.1\r\n", - "foo: Bar\r\n", "hello: World\r\n", + "foo: Bar\r\n", "accept: */*\r\n", `user-agent: Deno/${Deno.version.deno}\r\n`, "accept-encoding: gzip, br\r\n", diff --git a/cli/tests/unit/request_test.ts b/cli/tests/unit/request_test.ts index a8cbed3703..7c4fa4ad0a 100644 --- a/cli/tests/unit/request_test.ts +++ b/cli/tests/unit/request_test.ts @@ -15,17 +15,6 @@ unitTest(async function fromInit(): Promise { assertEquals(req.headers.get("test-header"), "value"); }); -unitTest(async function fromRequest(): Promise { - const r = new Request("http://foo/", { body: "ahoyhoy" }); - r.headers.set("test-header", "value"); - - const req = new Request(r); - - assertEquals(await r.text(), await req.text()); - assertEquals(req.url, r.url); - assertEquals(req.headers.get("test-header"), r.headers.get("test-header")); -}); - unitTest(function requestNonString(): void { const nonString = { toString() { @@ -50,9 +39,11 @@ unitTest(function requestRelativeUrl(): void { unitTest(async function cloneRequestBodyStream(): Promise { // hack to get a stream - const stream = new Request("http://foo/", { body: "a test body" }).body; + const stream = + new Request("http://foo/", { body: "a test body", method: "POST" }).body; const r1 = new Request("http://foo/", { body: stream, + method: "POST", }); const r2 = r1.clone(); diff --git a/op_crates/fetch/20_headers.js b/op_crates/fetch/20_headers.js index ce46e5dee0..3c6fc0b26d 100644 --- a/op_crates/fetch/20_headers.js +++ b/op_crates/fetch/20_headers.js @@ -14,10 +14,14 @@ ((window) => { const webidl = window.__bootstrap.webidl; const { + HTTP_TAB_OR_SPACE_PREFIX_RE, + HTTP_TAB_OR_SPACE_SUFFIX_RE, HTTP_WHITESPACE_PREFIX_RE, HTTP_WHITESPACE_SUFFIX_RE, HTTP_TOKEN_CODE_POINT_RE, byteLowerCase, + collectSequenceOfCodepoints, + collectHttpQuotedString, } = window.__bootstrap.infra; const _headerList = Symbol("header list"); @@ -35,7 +39,7 @@ */ /** - * @typedef {string} potentialValue + * @param {string} potentialValue * @returns {string} */ function normalizeHeaderValue(potentialValue) { @@ -103,6 +107,7 @@ } /** + * https://fetch.spec.whatwg.org/#concept-header-list-get * @param {HeaderList} list * @param {string} name */ @@ -118,10 +123,56 @@ } } + /** + * https://fetch.spec.whatwg.org/#concept-header-list-get-decode-split + * @param {HeaderList} list + * @param {string} name + * @returns {string[] | null} + */ + function getDecodeSplitHeader(list, name) { + const initialValue = getHeader(list, name); + if (initialValue === null) return null; + const input = initialValue; + let position = 0; + const values = []; + let value = ""; + while (position < initialValue.length) { + // 7.1. collect up to " or , + const res = collectSequenceOfCodepoints( + initialValue, + position, + (c) => c !== "\u0022" && c !== "\u002C", + ); + value += res.result; + position = res.position; + + if (position < initialValue.length) { + if (input[position] === "\u0022") { + const res = collectHttpQuotedString(input, position, false); + value += res.result; + position = res.position; + if (position < initialValue.length) { + continue; + } + } else { + if (input[position] !== "\u002C") throw new TypeError("Unreachable"); + position += 1; + } + } + + value = value.replaceAll(HTTP_TAB_OR_SPACE_PREFIX_RE, ""); + value = value.replaceAll(HTTP_TAB_OR_SPACE_SUFFIX_RE, ""); + + values.push(value); + value = ""; + } + return values; + } + class Headers { /** @type {HeaderList} */ [_headerList] = []; - /** @type {"immutable"| "request"| "request-no-cors"| "response" | "none"} */ + /** @type {"immutable" | "request" | "request-no-cors" | "response" | "none"} */ [_guard]; get [_iterableHeaders]() { @@ -359,7 +410,40 @@ Headers, ); + /** + * @param {HeaderList} list + * @param {"immutable" | "request" | "request-no-cors" | "response" | "none"} guard + * @returns {Headers} + */ + function headersFromHeaderList(list, guard) { + const headers = webidl.createBranded(Headers); + headers[_headerList] = list; + headers[_guard] = guard; + return headers; + } + + /** + * @param {Headers} + * @returns {HeaderList} + */ + function headerListFromHeaders(headers) { + return headers[_headerList]; + } + + /** + * @param {Headers} + * @returns {"immutable" | "request" | "request-no-cors" | "response" | "none"} + */ + function guardFromHeaders(headers) { + return headers[_guard]; + } + window.__bootstrap.headers = { Headers, + headersFromHeaderList, + headerListFromHeaders, + fillHeaders, + getDecodeSplitHeader, + guardFromHeaders, }; })(this); diff --git a/op_crates/fetch/21_formdata.js b/op_crates/fetch/21_formdata.js index 106b67da43..00f97f346c 100644 --- a/op_crates/fetch/21_formdata.js +++ b/op_crates/fetch/21_formdata.js @@ -442,6 +442,11 @@ * @returns {FormData} */ parse() { + // Body must be at least 2 boundaries + \r\n + -- on the last boundary. + if (this.body.length < (this.boundary.length * 2) + 4) { + throw new TypeError("Form data too short to be valid."); + } + const formData = new FormData(); let headerText = ""; let boundaryIndex = 0; @@ -525,5 +530,23 @@ return parser.parse(); } - globalThis.__bootstrap.formData = { FormData, encodeFormData, parseFormData }; + /** + * @param {FormDataEntry[]} entries + * @returns {FormData} + */ + function formDataFromEntries(entries) { + const fd = new FormData(); + fd[entryList] = entries; + return fd; + } + + webidl.converters["FormData"] = webidl + .createInterfaceConverter("FormData", FormData); + + globalThis.__bootstrap.formData = { + FormData, + encodeFormData, + parseFormData, + formDataFromEntries, + }; })(globalThis); diff --git a/op_crates/fetch/22_body.js b/op_crates/fetch/22_body.js new file mode 100644 index 0000000000..1c4ce42710 --- /dev/null +++ b/op_crates/fetch/22_body.js @@ -0,0 +1,338 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +"use strict"; + +((window) => { + const core = window.Deno.core; + const webidl = globalThis.__bootstrap.webidl; + const { parseUrlEncoded } = globalThis.__bootstrap.url; + const { parseFormData, formDataFromEntries, encodeFormData } = + globalThis.__bootstrap.formData; + const mimesniff = globalThis.__bootstrap.mimesniff; + const { isReadableStreamDisturbed } = globalThis.__bootstrap.streams; + + class InnerBody { + /** @type {ReadableStream | { body: Uint8Array, consumed: boolean }} */ + streamOrStatic; + /** @type {null | Uint8Array | Blob | FormData} */ + source = null; + /** @type {null | number} */ + length = null; + + /** + * @param {ReadableStream | { body: Uint8Array, consumed: boolean }} stream + */ + constructor(stream) { + this.streamOrStatic = stream ?? + { body: new Uint8Array(), consumed: false }; + } + + get stream() { + if (!(this.streamOrStatic instanceof ReadableStream)) { + const { body, consumed } = this.streamOrStatic; + this.streamOrStatic = new ReadableStream({ + start(controller) { + controller.enqueue(body); + controller.close(); + }, + }); + if (consumed) { + this.streamOrStatic.cancel(); + } + } + return this.streamOrStatic; + } + + /** + * https://fetch.spec.whatwg.org/#body-unusable + * @returns {boolean} + */ + unusable() { + if (this.streamOrStatic instanceof ReadableStream) { + return this.streamOrStatic.locked || + isReadableStreamDisturbed(this.streamOrStatic); + } + return this.streamOrStatic.consumed; + } + + /** + * @returns {boolean} + */ + consumed() { + if (this.streamOrStatic instanceof ReadableStream) { + return isReadableStreamDisturbed(this.streamOrStatic); + } + return this.streamOrStatic.consumed; + } + + /** + * https://fetch.spec.whatwg.org/#concept-body-consume-body + * @returns {Promise} + */ + async consume() { + if (this.unusable()) throw new TypeError("Body already consumed."); + if (this.streamOrStatic instanceof ReadableStream) { + const reader = this.stream.getReader(); + /** @type {Uint8Array[]} */ + const chunks = []; + let totalLength = 0; + while (true) { + const { value: chunk, done } = await reader.read(); + if (done) break; + chunks.push(chunk); + totalLength += chunk.byteLength; + } + const finalBuffer = new Uint8Array(totalLength); + let i = 0; + for (const chunk of chunks) { + finalBuffer.set(chunk, i); + i += chunk.byteLength; + } + return finalBuffer; + } else { + this.streamOrStatic.consumed = true; + return this.streamOrStatic.body; + } + } + + /** + * @returns {InnerBody} + */ + clone() { + const [out1, out2] = this.stream.tee(); + this.streamOrStatic = out1; + const second = new InnerBody(out2); + second.source = core.deserialize(core.serialize(this.source)); + second.length = this.length; + return second; + } + } + + /** + * @param {any} prototype + * @param {symbol} bodySymbol + * @param {symbol} mimeTypeSymbol + * @returns {void} + */ + function mixinBody(prototype, bodySymbol, mimeTypeSymbol) { + function consumeBody(object) { + if (object[bodySymbol] !== null) { + return object[bodySymbol].consume(); + } + return Promise.resolve(new Uint8Array()); + } + + /** @type {PropertyDescriptorMap} */ + const mixin = { + body: { + /** + * @returns {ReadableStream | null} + */ + get() { + webidl.assertBranded(this, prototype); + if (this[bodySymbol] === null) { + return null; + } else { + return this[bodySymbol].stream; + } + }, + }, + bodyUsed: { + /** + * @returns {boolean} + */ + get() { + webidl.assertBranded(this, prototype); + if (this[bodySymbol] !== null) { + return this[bodySymbol].consumed(); + } + return false; + }, + }, + arrayBuffer: { + /** @returns {Promise} */ + value: async function arrayBuffer() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "ArrayBuffer"); + }, + }, + blob: { + /** @returns {Promise} */ + value: async function blob() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "Blob", this[mimeTypeSymbol]); + }, + }, + formData: { + /** @returns {Promise} */ + value: async function formData() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "FormData", this[mimeTypeSymbol]); + }, + }, + json: { + /** @returns {Promise} */ + value: async function json() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "JSON"); + }, + }, + text: { + /** @returns {Promise} */ + value: async function text() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "text"); + }, + }, + }; + return Object.defineProperties(prototype.prototype, mixin); + } + + const decoder = new TextDecoder(); + + /** + * https://fetch.spec.whatwg.org/#concept-body-package-data + * @param {Uint8Array} bytes + * @param {"ArrayBuffer" | "Blob" | "FormData" | "JSON" | "text"} type + * @param {MimeType | null} [mimeType] + */ + function packageData(bytes, type, mimeType) { + switch (type) { + case "ArrayBuffer": + return bytes.buffer; + case "Blob": + return new Blob([bytes], { + type: mimeType !== null ? mimesniff.serializeMimeType(mimeType) : "", + }); + case "FormData": { + if (mimeType !== null) { + if (mimeType !== null) { + const essence = mimesniff.essence(mimeType); + if (essence === "multipart/form-data") { + const boundary = mimeType.parameters.get("boundary"); + if (boundary === null) { + throw new TypeError( + "Missing boundary parameter in mime type of multipart formdata.", + ); + } + return parseFormData(bytes, boundary); + } else if (essence === "application/x-www-form-urlencoded") { + const entries = parseUrlEncoded(bytes); + return formDataFromEntries( + entries.map((x) => ({ name: x[0], value: x[1] })), + ); + } + } + throw new TypeError("Invalid form data"); + } + throw new TypeError("Missing content type"); + } + case "JSON": + return JSON.parse(decoder.decode(bytes)); + case "text": + return decoder.decode(bytes); + } + } + + const encoder = new TextEncoder(); + + /** + * @param {BodyInit} object + * @returns {{body: InnerBody, contentType: string | null}} + */ + function extractBody(object) { + /** @type {ReadableStream | { body: Uint8Array, consumed: boolean }} */ + let stream; + let source = null; + let length = null; + let contentType = null; + if (object instanceof Blob) { + stream = object.stream(); + source = object; + length = object.size; + if (object.type.length !== 0) { + contentType = object.type; + } + } else if (ArrayBuffer.isView(object) || object instanceof ArrayBuffer) { + const u8 = ArrayBuffer.isView(object) + ? new Uint8Array( + object.buffer, + object.byteOffset, + object.byteLength, + ) + : new Uint8Array(object); + const copy = u8.slice(0, u8.byteLength); + source = copy; + } else if (object instanceof FormData) { + const res = encodeFormData(object); + stream = { body: res.body, consumed: false }; + source = object; + length = res.body.byteLength; + contentType = res.contentType; + } else if (object instanceof URLSearchParams) { + source = encoder.encode(object.toString()); + contentType = "application/x-www-form-urlencoded;charset=UTF-8"; + } else if (typeof object === "string") { + source = encoder.encode(object); + contentType = "text/plain;charset=UTF-8"; + } else if (object instanceof ReadableStream) { + stream = object; + if (object.locked || isReadableStreamDisturbed(object)) { + throw new TypeError("ReadableStream is locked or disturbed"); + } + } + if (source instanceof Uint8Array) { + stream = { body: source, consumed: false }; + length = source.byteLength; + } + const body = new InnerBody(stream); + body.source = source; + body.length = length; + return { body, contentType }; + } + + webidl.converters["BodyInit"] = (V, opts) => { + // Union for (ReadableStream or Blob or ArrayBufferView or ArrayBuffer or FormData or URLSearchParams or USVString) + if (V instanceof ReadableStream) { + // TODO(lucacasonato): ReadableStream is not branded + return V; + } else if (V instanceof Blob) { + return webidl.converters["Blob"](V, opts); + } else if (V instanceof FormData) { + return webidl.converters["FormData"](V, opts); + } else if (V instanceof URLSearchParams) { + // TODO(lucacasonato): URLSearchParams is not branded + return V; + } + if (typeof V === "object") { + if (V instanceof ArrayBuffer || V instanceof SharedArrayBuffer) { + return webidl.converters["ArrayBuffer"](V, opts); + } + if (ArrayBuffer.isView(V)) { + return webidl.converters["ArrayBufferView"](V, opts); + } + } + return webidl.converters["USVString"](V, opts); + }; + webidl.converters["BodyInit?"] = webidl.createNullableConverter( + webidl.converters["BodyInit"], + ); + + window.__bootstrap.fetchBody = { mixinBody, InnerBody, extractBody }; +})(globalThis); diff --git a/op_crates/fetch/22_http_client.js b/op_crates/fetch/22_http_client.js new file mode 100644 index 0000000000..0a4be9e9f0 --- /dev/null +++ b/op_crates/fetch/22_http_client.js @@ -0,0 +1,41 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// +/// +/// +/// +/// +/// +/// +/// +/// +"use strict"; + +((window) => { + const core = window.Deno.core; + + /** + * @param {Deno.CreateHttpClientOptions} options + * @returns {HttpClient} + */ + function createHttpClient(options) { + return new HttpClient(core.opSync("op_create_http_client", options)); + } + + class HttpClient { + /** + * @param {number} rid + */ + constructor(rid) { + this.rid = rid; + } + close() { + core.close(this.rid); + } + } + + window.__bootstrap.fetch ??= {}; + window.__bootstrap.fetch.createHttpClient = createHttpClient; + window.__bootstrap.fetch.HttpClient = HttpClient; +})(globalThis); diff --git a/op_crates/fetch/23_request.js b/op_crates/fetch/23_request.js new file mode 100644 index 0000000000..f3764d96fa --- /dev/null +++ b/op_crates/fetch/23_request.js @@ -0,0 +1,521 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// +/// +/// +/// +/// +/// +/// +/// +"use strict"; + +((window) => { + const webidl = window.__bootstrap.webidl; + const { HTTP_TOKEN_CODE_POINT_RE, byteUpperCase } = window.__bootstrap.infra; + const { URL } = window.__bootstrap.url; + const { guardFromHeaders } = window.__bootstrap.headers; + const { InnerBody, mixinBody, extractBody } = window.__bootstrap.fetchBody; + const { getLocationHref } = window.__bootstrap.location; + const mimesniff = window.__bootstrap.mimesniff; + const { + headersFromHeaderList, + headerListFromHeaders, + fillHeaders, + getDecodeSplitHeader, + } = window.__bootstrap.headers; + const { HttpClient } = window.__bootstrap.fetch; + + const _request = Symbol("request"); + const _headers = Symbol("headers"); + const _mimeType = Symbol("mime type"); + const _body = Symbol("body"); + + /** + * @typedef InnerRequest + * @property {string} method + * @property {() => string} url + * @property {() => string} currentUrl + * @property {[string, string][]} headerList + * @property {null | InnerBody} body + * @property {"follow" | "error" | "manual"} redirectMode + * @property {number} redirectCount + * @property {string[]} urlList + * @property {number | null} clientRid NOTE: non standard extension for `Deno.HttpClient`. + */ + + const defaultInnerRequest = { + url() { + return this.urlList[0]; + }, + currentUrl() { + return this.urlList[this.urlList.length - 1]; + }, + redirectMode: "follow", + redirectCount: 0, + clientRid: null, + }; + + /** + * @param {string} method + * @param {string} url + * @param {[string, string][]} headerList + * @param {InnerBody} body + * @returns + */ + function newInnerRequest(method, url, headerList = [], body = null) { + return { + method: method, + headerList, + body, + urlList: [url], + ...defaultInnerRequest, + }; + } + + /** + * https://fetch.spec.whatwg.org/#concept-request-clone + * @param {InnerRequest} request + * @returns {InnerRequest} + */ + function cloneInnerRequest(request) { + const headerList = [...request.headerList.map((x) => [x[0], x[1]])]; + let body = null; + if (request.body !== null) { + body = request.body.clone(); + } + + return { + method: request.method, + url() { + return this.urlList[0]; + }, + currentUrl() { + return this.urlList[this.urlList.length - 1]; + }, + headerList, + body, + redirectMode: request.redirectMode, + redirectCount: request.redirectCount, + urlList: request.urlList, + clientRid: request.clientRid, + }; + } + + /** + * @param {string} m + * @returns {boolean} + */ + function isKnownMethod(m) { + return ( + m === "DELETE" || + m === "GET" || + m === "HEAD" || + m === "OPTIONS" || + m === "POST" || + m === "PUT" + ); + } + /** + * @param {string} m + * @returns {string} + */ + function validateAndNormalizeMethod(m) { + // Fast path for well-known methods + if (isKnownMethod(m)) { + return m; + } + + // Regular path + if (!HTTP_TOKEN_CODE_POINT_RE.test(m)) { + throw new TypeError("Method is not valid."); + } + const upperCase = byteUpperCase(m); + if ( + upperCase === "CONNECT" || upperCase === "TRACE" || upperCase === "TRACK" + ) { + throw new TypeError("Method is forbidden."); + } + return upperCase; + } + + class Request { + /** @type {InnerRequest} */ + [_request]; + /** @type {Headers} */ + [_headers]; + get [_mimeType]() { + let charset = null; + let essence = null; + let mimeType = null; + const values = getDecodeSplitHeader( + headerListFromHeaders(this[_headers]), + "Content-Type", + ); + if (values === null) return null; + for (const value of values) { + const temporaryMimeType = mimesniff.parseMimeType(value); + if ( + temporaryMimeType === null || + mimesniff.essence(temporaryMimeType) == "*/*" + ) { + continue; + } + mimeType = temporaryMimeType; + if (mimesniff.essence(mimeType) !== essence) { + charset = null; + const newCharset = mimeType.parameters.get("charset"); + if (newCharset !== undefined) { + charset = newCharset; + } + essence = mimesniff.essence(mimeType); + } else { + if (mimeType.parameters.has("charset") === null && charset !== null) { + mimeType.parameters.set("charset", charset); + } + } + } + if (mimeType === null) return null; + return mimeType; + } + get [_body]() { + return this[_request].body; + } + + /** + * https://fetch.spec.whatwg.org/#dom-request + * @param {RequestInfo} input + * @param {RequestInit} init + */ + constructor(input, init = {}) { + const prefix = "Failed to construct 'Request'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + input = webidl.converters["RequestInfo"](input, { + prefix, + context: "Argument 1", + }); + init = webidl.converters["RequestInit"](init, { + prefix, + context: "Argument 2", + }); + + this[webidl.brand] = webidl.brand; + + /** @type {InnerRequest} */ + let request; + const baseURL = getLocationHref(); + + // 5. + if (typeof input === "string") { + const parsedURL = new URL(input, baseURL); + request = newInnerRequest("GET", parsedURL.href, [], null); + } else { // 6. + if (!(input instanceof Request)) throw new TypeError("Unreachable"); + request = input[_request]; + } + + // 22. + if (init.redirect !== undefined) { + request.redirectMode = init.redirect; + } + + // 25. + if (init.method !== undefined) { + let method = init.method; + method = validateAndNormalizeMethod(method); + request.method = method; + } + + // NOTE: non standard extension. This handles Deno.HttpClient parameter + if (init.client !== undefined) { + if (init.client !== null && !(init.client instanceof HttpClient)) { + throw webidl.makeException( + TypeError, + "`client` must be a Deno.HttpClient", + { prefix, context: "Argument 2" }, + ); + } + request.clientRid = init.client?.rid ?? null; + } + + // 27. + this[_request] = request; + + // 29. + this[_headers] = headersFromHeaderList(request.headerList, "request"); + + // 31. + if (Object.keys(init).length > 0) { + let headers = headerListFromHeaders(this[_headers]); + if (init.headers !== undefined) { + headers = init.headers; + } + headerListFromHeaders(this[_headers]).slice( + 0, + headerListFromHeaders(this[_headers]).length, + ); + fillHeaders(this[_headers], headers); + } + + // 32. + let inputBody = null; + if (input instanceof Request) { + inputBody = input[_body]; + } + + // 33. + if ( + (request.method === "GET" || request.method === "HEAD") && + ((init.body !== undefined && init.body !== null) || + inputBody !== null) + ) { + throw new TypeError("HEAD and GET requests may not have a body."); + } + + // 34. + let initBody = null; + + // 35. + if (init.body !== undefined && init.body !== null) { + const res = extractBody(init.body); + initBody = res.body; + if (res.contentType !== null && !this[_headers].has("content-type")) { + this[_headers].append("Content-Type", res.contentType); + } + } + + // 36. + const inputOrInitBody = initBody ?? inputBody; + + // 38. + const finalBody = inputOrInitBody; + + // 39. + // TODO(lucacasonato): implement this step. Is it needed? + + // 40. + request.body = finalBody; + } + + get method() { + webidl.assertBranded(this, Request); + return this[_request].method; + } + + get url() { + webidl.assertBranded(this, Request); + return this[_request].url(); + } + + get headers() { + webidl.assertBranded(this, Request); + return this[_headers]; + } + + get destination() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get referrer() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get referrerPolicy() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get mode() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get credentials() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get cache() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get redirect() { + webidl.assertBranded(this, Request); + return this[_request].redirectMode; + } + + get integrity() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get keepalive() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get isReloadNavigation() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get isHistoryNavigation() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + get signal() { + webidl.assertBranded(this, Request); + throw new TypeError("This property is not implemented."); + } + + clone() { + webidl.assertBranded(this, Request); + if (this[_body] && this[_body].unusable()) { + throw new TypeError("Body is unusable."); + } + const newReq = cloneInnerRequest(this[_request]); + return fromInnerRequest(newReq, guardFromHeaders(this[_headers])); + } + + get [Symbol.toStringTag]() { + return "Request"; + } + + [Symbol.for("Deno.customInspect")](inspect) { + const inner = { + bodyUsed: this.bodyUsed, + headers: this.headers, + method: this.method, + redirect: this.redirect, + url: this.url(), + }; + return `Request ${inspect(inner)}`; + } + } + + mixinBody(Request, _body, _mimeType); + + webidl.converters["Request"] = webidl.createInterfaceConverter( + "Request", + Request, + ); + webidl.converters["RequestInfo"] = (V, opts) => { + // Union for (Request or USVString) + if (typeof V == "object") { + if (V instanceof Request) { + return webidl.converters["Request"](V, opts); + } + } + return webidl.converters["USVString"](V, opts); + }; + + webidl.converters["ReferrerPolicy"] = webidl.createEnumConverter( + "ReferrerPolicy", + [ + "", + "no-referrer", + "no-referrer-when-downgrade", + "same-origin", + "origin", + "strict-origin", + "origin-when-cross-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + ], + ); + webidl.converters["RequestMode"] = webidl.createEnumConverter("RequestMode", [ + "navigate", + "same-origin", + "no-cors", + "cors", + ]); + webidl.converters["RequestCredentials"] = webidl.createEnumConverter( + "RequestCredentials", + [ + "omit", + "same-origin", + "include", + ], + ); + webidl.converters["RequestCache"] = webidl.createEnumConverter( + "RequestCache", + [ + "default", + "no-store", + "reload", + "no-cache", + "force-cache", + "only-if-cached", + ], + ); + webidl.converters["RequestRedirect"] = webidl.createEnumConverter( + "RequestRedirect", + [ + "follow", + "error", + "manual", + ], + ); + webidl.converters["RequestInit"] = webidl.createDictionaryConverter( + "RequestInit", + [ + { key: "method", converter: webidl.converters["ByteString"] }, + { key: "headers", converter: webidl.converters["HeadersInit"] }, + { + key: "body", + converter: webidl.createNullableConverter( + webidl.converters["BodyInit"], + ), + }, + { key: "referrer", converter: webidl.converters["USVString"] }, + { key: "referrerPolicy", converter: webidl.converters["ReferrerPolicy"] }, + { key: "mode", converter: webidl.converters["RequestMode"] }, + { + key: "credentials", + converter: webidl.converters["RequestCredentials"], + }, + { key: "cache", converter: webidl.converters["RequestCache"] }, + { key: "redirect", converter: webidl.converters["RequestRedirect"] }, + { key: "integrity", converter: webidl.converters["DOMString"] }, + { key: "keepalive", converter: webidl.converters["boolean"] }, + { + key: "signal", + converter: webidl.createNullableConverter( + webidl.converters["AbortSignal"], + ), + }, + { key: "client", converter: webidl.converters.any }, + ], + ); + + /** + * @param {Request} request + * @returns {InnerRequest} + */ + function toInnerRequest(request) { + return request[_request]; + } + + /** + * @param {InnerRequest} inner + * @param {"request" | "immutable" | "request-no-cors" | "response" | "none"} guard + * @returns {Request} + */ + function fromInnerRequest(inner, guard) { + const request = webidl.createBranded(Request); + request[_request] = inner; + request[_headers] = headersFromHeaderList(inner.headerList, guard); + return request; + } + + window.__bootstrap.fetch ??= {}; + window.__bootstrap.fetch.Request = Request; + window.__bootstrap.fetch.toInnerRequest = toInnerRequest; + window.__bootstrap.fetch.fromInnerRequest = fromInnerRequest; + window.__bootstrap.fetch.newInnerRequest = newInnerRequest; +})(globalThis); diff --git a/op_crates/fetch/23_response.js b/op_crates/fetch/23_response.js new file mode 100644 index 0000000000..44b74f789c --- /dev/null +++ b/op_crates/fetch/23_response.js @@ -0,0 +1,415 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// +/// +/// +/// +/// +/// +/// +/// +/// +"use strict"; + +((window) => { + const webidl = window.__bootstrap.webidl; + const { HTTP_TAB_OR_SPACE, regexMatcher } = window.__bootstrap.infra; + const { InnerBody, extractBody, mixinBody } = window.__bootstrap.fetchBody; + const { getLocationHref } = window.__bootstrap.location; + const mimesniff = window.__bootstrap.mimesniff; + const { URL } = window.__bootstrap.url; + const { + getDecodeSplitHeader, + headerListFromHeaders, + headersFromHeaderList, + guardFromHeaders, + fillHeaders, + } = window.__bootstrap.headers; + + const VCHAR = ["\x21-\x7E"]; + const OBS_TEXT = ["\x80-\xFF"]; + + const REASON_PHRASE = [...HTTP_TAB_OR_SPACE, ...VCHAR, ...OBS_TEXT]; + const REASON_PHRASE_MATCHER = regexMatcher(REASON_PHRASE); + const REASON_PHRASE_RE = new RegExp(`^[${REASON_PHRASE_MATCHER}]*$`); + + const _response = Symbol("response"); + const _headers = Symbol("headers"); + const _mimeType = Symbol("mime type"); + const _body = Symbol("body"); + + /** + * @typedef InnerResponse + * @property {"basic" | "cors" | "default" | "error" | "opaque" | "opaqueredirect"} type + * @property {() => string | null} url + * @property {string[]} urlList + * @property {number} status + * @property {string} statusMessage + * @property {[string, string][]} headerList + * @property {null | InnerBody} body + * @property {string} [error] + */ + + /** + * @param {number} status + * @returns {boolean} + */ + function nullBodyStatus(status) { + return status === 101 || status === 204 || status === 205 || status === 304; + } + + /** + * @param {number} status + * @returns {boolean} + */ + function redirectStatus(status) { + return status === 301 || status === 302 || status === 303 || + status === 307 || status === 308; + } + + /** + * https://fetch.spec.whatwg.org/#concept-response-clone + * @param {InnerResponse} response + * @returns {InnerResponse} + */ + function cloneInnerResponse(response) { + const urlList = [...response.urlList]; + const headerList = [...response.headerList.map((x) => [x[0], x[1]])]; + let body = null; + if (response.body !== null) { + body = response.body.clone(); + } + + return { + type: response.type, + body, + headerList, + url() { + if (this.urlList.length == 0) return null; + return this.urlList[this.urlList.length - 1]; + }, + urlList, + status: response.status, + statusMessage: response.statusMessage, + }; + } + + const defaultInnerResponse = { + type: "default", + body: null, + url() { + if (this.urlList.length == 0) return null; + return this.urlList[this.urlList.length - 1]; + }, + }; + + /** + * @returns {InnerResponse} + */ + function newInnerResponse(status = 200, statusMessage = "") { + return { + headerList: [], + urlList: [], + status, + statusMessage, + ...defaultInnerResponse, + }; + } + + /** + * @param {string} error + * @returns {InnerResponse} + */ + function networkError(error) { + const resp = newInnerResponse(0); + resp.type = "error"; + resp.error = error; + return resp; + } + + class Response { + /** @type {InnerResponse} */ + [_response]; + /** @type {Headers} */ + [_headers]; + get [_mimeType]() { + let charset = null; + let essence = null; + let mimeType = null; + const values = getDecodeSplitHeader( + headerListFromHeaders(this[_headers]), + "Content-Type", + ); + if (values === null) return null; + for (const value of values) { + const temporaryMimeType = mimesniff.parseMimeType(value); + if ( + temporaryMimeType === null || + mimesniff.essence(temporaryMimeType) == "*/*" + ) { + continue; + } + mimeType = temporaryMimeType; + if (mimesniff.essence(mimeType) !== essence) { + charset = null; + const newCharset = mimeType.parameters.get("charset"); + if (newCharset !== undefined) { + charset = newCharset; + } + essence = mimesniff.essence(mimeType); + } else { + if (mimeType.parameters.has("charset") === null && charset !== null) { + mimeType.parameters.set("charset", charset); + } + } + } + if (mimeType === null) return null; + return mimeType; + } + get [_body]() { + return this[_response].body; + } + + /** + * @returns {Response} + */ + static error() { + const inner = newInnerResponse(0); + inner.type = "error"; + const response = webidl.createBranded(Response); + response[_response] = inner; + response[_headers] = headersFromHeaderList( + response[_response].headerList, + "immutable", + ); + return response; + } + + /** + * @param {string} url + * @param {number} status + * @returns {Response} + */ + static redirect(url, status = 302) { + const prefix = "Failed to call 'Response.redirect'"; + url = webidl.converters["USVString"](url, { + prefix, + context: "Argument 1", + }); + status = webidl.converters["unsigned short"](status, { + prefix, + context: "Argument 2", + }); + + const baseURL = getLocationHref(); + const parsedURL = new URL(url, baseURL); + if (!redirectStatus(status)) { + throw new RangeError("Invalid redirect status code."); + } + const inner = newInnerResponse(status); + inner.type = "default"; + inner.headerList.push(["Location", parsedURL.href]); + const response = webidl.createBranded(Response); + response[_response] = inner; + response[_headers] = headersFromHeaderList( + response[_response].headerList, + "immutable", + ); + return response; + } + + /** + * @param {BodyInit | null} body + * @param {ResponseInit} init + */ + constructor(body = null, init = {}) { + const prefix = "Failed to construct 'Response'"; + body = webidl.converters["BodyInit?"](body, { + prefix, + context: "Argument 1", + }); + init = webidl.converters["ResponseInit"](init, { + prefix, + context: "Argument 2", + }); + + if (init.status < 200 || init.status > 599) { + throw new RangeError( + `The status provided (${init.status}) is outside the range [200, 599].`, + ); + } + + if (!REASON_PHRASE_RE.test(init.statusText)) { + throw new TypeError("Status text is not valid."); + } + + this[webidl.brand] = webidl.brand; + const response = newInnerResponse(init.status, init.statusText); + this[_response] = response; + this[_headers] = headersFromHeaderList(response.headerList, "response"); + if (init.headers !== undefined) { + fillHeaders(this[_headers], init.headers); + } + if (body !== null) { + if (nullBodyStatus(response.status)) { + throw new TypeError( + "Response with null body status cannot have body", + ); + } + const res = extractBody(body); + response.body = res.body; + if (res.contentType !== null && !this[_headers].has("content-type")) { + this[_headers].append("Content-Type", res.contentType); + } + } + } + + /** + * @returns {"basic" | "cors" | "default" | "error" | "opaque" | "opaqueredirect"} + */ + get type() { + webidl.assertBranded(this, Response); + return this[_response].type; + } + + /** + * @returns {string} + */ + get url() { + webidl.assertBranded(this, Response); + const url = this[_response].url(); + if (url === null) return ""; + const newUrl = new URL(url); + newUrl.hash = ""; + return newUrl.href; + } + + /** + * @returns {boolean} + */ + get redirected() { + webidl.assertBranded(this, Response); + return this[_response].urlList.length > 1; + } + + /** + * @returns {number} + */ + get status() { + webidl.assertBranded(this, Response); + return this[_response].status; + } + + /** + * @returns {boolean} + */ + get ok() { + webidl.assertBranded(this, Response); + const status = this[_response].status; + return status >= 200 && status <= 299; + } + + /** + * @returns {string} + */ + get statusText() { + webidl.assertBranded(this, Response); + return this[_response].statusMessage; + } + + /** + * @returns {Headers} + */ + get headers() { + webidl.assertBranded(this, Response); + return this[_headers]; + } + + /** + * @returns {Response} + */ + clone() { + webidl.assertBranded(this, Response); + if (this[_body] && this[_body].unusable()) { + throw new TypeError("Body is unusable."); + } + const second = webidl.createBranded(Response); + const newRes = cloneInnerResponse(this[_response]); + second[_response] = newRes; + second[_headers] = headersFromHeaderList( + newRes.headerList, + guardFromHeaders(this[_headers]), + ); + return second; + } + + get [Symbol.toStringTag]() { + return "Response"; + } + + [Symbol.for("Deno.customInspect")](inspect) { + const inner = { + body: this.body, + bodyUsed: this.bodyUsed, + headers: this.headers, + ok: this.ok, + redirected: this.redirected, + status: this.status, + statusText: this.statusText, + url: this.url(), + }; + return `Response ${inspect(inner)}`; + } + } + + mixinBody(Response, _body, _mimeType); + + webidl.converters["Response"] = webidl.createInterfaceConverter( + "Response", + Response, + ); + webidl.converters["ResponseInit"] = webidl.createDictionaryConverter( + "ResponseInit", + [{ + key: "status", + defaultValue: 200, + converter: webidl.converters["unsigned short"], + }, { + key: "statusText", + defaultValue: "", + converter: webidl.converters["ByteString"], + }, { + key: "headers", + converter: webidl.converters["HeadersInit"], + }], + ); + + /** + * @param {Response} response + * @returns {InnerResponse} + */ + function toInnerResponse(response) { + return response[_response]; + } + + /** + * @param {InnerResponse} inner + * @param {"request" | "immutable" | "request-no-cors" | "response" | "none"} guard + * @returns {Response} + */ + function fromInnerResponse(inner, guard) { + const response = webidl.createBranded(Response); + response[_response] = inner; + response[_headers] = headersFromHeaderList(inner.headerList, guard); + return response; + } + + window.__bootstrap.fetch ??= {}; + window.__bootstrap.fetch.Response = Response; + window.__bootstrap.fetch.toInnerResponse = toInnerResponse; + window.__bootstrap.fetch.fromInnerResponse = fromInnerResponse; + window.__bootstrap.fetch.redirectStatus = redirectStatus; + window.__bootstrap.fetch.nullBodyStatus = nullBodyStatus; + window.__bootstrap.fetch.networkError = networkError; +})(globalThis); diff --git a/op_crates/fetch/26_fetch.js b/op_crates/fetch/26_fetch.js index d07121e86a..a6d1608d0c 100644 --- a/op_crates/fetch/26_fetch.js +++ b/op_crates/fetch/26_fetch.js @@ -13,497 +13,36 @@ ((window) => { const core = window.Deno.core; + const webidl = window.__bootstrap.webidl; + const { byteLowerCase } = window.__bootstrap.infra; + const { InnerBody, extractBody } = window.__bootstrap.fetchBody; + const { + toInnerRequest, + fromInnerResponse, + redirectStatus, + nullBodyStatus, + networkError, + } = window.__bootstrap.fetch; - // provided by "deno_web" - const { URLSearchParams } = window.__bootstrap.url; - const { getLocationHref } = window.__bootstrap.location; - const { FormData, parseFormData, encodeFormData } = - window.__bootstrap.formData; - const { parseMimeType } = window.__bootstrap.mimesniff; - - const { ReadableStream, isReadableStreamDisturbed } = - window.__bootstrap.streams; - const { Headers } = window.__bootstrap.headers; - const { Blob, _byteSequence, File } = window.__bootstrap.file; - - const MAX_SIZE = 2 ** 32 - 2; + const REQUEST_BODY_HEADER_NAMES = [ + "content-encoding", + "content-language", + "content-location", + "content-type", + ]; /** - * @param {Uint8Array} src - * @param {Uint8Array} dst - * @param {number} off the offset into `dst` where it will at which to begin writing values from `src` - * - * @returns {number} number of bytes copied - */ - function copyBytes(src, dst, off = 0) { - const r = dst.byteLength - off; - if (src.byteLength > r) { - src = src.subarray(0, r); - } - dst.set(src, off); - return src.byteLength; - } - - class Buffer { - /** @type {Uint8Array} */ - #buf; // contents are the bytes buf[off : len(buf)] - #off = 0; // read at buf[off], write at buf[buf.byteLength] - - /** @param {ArrayBuffer} [ab] */ - constructor(ab) { - if (ab == null) { - this.#buf = new Uint8Array(0); - return; - } - - this.#buf = new Uint8Array(ab); - } - - /** - * @returns {Uint8Array} - */ - bytes(options = { copy: true }) { - if (options.copy === false) return this.#buf.subarray(this.#off); - return this.#buf.slice(this.#off); - } - - /** - * @returns {boolean} - */ - empty() { - return this.#buf.byteLength <= this.#off; - } - - /** - * @returns {number} - */ - get length() { - return this.#buf.byteLength - this.#off; - } - - /** - * @returns {number} - */ - get capacity() { - return this.#buf.buffer.byteLength; - } - - /** - * @returns {void} - */ - reset() { - this.#reslice(0); - this.#off = 0; - } - - /** - * @param {number} n - * @returns {number} - */ - #tryGrowByReslice = (n) => { - const l = this.#buf.byteLength; - if (n <= this.capacity - l) { - this.#reslice(l + n); - return l; - } - return -1; - }; - - /** - * @param {number} len - * @returns {void} - */ - #reslice = (len) => { - if (!(len <= this.#buf.buffer.byteLength)) { - throw new Error("assert"); - } - this.#buf = new Uint8Array(this.#buf.buffer, 0, len); - }; - - /** - * @param {Uint8Array} p - * @returns {number} - */ - writeSync(p) { - const m = this.#grow(p.byteLength); - return copyBytes(p, this.#buf, m); - } - - /** - * @param {Uint8Array} p - * @returns {Promise} - */ - write(p) { - const n = this.writeSync(p); - return Promise.resolve(n); - } - - /** - * @param {number} n - * @returns {number} - */ - #grow = (n) => { - const m = this.length; - // If buffer is empty, reset to recover space. - if (m === 0 && this.#off !== 0) { - this.reset(); - } - // Fast: Try to grow by means of a reslice. - const i = this.#tryGrowByReslice(n); - if (i >= 0) { - return i; - } - const c = this.capacity; - if (n <= Math.floor(c / 2) - m) { - // We can slide things down instead of allocating a new - // ArrayBuffer. We only need m+n <= c to slide, but - // we instead let capacity get twice as large so we - // don't spend all our time copying. - copyBytes(this.#buf.subarray(this.#off), this.#buf); - } else if (c + n > MAX_SIZE) { - throw new Error("The buffer cannot be grown beyond the maximum size."); - } else { - // Not enough space anywhere, we need to allocate. - const buf = new Uint8Array(Math.min(2 * c + n, MAX_SIZE)); - copyBytes(this.#buf.subarray(this.#off), buf); - this.#buf = buf; - } - // Restore this.#off and len(this.#buf). - this.#off = 0; - this.#reslice(Math.min(m + n, MAX_SIZE)); - return m; - }; - - /** - * @param {number} n - * @returns {void} - */ - grow(n) { - if (n < 0) { - throw Error("Buffer.grow: negative count"); - } - const m = this.#grow(n); - this.#reslice(m); - } - } - - /** - * @param {unknown} x - * @returns {x is ArrayBufferView} - */ - function isTypedArray(x) { - return ArrayBuffer.isView(x) && !(x instanceof DataView); - } - - /** - * @param {string} s - * @param {string} value - * @returns {boolean} - */ - function hasHeaderValueOf(s, value) { - return new RegExp(`^${value}(?:[\\s;]|$)`).test(s); - } - - /** - * @param {string} name - * @param {BodyInit | null} bodySource - */ - function validateBodyType(name, bodySource) { - if (isTypedArray(bodySource)) { - return true; - } else if (bodySource instanceof ArrayBuffer) { - return true; - } else if (typeof bodySource === "string") { - return true; - } else if (bodySource instanceof ReadableStream) { - return true; - } else if (bodySource instanceof FormData) { - return true; - } else if (bodySource instanceof URLSearchParams) { - return true; - } else if (!bodySource) { - return true; // null body is fine - } - throw new TypeError( - `Bad ${name} body type: ${bodySource.constructor.name}`, - ); - } - - /** - * @param {ReadableStreamReader} stream - * @param {number} [size] - */ - async function bufferFromStream( - stream, - size, - ) { - const encoder = new TextEncoder(); - const buffer = new Buffer(); - - if (size) { - // grow to avoid unnecessary allocations & copies - buffer.grow(size); - } - - while (true) { - const { done, value } = await stream.read(); - - if (done) break; - - if (typeof value === "string") { - buffer.writeSync(encoder.encode(value)); - } else if (value instanceof ArrayBuffer) { - buffer.writeSync(new Uint8Array(value)); - } else if (value instanceof Uint8Array) { - buffer.writeSync(value); - } else if (!value) { - // noop for undefined - } else { - throw new Error("unhandled type on stream read"); - } - } - - return buffer.bytes().buffer; - } - - /** - * @param {Exclude | null} bodySource - */ - function bodyToArrayBuffer(bodySource) { - if (isTypedArray(bodySource)) { - return bodySource.buffer; - } else if (bodySource instanceof ArrayBuffer) { - return bodySource; - } else if (typeof bodySource === "string") { - const enc = new TextEncoder(); - return enc.encode(bodySource).buffer; - } else if ( - bodySource instanceof FormData || - bodySource instanceof URLSearchParams - ) { - const enc = new TextEncoder(); - return enc.encode(bodySource.toString()).buffer; - } else if (!bodySource) { - return new ArrayBuffer(0); - } - throw new Error( - `Body type not implemented: ${bodySource.constructor.name}`, - ); - } - - const BodyUsedError = - "Failed to execute 'clone' on 'Body': body is already used"; - - const teeBody = Symbol("Body#tee"); - - // fastBody and dontValidateUrl allow users to opt out of certain behaviors - const fastBody = Symbol("Body#fast"); - const dontValidateUrl = Symbol("dontValidateUrl"); - const lazyHeaders = Symbol("lazyHeaders"); - - class Body { - #contentType = ""; - #size; - /** @type {BodyInit | null} */ - #bodySource; - /** @type {ReadableStream | null} */ - #stream = null; - - /** - * @param {BodyInit| null} bodySource - * @param {{contentType: string, size?: number}} meta - */ - constructor(bodySource, meta) { - validateBodyType(this.constructor.name, bodySource); - this.#bodySource = bodySource; - this.#contentType = meta.contentType; - this.#size = meta.size; - } - - get body() { - if (!this.#stream) { - if (!this.#bodySource) { - return null; - } else if (this.#bodySource instanceof ReadableStream) { - this.#stream = this.#bodySource; - } else { - const buf = bodyToArrayBuffer(this.#bodySource); - if (!(buf instanceof ArrayBuffer)) { - throw new Error( - `Expected ArrayBuffer from body`, - ); - } - - this.#stream = new ReadableStream({ - /** - * @param {ReadableStreamDefaultController} controller - */ - start(controller) { - controller.enqueue(new Uint8Array(buf)); - controller.close(); - }, - }); - } - } - - return this.#stream; - } - - // Optimization that allows caller to bypass expensive ReadableStream. - [fastBody]() { - if (!this.#bodySource) { - return null; - } else if (!(this.#bodySource instanceof ReadableStream)) { - return bodyToArrayBuffer(this.#bodySource); - } else { - return this.body; - } - } - - /** @returns {BodyInit | null} */ - [teeBody]() { - if (this.#stream || this.#bodySource instanceof ReadableStream) { - const body = this.body; - if (body) { - const [stream1, stream2] = body.tee(); - this.#stream = stream1; - return stream2; - } else { - return null; - } - } - - return this.#bodySource; - } - - get bodyUsed() { - if (this.body && isReadableStreamDisturbed(this.body)) { - return true; - } - return false; - } - - set bodyUsed(_) { - // this is a noop per spec - } - - /** @returns {Promise} */ - async blob() { - return new Blob([await this.arrayBuffer()], { - type: this.#contentType, - }); - } - - // ref: https://fetch.spec.whatwg.org/#body-mixin - /** @returns {Promise} */ - async formData() { - const formData = new FormData(); - const mimeType = parseMimeType(this.#contentType); - if (mimeType) { - if (mimeType.type === "multipart" && mimeType.subtype === "form-data") { - // ref: https://tools.ietf.org/html/rfc2046#section-5.1 - const boundary = mimeType.parameters.get("boundary"); - const body = new Uint8Array(await this.arrayBuffer()); - return parseFormData(body, boundary); - } else if ( - mimeType.type === "application" && - mimeType.subtype === "x-www-form-urlencoded" - ) { - // From https://github.com/github/fetch/blob/master/fetch.js - // Copyright (c) 2014-2016 GitHub, Inc. MIT License - const body = await this.text(); - try { - body - .trim() - .split("&") - .forEach((bytes) => { - if (bytes) { - const split = bytes.split("="); - if (split.length >= 2) { - // @ts-expect-error this is safe because of the above check - const name = split.shift().replace(/\+/g, " "); - const value = split.join("=").replace(/\+/g, " "); - formData.append( - decodeURIComponent(name), - decodeURIComponent(value), - ); - } - } - }); - } catch (e) { - throw new TypeError("Invalid form urlencoded format"); - } - return formData; - } - } - - throw new TypeError("Invalid form data"); - } - - /** @returns {Promise} */ - async text() { - if (typeof this.#bodySource === "string") { - return this.#bodySource; - } - - const ab = await this.arrayBuffer(); - const decoder = new TextDecoder("utf-8"); - return decoder.decode(ab); - } - - /** @returns {Promise} */ - async json() { - const raw = await this.text(); - return JSON.parse(raw); - } - - /** @returns {Promise} */ - arrayBuffer() { - if (this.#bodySource instanceof ReadableStream) { - const body = this.body; - if (!body) throw new TypeError("Unreachable state (no body)"); - return bufferFromStream(body.getReader(), this.#size); - } - return Promise.resolve(bodyToArrayBuffer(this.#bodySource)); - } - } - - /** - * @param {Deno.CreateHttpClientOptions} options - * @returns {HttpClient} - */ - function createHttpClient(options) { - return new HttpClient(core.opSync("op_create_http_client", options)); - } - - class HttpClient { - /** - * @param {number} rid - */ - constructor(rid) { - this.rid = rid; - } - close() { - core.close(this.rid); - } - } - - /** - * @param {{ headers: [string,string][], method: string, url: string, baseUrl: string | null, clientRid: number | null, hasBody: boolean }} args + * @param {{ method: string, url: string, headers: [string, string][], clientRid: number | null, hasBody: boolean }} args * @param {Uint8Array | null} body - * @returns {{requestRid: number, requestBodyRid: number | null}} + * @returns {{ requestRid: number, requestBodyRid: number | null }} */ function opFetch(args, body) { - let zeroCopy; - if (body != null) { - zeroCopy = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); - } - return core.opSync("op_fetch", args, zeroCopy); + return core.opSync("op_fetch", args, body); } /** - * @param {number} rid - * @returns {Promise<{status: number, statusText: string, headers: Record, url: string, responseRid: number}>} + * @param {number} rid + * @returns {Promise<{ status: number, statusText: string, headers: [string, string][], url: string, responseRid: number }>} */ function opFetchSend(rid) { return core.opAsync("op_fetch_send", rid); @@ -515,631 +54,254 @@ * @returns {Promise} */ function opFetchRequestWrite(rid, body) { - const zeroCopy = new Uint8Array( - body.buffer, - body.byteOffset, - body.byteLength, - ); - return core.opAsync("op_fetch_request_write", rid, zeroCopy); - } - - const NULL_BODY_STATUS = [101, 204, 205, 304]; - const REDIRECT_STATUS = [301, 302, 303, 307, 308]; - - /** - * @param {string} s - * @returns {string} - */ - function byteUpperCase(s) { - return String(s).replace(/[a-z]/g, function byteUpperCaseReplace(c) { - return c.toUpperCase(); - }); + return core.opAsync("op_fetch_request_write", rid, body); } /** - * @param {string} m - * @returns {boolean} + * @param {number} rid + * @param {Uint8Array} body + * @returns {Promise} */ - function isKnownMethod(m) { - return ( - m === "DELETE" || - m === "GET" || - m === "HEAD" || - m === "OPTIONS" || - m === "POST" || - m === "PUT" - ); + function opFetchResponseRead(rid, body) { + return core.opAsync("op_fetch_response_read", rid, body); } /** - * @param {string} m - * @returns {string} + * @param {number} responseBodyRid + * @returns {ReadableStream} */ - function normalizeMethod(m) { - // Fast path for already valid methods - if (isKnownMethod(m)) { - return m; - } - // Normalize lower case (slowpath and should be avoided ...) - const u = byteUpperCase(m); - if (isKnownMethod(u)) { - return u; - } - // Otherwise passthrough - return m; - } - - class Request extends Body { - /** @type {string} */ - #method = "GET"; - /** @type {string} */ - #url = ""; - /** @type {Headers | string[][]} */ - #headers; - /** @type {"include" | "omit" | "same-origin" | undefined} */ - #credentials = "omit"; - - /** - * @param {RequestInfo} input - * @param {RequestInit} init - */ - // @ts-expect-error because the use of super in this constructor is valid. - constructor(input, init) { - if (arguments.length < 1) { - throw TypeError("Not enough arguments"); - } - - if (!init) { - init = {}; - } - - let b; - - // prefer body from init - if (init.body) { - b = init.body; - } else if (input instanceof Request) { - if (input.bodyUsed) { - throw TypeError(BodyUsedError); - } - b = input[teeBody](); - } else if (typeof input === "object" && "body" in input && input.body) { - if (input.bodyUsed) { - throw TypeError(BodyUsedError); - } - b = input.body; - } else { - b = ""; - } - - let headers; - let contentType = ""; - // prefer headers from init - if (init.headers) { - if (init[lazyHeaders] && Array.isArray(init.headers)) { - // Trust the headers are valid, and only put them into the `Headers` - // strucutre when the user accesses the property. We also assume that - // all passed headers are lower-case (as is the case when they come - // from hyper in Rust), and that headers are of type - // `[string, string][]`. - headers = init.headers; - for (const tuple of headers) { - if (tuple[0] === "content-type") { - contentType = tuple[1]; - } + function createResponseBodyStream(responseBodyRid) { + return new ReadableStream({ + type: "bytes", + async pull(controller) { + try { + // This is the largest possible size for a single packet on a TLS + // stream. + const chunk = new Uint8Array(16 * 1024 + 256); + const read = await opFetchResponseRead( + responseBodyRid, + chunk, + ); + if (read > 0) { + // We read some data. Enqueue it onto the stream. + controller.enqueue(chunk.subarray(0, read)); + } else { + // We have reached the end of the body, so we close the stream. + controller.close(); + core.close(responseBodyRid); } - } else { - headers = new Headers(init.headers); - contentType = headers.get("content-type") || ""; + } catch (err) { + // There was an error while reading a chunk of the body, so we + // error. + controller.error(err); + controller.close(); + core.close(responseBodyRid); } - } else if (input instanceof Request) { - headers = input.headers; - contentType = headers.get("content-type") || ""; - } else { - headers = new Headers(); - } - - super(b, { contentType }); - this.#headers = headers; - - if (input instanceof Request) { - if (input.bodyUsed) { - throw TypeError(BodyUsedError); - } - // headers are already set above. no reason to do it again - this.#method = input.method; - this.#url = input.url; - this.#credentials = input.credentials; - } else { - // Constructing a URL just for validation is known to be expensive. - // dontValidateUrl allows one to opt out. - if (init[dontValidateUrl]) { - this.#url = input; - } else { - const baseUrl = getLocationHref(); - this.#url = baseUrl != null - ? new URL(String(input), baseUrl).href - : new URL(String(input)).href; - } - } - - if (init && "method" in init && init.method) { - this.#method = normalizeMethod(init.method); - } - - if ( - init && - "credentials" in init && - init.credentials && - ["omit", "same-origin", "include"].indexOf(init.credentials) !== -1 - ) { - this.credentials = init.credentials; - } - } - - clone() { - if (this.bodyUsed) { - throw TypeError(BodyUsedError); - } - - const iterators = this.headers.entries(); - const headersList = []; - for (const header of iterators) { - headersList.push(header); - } - - const body = this[teeBody](); - - return new Request(this.url, { - body, - method: this.method, - headers: new Headers(headersList), - credentials: this.credentials, - }); - } - - get method() { - return this.#method; - } - - set method(_) { - // can not set method - } - - get url() { - return this.#url; - } - - set url(_) { - // can not set url - } - - get headers() { - if (!(this.#headers instanceof Headers)) { - this.#headers = new Headers(this.#headers); - } - return this.#headers; - } - - set headers(_) { - // can not set headers - } - - get credentials() { - return this.#credentials; - } - - set credentials(_) { - // can not set credentials - } - } - - const responseData = new WeakMap(); - class Response extends Body { - /** - * @param {BodyInit | null} body - * @param {ResponseInit} [init] - */ - constructor(body = null, init) { - init = init ?? {}; - - if (typeof init !== "object") { - throw new TypeError(`'init' is not an object`); - } - - const extraInit = responseData.get(init) || {}; - let { type = "default", url = "" } = extraInit; - - let status = init.status === undefined ? 200 : Number(init.status || 0); - let statusText = init.statusText ?? ""; - let headers = init.headers instanceof Headers - ? init.headers - : new Headers(init.headers); - - if (init.status !== undefined && (status < 200 || status > 599)) { - throw new RangeError( - `The status provided (${init.status}) is outside the range [200, 599]`, - ); - } - - // null body status - if (body && NULL_BODY_STATUS.includes(status)) { - throw new TypeError("Response with null body status cannot have body"); - } - - if (!type) { - type = "default"; - } else { - if (type == "error") { - // spec: https://fetch.spec.whatwg.org/#concept-network-error - status = 0; - statusText = ""; - headers = new Headers(); - body = null; - /* spec for other Response types: - https://fetch.spec.whatwg.org/#concept-filtered-response-basic - Please note that type "basic" is not the same thing as "default".*/ - } else if (type == "basic") { - for (const h of headers) { - /* Forbidden Response-Header Names: - https://fetch.spec.whatwg.org/#forbidden-response-header-name */ - if (["set-cookie", "set-cookie2"].includes(h[0].toLowerCase())) { - headers.delete(h[0]); - } - } - } else if (type == "cors") { - /* CORS-safelisted Response-Header Names: - https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name */ - const allowedHeaders = [ - "Cache-Control", - "Content-Language", - "Content-Length", - "Content-Type", - "Expires", - "Last-Modified", - "Pragma", - ].map((c) => c.toLowerCase()); - for (const h of headers) { - /* Technically this is still not standards compliant because we are - supposed to allow headers allowed in the - 'Access-Control-Expose-Headers' header in the 'internal response' - However, this implementation of response doesn't seem to have an - easy way to access the internal response, so we ignore that - header. - TODO(serverhiccups): change how internal responses are handled - so we can do this properly. */ - if (!allowedHeaders.includes(h[0].toLowerCase())) { - headers.delete(h[0]); - } - } - /* TODO(serverhiccups): Once I fix the 'internal response' thing, - these actually need to treat the internal response differently */ - } else if (type == "opaque" || type == "opaqueredirect") { - url = ""; - status = 0; - statusText = ""; - headers = new Headers(); - body = null; - } - } - - const contentType = headers.get("content-type") || ""; - const size = Number(headers.get("content-length")) || undefined; - - super(body, { contentType, size }); - - this.url = url; - this.statusText = statusText; - this.status = extraInit.status || status; - this.headers = headers; - this.redirected = extraInit.redirected || false; - this.type = type; - } - - get ok() { - return 200 <= this.status && this.status < 300; - } - - clone() { - if (this.bodyUsed) { - throw TypeError(BodyUsedError); - } - - const iterators = this.headers.entries(); - const headersList = []; - for (const header of iterators) { - headersList.push(header); - } - - const body = this[teeBody](); - - return new Response(body, { - status: this.status, - statusText: this.statusText, - headers: new Headers(headersList), - }); - } - - /** - * @param {string } url - * @param {number} status - */ - static redirect(url, status = 302) { - if (![301, 302, 303, 307, 308].includes(status)) { - throw new RangeError( - "The redirection status must be one of 301, 302, 303, 307 and 308.", - ); - } - return new Response(null, { - status, - statusText: "", - headers: [["Location", String(url)]], - }); - } - } - - /** @type {string | null} */ - let baseUrl = null; - - /** @param {string} href */ - function setBaseUrl(href) { - baseUrl = href; - } - - /** - * @param {string} url - * @param {string} method - * @param {Headers} headers - * @param {ReadableStream | ArrayBufferView | undefined} body - * @param {number | null} clientRid - * @returns {Promise<{status: number, statusText: string, headers: Record, url: string, responseRid: number}>} - */ - async function sendFetchReq(url, method, headers, body, clientRid) { - /** @type {[string, string][]} */ - let headerArray = []; - if (headers) { - headerArray = Array.from(headers.entries()); - } - - const { requestRid, requestBodyRid } = opFetch( - { - method, - url, - baseUrl, - headers: headerArray, - clientRid, - hasBody: !!body, }, - body instanceof Uint8Array ? body : null, - ); - if (requestBodyRid) { - if (!(body instanceof ReadableStream)) { - throw new TypeError("Unreachable state (body is not ReadableStream)."); - } - const writer = new WritableStream({ - /** - * @param {Uint8Array} chunk - * @param {WritableStreamDefaultController} controller - */ - async write(chunk, controller) { - try { - await opFetchRequestWrite(requestBodyRid, chunk); - } catch (err) { - controller.error(err); - } - }, - close() { - core.close(requestBodyRid); - }, - }); - body.pipeTo(writer); - } - - return await opFetchSend(requestRid); + cancel() { + core.close(responseBodyRid); + }, + }); } /** - * @param {Request | URL | string} input - * @param {RequestInit & {client: Deno.HttpClient}} [init] - * @returns {Promise} + * @param {InnerRequest} req + * @param {boolean} recursive + * @returns {Promise} */ - async function fetch(input, init) { - /** @type {string | null} */ - let url; - let method = null; - let headers = null; - let body; - let clientRid = null; - let redirected = false; - let remRedirectCount = 20; // TODO(bartlomieju): use a better way to handle - - if (typeof input === "string" || input instanceof URL) { - url = typeof input === "string" ? input : input.href; - if (init != null) { - method = init.method || null; - if (init.headers) { - headers = init.headers instanceof Headers - ? init.headers - : new Headers(init.headers); + async function mainFetch(req, recursive) { + /** @type {ReadableStream | Uint8Array | null} */ + let reqBody = null; + if (req.body !== null) { + if (req.body.streamOrStatic instanceof ReadableStream) { + if (req.body.length === null) { + reqBody = req.body.stream; } else { - headers = null; - } - - // ref: https://fetch.spec.whatwg.org/#body-mixin - // Body should have been a mixin - // but we are treating it as a separate class - if (init.body) { - if (!headers) { - headers = new Headers(); - } - let contentType = ""; - if (typeof init.body === "string") { - body = new TextEncoder().encode(init.body); - contentType = "text/plain;charset=UTF-8"; - } else if (isTypedArray(init.body)) { - body = init.body; - } else if (init.body instanceof ArrayBuffer) { - body = new Uint8Array(init.body); - } else if (init.body instanceof URLSearchParams) { - body = new TextEncoder().encode(init.body.toString()); - contentType = "application/x-www-form-urlencoded;charset=UTF-8"; - } else if (init.body instanceof Blob) { - body = init.body[_byteSequence]; - contentType = init.body.type; - } else if (init.body instanceof FormData) { - const res = encodeFormData(init.body); - body = res.body; - contentType = res.contentType; - } else if (init.body instanceof ReadableStream) { - body = init.body; - } - if (contentType && !headers.has("content-type")) { - headers.set("content-type", contentType); - } - } - - if (init.client instanceof HttpClient) { - clientRid = init.client.rid; + const reader = req.body.stream.getReader(); + const r1 = await reader.read(); + if (r1.done) throw new TypeError("Unreachable"); + reqBody = r1.value; + const r2 = await reader.read(); + if (!r2.done) throw new TypeError("Unreachable"); } + } else { + req.body.streamOrStatic.consumed = true; + reqBody = req.body.streamOrStatic.body; } + } + + const { requestRid, requestBodyRid } = opFetch({ + method: req.method, + url: req.currentUrl(), + headers: req.headerList, + clientRid: req.clientRid, + hasBody: reqBody !== null, + }, reqBody instanceof Uint8Array ? reqBody : null); + + if (requestBodyRid !== null) { + if (reqBody === null || !(reqBody instanceof ReadableStream)) { + throw new TypeError("Unreachable"); + } + const reader = reqBody.getReader(); + (async () => { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + await reader.cancel("value not a Uint8Array"); + break; + } + try { + await opFetchRequestWrite(requestBodyRid, value); + } catch (err) { + await reader.cancel(err); + break; + } + } + core.close(requestBodyRid); + })(); + } + + const resp = await opFetchSend(requestRid); + /** @type {InnerResponse} */ + const response = { + headerList: resp.headers, + status: resp.status, + body: null, + statusMessage: resp.statusText, + type: "basic", + url() { + if (this.urlList.length == 0) return null; + return this.urlList[this.urlList.length - 1]; + }, + urlList: req.urlList, + }; + if (redirectStatus(resp.status)) { + switch (req.redirectMode) { + case "error": + core.close(resp.responseRid); + return networkError( + "Encountered redirect while redirect mode is set to 'error'", + ); + case "follow": + core.close(resp.responseRid); + return httpRedirectFetch(req, response); + case "manual": + break; + } + } + + if (nullBodyStatus(response.status)) { + core.close(resp.responseRid); } else { - url = input.url; - method = input.method; - headers = input.headers; - - if (input.body) { - body = input.body; - } + response.body = new InnerBody(createResponseBodyStream(resp.responseRid)); } - let responseBody; - let responseInit = {}; - while (remRedirectCount) { - const fetchResp = await sendFetchReq( - url, - method ?? "GET", - headers ?? new Headers(), - body, - clientRid, - ); - const rid = fetchResp.responseRid; + if (recursive) return response; - if ( - NULL_BODY_STATUS.includes(fetchResp.status) || - REDIRECT_STATUS.includes(fetchResp.status) - ) { - // We won't use body of received response, so close it now - // otherwise it will be kept in resource table. - core.close(rid); - responseBody = null; - } else { - responseBody = new ReadableStream({ - type: "bytes", - /** @param {ReadableStreamDefaultController} controller */ - async pull(controller) { - try { - const chunk = new Uint8Array(16 * 1024 + 256); - const read = await core.opAsync( - "op_fetch_response_read", - rid, - chunk, - ); - if (read != 0) { - if (chunk.length == read) { - controller.enqueue(chunk); - } else { - controller.enqueue(chunk.subarray(0, read)); - } - } else { - controller.close(); - core.close(rid); - } - } catch (e) { - controller.error(e); - controller.close(); - core.close(rid); - } - }, - cancel() { - // When reader.cancel() is called - core.close(rid); - }, - }); - } - - responseInit = { - status: 200, - statusText: fetchResp.statusText, - headers: fetchResp.headers, - }; - - responseData.set(responseInit, { - redirected, - rid: fetchResp.responseRid, - status: fetchResp.status, - url: fetchResp.url, - }); - - const response = new Response(responseBody, responseInit); - - if (REDIRECT_STATUS.includes(fetchResp.status)) { - // We're in a redirect status - switch ((init && init.redirect) || "follow") { - case "error": - responseInit = {}; - responseData.set(responseInit, { - type: "error", - redirected: false, - url: "", - }); - return new Response(null, responseInit); - case "manual": - // On the web this would return a `opaqueredirect` response, but - // those don't make sense server side. See denoland/deno#8351. - return response; - case "follow": - // fallthrough - default: { - /** @type {string | null} */ - let redirectUrl = response.headers.get("Location"); - if (redirectUrl == null) { - return response; // Unspecified - } - if ( - !redirectUrl.startsWith("http://") && - !redirectUrl.startsWith("https://") - ) { - redirectUrl = new URL(redirectUrl, fetchResp.url).href; - } - url = redirectUrl; - redirected = true; - remRedirectCount--; - } - } - } else { - return response; - } + if (response.urlList.length === 0) { + response.urlList = [...req.urlList]; } - responseData.set(responseInit, { - type: "error", - redirected: false, - url: "", - }); - - return new Response(null, responseInit); + return response; } - window.__bootstrap.fetch = { - FormData, - setBaseUrl, - fetch, - Request, - Response, - HttpClient, - createHttpClient, - fastBody, - dontValidateUrl, - lazyHeaders, - }; + /** + * @param {InnerRequest} request + * @param {InnerResponse} response + * @returns {Promise} + */ + function httpRedirectFetch(request, response) { + const locationHeaders = response.headerList.filter((entry) => + byteLowerCase(entry[0]) === "location" + ); + if (locationHeaders.length === 0) { + return response; + } + const locationURL = new URL( + locationHeaders[0][1], + response.url() ?? undefined, + ); + if (locationURL.hash === "") { + locationURL.hash = request.currentUrl().hash; + } + if (locationURL.protocol !== "https:" && locationURL.protocol !== "http:") { + return networkError("Can not redirect to a non HTTP(s) url"); + } + if (request.redirectCount === 20) { + return networkError("Maximum number of redirects (20) reached"); + } + request.redirectCount++; + if ( + response.status !== 303 && request.body !== null && + request.body.source === null + ) { + return networkError( + "Can not redeliver a streaming request body after a redirect", + ); + } + if ( + ((response.status === 301 || response.status === 302) && + request.method === "POST") || + (response.status === 303 && + (request.method !== "GET" && request.method !== "HEAD")) + ) { + request.method = "GET"; + request.body = null; + for (let i = 0; i < request.headerList.length; i++) { + if ( + REQUEST_BODY_HEADER_NAMES.includes( + byteLowerCase(request.headerList[i][0]), + ) + ) { + request.headerList.splice(i, 1); + i--; + } + } + } + if (request.body !== null) { + const res = extractBody(request.body.source); + request.body = res.body; + } + request.urlList.push(locationURL.href); + return mainFetch(request, true); + } + + /** + * @param {RequestInfo} input + * @param {RequestInit} init + */ + async function fetch(input, init = {}) { + const prefix = "Failed to call 'fetch'"; + input = webidl.converters["RequestInfo"](input, { + prefix, + context: "Argument 1", + }); + init = webidl.converters["RequestInit"](init, { + prefix, + context: "Argument 2", + }); + + // 1. + const requestObject = new Request(input, init); + // 2. + const request = toInnerRequest(requestObject); + // 10. + if (!requestObject.headers.has("Accept")) { + request.headerList.push(["Accept", "*/*"]); + } + + // 12. + const response = await mainFetch(request, false); + if (response.type === "error") { + throw new TypeError( + "Fetch failed: " + (response.error ?? "unknown error"), + ); + } + + return fromInnerResponse(response, "immutable"); + } + + window.__bootstrap.fetch ??= {}; + window.__bootstrap.fetch.fetch = fetch; })(this); diff --git a/op_crates/fetch/internal.d.ts b/op_crates/fetch/internal.d.ts index 3206008c5f..86de527613 100644 --- a/op_crates/fetch/internal.d.ts +++ b/op_crates/fetch/internal.d.ts @@ -15,22 +15,99 @@ declare namespace globalThis { DomIterableMixin(base: any, dataSymbol: symbol): any; }; - declare var headers: { - Headers: typeof Headers; - }; + declare namespace headers { + class Headers { + } + type HeaderList = [string, string][]; + function headersFromHeaderList( + list: HeaderList, + guard: + | "immutable" + | "request" + | "request-no-cors" + | "response" + | "none", + ): Headers; + function headerListFromHeaders(headers: Headers): HeaderList; + function fillHeaders(headers: Headers, object: HeadersInit): void; + function getDecodeSplitHeader( + list: HeaderList, + name: string, + ): string[] | null; + function guardFromHeaders( + headers: Headers, + ): "immutable" | "request" | "request-no-cors" | "response" | "none"; + } - declare var formData: { - FormData: typeof FormData; - encodeFormData(formdata: FormData): { + declare namespace formData { + declare type FormData = typeof FormData; + declare function encodeFormData(formdata: FormData): { body: Uint8Array; contentType: string; }; - parseFormData(body: Uint8Array, boundary: string | undefined): FormData; - }; + declare function parseFormData( + body: Uint8Array, + boundary: string | undefined, + ): FormData; + declare function formDataFromEntries(entries: FormDataEntry[]): FormData; + } declare var streams: { ReadableStream: typeof ReadableStream; isReadableStreamDisturbed(stream: ReadableStream): boolean; }; + + declare namespace fetchBody { + function mixinBody( + prototype: any, + bodySymbol: symbol, + mimeTypeSymbol: symbol, + ): void; + class InnerBody { + constructor(stream?: ReadableStream); + stream: ReadableStream; + source: null | Uint8Array | Blob | FormData; + length: null | number; + unusable(): boolean; + consume(): Promise; + clone(): InnerBody; + } + function extractBody(object: BodyInit): { + body: InnerBody; + contentType: string | null; + }; + } + + declare namespace fetch { + function toInnerRequest(request: Request): InnerRequest; + function fromInnerRequest( + inner: InnerRequest, + guard: + | "request" + | "immutable" + | "request-no-cors" + | "response" + | "none", + ): Request; + function redirectStatus(status: number): boolean; + function nullBodyStatus(status: number): boolean; + function newInnerRequest( + method: string, + url: any, + headerList?: [string, string][], + body?: globalThis.__bootstrap.fetchBody.InnerBody, + ): InnerResponse; + function toInnerResponse(response: Response): InnerResponse; + function fromInnerResponse( + inner: InnerResponse, + guard: + | "request" + | "immutable" + | "request-no-cors" + | "response" + | "none", + ): Response; + function networkError(error: string): InnerResponse; + } } } diff --git a/op_crates/fetch/lib.rs b/op_crates/fetch/lib.rs index 030f8a8097..41fb153e00 100644 --- a/op_crates/fetch/lib.rs +++ b/op_crates/fetch/lib.rs @@ -70,13 +70,29 @@ pub fn init(isolate: &mut JsRuntime) { "deno:op_crates/fetch/21_formdata.js", include_str!("21_formdata.js"), ), + ( + "deno:op_crates/fetch/22_body.js", + include_str!("22_body.js"), + ), + ( + "deno:op_crates/fetch/22_http_client.js", + include_str!("22_http_client.js"), + ), + ( + "deno:op_crates/fetch/23_request.js", + include_str!("23_request.js"), + ), + ( + "deno:op_crates/fetch/23_response.js", + include_str!("23_response.js"), + ), ( "deno:op_crates/fetch/26_fetch.js", include_str!("26_fetch.js"), ), ]; for (url, source_code) in files { - isolate.execute(url, source_code).unwrap(); + isolate.execute(url, source_code).expect(url); } } @@ -110,9 +126,8 @@ pub fn get_declaration() -> PathBuf { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct FetchArgs { - method: Option, + method: String, url: String, - base_url: Option, headers: Vec<(String, String)>, client_rid: Option, has_body: bool, @@ -144,18 +159,8 @@ where client.clone() }; - let method = match args.method { - Some(method_str) => Method::from_bytes(method_str.as_bytes())?, - None => Method::GET, - }; - - let base_url = match args.base_url { - Some(base_url) => Some(Url::parse(&base_url)?), - _ => None, - }; - let url = Url::options() - .base_url(base_url.as_ref()) - .parse(&args.url)?; + let method = Method::from_bytes(args.method.as_bytes())?; + let url = Url::parse(&args.url)?; // Check scheme before asking for net permission let scheme = url.scheme(); diff --git a/op_crates/url/00_url.js b/op_crates/url/00_url.js index 7c24a871ae..f51b4aedca 100644 --- a/op_crates/url/00_url.js +++ b/op_crates/url/00_url.js @@ -391,8 +391,19 @@ } } + /** + * This function implements application/x-www-form-urlencoded parsing. + * https://url.spec.whatwg.org/#concept-urlencoded-parser + * @param {Uint8Array} bytes + * @returns {[string, string][]} + */ + function parseUrlEncoded(bytes) { + return core.opSync("op_url_parse_search_params", null, bytes); + } + window.__bootstrap.url = { URL, URLSearchParams, + parseUrlEncoded, }; })(this); diff --git a/op_crates/url/Cargo.toml b/op_crates/url/Cargo.toml index ab3ac9e1dc..a67b59d7fe 100644 --- a/op_crates/url/Cargo.toml +++ b/op_crates/url/Cargo.toml @@ -16,6 +16,7 @@ path = "lib.rs" [dependencies] deno_core = { version = "0.84.0", path = "../../core" } idna = "0.2.2" +percent-encoding = "2.1.0" serde = { version = "1.0.125", features = ["derive"] } [dev-dependencies] diff --git a/op_crates/url/internal.d.ts b/op_crates/url/internal.d.ts index f852928d35..ec2c2688ce 100644 --- a/op_crates/url/internal.d.ts +++ b/op_crates/url/internal.d.ts @@ -8,6 +8,7 @@ declare namespace globalThis { declare var url: { URL: typeof URL; URLSearchParams: typeof URLSearchParams; + parseUrlEncoded(bytes: Uint8Array): [string, string][]; }; } } diff --git a/op_crates/url/lib.rs b/op_crates/url/lib.rs index f216768c33..04663e411a 100644 --- a/op_crates/url/lib.rs +++ b/op_crates/url/lib.rs @@ -118,14 +118,21 @@ pub fn op_url_parse( pub fn op_url_parse_search_params( _state: &mut deno_core::OpState, - args: String, - _zero_copy: Option, + args: Option, + zero_copy: Option, ) -> Result, AnyError> { - let search_params: Vec<_> = form_urlencoded::parse(args.as_bytes()) - .into_iter() - .map(|(k, v)| (k.as_ref().to_owned(), v.as_ref().to_owned())) - .collect(); - Ok(search_params) + let params = match (args, zero_copy) { + (None, Some(zero_copy)) => form_urlencoded::parse(&zero_copy) + .into_iter() + .map(|(k, v)| (k.as_ref().to_owned(), v.as_ref().to_owned())) + .collect(), + (Some(args), None) => form_urlencoded::parse(args.as_bytes()) + .into_iter() + .map(|(k, v)| (k.as_ref().to_owned(), v.as_ref().to_owned())) + .collect(), + _ => return Err(type_error("invalid parameters")), + }; + Ok(params) } pub fn op_url_stringify_search_params( diff --git a/op_crates/web/00_infra.js b/op_crates/web/00_infra.js index ff9cb7cd47..bc87c8217f 100644 --- a/op_crates/web/00_infra.js +++ b/op_crates/web/00_infra.js @@ -46,6 +46,15 @@ const HTTP_QUOTED_STRING_TOKEN_POINT_RE = new RegExp( `^[${regexMatcher(HTTP_QUOTED_STRING_TOKEN_POINT)}]+$`, ); + const HTTP_TAB_OR_SPACE_MATCHER = regexMatcher(HTTP_TAB_OR_SPACE); + const HTTP_TAB_OR_SPACE_PREFIX_RE = new RegExp( + `^[${HTTP_TAB_OR_SPACE_MATCHER}]+`, + "g", + ); + const HTTP_TAB_OR_SPACE_SUFFIX_RE = new RegExp( + `[${HTTP_TAB_OR_SPACE_MATCHER}]+$`, + "g", + ); const HTTP_WHITESPACE_MATCHER = regexMatcher(HTTP_WHITESPACE); const HTTP_WHITESPACE_PREFIX_RE = new RegExp( `^[${HTTP_WHITESPACE_MATCHER}]+`, @@ -113,6 +122,62 @@ }); } + /** + * https://fetch.spec.whatwg.org/#collect-an-http-quoted-string + * @param {string} input + * @param {number} position + * @param {boolean} extractValue + * @returns {{result: string, position: number}} + */ + function collectHttpQuotedString(input, position, extractValue) { + // 1. + const positionStart = position; + // 2. + let value = ""; + // 3. + if (input[position] !== "\u0022") throw new Error('must be "'); + // 4. + position++; + // 5. + while (true) { + // 5.1. + const res = collectSequenceOfCodepoints( + input, + position, + (c) => c !== "\u0022" && c !== "\u005C", + ); + value += res.result; + position = res.position; + // 5.2. + if (position >= input.length) break; + // 5.3. + const quoteOrBackslash = input[position]; + // 5.4. + position++; + // 5.5. + if (quoteOrBackslash === "\u005C") { + // 5.5.1. + if (position >= input.length) { + value += "\u005C"; + break; + } + // 5.5.2. + value += input[position]; + // 5.5.3. + position++; + } else { // 5.6. + // 5.6.1 + if (quoteOrBackslash !== "\u0022") throw new Error('must be "'); + // 5.6.2 + break; + } + } + // 6. + if (extractValue) return { result: value, position }; + // 7. + return { result: input.substring(positionStart, position + 1), position }; + } + window.__bootstrap.infra = { collectSequenceOfCodepoints, ASCII_DIGIT, @@ -126,10 +191,13 @@ HTTP_TOKEN_CODE_POINT_RE, HTTP_QUOTED_STRING_TOKEN_POINT, HTTP_QUOTED_STRING_TOKEN_POINT_RE, + HTTP_TAB_OR_SPACE_PREFIX_RE, + HTTP_TAB_OR_SPACE_SUFFIX_RE, HTTP_WHITESPACE_PREFIX_RE, HTTP_WHITESPACE_SUFFIX_RE, regexMatcher, byteUpperCase, byteLowerCase, + collectHttpQuotedString, }; })(globalThis); diff --git a/op_crates/web/01_mimesniff.js b/op_crates/web/01_mimesniff.js index 534e39c313..077a40df11 100644 --- a/op_crates/web/01_mimesniff.js +++ b/op_crates/web/01_mimesniff.js @@ -15,64 +15,9 @@ HTTP_WHITESPACE_SUFFIX_RE, HTTP_QUOTED_STRING_TOKEN_POINT_RE, HTTP_TOKEN_CODE_POINT_RE, + collectHttpQuotedString, } = window.__bootstrap.infra; - /** - * https://fetch.spec.whatwg.org/#collect-an-http-quoted-string - * @param {string} input - * @param {number} position - * @param {boolean} extractValue - * @returns {{result: string, position: number}} - */ - function collectHttpQuotedString(input, position, extractValue) { - // 1. - const positionStart = position; - // 2. - let value = ""; - // 3. - if (input[position] !== "\u0022") throw new Error('must be "'); - // 4. - position++; - // 5. - while (true) { - // 5.1. - const res = collectSequenceOfCodepoints( - input, - position, - (c) => c !== "\u0022" && c !== "\u005C", - ); - value += res.result; - position = res.position; - // 5.2. - if (position >= input.length) break; - // 5.3. - const quoteOrBackslash = input[position]; - // 5.4. - position++; - // 5.5. - if (quoteOrBackslash === "\u005C") { - // 5.5.1. - if (position >= input.length) { - value += "\u005C"; - break; - } - // 5.5.2. - value += input[position]; - // 5.5.3. - position++; - } else { // 5.6. - // 5.6.1 - if (input[position] !== "\u0022") throw new Error('must be "'); - // 5.6.2 - break; - } - } - // 6. - if (extractValue) return { result: value, position }; - // 7. - return { result: input.substring(positionStart, position + 1), position }; - } - /** * @typedef MimeType * @property {string} type @@ -172,7 +117,7 @@ let parameterValue = null; // 11.8. - if (input[position] == "\u0022") { + if (input[position] === "\u0022") { // 11.8.1. const res = collectHttpQuotedString(input, position, true); parameterValue = res.result; @@ -214,5 +159,32 @@ return mimeType; } - window.__bootstrap.mimesniff = { parseMimeType }; + /** + * @param {MimeType} mimeType + * @returns {string} + */ + function essence(mimeType) { + return `${mimeType.type}/${mimeType.subtype}`; + } + + /** + * @param {MimeType} mimeType + * @returns {string} + */ + function serializeMimeType(mimeType) { + let serialization = essence(mimeType); + for (const param of mimeType.parameters) { + serialization += `;${param[0]}=`; + let value = param[1]; + if (!HTTP_TOKEN_CODE_POINT_RE.test(value)) { + value = value.replaceAll("\\", "\\\\"); + value = value.replaceAll('"', '\\"'); + value = `"${value}"`; + } + serialization += value; + } + return serialization; + } + + window.__bootstrap.mimesniff = { parseMimeType, essence, serializeMimeType }; })(this); diff --git a/op_crates/web/03_abort_signal.js b/op_crates/web/03_abort_signal.js index 693b5342a6..b87a56ce3f 100644 --- a/op_crates/web/03_abort_signal.js +++ b/op_crates/web/03_abort_signal.js @@ -2,6 +2,7 @@ "use strict"; ((window) => { + const webidl = window.__bootstrap.webidl; const { setIsTrusted } = window.__bootstrap.event; const add = Symbol("add"); @@ -47,6 +48,7 @@ throw new TypeError("Illegal constructor."); } super(); + this[webidl.brand] = webidl.brand; } get aborted() { @@ -111,6 +113,11 @@ }); } + webidl.converters["AbortSignal"] = webidl.createInterfaceConverter( + "AbortSignal", + AbortSignal, + ); + window.AbortSignal = AbortSignal; window.AbortController = AbortController; window.__bootstrap = window.__bootstrap || {}; diff --git a/op_crates/web/internal.d.ts b/op_crates/web/internal.d.ts index a5b653218f..bfce3e1e1b 100644 --- a/op_crates/web/internal.d.ts +++ b/op_crates/web/internal.d.ts @@ -28,11 +28,21 @@ declare namespace globalThis { HTTP_TOKEN_CODE_POINT_RE: RegExp; HTTP_QUOTED_STRING_TOKEN_POINT: string[]; HTTP_QUOTED_STRING_TOKEN_POINT_RE: RegExp; + HTTP_TAB_OR_SPACE_PREFIX_RE: RegExp; + HTTP_TAB_OR_SPACE_SUFFIX_RE: RegExp; HTTP_WHITESPACE_PREFIX_RE: RegExp; HTTP_WHITESPACE_SUFFIX_RE: RegExp; regexMatcher(chars: string[]): string; byteUpperCase(s: string): string; byteLowerCase(s: string): string; + collectHttpQuotedString( + input: string, + position: number, + extractValue: boolean, + ): { + result: string; + position: number; + }; }; declare namespace mimesniff { @@ -42,6 +52,8 @@ declare namespace globalThis { parameters: Map; } declare function parseMimeType(input: string): MimeType | null; + declare function essence(mimeType: MimeType): string; + declare function serializeMimeType(mimeType: MimeType): string; } declare var eventTarget: { diff --git a/op_crates/webgpu/02_idl_types.js b/op_crates/webgpu/02_idl_types.js index bcc323893c..f990a40e82 100644 --- a/op_crates/webgpu/02_idl_types.js +++ b/op_crates/webgpu/02_idl_types.js @@ -135,7 +135,9 @@ converter: webidl.createSequenceConverter( webidl.converters["GPUFeatureName"], ), - defaultValue: [], + get defaultValue() { + return []; + }, }, { key: "nonGuaranteedLimits", @@ -143,7 +145,9 @@ webidl.converters["DOMString"], webidl.converters["GPUSize32"], ), - defaultValue: {}, + get defaultValue() { + return {}; + }, }, ]; webidl.converters["GPUDeviceDescriptor"] = webidl.createDictionaryConverter( @@ -1046,7 +1050,9 @@ webidl.converters["GPUVertexBufferLayout"], ), ), - defaultValue: [], + get defaultValue() { + return []; + }, }, ]; webidl.converters["GPUVertexState"] = webidl.createDictionaryConverter( @@ -1187,12 +1193,16 @@ { key: "stencilFront", converter: webidl.converters["GPUStencilFaceState"], - defaultValue: {}, + get defaultValue() { + return {}; + }, }, { key: "stencilBack", converter: webidl.converters["GPUStencilFaceState"], - defaultValue: {}, + get defaultValue() { + return {}; + }, }, { key: "stencilReadMask", @@ -1379,7 +1389,9 @@ { key: "primitive", converter: webidl.converters["GPUPrimitiveState"], - defaultValue: {}, + get defaultValue() { + return {}; + }, }, { key: "depthStencil", @@ -1388,7 +1400,9 @@ { key: "multisample", converter: webidl.converters["GPUMultisampleState"], - defaultValue: {}, + get defaultValue() { + return {}; + }, }, { key: "fragment", converter: webidl.converters["GPUFragmentState"] }, ]; @@ -1530,7 +1544,9 @@ { key: "origin", converter: webidl.converters["GPUOrigin3D"], - defaultValue: {}, + get defaultValue() { + return {}; + }, }, { key: "aspect", @@ -1793,7 +1809,9 @@ converter: webidl.createSequenceConverter( webidl.converters["GPUPipelineStatisticName"], ), - defaultValue: [], + get defaultValue() { + return []; + }, }, ]; webidl.converters["GPUQuerySetDescriptor"] = webidl.createDictionaryConverter( diff --git a/op_crates/webidl/00_webidl.js b/op_crates/webidl/00_webidl.js index 63946c9a18..6bf98be064 100644 --- a/op_crates/webidl/00_webidl.js +++ b/op_crates/webidl/00_webidl.js @@ -375,40 +375,12 @@ return V; } - const abByteLengthGetter = Object.getOwnPropertyDescriptor( - ArrayBuffer.prototype, - "byteLength", - ).get; - function isNonSharedArrayBuffer(V) { - try { - // This will throw on SharedArrayBuffers, but not detached ArrayBuffers. - // (The spec says it should throw, but the spec conflicts with implementations: https://github.com/tc39/ecma262/issues/678) - abByteLengthGetter.call(V); - - return true; - } catch { - return false; - } + return V instanceof ArrayBuffer; } - let sabByteLengthGetter; - function isSharedArrayBuffer(V) { - // TODO(lucacasonato): vulnerable to prototype pollution. Needs to happen - // here because SharedArrayBuffer is not available during snapshotting. - if (!sabByteLengthGetter) { - sabByteLengthGetter = Object.getOwnPropertyDescriptor( - SharedArrayBuffer.prototype, - "byteLength", - ).get; - } - try { - sabByteLengthGetter.call(V); - return true; - } catch { - return false; - } + return V instanceof SharedArrayBuffer; } function isArrayBufferDetached(V) { @@ -439,14 +411,8 @@ return V; }; - const dvByteLengthGetter = Object.getOwnPropertyDescriptor( - DataView.prototype, - "byteLength", - ).get; converters.DataView = (V, opts = {}) => { - try { - dvByteLengthGetter.call(V); - } catch (e) { + if (!(V instanceof DataView)) { throw makeException(TypeError, "is not a DataView", opts); } @@ -614,10 +580,19 @@ } } + function isEmptyObject(V) { + for (const _ in V) return false; + return true; + } + function createDictionaryConverter(name, ...dictionaries) { + let hasRequiredKey = false; const allMembers = []; for (const members of dictionaries) { for (const member of members) { + if (member.required) { + hasRequiredKey = true; + } allMembers.push(member); } } @@ -628,6 +603,29 @@ return a.key < b.key ? -1 : 1; }); + const defaultValues = {}; + for (const member of allMembers) { + if ("defaultValue" in member) { + const idlMemberValue = member.defaultValue; + const imvType = typeof idlMemberValue; + // Copy by value types can be directly assigned, copy by reference types + // need to be re-created for each allocation. + if ( + imvType === "number" || imvType === "boolean" || + imvType === "string" || imvType === "bigint" || + imvType === "undefined" + ) { + defaultValues[member.key] = idlMemberValue; + } else { + Object.defineProperty(defaultValues, member.key, { + get() { + return member.defaultValue; + }, + }); + } + } + } + return function (V, opts = {}) { const typeV = type(V); switch (typeV) { @@ -644,7 +642,14 @@ } const esDict = V; - const idlDict = {}; + const idlDict = { ...defaultValues }; + + // NOTE: fast path Null and Undefined and empty objects. + if ( + (V === undefined || V === null || isEmptyObject(V)) && !hasRequiredKey + ) { + return idlDict; + } for (const member of allMembers) { const key = member.key; @@ -656,20 +661,12 @@ esMemberValue = esDict[key]; } - const context = `'${key}' of '${name}'${ - opts.context ? ` (${opts.context})` : "" - }`; - if (esMemberValue !== undefined) { + const context = `'${key}' of '${name}'${ + opts.context ? ` (${opts.context})` : "" + }`; const converter = member.converter; - const idlMemberValue = converter(esMemberValue, { - ...opts, - context, - }); - idlDict[key] = idlMemberValue; - } else if ("defaultValue" in member) { - const defaultValue = member.defaultValue; - const idlMemberValue = defaultValue; + const idlMemberValue = converter(esMemberValue, { ...opts, context }); idlDict[key] = idlMemberValue; } else if (member.required) { throw makeException( diff --git a/runtime/js/40_http.js b/runtime/js/40_http.js index 45361ee347..67faf19cba 100644 --- a/runtime/js/40_http.js +++ b/runtime/js/40_http.js @@ -2,9 +2,9 @@ "use strict"; ((window) => { - const { Request, dontValidateUrl, lazyHeaders, fastBody, Response } = + const { InnerBody } = window.__bootstrap.fetchBody; + const { Response, fromInnerRequest, toInnerResponse, newInnerRequest } = window.__bootstrap.fetch; - const { Headers } = window.__bootstrap.headers; const errors = window.__bootstrap.errors.errors; const core = window.Deno.core; const { ReadableStream } = window.__bootstrap.streams; @@ -53,18 +53,18 @@ ] = nextRequest; /** @type {ReadableStream | undefined} */ - let body = undefined; + let body = null; if (typeof requestBodyRid === "number") { body = createRequestBodyStream(requestBodyRid); } - const request = new Request(url, { - body, + const innerRequest = newInnerRequest( method, - headers: headersList, - [dontValidateUrl]: true, - [lazyHeaders]: true, - }); + url, + headersList, + body !== null ? new InnerBody(body) : null, + ); + const request = fromInnerRequest(innerRequest, "immutable"); const respondWith = createRespondWith(responseSenderRid, this.#rid); @@ -96,16 +96,6 @@ ); } - /** IMPORTANT: Equivalent to `Array.from(headers).flat()` but more performant. - * Please preserve. */ - function flattenHeaders(headers) { - const array = []; - for (const pair of headers) { - array.push(pair[0], pair[1]); - } - return array; - } - function createRespondWith(responseSenderRid) { return async function respondWith(resp) { if (resp instanceof Promise) { @@ -117,46 +107,66 @@ "First argument to respondWith must be a Response or a promise resolving to a Response.", ); } - // If response body is Uint8Array it will be sent synchronously - // in a single op, in other case a "response body" resource will be - // created and we'll be streaming it. - const body = resp[fastBody](); - let zeroCopyBuf; - if (body instanceof ArrayBuffer) { - zeroCopyBuf = new Uint8Array(body); - } else if (!body) { - zeroCopyBuf = new Uint8Array(0); + + const innerResp = toInnerResponse(resp); + + // If response body length is known, it will be sent synchronously in a + // single op, in other case a "response body" resource will be created and + // we'll be streaming it. + /** @type {ReadableStream | Uint8Array | null} */ + let respBody = null; + if (innerResp.body !== null) { + if (innerResp.body.unusable()) throw new TypeError("Body is unusable."); + if (innerResp.body.streamOrStatic instanceof ReadableStream) { + if (innerResp.body.length === null) { + respBody = innerResp.body.stream; + } else { + const reader = innerResp.body.stream.getReader(); + const r1 = await reader.read(); + if (r1.done) throw new TypeError("Unreachable"); + respBody = r1.value; + const r2 = await reader.read(); + if (!r2.done) throw new TypeError("Unreachable"); + } + } else { + innerResp.body.streamOrStatic.consumed = true; + respBody = innerResp.body.streamOrStatic.body; + } } else { - zeroCopyBuf = null; + respBody = new Uint8Array(0); } const responseBodyRid = await Deno.core.opAsync("op_http_response", [ responseSenderRid, - resp.status ?? 200, - flattenHeaders(resp.headers), - ], zeroCopyBuf); + innerResp.status ?? 200, + innerResp.headerList, + ], respBody instanceof Uint8Array ? respBody : null); // If `respond` returns a responseBodyRid, we should stream the body // to that resource. - if (typeof responseBodyRid === "number") { - if (!body || !(body instanceof ReadableStream)) { - throw new Error( - "internal error: recieved responseBodyRid, but response has no body or is not a stream", - ); + if (responseBodyRid !== null) { + if (respBody === null || !(respBody instanceof ReadableStream)) { + throw new TypeError("Unreachable"); } - for await (const chunk of body) { - const data = new Uint8Array( - chunk.buffer, - chunk.byteOffset, - chunk.byteLength, - ); - await Deno.core.opAsync( - "op_http_response_write", - responseBodyRid, - data, - ); + const reader = respBody.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + await reader.cancel("value not a Uint8Array"); + break; + } + try { + await Deno.core.opAsync( + "op_http_response_write", + responseBodyRid, + value, + ); + } catch (err) { + await reader.cancel(err); + break; + } } - // Once all chunks are sent, and the request body is closed, we can close // the response body. await Deno.core.opAsync("op_http_response_close", responseBodyRid); diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index dd7e3793c6..7742e2ba24 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -421,7 +421,6 @@ delete Object.prototype.__proto__; if (locationHref != null) { location.setLocationHref(locationHref); - fetch.setBaseUrl(locationHref); } registerErrors(); @@ -488,7 +487,6 @@ delete Object.prototype.__proto__; runtimeOptions; location.setLocationHref(locationHref); - fetch.setBaseUrl(locationHref); registerErrors(); const internalSymbol = Symbol("Deno.internal"); diff --git a/runtime/ops/http.rs b/runtime/ops/http.rs index bdef145947..4d9787cdef 100644 --- a/runtime/ops/http.rs +++ b/runtime/ops/http.rs @@ -331,7 +331,7 @@ struct RespondArgs( // status: u16, // headers: - Vec, + Vec<(String, String)>, ); async fn op_http_response( @@ -358,11 +358,9 @@ async fn op_http_response( let mut builder = Response::builder().status(status); - debug_assert_eq!(headers.len() % 2, 0); - let headers_count = headers.len() / 2; - builder.headers_mut().unwrap().reserve(headers_count); - for i in 0..headers_count { - builder = builder.header(&headers[2 * i], &headers[2 * i + 1]); + builder.headers_mut().unwrap().reserve(headers.len()); + for (key, value) in &headers { + builder = builder.header(key, value); } let res; diff --git a/test_util/wpt b/test_util/wpt index 5796085849..5d9a0686bd 160000 --- a/test_util/wpt +++ b/test_util/wpt @@ -1 +1 @@ -Subproject commit 579608584916d582d38d0159666aae9a6aaf07ad +Subproject commit 5d9a0686bd51cc20df785fc013700c7b18fc0e0b diff --git a/tools/wpt/expectation.json b/tools/wpt/expectation.json index 5291b95f2a..bcb12ce93b 100644 --- a/tools/wpt/expectation.json +++ b/tools/wpt/expectation.json @@ -627,28 +627,7 @@ "Setting pathname with trailing U+001F (wpt++:)" ], "url-tojson.any.js": true, - "urlencoded-parser.any.js": [ - "request.formData() with input: test=", - "response.formData() with input: test=", - "request.formData() with input: †&†=x", - "response.formData() with input: †&†=x", - "request.formData() with input: _charset_=windows-1252&test=%C2x", - "response.formData() with input: _charset_=windows-1252&test=%C2x", - "request.formData() with input: %=a", - "response.formData() with input: %=a", - "request.formData() with input: %a=a", - "response.formData() with input: %a=a", - "request.formData() with input: %a_=a", - "response.formData() with input: %a_=a", - "request.formData() with input: id=0&value=%", - "response.formData() with input: id=0&value=%", - "request.formData() with input: b=%2sf%2a", - "response.formData() with input: b=%2sf%2a", - "request.formData() with input: b=%2%2af%2a", - "response.formData() with input: b=%2%2af%2a", - "request.formData() with input: b=%%2a", - "response.formData() with input: b=%%2a" - ], + "urlencoded-parser.any.js": true, "urlsearchparams-append.any.js": true, "urlsearchparams-constructor.any.js": [ "Construct with 2 unpaired surrogates (no trailing)", @@ -672,18 +651,16 @@ "fetch": { "api": { "request": { - "request-structure.any.js": [ - "Check destination attribute", - "Check referrer attribute", - "Check referrerPolicy attribute", - "Check mode attribute", - "Check credentials attribute", - "Check cache attribute", - "Check redirect attribute", - "Check integrity attribute", - "Check isReloadNavigation attribute", - "Check isHistoryNavigation attribute" - ] + "request-init-002.any.js": true, + "request-init-stream.any.js": [ + "Constructing a Request with a Request on which body.getReader() is called", + "Constructing a Request with a Request on which body.getReader().read() is called", + "Constructing a Request with a Request on which read() and releaseLock() are called" + ], + "request-consume-empty.any.js": [ + "Consume empty FormData request body as text" + ], + "request-consume.any.js": true }, "headers": { "headers-basic.any.js": true, @@ -693,12 +670,143 @@ "headers-normalize.any.js": true, "headers-record.any.js": true, "headers-structure.any.js": true + }, + "basic": { + "request-head.any.js": true, + "request-headers-case.any.js": false, + "request-headers-nonascii.any.js": false, + "request-headers.any.js": [ + "Fetch with PUT without body", + "Fetch with PUT with body", + "Fetch with POST without body", + "Fetch with POST with text body", + "Fetch with POST with FormData body", + "Fetch with POST with URLSearchParams body", + "Fetch with POST with Blob body", + "Fetch with POST with ArrayBuffer body", + "Fetch with POST with Uint8Array body", + "Fetch with POST with Int8Array body", + "Fetch with POST with Float32Array body", + "Fetch with POST with Float64Array body", + "Fetch with POST with DataView body", + "Fetch with POST with Blob body with mime type", + "Fetch with Chicken", + "Fetch with Chicken with body", + "Fetch with POST and mode \"same-origin\" needs an Origin header", + "Fetch with POST and mode \"no-cors\" needs an Origin header", + "Fetch with PUT and mode \"same-origin\" needs an Origin header", + "Fetch with TacO and mode \"same-origin\" needs an Origin header", + "Fetch with TacO and mode \"cors\" needs an Origin header" + ], + "text-utf8.any.js": true, + "accept-header.any.js": [ + "Request through fetch should have a 'accept-language' header" + ], + "conditional-get.any.js": false, + "error-after-response.any.js": false, + "header-value-combining.any.js": false, + "header-value-null-byte.any.js": true, + "historical.any.js": true, + "http-response-code.any.js": true, + "integrity.sub.any.js": [ + "Invalid integrity", + "Multiple integrities: invalid stronger than valid", + "Multiple integrities: both are invalid", + "CORS invalid integrity", + "Empty string integrity for opaque response" + ], + "request-upload.any.js": [ + "Fetch with POST with ReadableStream", + "Fetch with POST with ReadableStream containing String", + "Fetch with POST with ReadableStream containing null", + "Fetch with POST with ReadableStream containing number", + "Fetch with POST with ReadableStream containing ArrayBuffer", + "Fetch with POST with ReadableStream containing Blob", + "Fetch with POST with text body on 421 response should be retried once on new connection." + ], + "response-url.sub.any.js": true, + "scheme-about.any.js": true, + "scheme-blob.sub.any.js": true, + "scheme-data.any.js": false, + "scheme-others.sub.any.js": true, + "stream-response.any.js": true, + "stream-safe-creation.any.js": false + }, + "response": { + "json.any.js": true, + "response-init-001.any.js": true, + "response-init-002.any.js": true, + "response-static-error.any.js": true, + "response-static-redirect.any.js": true, + "response-stream-disturbed-1.any.js": true, + "response-stream-disturbed-2.any.js": true, + "response-stream-disturbed-3.any.js": true, + "response-stream-disturbed-4.any.js": true, + "response-stream-disturbed-5.any.js": true, + "response-stream-disturbed-6.any.js": true, + "response-stream-disturbed-by-pipe.any.js": true, + "response-stream-with-broken-then.any.js": [ + "Attempt to inject {done: false, value: bye} via Object.prototype.then.", + "Attempt to inject value: undefined via Object.prototype.then.", + "Attempt to inject undefined via Object.prototype.then.", + "Attempt to inject 8.2 via Object.prototype.then.", + "intercepting arraybuffer to text conversion via Object.prototype.then should not be possible" + ], + "response-error-from-stream.any.js": true, + "response-error.any.js": true, + "response-from-stream.any.js": true, + "response-cancel-stream.any.js": true, + "response-clone.any.js": [ + "Check response clone use structureClone for teed ReadableStreams (Int8Arraychunk)", + "Check response clone use structureClone for teed ReadableStreams (Int16Arraychunk)", + "Check response clone use structureClone for teed ReadableStreams (Int32Arraychunk)", + "Check response clone use structureClone for teed ReadableStreams (ArrayBufferchunk)", + "Check response clone use structureClone for teed ReadableStreams (Uint8Arraychunk)", + "Check response clone use structureClone for teed ReadableStreams (Uint8ClampedArraychunk)", + "Check response clone use structureClone for teed ReadableStreams (Uint16Arraychunk)", + "Check response clone use structureClone for teed ReadableStreams (Uint32Arraychunk)", + "Check response clone use structureClone for teed ReadableStreams (Float32Arraychunk)", + "Check response clone use structureClone for teed ReadableStreams (Float64Arraychunk)", + "Check response clone use structureClone for teed ReadableStreams (DataViewchunk)" + ], + "response-consume-empty.any.js": [ + "Consume empty FormData response body as text" + ], + "response-consume-stream.any.js": true + }, + "body": { + "mime-type.any.js": true + }, + "redirect": { + "redirect-count.any.js": true, + "redirect-empty-location.any.js": [ + "redirect response with empty Location, manual mode" + ], + "redirect-location.any.js": [ + "Redirect 301 in \"manual\" mode without location", + "Redirect 301 in \"manual\" mode with invalid location", + "Redirect 301 in \"manual\" mode with data location", + "Redirect 302 in \"manual\" mode without location", + "Redirect 302 in \"manual\" mode with invalid location", + "Redirect 302 in \"manual\" mode with data location", + "Redirect 303 in \"manual\" mode without location", + "Redirect 303 in \"manual\" mode with invalid location", + "Redirect 303 in \"manual\" mode with data location", + "Redirect 307 in \"manual\" mode without location", + "Redirect 307 in \"manual\" mode with invalid location", + "Redirect 307 in \"manual\" mode with data location", + "Redirect 308 in \"manual\" mode without location", + "Redirect 308 in \"manual\" mode with invalid location", + "Redirect 308 in \"manual\" mode with data location" + ], + "redirect-method.any.js": true, + "redirect-schemes.any.js": true, + "redirect-to-dataurl.any.js": true } }, "data-urls": { "base64.any.js": true, "processing.any.js": [ - "\"data://test:test/,X\"", "\"data:text/plain;a=\\\",\\\",X\"" ] } diff --git a/tools/wpt/runner.ts b/tools/wpt/runner.ts index 0ea14c5b72..972519d271 100644 --- a/tools/wpt/runner.ts +++ b/tools/wpt/runner.ts @@ -100,6 +100,7 @@ export async function runSingleTest( reporter(result); } else { stderr += line + "\n"; + console.error(stderr); } }