From 6ecc86cf2ae4bb0aac6f3d0e954382a69176b387 Mon Sep 17 00:00:00 2001 From: Luca Casonato Date: Thu, 28 Jan 2021 21:37:21 +0100 Subject: [PATCH] chore: add jsdoc to 26_fetch.js and enable some fetch tests (#9305) --- cli/dts/lib.deno.shared_globals.d.ts | 169 -------- cli/tests/unit/fetch_test.ts | 7 +- cli/tests/unit/form_data_test.ts | 2 +- cli/tests/unit/request_test.ts | 19 +- core/lib.deno_core.d.ts | 39 ++ op_crates/fetch/26_fetch.js | 554 +++++++++++++++++++++------ op_crates/fetch/internal.d.ts | 27 ++ op_crates/web/internal.d.ts | 16 + op_crates/web/lib.deno_web.d.ts | 169 ++++++++ runtime/js/99_main.js | 2 +- test_util/wpt | 2 +- tools/wpt/expectation.json | 28 +- 12 files changed, 728 insertions(+), 306 deletions(-) create mode 100644 core/lib.deno_core.d.ts create mode 100644 op_crates/fetch/internal.d.ts create mode 100644 op_crates/web/internal.d.ts diff --git a/cli/dts/lib.deno.shared_globals.d.ts b/cli/dts/lib.deno.shared_globals.d.ts index 785650c82f..52ad132f71 100644 --- a/cli/dts/lib.deno.shared_globals.d.ts +++ b/cli/dts/lib.deno.shared_globals.d.ts @@ -436,175 +436,6 @@ declare interface Crypto { ): T; } -declare class URLSearchParams { - constructor( - init?: string[][] | Record | string | URLSearchParams, - ); - static toString(): string; - - /** Appends a specified key/value pair as a new search parameter. - * - * ```ts - * let searchParams = new URLSearchParams(); - * searchParams.append('name', 'first'); - * searchParams.append('name', 'second'); - * ``` - */ - append(name: string, value: string): void; - - /** Deletes the given search parameter and its associated value, - * from the list of all search parameters. - * - * ```ts - * let searchParams = new URLSearchParams([['name', 'value']]); - * searchParams.delete('name'); - * ``` - */ - delete(name: string): void; - - /** Returns all the values associated with a given search parameter - * as an array. - * - * ```ts - * searchParams.getAll('name'); - * ``` - */ - getAll(name: string): string[]; - - /** Returns the first value associated to the given search parameter. - * - * ```ts - * searchParams.get('name'); - * ``` - */ - get(name: string): string | null; - - /** Returns a Boolean that indicates whether a parameter with the - * specified name exists. - * - * ```ts - * searchParams.has('name'); - * ``` - */ - has(name: string): boolean; - - /** Sets the value associated with a given search parameter to the - * given value. If there were several matching values, this method - * deletes the others. If the search parameter doesn't exist, this - * method creates it. - * - * ```ts - * searchParams.set('name', 'value'); - * ``` - */ - set(name: string, value: string): void; - - /** Sort all key/value pairs contained in this object in place and - * return undefined. The sort order is according to Unicode code - * points of the keys. - * - * ```ts - * searchParams.sort(); - * ``` - */ - sort(): void; - - /** Calls a function for each element contained in this object in - * place and return undefined. Optionally accepts an object to use - * as this when executing callback as second argument. - * - * ```ts - * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); - * params.forEach((value, key, parent) => { - * console.log(value, key, parent); - * }); - * ``` - * - */ - forEach( - callbackfn: (value: string, key: string, parent: this) => void, - thisArg?: any, - ): void; - - /** Returns an iterator allowing to go through all keys contained - * in this object. - * - * ```ts - * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); - * for (const key of params.keys()) { - * console.log(key); - * } - * ``` - */ - keys(): IterableIterator; - - /** Returns an iterator allowing to go through all values contained - * in this object. - * - * ```ts - * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); - * for (const value of params.values()) { - * console.log(value); - * } - * ``` - */ - values(): IterableIterator; - - /** Returns an iterator allowing to go through all key/value - * pairs contained in this object. - * - * ```ts - * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); - * for (const [key, value] of params.entries()) { - * console.log(key, value); - * } - * ``` - */ - entries(): IterableIterator<[string, string]>; - - /** Returns an iterator allowing to go through all key/value - * pairs contained in this object. - * - * ```ts - * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); - * for (const [key, value] of params) { - * console.log(key, value); - * } - * ``` - */ - [Symbol.iterator](): IterableIterator<[string, string]>; - - /** Returns a query string suitable for use in a URL. - * - * ```ts - * searchParams.toString(); - * ``` - */ - toString(): string; -} - -/** The URL interface represents an object providing static methods used for creating object URLs. */ -declare class URL { - constructor(url: string, base?: string | URL); - createObjectURL(object: any): string; - revokeObjectURL(url: string): void; - - hash: string; - host: string; - hostname: string; - href: string; - toString(): string; - readonly origin: string; - password: string; - pathname: string; - port: string; - protocol: string; - search: string; - readonly searchParams: URLSearchParams; - username: string; - toJSON(): string; -} - interface MessageEventInit extends EventInit { data?: T; origin?: string; diff --git a/cli/tests/unit/fetch_test.ts b/cli/tests/unit/fetch_test.ts index a01b09d133..9776baa2fb 100644 --- a/cli/tests/unit/fetch_test.ts +++ b/cli/tests/unit/fetch_test.ts @@ -108,10 +108,9 @@ unitTest({ perms: { net: true } }, async function fetchBodyUsed(): Promise< > { const response = await fetch("http://localhost:4545/cli/tests/fixture.json"); assertEquals(response.bodyUsed, false); - assertThrows((): void => { - // deno-lint-ignore no-explicit-any - (response as any).bodyUsed = true; - }); + // deno-lint-ignore no-explicit-any + (response as any).bodyUsed = true; + assertEquals(response.bodyUsed, false); await response.blob(); assertEquals(response.bodyUsed, true); }); diff --git a/cli/tests/unit/form_data_test.ts b/cli/tests/unit/form_data_test.ts index 76d137634f..1a948631de 100644 --- a/cli/tests/unit/form_data_test.ts +++ b/cli/tests/unit/form_data_test.ts @@ -78,7 +78,7 @@ unitTest(function formDataParamsSetSuccess(): void { assertEquals(formData.get("e"), "null"); }); -unitTest(function fromDataUseDomFile(): void { +unitTest(function fromDataUseFile(): void { const formData = new FormData(); const file = new File(["foo"], "bar", { type: "text/plain", diff --git a/cli/tests/unit/request_test.ts b/cli/tests/unit/request_test.ts index 0214d99c38..a8cbed3703 100644 --- a/cli/tests/unit/request_test.ts +++ b/cli/tests/unit/request_test.ts @@ -1,7 +1,7 @@ // Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. -import { assert, assertEquals, unitTest } from "./test_util.ts"; +import { assertEquals, unitTest } from "./test_util.ts"; -unitTest(function fromInit(): void { +unitTest(async function fromInit(): Promise { const req = new Request("http://foo/", { body: "ahoyhoy", method: "POST", @@ -10,22 +10,18 @@ unitTest(function fromInit(): void { }, }); - // deno-lint-ignore no-explicit-any - assertEquals("ahoyhoy", (req as any)._bodySource); + assertEquals("ahoyhoy", await req.text()); assertEquals(req.url, "http://foo/"); assertEquals(req.headers.get("test-header"), "value"); }); -unitTest(function fromRequest(): void { - const r = new Request("http://foo/"); - // deno-lint-ignore no-explicit-any - (r as any)._bodySource = "ahoyhoy"; +unitTest(async function fromRequest(): Promise { + const r = new Request("http://foo/", { body: "ahoyhoy" }); r.headers.set("test-header", "value"); const req = new Request(r); - // deno-lint-ignore no-explicit-any - assertEquals((req as any)._bodySource, (r as any)._bodySource); + assertEquals(await r.text(), await req.text()); assertEquals(req.url, r.url); assertEquals(req.headers.get("test-header"), r.headers.get("test-header")); }); @@ -65,7 +61,4 @@ unitTest(async function cloneRequestBodyStream(): Promise { const b2 = await r2.text(); assertEquals(b1, b2); - - // deno-lint-ignore no-explicit-any - assert((r1 as any)._bodySource !== (r2 as any)._bodySource); }); diff --git a/core/lib.deno_core.d.ts b/core/lib.deno_core.d.ts new file mode 100644 index 0000000000..3e113ca151 --- /dev/null +++ b/core/lib.deno_core.d.ts @@ -0,0 +1,39 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// deno-lint-ignore-file no-explicit-any + +/// +/// + +declare namespace Deno { + declare namespace core { + /** Send a JSON op to Rust, and synchronously recieve the result. */ + function jsonOpSync( + opName: string, + args: any, + ...zeroCopy: Uint8Array[] + ): any; + + /** Send a JSON op to Rust, and asynchronously recieve the result. */ + function jsonOpAsync( + opName: string, + args: any, + ...zeroCopy: Uint8Array[] + ): Promise; + + /** + * Retrieve a list of all registered ops, in the form of a map that maps op + * name to internal numerical op id. + */ + function ops(): Record; + + /** + * Retrieve a list of all open resources, in the form of a map that maps + * resource id to the resource name. + */ + function resources(): Record; + + /** Close the resource with the specified op id. */ + function close(rid: number): void; + } +} diff --git a/op_crates/fetch/26_fetch.js b/op_crates/fetch/26_fetch.js index 52c281d3b2..0176454670 100644 --- a/op_crates/fetch/26_fetch.js +++ b/op_crates/fetch/26_fetch.js @@ -1,5 +1,14 @@ // Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. +// @ts-check +/// +/// +/// +/// +/// +/// +/// + ((window) => { const core = window.Deno.core; @@ -20,9 +29,13 @@ const MAX_SIZE = 2 ** 32 - 2; - // `off` is the offset into `dst` where it will at which to begin writing values - // from `src`. - // Returns the number of bytes copied. + /** + * @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) { @@ -33,9 +46,11 @@ } class Buffer { - #buf = null; // contents are the bytes buf[off : len(buf)] + /** @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); @@ -45,28 +60,47 @@ 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) { @@ -76,6 +110,10 @@ return -1; }; + /** + * @param {number} len + * @returns {void} + */ #reslice = (len) => { if (!(len <= this.#buf.buffer.byteLength)) { throw new Error("assert"); @@ -83,16 +121,28 @@ 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. @@ -125,6 +175,10 @@ return m; }; + /** + * @param {number} n + * @returns {void} + */ grow(n) { if (n < 0) { throw Error("Buffer.grow: negative count"); @@ -134,15 +188,29 @@ } } + /** + * @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} value + * @returns {Map} + */ function getHeaderValueParams(value) { + /** @type {Map} */ const params = new Map(); // Forced to do so for some Map constructor param mismatch value @@ -163,6 +231,10 @@ const dataSymbol = Symbol("data"); const bytesSymbol = Symbol("bytes"); + /** + * @param {string} str + * @returns {boolean} + */ function containsOnlyASCII(str) { if (typeof str !== "string") { return false; @@ -171,6 +243,10 @@ return /^[\x00-\x7F]*$/.test(str); } + /** + * @param {string} s + * @returns {string} + */ function convertLineEndingsToNative(s) { const nativeLineEnd = build.os == "windows" ? "\r\n" : "\n"; @@ -207,6 +283,11 @@ return result; } + /** + * @param {string} s + * @param {number} position + * @returns {{ collected: string, newPosition: number }} + */ function collectSequenceNotCRLF( s, position, @@ -220,10 +301,16 @@ return { collected: s.slice(start, position), newPosition: position }; } + /** + * @param {BlobPart[]} blobParts + * @param {boolean} doNormalizeLineEndingsToNative + * @returns {Uint8Array[]} + */ function toUint8Arrays( blobParts, doNormalizeLineEndingsToNative, ) { + /** @type {Uint8Array[]} */ const ret = []; const enc = new TextEncoder(); for (const element of blobParts) { @@ -259,6 +346,11 @@ return ret; } + /** + * @param {BlobPart[]} blobParts + * @param {BlobPropertyBag} options + * @returns {Uint8Array} + */ function processBlobParts( blobParts, options, @@ -282,10 +374,15 @@ return bytes; } + /** + * @param {Uint8Array} blobBytes + */ function getStream(blobBytes) { // TODO(bartlomieju): Align to spec https://fetch.spec.whatwg.org/#concept-construct-readablestream + /** @type {ReadableStream} */ return new ReadableStream({ type: "bytes", + /** @param {ReadableStreamDefaultController} controller */ start: (controller) => { controller.enqueue(blobBytes); controller.close(); @@ -293,6 +390,7 @@ }); } + /** @param {ReadableStreamReader} reader */ async function readBytes( reader, ) { @@ -321,6 +419,16 @@ // const blobBytesWeakMap = new WeakMap(); class Blob { + /** @type {number} */ + size = 0; + /** @type {string} */ + type = ""; + + /** + * + * @param {BlobPart[]} blobParts + * @param {BlobPropertyBag | undefined} options + */ constructor(blobParts, options) { if (arguments.length === 0) { this[bytesSymbol] = new Uint8Array(); @@ -351,28 +459,48 @@ this.type = normalizedType; } + /** + * @param {number} start + * @param {number} end + * @param {string} contentType + * @returns {Blob} + */ slice(start, end, contentType) { return new Blob([this[bytesSymbol].slice(start, end)], { type: contentType || this.type, }); } + /** + * @returns {ReadableStream} + */ stream() { return getStream(this[bytesSymbol]); } + /** + * @returns {Promise} + */ async text() { const reader = getStream(this[bytesSymbol]).getReader(); const decoder = new TextDecoder(); return decoder.decode(await readBytes(reader)); } + /** + * @returns {Promise} + */ arrayBuffer() { return readBytes(getStream(this[bytesSymbol]).getReader()); } } class DomFile extends Blob { + /** + * @param {globalThis.BlobPart[]} fileBits + * @param {string} fileName + * @param {FilePropertyBag | undefined} options + */ constructor( fileBits, fileName, @@ -390,6 +518,11 @@ } } + /** + * @param {Blob | string} value + * @param {string | undefined} filename + * @returns {FormDataEntryValue} + */ function parseFormDataValue(value, filename) { if (value instanceof DomFile) { return new DomFile([value], filename || value.name, { @@ -406,14 +539,25 @@ } class FormDataBase { + /** @type {[name: string, entry: FormDataEntryValue][]} */ [dataSymbol] = []; + /** + * @param {string} name + * @param {string | Blob} value + * @param {string} [filename] + * @returns {void} + */ append(name, value, filename) { requiredArguments("FormData.append", arguments.length, 2); name = String(name); this[dataSymbol].push([name, parseFormDataValue(value, filename)]); } + /** + * @param {string} name + * @returns {void} + */ delete(name) { requiredArguments("FormData.delete", arguments.length, 1); name = String(name); @@ -427,6 +571,10 @@ } } + /** + * @param {string} name + * @returns {FormDataEntryValue[]} + */ getAll(name) { requiredArguments("FormData.getAll", arguments.length, 1); name = String(name); @@ -440,6 +588,10 @@ return values; } + /** + * @param {string} name + * @returns {FormDataEntryValue | null} + */ get(name) { requiredArguments("FormData.get", arguments.length, 1); name = String(name); @@ -452,12 +604,22 @@ return null; } + /** + * @param {string} name + * @returns {boolean} + */ has(name) { requiredArguments("FormData.has", arguments.length, 1); name = String(name); return this[dataSymbol].some((entry) => entry[0] === name); } + /** + * @param {string} name + * @param {string | Blob} value + * @param {string} [filename] + * @returns {void} + */ set(name, value, filename) { requiredArguments("FormData.set", arguments.length, 2); name = String(name); @@ -493,16 +655,26 @@ class FormData extends DomIterableMixin(FormDataBase, dataSymbol) {} class MultipartBuilder { + /** + * @param {FormData} formData + * @param {string} [boundary] + */ constructor(formData, boundary) { this.formData = formData; this.boundary = boundary ?? this.#createBoundary(); this.writer = new Buffer(); } + /** + * @returns {string} + */ getContentType() { return `multipart/form-data; boundary=${this.boundary}`; } + /** + * @returns {Uint8Array} + */ getBody() { for (const [fieldName, fieldValue] of this.formData.entries()) { if (fieldValue instanceof DomFile) { @@ -524,6 +696,10 @@ ); }; + /** + * @param {[string, string][]} headers + * @returns {void} + */ #writeHeaders = (headers) => { let buf = this.writer.empty() ? "" : "\r\n"; @@ -533,15 +709,21 @@ } buf += `\r\n`; - // FIXME(Bartlomieju): this should use `writeSync()` - this.writer.write(encoder.encode(buf)); + this.writer.writeSync(encoder.encode(buf)); }; + /** + * @param {string} field + * @param {string} filename + * @param {string} [type] + * @returns {void} + */ #writeFileHeaders = ( field, filename, type, ) => { + /** @type {[string, string][]} */ const headers = [ [ "Content-Disposition", @@ -552,16 +734,31 @@ return this.#writeHeaders(headers); }; + /** + * @param {string} field + * @returns {void} + */ #writeFieldHeaders = (field) => { + /** @type {[string, string][]} */ const headers = [["Content-Disposition", `form-data; name="${field}"`]]; return this.#writeHeaders(headers); }; + /** + * @param {string} field + * @param {string} value + * @returns {void} + */ #writeField = (field, value) => { this.#writeFieldHeaders(field); this.writer.writeSync(encoder.encode(value)); }; + /** + * @param {string} field + * @param {DomFile} value + * @returns {void} + */ #writeFile = (field, value) => { this.#writeFileHeaders(field, value.name, value.type); this.writer.writeSync(value[bytesSymbol]); @@ -569,6 +766,10 @@ } class MultipartParser { + /** + * @param {Uint8Array} body + * @param {string | undefined} boundary + */ constructor(body, boundary) { if (!boundary) { throw new TypeError("multipart/form-data must provide a boundary"); @@ -579,6 +780,10 @@ this.boundaryChars = encoder.encode(this.boundary); } + /** + * @param {string} headersText + * @returns {{ headers: Headers, disposition: Map }} + */ #parseHeaders = (headersText) => { const headers = new Headers(); const rawHeaders = headersText.split("\r\n"); @@ -600,6 +805,9 @@ }; }; + /** + * @returns {FormData} + */ parse() { const formData = new FormData(); let headerText = ""; @@ -674,7 +882,11 @@ } } - function validateBodyType(owner, bodySource) { + /** + * @param {string} name + * @param {BodyInit | null} bodySource + */ + function validateBodyType(name, bodySource) { if (isTypedArray(bodySource)) { return true; } else if (bodySource instanceof ArrayBuffer) { @@ -690,11 +902,15 @@ } else if (!bodySource) { return true; // null body is fine } - throw new Error( - `Bad ${owner.constructor.name} body type: ${bodySource.constructor.name}`, + throw new TypeError( + `Bad ${name} body type: ${bodySource.constructor.name}`, ); } + /** + * @param {ReadableStreamReader} stream + * @param {number} [size] + */ async function bufferFromStream( stream, size, @@ -728,6 +944,9 @@ return buffer.bytes().buffer; } + /** + * @param {Exclude | null} bodySource + */ function bodyToArrayBuffer(bodySource) { if (isTypedArray(bodySource)) { return bodySource.buffer; @@ -736,10 +955,6 @@ } else if (typeof bodySource === "string") { const enc = new TextEncoder(); return enc.encode(bodySource).buffer; - } else if (bodySource instanceof ReadableStream) { - throw new Error( - `Can't convert stream to ArrayBuffer (try bufferFromStream)`, - ); } else if ( bodySource instanceof FormData || bodySource instanceof URLSearchParams @@ -757,44 +972,70 @@ const BodyUsedError = "Failed to execute 'clone' on 'Body': body is already used"; + const teeBody = Symbol("Body#tee"); + class Body { #contentType = ""; - #size = undefined; + #size; + /** @type {BodyInit | null} */ + #bodySource; + /** @type {ReadableStream | null} */ + #stream = null; - constructor(_bodySource, meta) { - validateBodyType(this, _bodySource); - this._bodySource = _bodySource; + /** + * @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; - this._stream = null; } get body() { - if (this._stream) { - return this._stream; - } + 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`, + ); + } - 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(); + }, + }); } - - this._stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(buf)); - controller.close(); - }, - }); } - return this._stream; + return this.#stream; + } + + /** @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() { @@ -804,6 +1045,11 @@ return false; } + set bodyUsed(_) { + // this is a noop per spec + } + + /** @returns {Promise} */ async blob() { return new Blob([await this.arrayBuffer()], { type: this.#contentType, @@ -811,6 +1057,7 @@ } // ref: https://fetch.spec.whatwg.org/#body-mixin + /** @returns {Promise} */ async formData() { const formData = new FormData(); if (hasHeaderValueOf(this.#contentType, "multipart/form-data")) { @@ -835,12 +1082,15 @@ .forEach((bytes) => { if (bytes) { const split = bytes.split("="); - const name = split.shift().replace(/\+/g, " "); - const value = split.join("=").replace(/\+/g, " "); - formData.append( - decodeURIComponent(name), - decodeURIComponent(value), - ); + 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) { @@ -852,9 +1102,10 @@ } } + /** @returns {Promise} */ async text() { - if (typeof this._bodySource === "string") { - return this._bodySource; + if (typeof this.#bodySource === "string") { + return this.#bodySource; } const ab = await this.arrayBuffer(); @@ -862,28 +1113,35 @@ 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) { - return bufferFromStream(this._bodySource.getReader(), this.#size); + 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)); + return Promise.resolve(bodyToArrayBuffer(this.#bodySource)); } } + /** + * @param {Deno.CreateHttpClientOptions} options + * @returns {HttpClient} + */ function createHttpClient(options) { - return new HttpClient(opCreateHttpClient(options)); - } - - function opCreateHttpClient(args) { - return core.jsonOpSync("op_create_http_client", args); + return new HttpClient(core.jsonOpSync("op_create_http_client", options)); } class HttpClient { + /** + * @param {number} rid + */ constructor(rid) { this.rid = rid; } @@ -892,6 +1150,11 @@ } } + /** + * @param {{ headers: [string,string][], method: string, url: string, baseUrl: string | null, clientRid: number | null, hasBody: boolean }} args + * @param {Uint8Array | null} body + * @returns {{requestRid: number, requestBodyRid: number | null}} + */ function opFetch(args, body) { let zeroCopy; if (body != null) { @@ -900,10 +1163,19 @@ return core.jsonOpSync("op_fetch", args, ...(zeroCopy ? [zeroCopy] : [])); } + /** + * @param {{rid: number}} args + * @returns {Promise<{status: number, statusText: string, headers: Record, url: string, responseRid: number}>} + */ function opFetchSend(args) { return core.jsonOpAsync("op_fetch_send", args); } + /** + * @param {{rid: number}} args + * @param {Uint8Array} body + * @returns {Promise} + */ function opFetchRequestWrite(args, body) { const zeroCopy = new Uint8Array( body.buffer, @@ -916,12 +1188,20 @@ 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(); }); } + /** + * @param {string} m + * @returns {string} + */ function normalizeMethod(m) { const u = byteUpperCase(m); if ( @@ -938,6 +1218,20 @@ } class Request extends Body { + /** @type {string} */ + #method = "GET"; + /** @type {string} */ + #url = ""; + /** @type {Headers} */ + #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"); @@ -952,11 +1246,11 @@ // prefer body from init if (init.body) { b = init.body; - } else if (input instanceof Request && input._bodySource) { + } else if (input instanceof Request) { if (input.bodyUsed) { throw TypeError(BodyUsedError); } - b = input._bodySource; + b = input[teeBody](); } else if (typeof input === "object" && "body" in input && input.body) { if (input.bodyUsed) { throw TypeError(BodyUsedError); @@ -967,7 +1261,6 @@ } let headers; - // prefer headers from init if (init.headers) { headers = new Headers(init.headers); @@ -979,35 +1272,25 @@ const contentType = headers.get("content-type") || ""; super(b, { contentType }); - this.headers = headers; - - // readonly attribute ByteString method; - this.method = "GET"; - - // readonly attribute USVString url; - this.url = ""; - - // readonly attribute RequestCredentials credentials; - this.credentials = "omit"; + this.#headers = headers; if (input instanceof Request) { if (input.bodyUsed) { throw TypeError(BodyUsedError); } - this.method = input.method; - this.url = input.url; - this.headers = new Headers(input.headers); - this.credentials = input.credentials; - this._stream = input._stream; + this.#method = input.method; + this.#url = input.url; + this.#headers = new Headers(input.headers); + this.#credentials = input.credentials; } else { const baseUrl = getLocationHref(); - this.url = baseUrl != null + 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); + this.#method = normalizeMethod(init.method); } if ( @@ -1031,25 +1314,55 @@ headersList.push(header); } - let body2 = this._bodySource; - - if (this._bodySource instanceof ReadableStream) { - const tees = this._bodySource.tee(); - this._stream = this._bodySource = tees[0]; - body2 = tees[1]; - } + const body = this[teeBody](); return new Request(this.url, { - body: body2, + 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() { + 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 ?? {}; @@ -1161,22 +1474,20 @@ headersList.push(header); } - let resBody = this._bodySource; + const body = this[teeBody](); - if (this._bodySource instanceof ReadableStream) { - const tees = this._bodySource.tee(); - this._stream = this._bodySource = tees[0]; - resBody = tees[1]; - } - - return new Response(resBody, { + return new Response(body, { status: this.status, statusText: this.statusText, headers: new Headers(headersList), }); } - static redirect(url, status) { + /** + * @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.", @@ -1185,18 +1496,29 @@ return new Response(null, { status, statusText: "", - headers: [["Location", typeof url === "string" ? url : url.toString()]], + 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()); @@ -1211,16 +1533,22 @@ clientRid, hasBody: !!body, }, - body instanceof Uint8Array ? body : undefined, + 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({ rid: requestBodyRid }, chunk); } catch (err) { controller.error(err); - controller.close(); } }, close() { @@ -1233,7 +1561,13 @@ return await opFetchSend({ rid: requestRid }); } + /** + * @param {Request | URL | string} input + * @param {RequestInit & {client: Deno.HttpClient}} [init] + * @returns {Promise} + */ async function fetch(input, init) { + /** @type {string | null} */ let url; let method = null; let headers = null; @@ -1312,18 +1646,18 @@ let responseBody; let responseInit = {}; while (remRedirectCount) { - const fetchResponse = await sendFetchReq( + const fetchResp = await sendFetchReq( url, - method, - headers, + method ?? "GET", + headers ?? new Headers(), body, clientRid, ); - const rid = fetchResponse.responseRid; + const rid = fetchResp.responseRid; if ( - NULL_BODY_STATUS.includes(fetchResponse.status) || - REDIRECT_STATUS.includes(fetchResponse.status) + 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. @@ -1332,6 +1666,7 @@ } else { responseBody = new ReadableStream({ type: "bytes", + /** @param {ReadableStreamDefaultController} controller */ async pull(controller) { try { const chunk = new Uint8Array(16 * 1024 + 256); @@ -1365,20 +1700,20 @@ responseInit = { status: 200, - statusText: fetchResponse.statusText, - headers: fetchResponse.headers, + statusText: fetchResp.statusText, + headers: fetchResp.headers, }; responseData.set(responseInit, { redirected, - rid: fetchResponse.bodyRid, - status: fetchResponse.status, - url: fetchResponse.url, + rid: fetchResp.responseRid, + status: fetchResp.status, + url: fetchResp.url, }); const response = new Response(responseBody, responseInit); - if (REDIRECT_STATUS.includes(fetchResponse.status)) { + if (REDIRECT_STATUS.includes(fetchResp.status)) { // We're in a redirect status switch ((init && init.redirect) || "follow") { case "error": @@ -1396,6 +1731,7 @@ case "follow": // fallthrough default: { + /** @type {string | null} */ let redirectUrl = response.headers.get("Location"); if (redirectUrl == null) { return response; // Unspecified @@ -1404,7 +1740,7 @@ !redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://") ) { - redirectUrl = new URL(redirectUrl, fetchResponse.url).href; + redirectUrl = new URL(redirectUrl, fetchResp.url).href; } url = redirectUrl; redirected = true; @@ -1427,7 +1763,7 @@ window.__bootstrap.fetch = { Blob, - DomFile, + File: DomFile, FormData, setBaseUrl, fetch, diff --git a/op_crates/fetch/internal.d.ts b/op_crates/fetch/internal.d.ts new file mode 100644 index 0000000000..e02bc6ed2b --- /dev/null +++ b/op_crates/fetch/internal.d.ts @@ -0,0 +1,27 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// deno-lint-ignore-file no-explicit-any + +/// +/// + +declare namespace globalThis { + declare namespace __bootstrap { + declare var fetchUtil: { + requiredArguments(name: string, length: number, required: number): void; + }; + + declare var domIterable: { + DomIterableMixin(base: any, dataSymbol: symbol): any; + }; + + declare var headers: { + Headers: typeof Headers; + }; + + declare var streams: { + ReadableStream: typeof ReadableStream; + isReadableStreamDisturbed(stream: ReadableStream): boolean; + }; + } +} diff --git a/op_crates/web/internal.d.ts b/op_crates/web/internal.d.ts new file mode 100644 index 0000000000..8f91601654 --- /dev/null +++ b/op_crates/web/internal.d.ts @@ -0,0 +1,16 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +/// +/// + +declare namespace globalThis { + declare namespace __bootstrap { + declare var url: { + URLSearchParams: typeof URLSearchParams; + }; + + declare var location: { + getLocationHref(): string | undefined; + }; + } +} diff --git a/op_crates/web/lib.deno_web.d.ts b/op_crates/web/lib.deno_web.d.ts index 79b56f68e2..24a8f929d2 100644 --- a/op_crates/web/lib.deno_web.d.ts +++ b/op_crates/web/lib.deno_web.d.ts @@ -313,3 +313,172 @@ declare var FileReader: { readonly EMPTY: number; readonly LOADING: number; }; + +declare class URLSearchParams { + constructor( + init?: string[][] | Record | string | URLSearchParams, + ); + static toString(): string; + + /** Appends a specified key/value pair as a new search parameter. + * + * ```ts + * let searchParams = new URLSearchParams(); + * searchParams.append('name', 'first'); + * searchParams.append('name', 'second'); + * ``` + */ + append(name: string, value: string): void; + + /** Deletes the given search parameter and its associated value, + * from the list of all search parameters. + * + * ```ts + * let searchParams = new URLSearchParams([['name', 'value']]); + * searchParams.delete('name'); + * ``` + */ + delete(name: string): void; + + /** Returns all the values associated with a given search parameter + * as an array. + * + * ```ts + * searchParams.getAll('name'); + * ``` + */ + getAll(name: string): string[]; + + /** Returns the first value associated to the given search parameter. + * + * ```ts + * searchParams.get('name'); + * ``` + */ + get(name: string): string | null; + + /** Returns a Boolean that indicates whether a parameter with the + * specified name exists. + * + * ```ts + * searchParams.has('name'); + * ``` + */ + has(name: string): boolean; + + /** Sets the value associated with a given search parameter to the + * given value. If there were several matching values, this method + * deletes the others. If the search parameter doesn't exist, this + * method creates it. + * + * ```ts + * searchParams.set('name', 'value'); + * ``` + */ + set(name: string, value: string): void; + + /** Sort all key/value pairs contained in this object in place and + * return undefined. The sort order is according to Unicode code + * points of the keys. + * + * ```ts + * searchParams.sort(); + * ``` + */ + sort(): void; + + /** Calls a function for each element contained in this object in + * place and return undefined. Optionally accepts an object to use + * as this when executing callback as second argument. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * params.forEach((value, key, parent) => { + * console.log(value, key, parent); + * }); + * ``` + * + */ + forEach( + callbackfn: (value: string, key: string, parent: this) => void, + thisArg?: any, + ): void; + + /** Returns an iterator allowing to go through all keys contained + * in this object. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * for (const key of params.keys()) { + * console.log(key); + * } + * ``` + */ + keys(): IterableIterator; + + /** Returns an iterator allowing to go through all values contained + * in this object. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * for (const value of params.values()) { + * console.log(value); + * } + * ``` + */ + values(): IterableIterator; + + /** Returns an iterator allowing to go through all key/value + * pairs contained in this object. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * for (const [key, value] of params.entries()) { + * console.log(key, value); + * } + * ``` + */ + entries(): IterableIterator<[string, string]>; + + /** Returns an iterator allowing to go through all key/value + * pairs contained in this object. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * for (const [key, value] of params) { + * console.log(key, value); + * } + * ``` + */ + [Symbol.iterator](): IterableIterator<[string, string]>; + + /** Returns a query string suitable for use in a URL. + * + * ```ts + * searchParams.toString(); + * ``` + */ + toString(): string; +} + +/** The URL interface represents an object providing static methods used for creating object URLs. */ +declare class URL { + constructor(url: string, base?: string | URL); + createObjectURL(object: any): string; + revokeObjectURL(url: string): void; + + hash: string; + host: string; + hostname: string; + href: string; + toString(): string; + readonly origin: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + readonly searchParams: URLSearchParams; + username: string; + toJSON(): string; +} diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index fd846af207..e227c3ecac 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -210,7 +210,7 @@ delete Object.prototype.__proto__; ErrorEvent: util.nonEnumerable(ErrorEvent), Event: util.nonEnumerable(Event), EventTarget: util.nonEnumerable(EventTarget), - File: util.nonEnumerable(fetch.DomFile), + File: util.nonEnumerable(fetch.File), FileReader: util.nonEnumerable(fileReader.FileReader), FormData: util.nonEnumerable(fetch.FormData), Headers: util.nonEnumerable(headers.Headers), diff --git a/test_util/wpt b/test_util/wpt index 928edf7353..ec40449a41 160000 --- a/test_util/wpt +++ b/test_util/wpt @@ -1 +1 @@ -Subproject commit 928edf7353e946398020326964d42de56b3cd542 +Subproject commit ec40449a41939504a6adc039e7d98f52ec8894c9 diff --git a/tools/wpt/expectation.json b/tools/wpt/expectation.json index fcb9698c36..b8a02d5a3e 100644 --- a/tools/wpt/expectation.json +++ b/tools/wpt/expectation.json @@ -961,14 +961,8 @@ "URLSearchParams constructed with: %EF%BB%BFtest=%EF%BB%BF", "request.formData() with input: test=", "response.formData() with input: test=", - "request.formData() with input: %FE%FF", - "response.formData() with input: %FE%FF", - "request.formData() with input: %FF%FE", - "response.formData() with input: %FF%FE", - "request.formData() with input: %C2", - "response.formData() with input: %C2", - "request.formData() with input: %C2x", - "response.formData() with input: %C2x", + "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", @@ -1006,5 +1000,23 @@ "urlsearchparams-set.any.js": true, "urlsearchparams-sort.any.js": true, "urlsearchparams-stringifier.any.js": true + }, + "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" + ] + } + } } } \ No newline at end of file