diff options
author | Luca Casonato <lucacasonato@yahoo.com> | 2021-04-20 14:47:22 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-20 14:47:22 +0200 |
commit | 9e6cd91014ac4a0d34556b0d09cbe25e4e0930c6 (patch) | |
tree | 4523790510a17676c987039feb03f208a258dc16 /op_crates/fetch | |
parent | 115197ffb06aad2a3045e8478980ab911b5a5eeb (diff) |
chore: align fetch to spec (#10203)
This commit aligns the `fetch` API and the `Request` / `Response`
classes belonging to it to the spec. This commit enables all the
relevant `fetch` WPT tests. Spec compliance is now at around 90%.
Performance is essentially identical now (within 1% of 1.9.0).
Diffstat (limited to 'op_crates/fetch')
-rw-r--r-- | op_crates/fetch/20_headers.js | 88 | ||||
-rw-r--r-- | op_crates/fetch/21_formdata.js | 25 | ||||
-rw-r--r-- | op_crates/fetch/22_body.js | 338 | ||||
-rw-r--r-- | op_crates/fetch/22_http_client.js | 41 | ||||
-rw-r--r-- | op_crates/fetch/23_request.js | 521 | ||||
-rw-r--r-- | op_crates/fetch/23_response.js | 415 | ||||
-rw-r--r-- | op_crates/fetch/26_fetch.js | 1296 | ||||
-rw-r--r-- | op_crates/fetch/internal.d.ts | 93 | ||||
-rw-r--r-- | op_crates/fetch/lib.rs | 35 |
9 files changed, 1759 insertions, 1093 deletions
diff --git a/op_crates/fetch/20_headers.js b/op_crates/fetch/20_headers.js index ce46e5dee..3c6fc0b26 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 106b67da4..00f97f346 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 000000000..1c4ce4271 --- /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 +/// <reference path="../webidl/internal.d.ts" /> +/// <reference path="../url/internal.d.ts" /> +/// <reference path="../url/lib.deno_url.d.ts" /> +/// <reference path="../web/internal.d.ts" /> +/// <reference path="../file/internal.d.ts" /> +/// <reference path="../file/lib.deno_file.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="./11_streams_types.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"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<Uint8Array> | { body: Uint8Array, consumed: boolean }} */ + streamOrStatic; + /** @type {null | Uint8Array | Blob | FormData} */ + source = null; + /** @type {null | number} */ + length = null; + + /** + * @param {ReadableStream<Uint8Array> | { 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<Uint8Array>} + */ + 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<Uint8Array> | 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<ArrayBuffer>} */ + value: async function arrayBuffer() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "ArrayBuffer"); + }, + }, + blob: { + /** @returns {Promise<Blob>} */ + value: async function blob() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "Blob", this[mimeTypeSymbol]); + }, + }, + formData: { + /** @returns {Promise<FormData>} */ + value: async function formData() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "FormData", this[mimeTypeSymbol]); + }, + }, + json: { + /** @returns {Promise<any>} */ + value: async function json() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "JSON"); + }, + }, + text: { + /** @returns {Promise<string>} */ + 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<Uint8Array> | { 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 000000000..0a4be9e9f --- /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 +/// <reference path="../webidl/internal.d.ts" /> +/// <reference path="../web/internal.d.ts" /> +/// <reference path="../url/internal.d.ts" /> +/// <reference path="../file/internal.d.ts" /> +/// <reference path="../file/lib.deno_file.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="./11_streams_types.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"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 000000000..f3764d96f --- /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 +/// <reference path="../webidl/internal.d.ts" /> +/// <reference path="../web/internal.d.ts" /> +/// <reference path="../file/internal.d.ts" /> +/// <reference path="../file/lib.deno_file.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="./11_streams_types.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"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 000000000..44b74f789 --- /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 +/// <reference path="../webidl/internal.d.ts" /> +/// <reference path="../web/internal.d.ts" /> +/// <reference path="../url/internal.d.ts" /> +/// <reference path="../file/internal.d.ts" /> +/// <reference path="../file/lib.deno_file.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="./11_streams_types.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"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 d07121e86..a6d1608d0 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; - - // 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; - - /** - * @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<number>} - */ - 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<Uint8Array>} 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<BodyInit, ReadableStream> | 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<Uint8Array> | 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<Uint8Array>} 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<Blob>} */ - async blob() { - return new Blob([await this.arrayBuffer()], { - type: this.#contentType, - }); - } - - // ref: https://fetch.spec.whatwg.org/#body-mixin - /** @returns {Promise<FormData>} */ - 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<string>} */ - 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<any>} */ - async json() { - const raw = await this.text(); - return JSON.parse(raw); - } - - /** @returns {Promise<ArrayBuffer>} */ - 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)); - } - } + 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; + + const REQUEST_BODY_HEADER_NAMES = [ + "content-encoding", + "content-language", + "content-location", + "content-type", + ]; /** - * @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<string,string[]>, 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<void>} */ function opFetchRequestWrite(rid, body) { - const zeroCopy = new Uint8Array( - body.buffer, - body.byteOffset, - body.byteLength, - ); - return core.opAsync("op_fetch_request_write", rid, zeroCopy); + return core.opAsync("op_fetch_request_write", rid, body); } - const NULL_BODY_STATUS = [101, 204, 205, 304]; - const REDIRECT_STATUS = [301, 302, 303, 307, 308]; - /** - * @param {string} s - * @returns {string} + * @param {number} rid + * @param {Uint8Array} body + * @returns {Promise<number>} */ - function byteUpperCase(s) { - return String(s).replace(/[a-z]/g, function byteUpperCaseReplace(c) { - return c.toUpperCase(); - }); + function opFetchResponseRead(rid, body) { + return core.opAsync("op_fetch_response_read", rid, body); } /** - * @param {string} m - * @returns {boolean} + * @param {number} responseBodyRid + * @returns {ReadableStream<Uint8Array>} */ - function isKnownMethod(m) { - return ( - m === "DELETE" || - m === "GET" || - m === "HEAD" || - m === "OPTIONS" || - m === "POST" || - m === "PUT" - ); + 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); + } + } catch (err) { + // There was an error while reading a chunk of the body, so we + // error. + controller.error(err); + controller.close(); + core.close(responseBodyRid); + } + }, + cancel() { + core.close(responseBodyRid); + }, + }); } /** - * @param {string} m - * @returns {string} + * @param {InnerRequest} req + * @param {boolean} recursive + * @returns {Promise<InnerResponse>} */ - 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]; - } - } + async function mainFetch(req, recursive) { + /** @type {ReadableStream<Uint8Array> | 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 = new Headers(init.headers); - contentType = headers.get("content-type") || ""; + 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 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]); - } + 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; } - } 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]); - } + try { + await opFetchRequestWrite(requestBodyRid, value); + } catch (err) { + await reader.cancel(err); + break; } - /* 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; + 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 { + response.body = new InnerBody(createResponseBodyStream(resp.responseRid)); } - 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](); + if (recursive) return response; - return new Response(body, { - status: this.status, - statusText: this.statusText, - headers: new Headers(headersList), - }); + if (response.urlList.length === 0) { + response.urlList = [...req.urlList]; } - /** - * @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; + return response; } /** - * @param {string} url - * @param {string} method - * @param {Headers} headers - * @param {ReadableStream<Uint8Array> | ArrayBufferView | undefined} body - * @param {number | null} clientRid - * @returns {Promise<{status: number, statusText: string, headers: Record<string,string[]>, url: string, responseRid: number}>} + * @param {InnerRequest} request + * @param {InnerResponse} response + * @returns {Promise<InnerResponse>} */ - async function sendFetchReq(url, method, headers, body, clientRid) { - /** @type {[string, string][]} */ - let headerArray = []; - if (headers) { - headerArray = Array.from(headers.entries()); + function httpRedirectFetch(request, response) { + const locationHeaders = response.headerList.filter((entry) => + byteLowerCase(entry[0]) === "location" + ); + if (locationHeaders.length === 0) { + return response; } - - const { requestRid, requestBodyRid } = opFetch( - { - method, - url, - baseUrl, - headers: headerArray, - clientRid, - hasBody: !!body, - }, - body instanceof Uint8Array ? body : null, + const locationURL = new URL( + locationHeaders[0][1], + response.url() ?? undefined, ); - if (requestBodyRid) { - if (!(body instanceof ReadableStream)) { - throw new TypeError("Unreachable state (body is not ReadableStream)."); + 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--; + } } - 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); + if (request.body !== null) { + const res = extractBody(request.body.source); + request.body = res.body; + } + request.urlList.push(locationURL.href); + return mainFetch(request, true); } /** - * @param {Request | URL | string} input - * @param {RequestInit & {client: Deno.HttpClient}} [init] - * @returns {Promise<Response>} + * @param {RequestInfo} input + * @param {RequestInit} init */ - 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); - } 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; - } - } - } else { - url = input.url; - method = input.method; - headers = input.headers; + 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", + }); - if (input.body) { - body = input.body; - } + // 1. + const requestObject = new Request(input, init); + // 2. + const request = toInnerRequest(requestObject); + // 10. + if (!requestObject.headers.has("Accept")) { + request.headerList.push(["Accept", "*/*"]); } - let responseBody; - let responseInit = {}; - while (remRedirectCount) { - const fetchResp = await sendFetchReq( - url, - method ?? "GET", - headers ?? new Headers(), - body, - clientRid, + // 12. + const response = await mainFetch(request, false); + if (response.type === "error") { + throw new TypeError( + "Fetch failed: " + (response.error ?? "unknown error"), ); - const rid = fetchResp.responseRid; - - 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<Uint8Array>} 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; - } } - responseData.set(responseInit, { - type: "error", - redirected: false, - url: "", - }); - - return new Response(null, responseInit); + return fromInnerResponse(response, "immutable"); } - window.__bootstrap.fetch = { - FormData, - setBaseUrl, - fetch, - Request, - Response, - HttpClient, - createHttpClient, - fastBody, - dontValidateUrl, - lazyHeaders, - }; + 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 3206008c5..86de52761 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<Uint8Array>); + stream: ReadableStream<Uint8Array>; + source: null | Uint8Array | Blob | FormData; + length: null | number; + unusable(): boolean; + consume(): Promise<Uint8Array>; + 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 030f8a809..41fb153e0 100644 --- a/op_crates/fetch/lib.rs +++ b/op_crates/fetch/lib.rs @@ -71,12 +71,28 @@ pub fn init(isolate: &mut JsRuntime) { 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<String>, + method: String, url: String, - base_url: Option<String>, headers: Vec<(String, String)>, client_rid: Option<u32>, 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(); |