diff options
Diffstat (limited to 'ext/fetch')
-rw-r--r-- | ext/fetch/01_fetch_util.js | 22 | ||||
-rw-r--r-- | ext/fetch/20_headers.js | 479 | ||||
-rw-r--r-- | ext/fetch/21_formdata.js | 507 | ||||
-rw-r--r-- | ext/fetch/22_body.js | 403 | ||||
-rw-r--r-- | ext/fetch/22_http_client.js | 40 | ||||
-rw-r--r-- | ext/fetch/23_request.js | 484 | ||||
-rw-r--r-- | ext/fetch/23_response.js | 451 | ||||
-rw-r--r-- | ext/fetch/26_fetch.js | 542 | ||||
-rw-r--r-- | ext/fetch/Cargo.toml | 28 | ||||
-rw-r--r-- | ext/fetch/README.md | 5 | ||||
-rw-r--r-- | ext/fetch/internal.d.ts | 108 | ||||
-rw-r--r-- | ext/fetch/lib.deno_fetch.d.ts | 437 | ||||
-rw-r--r-- | ext/fetch/lib.rs | 567 |
13 files changed, 4073 insertions, 0 deletions
diff --git a/ext/fetch/01_fetch_util.js b/ext/fetch/01_fetch_util.js new file mode 100644 index 000000000..9cf19588b --- /dev/null +++ b/ext/fetch/01_fetch_util.js @@ -0,0 +1,22 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. +"use strict"; + +((window) => { + const { TypeError } = window.__bootstrap.primordials; + function requiredArguments( + name, + length, + required, + ) { + if (length < required) { + const errMsg = `${name} requires at least ${required} argument${ + required === 1 ? "" : "s" + }, but only ${length} present`; + throw new TypeError(errMsg); + } + } + + window.__bootstrap.fetchUtil = { + requiredArguments, + }; +})(this); diff --git a/ext/fetch/20_headers.js b/ext/fetch/20_headers.js new file mode 100644 index 000000000..91154d958 --- /dev/null +++ b/ext/fetch/20_headers.js @@ -0,0 +1,479 @@ +// 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="../web/lib.deno_web.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="../web/06_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_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 { + ArrayIsArray, + ArrayPrototypeMap, + ArrayPrototypePush, + ArrayPrototypeSort, + ArrayPrototypeJoin, + ArrayPrototypeSplice, + ArrayPrototypeFilter, + ObjectKeys, + ObjectEntries, + RegExpPrototypeTest, + Symbol, + SymbolFor, + SymbolIterator, + SymbolToStringTag, + StringPrototypeReplaceAll, + StringPrototypeIncludes, + TypeError, + } = window.__bootstrap.primordials; + + const _headerList = Symbol("header list"); + const _iterableHeaders = Symbol("iterable headers"); + const _guard = Symbol("guard"); + + /** + * @typedef Header + * @type {[string, string]} + */ + + /** + * @typedef HeaderList + * @type {Header[]} + */ + + /** + * @param {string} potentialValue + * @returns {string} + */ + function normalizeHeaderValue(potentialValue) { + potentialValue = StringPrototypeReplaceAll( + potentialValue, + HTTP_WHITESPACE_PREFIX_RE, + "", + ); + potentialValue = StringPrototypeReplaceAll( + potentialValue, + HTTP_WHITESPACE_SUFFIX_RE, + "", + ); + return potentialValue; + } + + /** + * @param {Headers} headers + * @param {HeadersInit} object + */ + function fillHeaders(headers, object) { + if (ArrayIsArray(object)) { + for (const header of object) { + if (header.length !== 2) { + throw new TypeError( + `Invalid header. Length must be 2, but is ${header.length}`, + ); + } + appendHeader(headers, header[0], header[1]); + } + } else { + for (const key of ObjectKeys(object)) { + appendHeader(headers, key, object[key]); + } + } + } + + /** + * https://fetch.spec.whatwg.org/#concept-headers-append + * @param {Headers} headers + * @param {string} name + * @param {string} value + */ + function appendHeader(headers, name, value) { + // 1. + value = normalizeHeaderValue(value); + + // 2. + if (!RegExpPrototypeTest(HTTP_TOKEN_CODE_POINT_RE, name)) { + throw new TypeError("Header name is not valid."); + } + if ( + StringPrototypeIncludes(value, "\x00") || + StringPrototypeIncludes(value, "\x0A") || + StringPrototypeIncludes(value, "\x0D") + ) { + throw new TypeError("Header value is not valid."); + } + + // 3. + if (headers[_guard] == "immutable") { + throw new TypeError("Headers are immutable."); + } + + // 7. + const list = headers[_headerList]; + name = byteLowerCase(name); + ArrayPrototypePush(list, [name, value]); + } + + /** + * https://fetch.spec.whatwg.org/#concept-header-list-get + * @param {HeaderList} list + * @param {string} name + */ + function getHeader(list, name) { + const lowercaseName = byteLowerCase(name); + const entries = ArrayPrototypeMap( + ArrayPrototypeFilter(list, (entry) => entry[0] === lowercaseName), + (entry) => entry[1], + ); + if (entries.length === 0) { + return null; + } else { + return ArrayPrototypeJoin(entries, "\x2C\x20"); + } + } + + /** + * 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 = StringPrototypeReplaceAll(value, HTTP_TAB_OR_SPACE_PREFIX_RE, ""); + value = StringPrototypeReplaceAll(value, HTTP_TAB_OR_SPACE_SUFFIX_RE, ""); + + ArrayPrototypePush(values, value); + value = ""; + } + return values; + } + + class Headers { + /** @type {HeaderList} */ + [_headerList] = []; + /** @type {"immutable" | "request" | "request-no-cors" | "response" | "none"} */ + [_guard]; + + get [_iterableHeaders]() { + const list = this[_headerList]; + + // The order of steps are not similar to the ones suggested by the + // spec but produce the same result. + const headers = {}; + const cookies = []; + for (const entry of list) { + const name = entry[0]; + const value = entry[1]; + if (value === null) throw new TypeError("Unreachable"); + // The following if statement is not spec compliant. + // `set-cookie` is the only header that can not be concatentated, + // so must be given to the user as multiple headers. + // The else block of the if statement is spec compliant again. + if (name === "set-cookie") { + ArrayPrototypePush(cookies, [name, value]); + } else { + // The following code has the same behaviour as getHeader() + // at the end of loop. But it avoids looping through the entire + // list to combine multiple values with same header name. It + // instead gradually combines them as they are found. + let header = headers[name]; + if (header && header.length > 0) { + header += "\x2C\x20" + value; + } else { + header = value; + } + headers[name] = header; + } + } + + return ArrayPrototypeSort( + [...ObjectEntries(headers), ...cookies], + (a, b) => { + const akey = a[0]; + const bkey = b[0]; + if (akey > bkey) return 1; + if (akey < bkey) return -1; + return 0; + }, + ); + } + + /** @param {HeadersInit} [init] */ + constructor(init = undefined) { + const prefix = "Failed to construct 'Event'"; + if (init !== undefined) { + init = webidl.converters["HeadersInit"](init, { + prefix, + context: "Argument 1", + }); + } + + this[webidl.brand] = webidl.brand; + this[_guard] = "none"; + if (init !== undefined) { + fillHeaders(this, init); + } + } + + /** + * @param {string} name + * @param {string} value + */ + append(name, value) { + webidl.assertBranded(this, Headers); + const prefix = "Failed to execute 'append' on 'Headers'"; + webidl.requiredArguments(arguments.length, 2, { prefix }); + name = webidl.converters["ByteString"](name, { + prefix, + context: "Argument 1", + }); + value = webidl.converters["ByteString"](value, { + prefix, + context: "Argument 2", + }); + appendHeader(this, name, value); + } + + /** + * @param {string} name + */ + delete(name) { + const prefix = "Failed to execute 'delete' on 'Headers'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + name = webidl.converters["ByteString"](name, { + prefix, + context: "Argument 1", + }); + + if (!RegExpPrototypeTest(HTTP_TOKEN_CODE_POINT_RE, name)) { + throw new TypeError("Header name is not valid."); + } + if (this[_guard] == "immutable") { + throw new TypeError("Headers are immutable."); + } + + const list = this[_headerList]; + name = byteLowerCase(name); + for (let i = 0; i < list.length; i++) { + if (list[i][0] === name) { + ArrayPrototypeSplice(list, i, 1); + i--; + } + } + } + + /** + * @param {string} name + */ + get(name) { + const prefix = "Failed to execute 'get' on 'Headers'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + name = webidl.converters["ByteString"](name, { + prefix, + context: "Argument 1", + }); + + if (!RegExpPrototypeTest(HTTP_TOKEN_CODE_POINT_RE, name)) { + throw new TypeError("Header name is not valid."); + } + + const list = this[_headerList]; + return getHeader(list, name); + } + + /** + * @param {string} name + */ + has(name) { + const prefix = "Failed to execute 'has' on 'Headers'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + name = webidl.converters["ByteString"](name, { + prefix, + context: "Argument 1", + }); + + if (!RegExpPrototypeTest(HTTP_TOKEN_CODE_POINT_RE, name)) { + throw new TypeError("Header name is not valid."); + } + + const list = this[_headerList]; + name = byteLowerCase(name); + for (let i = 0; i < list.length; i++) { + if (list[i][0] === name) { + return true; + } + } + return false; + } + + /** + * @param {string} name + * @param {string} value + */ + set(name, value) { + webidl.assertBranded(this, Headers); + const prefix = "Failed to execute 'set' on 'Headers'"; + webidl.requiredArguments(arguments.length, 2, { prefix }); + name = webidl.converters["ByteString"](name, { + prefix, + context: "Argument 1", + }); + value = webidl.converters["ByteString"](value, { + prefix, + context: "Argument 2", + }); + + value = normalizeHeaderValue(value); + + // 2. + if (!RegExpPrototypeTest(HTTP_TOKEN_CODE_POINT_RE, name)) { + throw new TypeError("Header name is not valid."); + } + if ( + StringPrototypeIncludes(value, "\x00") || + StringPrototypeIncludes(value, "\x0A") || + StringPrototypeIncludes(value, "\x0D") + ) { + throw new TypeError("Header value is not valid."); + } + + if (this[_guard] == "immutable") { + throw new TypeError("Headers are immutable."); + } + + const list = this[_headerList]; + name = byteLowerCase(name); + let added = false; + for (let i = 0; i < list.length; i++) { + if (list[i][0] === name) { + if (!added) { + list[i][1] = value; + added = true; + } else { + ArrayPrototypeSplice(list, i, 1); + i--; + } + } + } + if (!added) { + ArrayPrototypePush(list, [name, value]); + } + } + + [SymbolFor("Deno.privateCustomInspect")](inspect) { + const headers = {}; + for (const header of this) { + headers[header[0]] = header[1]; + } + return `Headers ${inspect(headers)}`; + } + + get [SymbolToStringTag]() { + return "Headers"; + } + } + + webidl.mixinPairIterable("Headers", Headers, _iterableHeaders, 0, 1); + + webidl.configurePrototype(Headers); + + webidl.converters["HeadersInit"] = (V, opts) => { + // Union for (sequence<sequence<ByteString>> or record<ByteString, ByteString>) + if (webidl.type(V) === "Object" && V !== null) { + if (V[SymbolIterator] !== undefined) { + return webidl.converters["sequence<sequence<ByteString>>"](V, opts); + } + return webidl.converters["record<ByteString, ByteString>"](V, opts); + } + throw webidl.makeException( + TypeError, + "The provided value is not of type '(sequence<sequence<ByteString>> or record<ByteString, ByteString>)'", + opts, + ); + }; + webidl.converters["Headers"] = webidl.createInterfaceConverter( + "Headers", + 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/ext/fetch/21_formdata.js b/ext/fetch/21_formdata.js new file mode 100644 index 000000000..25ed32c2d --- /dev/null +++ b/ext/fetch/21_formdata.js @@ -0,0 +1,507 @@ +// 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="../web/lib.deno_web.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="../web/06_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 { Blob, File } = globalThis.__bootstrap.file; + const { + ArrayPrototypeMap, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSplice, + ArrayPrototypeFilter, + ArrayPrototypeForEach, + Map, + MapPrototypeGet, + MapPrototypeSet, + MathRandom, + Symbol, + SymbolToStringTag, + StringFromCharCode, + StringPrototypeTrim, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeReplace, + StringPrototypeIndexOf, + StringPrototypePadStart, + StringPrototypeCodePointAt, + StringPrototypeReplaceAll, + TypeError, + TypedArrayPrototypeSubarray, + } = window.__bootstrap.primordials; + + const entryList = Symbol("entry list"); + + /** + * @param {string} name + * @param {string | Blob} value + * @param {string | undefined} filename + * @returns {FormDataEntry} + */ + function createEntry(name, value, filename) { + if (value instanceof Blob && !(value instanceof File)) { + value = new File([value], "blob", { type: value.type }); + } + if (value instanceof File && filename !== undefined) { + value = new File([value], filename, { + type: value.type, + lastModified: value.lastModified, + }); + } + return { + name, + // @ts-expect-error because TS is not smart enough + value, + }; + } + + /** + * @typedef FormDataEntry + * @property {string} name + * @property {FormDataEntryValue} value + */ + + class FormData { + get [SymbolToStringTag]() { + return "FormData"; + } + + /** @type {FormDataEntry[]} */ + [entryList] = []; + + /** @param {void} form */ + constructor(form) { + if (form !== undefined) { + webidl.illegalConstructor(); + } + this[webidl.brand] = webidl.brand; + } + + /** + * @param {string} name + * @param {string | Blob} valueOrBlobValue + * @param {string} [filename] + * @returns {void} + */ + append(name, valueOrBlobValue, filename) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'append' on 'FormData'"; + webidl.requiredArguments(arguments.length, 2, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + if (valueOrBlobValue instanceof Blob) { + valueOrBlobValue = webidl.converters["Blob"](valueOrBlobValue, { + prefix, + context: "Argument 2", + }); + if (filename !== undefined) { + filename = webidl.converters["USVString"](filename, { + prefix, + context: "Argument 3", + }); + } + } else { + valueOrBlobValue = webidl.converters["USVString"](valueOrBlobValue, { + prefix, + context: "Argument 2", + }); + } + + const entry = createEntry(name, valueOrBlobValue, filename); + + ArrayPrototypePush(this[entryList], entry); + } + + /** + * @param {string} name + * @returns {void} + */ + delete(name) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'name' on 'FormData'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + + const list = this[entryList]; + for (let i = 0; i < list.length; i++) { + if (list[i].name === name) { + ArrayPrototypeSplice(list, i, 1); + i--; + } + } + } + + /** + * @param {string} name + * @returns {FormDataEntryValue | null} + */ + get(name) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'get' on 'FormData'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + + for (const entry of this[entryList]) { + if (entry.name === name) return entry.value; + } + return null; + } + + /** + * @param {string} name + * @returns {FormDataEntryValue[]} + */ + getAll(name) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'getAll' on 'FormData'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + + const returnList = []; + for (const entry of this[entryList]) { + if (entry.name === name) ArrayPrototypePush(returnList, entry.value); + } + return returnList; + } + + /** + * @param {string} name + * @returns {boolean} + */ + has(name) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'has' on 'FormData'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + + for (const entry of this[entryList]) { + if (entry.name === name) return true; + } + return false; + } + + /** + * @param {string} name + * @param {string | Blob} valueOrBlobValue + * @param {string} [filename] + * @returns {void} + */ + set(name, valueOrBlobValue, filename) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'set' on 'FormData'"; + webidl.requiredArguments(arguments.length, 2, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + if (valueOrBlobValue instanceof Blob) { + valueOrBlobValue = webidl.converters["Blob"](valueOrBlobValue, { + prefix, + context: "Argument 2", + }); + if (filename !== undefined) { + filename = webidl.converters["USVString"](filename, { + prefix, + context: "Argument 3", + }); + } + } else { + valueOrBlobValue = webidl.converters["USVString"](valueOrBlobValue, { + prefix, + context: "Argument 2", + }); + } + + const entry = createEntry(name, valueOrBlobValue, filename); + + const list = this[entryList]; + let added = false; + for (let i = 0; i < list.length; i++) { + if (list[i].name === name) { + if (!added) { + list[i] = entry; + added = true; + } else { + ArrayPrototypeSplice(list, i, 1); + i--; + } + } + } + if (!added) { + ArrayPrototypePush(list, entry); + } + } + } + + webidl.mixinPairIterable("FormData", FormData, entryList, "name", "value"); + + webidl.configurePrototype(FormData); + + const escape = (str, isFilename) => + StringPrototypeReplace( + StringPrototypeReplace( + StringPrototypeReplace( + (isFilename ? str : StringPrototypeReplace(str, /\r?\n|\r/g, "\r\n")), + /\n/g, + "%0A", + ), + /\r/g, + "%0D", + ), + /"/g, + "%22", + ); + + /** + * convert FormData to a Blob synchronous without reading all of the files + * @param {globalThis.FormData} formData + */ + function formDataToBlob(formData) { + const boundary = StringPrototypePadStart( + StringPrototypeSlice( + StringPrototypeReplaceAll(`${MathRandom()}${MathRandom()}`, ".", ""), + -28, + ), + 32, + "-", + ); + const chunks = []; + const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="`; + + for (const [name, value] of formData) { + if (typeof value === "string") { + ArrayPrototypePush( + chunks, + prefix + escape(name) + '"' + CRLF + CRLF + + StringPrototypeReplace(value, /\r(?!\n)|(?<!\r)\n/g, CRLF) + CRLF, + ); + } else { + ArrayPrototypePush( + chunks, + prefix + escape(name) + `"; filename="${escape(value.name, true)}"` + + CRLF + + `Content-Type: ${value.type || "application/octet-stream"}\r\n\r\n`, + value, + CRLF, + ); + } + } + + ArrayPrototypePush(chunks, `--${boundary}--`); + + return new Blob(chunks, { + type: "multipart/form-data; boundary=" + boundary, + }); + } + + /** + * @param {string} value + * @returns {Map<string, string>} + */ + function parseContentDisposition(value) { + /** @type {Map<string, string>} */ + const params = new Map(); + // Forced to do so for some Map constructor param mismatch + ArrayPrototypeForEach( + ArrayPrototypeMap( + ArrayPrototypeFilter( + ArrayPrototypeMap( + ArrayPrototypeSlice(StringPrototypeSplit(value, ";"), 1), + (s) => StringPrototypeSplit(StringPrototypeTrim(s), "="), + ), + (arr) => arr.length > 1, + ), + ([k, v]) => [k, StringPrototypeReplace(v, /^"([^"]*)"$/, "$1")], + ), + ([k, v]) => MapPrototypeSet(params, k, v), + ); + + return params; + } + + const CRLF = "\r\n"; + const LF = StringPrototypeCodePointAt(CRLF, 1); + const CR = StringPrototypeCodePointAt(CRLF, 0); + + class MultipartParser { + /** + * @param {Uint8Array} body + * @param {string | undefined} boundary + */ + constructor(body, boundary) { + if (!boundary) { + throw new TypeError("multipart/form-data must provide a boundary"); + } + + this.boundary = `--${boundary}`; + this.body = body; + this.boundaryChars = core.encode(this.boundary); + } + + /** + * @param {string} headersText + * @returns {{ headers: Headers, disposition: Map<string, string> }} + */ + #parseHeaders(headersText) { + const headers = new Headers(); + const rawHeaders = StringPrototypeSplit(headersText, "\r\n"); + for (const rawHeader of rawHeaders) { + const sepIndex = StringPrototypeIndexOf(rawHeader, ":"); + if (sepIndex < 0) { + continue; // Skip this header + } + const key = StringPrototypeSlice(rawHeader, 0, sepIndex); + const value = StringPrototypeSlice(rawHeader, sepIndex + 1); + headers.set(key, value); + } + + const disposition = parseContentDisposition( + headers.get("Content-Disposition") ?? "", + ); + + return { headers, disposition }; + } + + /** + * @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; + let state = 0; + let fileStart = 0; + + for (let i = 0; i < this.body.length; i++) { + const byte = this.body[i]; + const prevByte = this.body[i - 1]; + const isNewLine = byte === LF && prevByte === CR; + + if (state === 1 || state === 2 || state == 3) { + headerText += StringFromCharCode(byte); + } + if (state === 0 && isNewLine) { + state = 1; + } else if (state === 1 && isNewLine) { + state = 2; + const headersDone = this.body[i + 1] === CR && + this.body[i + 2] === LF; + + if (headersDone) { + state = 3; + } + } else if (state === 2 && isNewLine) { + state = 3; + } else if (state === 3 && isNewLine) { + state = 4; + fileStart = i + 1; + } else if (state === 4) { + if (this.boundaryChars[boundaryIndex] !== byte) { + boundaryIndex = 0; + } else { + boundaryIndex++; + } + + if (boundaryIndex >= this.boundary.length) { + const { headers, disposition } = this.#parseHeaders(headerText); + const content = TypedArrayPrototypeSubarray( + this.body, + fileStart, + i - boundaryIndex - 1, + ); + // https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata + const filename = MapPrototypeGet(disposition, "filename"); + const name = MapPrototypeGet(disposition, "name"); + + state = 5; + // Reset + boundaryIndex = 0; + headerText = ""; + + if (!name) { + continue; // Skip, unknown name + } + + if (filename) { + const blob = new Blob([content], { + type: headers.get("Content-Type") || "application/octet-stream", + }); + formData.append(name, blob, filename); + } else { + formData.append(name, core.decode(content)); + } + } + } else if (state === 5 && isNewLine) { + state = 1; + } + } + + return formData; + } + } + + /** + * @param {Uint8Array} body + * @param {string | undefined} boundary + * @returns {FormData} + */ + function parseFormData(body, boundary) { + const parser = new MultipartParser(body, boundary); + return parser.parse(); + } + + /** + * @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, + formDataToBlob, + parseFormData, + formDataFromEntries, + }; +})(globalThis); diff --git a/ext/fetch/22_body.js b/ext/fetch/22_body.js new file mode 100644 index 000000000..49da149c2 --- /dev/null +++ b/ext/fetch/22_body.js @@ -0,0 +1,403 @@ +// 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="../web/lib.deno_web.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="../web/06_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, formDataToBlob } = + globalThis.__bootstrap.formData; + const mimesniff = globalThis.__bootstrap.mimesniff; + const { isReadableStreamDisturbed, errorReadableStream, createProxy } = + globalThis.__bootstrap.streams; + const { + ArrayBuffer, + ArrayBufferIsView, + ArrayPrototypePush, + ArrayPrototypeMap, + JSONParse, + ObjectDefineProperties, + PromiseResolve, + TypedArrayPrototypeSet, + TypedArrayPrototypeSlice, + TypeError, + Uint8Array, + } = window.__bootstrap.primordials; + + 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; + if (consumed) { + this.streamOrStatic = new ReadableStream(); + this.streamOrStatic.getReader(); + } else { + this.streamOrStatic = new ReadableStream({ + start(controller) { + controller.enqueue(body); + controller.close(); + }, + }); + } + } + 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; + ArrayPrototypePush(chunks, chunk); + totalLength += chunk.byteLength; + } + const finalBuffer = new Uint8Array(totalLength); + let i = 0; + for (const chunk of chunks) { + TypedArrayPrototypeSet(finalBuffer, chunk, i); + i += chunk.byteLength; + } + return finalBuffer; + } else { + this.streamOrStatic.consumed = true; + return this.streamOrStatic.body; + } + } + + cancel(error) { + if (this.streamOrStatic instanceof ReadableStream) { + this.streamOrStatic.cancel(error); + } else { + this.streamOrStatic.consumed = true; + } + } + + error(error) { + if (this.streamOrStatic instanceof ReadableStream) { + errorReadableStream(this.streamOrStatic, error); + } else { + this.streamOrStatic.consumed = true; + } + } + + /** + * @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; + } + + /** + * @returns {InnerBody} + */ + createProxy() { + let proxyStreamOrStatic; + if (this.streamOrStatic instanceof ReadableStream) { + proxyStreamOrStatic = createProxy(this.streamOrStatic); + } else { + proxyStreamOrStatic = { ...this.streamOrStatic }; + this.streamOrStatic.consumed = true; + } + const proxy = new InnerBody(proxyStreamOrStatic); + proxy.source = this.source; + proxy.length = this.length; + return proxy; + } + } + + /** + * @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 PromiseResolve(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; + } + }, + configurable: true, + enumerable: true, + }, + bodyUsed: { + /** + * @returns {boolean} + */ + get() { + webidl.assertBranded(this, prototype); + if (this[bodySymbol] !== null) { + return this[bodySymbol].consumed(); + } + return false; + }, + configurable: true, + enumerable: true, + }, + arrayBuffer: { + /** @returns {Promise<ArrayBuffer>} */ + value: async function arrayBuffer() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "ArrayBuffer"); + }, + writable: true, + configurable: true, + enumerable: true, + }, + blob: { + /** @returns {Promise<Blob>} */ + value: async function blob() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "Blob", this[mimeTypeSymbol]); + }, + writable: true, + configurable: true, + enumerable: true, + }, + formData: { + /** @returns {Promise<FormData>} */ + value: async function formData() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "FormData", this[mimeTypeSymbol]); + }, + writable: true, + configurable: true, + enumerable: true, + }, + json: { + /** @returns {Promise<any>} */ + value: async function json() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "JSON"); + }, + writable: true, + configurable: true, + enumerable: true, + }, + text: { + /** @returns {Promise<string>} */ + value: async function text() { + webidl.assertBranded(this, prototype); + const body = await consumeBody(this); + return packageData(body, "text"); + }, + writable: true, + configurable: true, + enumerable: true, + }, + }; + return ObjectDefineProperties(prototype.prototype, mixin); + } + + /** + * 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) { + 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( + ArrayPrototypeMap( + entries, + (x) => ({ name: x[0], value: x[1] }), + ), + ); + } + throw new TypeError("Body can not be decoded as form data"); + } + throw new TypeError("Missing content type"); + } + case "JSON": + return JSONParse(core.decode(bytes)); + case "text": + return core.decode(bytes); + } + } + + /** + * @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 (ArrayBufferIsView(object) || object instanceof ArrayBuffer) { + const u8 = ArrayBufferIsView(object) + ? new Uint8Array( + object.buffer, + object.byteOffset, + object.byteLength, + ) + : new Uint8Array(object); + const copy = TypedArrayPrototypeSlice(u8, 0, u8.byteLength); + source = copy; + } else if (object instanceof FormData) { + const res = formDataToBlob(object); + stream = res.stream(); + source = res; + length = res.size; + contentType = res.type; + } else if (object instanceof URLSearchParams) { + // TODO(@satyarohith): not sure what primordial here. + source = core.encode(object.toString()); + contentType = "application/x-www-form-urlencoded;charset=UTF-8"; + } else if (typeof object === "string") { + source = core.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 (ArrayBufferIsView(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/ext/fetch/22_http_client.js b/ext/fetch/22_http_client.js new file mode 100644 index 000000000..60b069aa7 --- /dev/null +++ b/ext/fetch/22_http_client.js @@ -0,0 +1,40 @@ +// 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="../web/lib.deno_web.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="../web/06_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/ext/fetch/23_request.js b/ext/fetch/23_request.js new file mode 100644 index 000000000..1372125c1 --- /dev/null +++ b/ext/fetch/23_request.js @@ -0,0 +1,484 @@ +// 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="../web/lib.deno_web.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="../web/06_streams_types.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"use strict"; + +((window) => { + const webidl = window.__bootstrap.webidl; + const consoleInternal = window.__bootstrap.console; + const { HTTP_TOKEN_CODE_POINT_RE, byteUpperCase } = window.__bootstrap.infra; + const { URL } = window.__bootstrap.url; + const { guardFromHeaders } = window.__bootstrap.headers; + const { 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 abortSignal = window.__bootstrap.abortSignal; + const { + ArrayPrototypeMap, + ArrayPrototypeSlice, + ArrayPrototypeSplice, + MapPrototypeHas, + MapPrototypeGet, + MapPrototypeSet, + ObjectKeys, + RegExpPrototypeTest, + Symbol, + SymbolFor, + SymbolToStringTag, + TypeError, + } = window.__bootstrap.primordials; + + const _request = Symbol("request"); + const _headers = Symbol("headers"); + const _signal = Symbol("signal"); + 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 | typeof __window.bootstrap.fetchBody.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 {typeof __window.bootstrap.fetchBody.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 = [ + ...ArrayPrototypeMap(request.headerList, (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 (!RegExpPrototypeTest(HTTP_TOKEN_CODE_POINT_RE, 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]; + /** @type {AbortSignal} */ + [_signal]; + get [_mimeType]() { + let charset = null; + let essence = null; + let mimeType = null; + const headerList = headerListFromHeaders(this[_headers]); + const values = getDecodeSplitHeader(headerList, "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 = MapPrototypeGet(mimeType.parameters, "charset"); + if (newCharset !== undefined) { + charset = newCharset; + } + essence = mimesniff.essence(mimeType); + } else { + if ( + MapPrototypeHas(mimeType.parameters, "charset") === null && + charset !== null + ) { + MapPrototypeSet(mimeType.parameters, "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(); + + // 4. + let signal = null; + + // 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]; + signal = input[_signal]; + } + + // 12. + // TODO(lucacasonato): create a copy of `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; + } + + // 26. + if (init.signal !== undefined) { + signal = init.signal; + } + + // 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; + + // 28. + this[_signal] = abortSignal.newSignal(); + + // 29. + if (signal !== null) { + abortSignal.follow(this[_signal], signal); + } + + // 30. + this[_headers] = headersFromHeaderList(request.headerList, "request"); + + // 32. + if (ObjectKeys(init).length > 0) { + let headers = ArrayPrototypeSlice( + headerListFromHeaders(this[_headers]), + 0, + headerListFromHeaders(this[_headers]).length, + ); + if (init.headers !== undefined) { + headers = init.headers; + } + ArrayPrototypeSplice( + headerListFromHeaders(this[_headers]), + 0, + headerListFromHeaders(this[_headers]).length, + ); + fillHeaders(this[_headers], headers); + } + + // 33. + let inputBody = null; + if (input instanceof Request) { + inputBody = input[_body]; + } + + // 34. + if ( + (request.method === "GET" || request.method === "HEAD") && + ((init.body !== undefined && init.body !== null) || + inputBody !== null) + ) { + throw new TypeError("Request with GET/HEAD method cannot have body."); + } + + // 35. + let initBody = null; + + // 36. + 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); + } + } + + // 37. + const inputOrInitBody = initBody ?? inputBody; + + // 39. + let finalBody = inputOrInitBody; + + // 40. + if (initBody === null && inputBody !== null) { + if (input[_body] && input[_body].unusable()) { + throw new TypeError("Input request's body is unusable."); + } + finalBody = inputBody.createProxy(); + } + + // 41. + 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 redirect() { + webidl.assertBranded(this, Request); + return this[_request].redirectMode; + } + + get signal() { + webidl.assertBranded(this, Request); + return this[_signal]; + } + + clone() { + webidl.assertBranded(this, Request); + if (this[_body] && this[_body].unusable()) { + throw new TypeError("Body is unusable."); + } + const newReq = cloneInnerRequest(this[_request]); + const newSignal = abortSignal.newSignal(); + abortSignal.follow(newSignal, this[_signal]); + return fromInnerRequest( + newReq, + newSignal, + guardFromHeaders(this[_headers]), + ); + } + + get [SymbolToStringTag]() { + return "Request"; + } + + [SymbolFor("Deno.customInspect")](inspect) { + return inspect(consoleInternal.createFilteredInspectProxy({ + object: this, + evaluate: this instanceof Request, + keys: [ + "bodyUsed", + "headers", + "method", + "redirect", + "url", + ], + })); + } + } + + mixinBody(Request, _body, _mimeType); + + webidl.configurePrototype(Request); + + 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["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: "redirect", converter: webidl.converters["RequestRedirect"] }, + { + 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, signal, guard) { + const request = webidl.createBranded(Request); + request[_request] = inner; + request[_signal] = signal; + 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/ext/fetch/23_response.js b/ext/fetch/23_response.js new file mode 100644 index 000000000..0db20e90e --- /dev/null +++ b/ext/fetch/23_response.js @@ -0,0 +1,451 @@ +// 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="../web/lib.deno_web.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="../web/06_streams_types.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"use strict"; + +((window) => { + const webidl = window.__bootstrap.webidl; + const consoleInternal = window.__bootstrap.console; + const { HTTP_TAB_OR_SPACE, regexMatcher } = window.__bootstrap.infra; + const { 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 { + ArrayPrototypeMap, + ArrayPrototypePush, + MapPrototypeHas, + MapPrototypeGet, + MapPrototypeSet, + RangeError, + RegExp, + RegExpPrototypeTest, + Symbol, + SymbolFor, + SymbolToStringTag, + TypeError, + } = window.__bootstrap.primordials; + + 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 | typeof __window.bootstrap.fetchBody.InnerBody} body + * @property {boolean} aborted + * @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 = [ + ...ArrayPrototypeMap(response.headerList, (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, + aborted: response.aborted, + }; + } + + const defaultInnerResponse = { + type: "default", + body: null, + aborted: false, + 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; + } + + /** + * @returns {InnerResponse} + */ + function abortedNetworkError() { + const resp = networkError("aborted"); + resp.aborted = true; + return resp; + } + + class Response { + /** @type {InnerResponse} */ + [_response]; + /** @type {Headers} */ + [_headers]; + get [_mimeType]() { + let charset = null; + let essence = null; + let mimeType = null; + const headerList = headerListFromHeaders(this[_headers]); + const values = getDecodeSplitHeader(headerList, "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 = MapPrototypeGet(mimeType.parameters, "charset"); + if (newCharset !== undefined) { + charset = newCharset; + } + essence = mimesniff.essence(mimeType); + } else { + if ( + MapPrototypeHas(mimeType.parameters, "charset") === null && + charset !== null + ) { + MapPrototypeSet(mimeType.parameters, "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"; + ArrayPrototypePush(inner.headerList, ["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 (!RegExpPrototypeTest(REASON_PHRASE_RE, 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 [SymbolToStringTag]() { + return "Response"; + } + + [SymbolFor("Deno.customInspect")](inspect) { + return inspect(consoleInternal.createFilteredInspectProxy({ + object: this, + evaluate: this instanceof Response, + keys: [ + "body", + "bodyUsed", + "headers", + "ok", + "redirected", + "status", + "statusText", + "url", + ], + })); + } + } + + mixinBody(Response, _body, _mimeType); + + webidl.configurePrototype(Response); + + 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.newInnerResponse = newInnerResponse; + 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; + window.__bootstrap.fetch.abortedNetworkError = abortedNetworkError; +})(globalThis); diff --git a/ext/fetch/26_fetch.js b/ext/fetch/26_fetch.js new file mode 100644 index 000000000..f7166001e --- /dev/null +++ b/ext/fetch/26_fetch.js @@ -0,0 +1,542 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// <reference path="../../core/lib.deno_core.d.ts" /> +/// <reference path="../web/internal.d.ts" /> +/// <reference path="../url/internal.d.ts" /> +/// <reference path="../web/lib.deno_web.d.ts" /> +/// <reference path="../web/06_streams_types.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"use strict"; + +((window) => { + const core = window.Deno.core; + const webidl = window.__bootstrap.webidl; + const { errorReadableStream } = window.__bootstrap.streams; + const { InnerBody, extractBody } = window.__bootstrap.fetchBody; + const { + toInnerRequest, + toInnerResponse, + fromInnerResponse, + redirectStatus, + nullBodyStatus, + networkError, + abortedNetworkError, + } = window.__bootstrap.fetch; + const abortSignal = window.__bootstrap.abortSignal; + const { DOMException } = window.__bootstrap.domException; + const { + ArrayPrototypePush, + ArrayPrototypeSplice, + ArrayPrototypeFilter, + ArrayPrototypeIncludes, + Promise, + PromisePrototypeThen, + PromisePrototypeCatch, + StringPrototypeToLowerCase, + TypedArrayPrototypeSubarray, + TypeError, + Uint8Array, + } = window.__bootstrap.primordials; + + const REQUEST_BODY_HEADER_NAMES = [ + "content-encoding", + "content-language", + "content-location", + "content-type", + ]; + + /** + * @param {{ method: string, url: string, headers: [string, string][], clientRid: number | null, hasBody: boolean }} args + * @param {Uint8Array | null} body + * @returns {{ requestRid: number, requestBodyRid: number | null }} + */ + function opFetch(args, body) { + return core.opSync("op_fetch", args, body); + } + + /** + * @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); + } + + /** + * @param {number} rid + * @param {Uint8Array} body + * @returns {Promise<void>} + */ + function opFetchRequestWrite(rid, body) { + return core.opAsync("op_fetch_request_write", rid, body); + } + + /** + * @param {number} rid + * @param {Uint8Array} body + * @returns {Promise<number>} + */ + function opFetchResponseRead(rid, body) { + return core.opAsync("op_fetch_response_read", rid, body); + } + + // A finalization registry to clean up underlying fetch resources that are GC'ed. + const RESOURCE_REGISTRY = new FinalizationRegistry((rid) => { + try { + core.close(rid); + } catch { + // might have already been closed + } + }); + + /** + * @param {number} responseBodyRid + * @param {AbortSignal} [terminator] + * @returns {ReadableStream<Uint8Array>} + */ + function createResponseBodyStream(responseBodyRid, terminator) { + function onAbort() { + if (readable) { + errorReadableStream( + readable, + new DOMException("Ongoing fetch was aborted.", "AbortError"), + ); + } + try { + core.close(responseBodyRid); + } catch (_) { + // might have already been closed + } + } + // TODO(lucacasonato): clean up registration + terminator[abortSignal.add](onAbort); + const readable = 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(TypedArrayPrototypeSubarray(chunk, 0, read)); + } else { + RESOURCE_REGISTRY.unregister(readable); + // We have reached the end of the body, so we close the stream. + controller.close(); + try { + core.close(responseBodyRid); + } catch (_) { + // might have already been closed + } + } + } catch (err) { + RESOURCE_REGISTRY.unregister(readable); + if (terminator.aborted) { + controller.error( + new DOMException("Ongoing fetch was aborted.", "AbortError"), + ); + } else { + // There was an error while reading a chunk of the body, so we + // error. + controller.error(err); + } + try { + core.close(responseBodyRid); + } catch (_) { + // might have already been closed + } + } + }, + cancel() { + if (!terminator.aborted) { + terminator[abortSignal.signalAbort](); + } + }, + }); + RESOURCE_REGISTRY.register(readable, responseBodyRid, readable); + return readable; + } + + /** + * @param {InnerRequest} req + * @param {boolean} recursive + * @param {AbortSignal} terminator + * @returns {Promise<InnerResponse>} + */ + async function mainFetch(req, recursive, terminator) { + /** @type {ReadableStream<Uint8Array> | Uint8Array | null} */ + let reqBody = null; + + if (req.body !== null) { + if (req.body.streamOrStatic instanceof ReadableStream) { + if (req.body.length === null || req.body.source instanceof Blob) { + reqBody = req.body.stream; + } else { + const reader = req.body.stream.getReader(); + const r1 = await reader.read(); + if (r1.done) { + reqBody = new Uint8Array(0); + } else { + reqBody = r1.value; + const r2 = await reader.read(); + if (!r2.done) throw new TypeError("Unreachable"); + } + } + } else { + req.body.streamOrStatic.consumed = true; + reqBody = req.body.streamOrStatic.body; + } + } + + const { requestRid, requestBodyRid, cancelHandleRid } = opFetch({ + method: req.method, + url: req.currentUrl(), + headers: req.headerList, + clientRid: req.clientRid, + hasBody: reqBody !== null, + bodyLength: req.body?.length, + }, reqBody instanceof Uint8Array ? reqBody : null); + + function onAbort() { + try { + core.close(cancelHandleRid); + } catch (_) { + // might have already been closed + } + try { + core.close(requestBodyRid); + } catch (_) { + // might have already been closed + } + } + terminator[abortSignal.add](onAbort); + + if (requestBodyRid !== null) { + if (reqBody === null || !(reqBody instanceof ReadableStream)) { + throw new TypeError("Unreachable"); + } + const reader = reqBody.getReader(); + (async () => { + while (true) { + const { value, done } = await PromisePrototypeCatch( + reader.read(), + (err) => { + if (terminator.aborted) return { done: true, value: undefined }; + throw err; + }, + ); + if (done) break; + if (!(value instanceof Uint8Array)) { + await reader.cancel("value not a Uint8Array"); + break; + } + try { + await PromisePrototypeCatch( + opFetchRequestWrite(requestBodyRid, value), + (err) => { + if (terminator.aborted) return; + throw err; + }, + ); + if (terminator.aborted) break; + } catch (err) { + await reader.cancel(err); + break; + } + } + try { + core.close(requestBodyRid); + } catch (_) { + // might have already been closed + } + })(); + } + + let resp; + try { + resp = await PromisePrototypeCatch(opFetchSend(requestRid), (err) => { + if (terminator.aborted) return; + throw err; + }); + } finally { + try { + core.close(cancelHandleRid); + } catch (_) { + // might have already been closed + } + } + if (terminator.aborted) return abortedNetworkError(); + + /** @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, terminator); + case "manual": + break; + } + } + + if (nullBodyStatus(response.status)) { + core.close(resp.responseRid); + } else { + if (req.method === "HEAD" || req.method === "CONNECT") { + response.body = null; + core.close(resp.responseRid); + } else { + response.body = new InnerBody( + createResponseBodyStream(resp.responseRid, terminator), + ); + } + } + + if (recursive) return response; + + if (response.urlList.length === 0) { + response.urlList = [...req.urlList]; + } + + return response; + } + + /** + * @param {InnerRequest} request + * @param {InnerResponse} response + * @returns {Promise<InnerResponse>} + */ + function httpRedirectFetch(request, response, terminator) { + const locationHeaders = ArrayPrototypeFilter( + response.headerList, + (entry) => entry[0] === "location", + ); + if (locationHeaders.length === 0) { + return response; + } + const locationURL = new URL( + locationHeaders[0][1], + response.url() ?? undefined, + ); + if (locationURL.hash === "") { + locationURL.hash = request.currentUrl().hash; + } + if (locationURL.protocol !== "https:" && locationURL.protocol !== "http:") { + return networkError("Can not redirect to a non HTTP(s) url"); + } + if (request.redirectCount === 20) { + return networkError("Maximum number of redirects (20) reached"); + } + request.redirectCount++; + if ( + response.status !== 303 && + request.body !== null && + request.body.source === null + ) { + return networkError( + "Can not redeliver a streaming request body after a redirect", + ); + } + if ( + ((response.status === 301 || response.status === 302) && + request.method === "POST") || + (response.status === 303 && + request.method !== "GET" && + request.method !== "HEAD") + ) { + request.method = "GET"; + request.body = null; + for (let i = 0; i < request.headerList.length; i++) { + if ( + ArrayPrototypeIncludes( + REQUEST_BODY_HEADER_NAMES, + request.headerList[i][0], + ) + ) { + ArrayPrototypeSplice(request.headerList, i, 1); + i--; + } + } + } + if (request.body !== null) { + const res = extractBody(request.body.source); + request.body = res.body; + } + ArrayPrototypePush(request.urlList, locationURL.href); + return mainFetch(request, true, terminator); + } + + /** + * @param {RequestInfo} input + * @param {RequestInit} init + */ + function fetch(input, init = {}) { + // 1. + const p = new Promise((resolve, reject) => { + const prefix = "Failed to call 'fetch'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + input = webidl.converters["RequestInfo"](input, { + prefix, + context: "Argument 1", + }); + init = webidl.converters["RequestInit"](init, { + prefix, + context: "Argument 2", + }); + + // 2. + const requestObject = new Request(input, init); + // 3. + const request = toInnerRequest(requestObject); + // 4. + if (requestObject.signal.aborted) { + reject(abortFetch(request, null)); + return; + } + + // 7. + let responseObject = null; + // 9. + let locallyAborted = false; + // 10. + function onabort() { + locallyAborted = true; + reject(abortFetch(request, responseObject)); + } + requestObject.signal[abortSignal.add](onabort); + + if (!requestObject.headers.has("accept")) { + ArrayPrototypePush(request.headerList, ["accept", "*/*"]); + } + + // 12. + PromisePrototypeCatch( + PromisePrototypeThen( + mainFetch(request, false, requestObject.signal), + (response) => { + // 12.1. + if (locallyAborted) return; + // 12.2. + if (response.aborted) { + reject(request, responseObject); + requestObject.signal[abortSignal.remove](onabort); + return; + } + // 12.3. + if (response.type === "error") { + const err = new TypeError( + "Fetch failed: " + (response.error ?? "unknown error"), + ); + reject(err); + requestObject.signal[abortSignal.remove](onabort); + return; + } + responseObject = fromInnerResponse(response, "immutable"); + resolve(responseObject); + requestObject.signal[abortSignal.remove](onabort); + }, + ), + (err) => { + reject(err); + requestObject.signal[abortSignal.remove](onabort); + }, + ); + }); + return p; + } + + function abortFetch(request, responseObject) { + const error = new DOMException("Ongoing fetch was aborted.", "AbortError"); + if (request.body !== null) request.body.cancel(error); + if (responseObject !== null) { + const response = toInnerResponse(responseObject); + if (response.body !== null) response.body.error(error); + } + return error; + } + + /** + * Handle the Promise<Response> argument to the WebAssembly streaming + * APIs. This function should be registered through + * `Deno.core.setWasmStreamingCallback`. + * + * @param {any} source The source parameter that the WebAssembly + * streaming API was called with. + * @param {number} rid An rid that can be used with + * `Deno.core.wasmStreamingFeed`. + */ + function handleWasmStreaming(source, rid) { + // This implements part of + // https://webassembly.github.io/spec/web-api/#compile-a-potential-webassembly-response + (async () => { + try { + const res = webidl.converters["Response"](await source, { + prefix: "Failed to call 'WebAssembly.compileStreaming'", + context: "Argument 1", + }); + + // 2.3. + // The spec is ambiguous here, see + // https://github.com/WebAssembly/spec/issues/1138. The WPT tests + // expect the raw value of the Content-Type attribute lowercased. + if ( + StringPrototypeToLowerCase(res.headers.get("Content-Type")) !== + "application/wasm" + ) { + throw new TypeError("Invalid WebAssembly content type."); + } + + // 2.5. + if (!res.ok) { + throw new TypeError(`HTTP status code ${res.status}`); + } + + // 2.6. + // Rather than consuming the body as an ArrayBuffer, this passes each + // chunk to the feed as soon as it's available. + if (res.body !== null) { + const reader = res.body.getReader(); + while (true) { + const { value: chunk, done } = await reader.read(); + if (done) break; + Deno.core.wasmStreamingFeed(rid, "bytes", chunk); + } + } + + // 2.7. + Deno.core.wasmStreamingFeed(rid, "finish"); + } catch (err) { + // 2.8 and 3 + Deno.core.wasmStreamingFeed(rid, "abort", err); + } + })(); + } + + window.__bootstrap.fetch ??= {}; + window.__bootstrap.fetch.fetch = fetch; + window.__bootstrap.fetch.handleWasmStreaming = handleWasmStreaming; +})(this); diff --git a/ext/fetch/Cargo.toml b/ext/fetch/Cargo.toml new file mode 100644 index 000000000..80d0cb2e1 --- /dev/null +++ b/ext/fetch/Cargo.toml @@ -0,0 +1,28 @@ +# Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +[package] +name = "deno_fetch" +version = "0.37.0" +authors = ["the Deno authors"] +edition = "2018" +license = "MIT" +readme = "README.md" +repository = "https://github.com/denoland/deno" +description = "Fetch API implementation for Deno" + +[lib] +path = "lib.rs" + +[dependencies] +bytes = "1.0.1" +data-url = "0.1.0" +deno_core = { version = "0.96.0", path = "../../core" } +deno_tls = { version = "0.1.0", path = "../tls" } +deno_web = { version = "0.45.0", path = "../web" } +http = "0.2.4" +lazy_static = "1.4.0" +reqwest = { version = "0.11.4", default-features = false, features = ["rustls-tls", "stream", "gzip", "brotli"] } +serde = { version = "1.0.126", features = ["derive"] } +tokio = { version = "1.8.1", features = ["full"] } +tokio-stream = "0.1.7" +tokio-util = "0.6.7" diff --git a/ext/fetch/README.md b/ext/fetch/README.md new file mode 100644 index 000000000..2c946197e --- /dev/null +++ b/ext/fetch/README.md @@ -0,0 +1,5 @@ +# deno_fetch + +This crate implements the Fetch API. + +Spec: https://fetch.spec.whatwg.org/ diff --git a/ext/fetch/internal.d.ts b/ext/fetch/internal.d.ts new file mode 100644 index 000000000..a84e0bcce --- /dev/null +++ b/ext/fetch/internal.d.ts @@ -0,0 +1,108 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// deno-lint-ignore-file no-explicit-any + +/// <reference no-default-lib="true" /> +/// <reference lib="esnext" /> + +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 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 namespace formData { + declare type FormData = typeof FormData; + declare function formDataToBlob( + formData: globalThis.FormData, + ): Blob; + declare function parseFormData( + body: Uint8Array, + boundary: string | undefined, + ): FormData; + declare function formDataFromEntries(entries: FormDataEntry[]): FormData; + } + + 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, + signal: AbortSignal | null, + 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/ext/fetch/lib.deno_fetch.d.ts b/ext/fetch/lib.deno_fetch.d.ts new file mode 100644 index 000000000..7fe7d9453 --- /dev/null +++ b/ext/fetch/lib.deno_fetch.d.ts @@ -0,0 +1,437 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// deno-lint-ignore-file no-explicit-any + +/// <reference no-default-lib="true" /> +/// <reference lib="esnext" /> + +interface DomIterable<K, V> { + keys(): IterableIterator<K>; + values(): IterableIterator<V>; + entries(): IterableIterator<[K, V]>; + [Symbol.iterator](): IterableIterator<[K, V]>; + forEach( + callback: (value: V, key: K, parent: this) => void, + thisArg?: any, + ): void; +} + +type FormDataEntryValue = File | string; + +/** Provides a way to easily construct a set of key/value pairs representing + * form fields and their values, which can then be easily sent using the + * XMLHttpRequest.send() method. It uses the same format a form would use if the + * encoding type were set to "multipart/form-data". */ +declare class FormData implements DomIterable<string, FormDataEntryValue> { + // TODO(ry) FormData constructor is non-standard. + // new(form?: HTMLFormElement): FormData; + constructor(); + + append(name: string, value: string | Blob, fileName?: string): void; + delete(name: string): void; + get(name: string): FormDataEntryValue | null; + getAll(name: string): FormDataEntryValue[]; + has(name: string): boolean; + set(name: string, value: string | Blob, fileName?: string): void; + keys(): IterableIterator<string>; + values(): IterableIterator<string>; + entries(): IterableIterator<[string, FormDataEntryValue]>; + [Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]>; + forEach( + callback: (value: FormDataEntryValue, key: string, parent: this) => void, + thisArg?: any, + ): void; +} + +interface Body { + /** A simple getter used to expose a `ReadableStream` of the body contents. */ + readonly body: ReadableStream<Uint8Array> | null; + /** Stores a `Boolean` that declares whether the body has been used in a + * response yet. + */ + readonly bodyUsed: boolean; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with an `ArrayBuffer`. + */ + arrayBuffer(): Promise<ArrayBuffer>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `Blob`. + */ + blob(): Promise<Blob>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `FormData` object. + */ + formData(): Promise<FormData>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with the result of parsing the body text as JSON. + */ + json(): Promise<any>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `USVString` (text). + */ + text(): Promise<string>; +} + +type HeadersInit = Headers | string[][] | Record<string, string>; + +/** This Fetch API interface allows you to perform various actions on HTTP + * request and response headers. These actions include retrieving, setting, + * adding to, and removing. A Headers object has an associated header list, + * which is initially empty and consists of zero or more name and value pairs. + * You can add to this using methods like append() (see Examples). In all + * methods of this interface, header names are matched by case-insensitive byte + * sequence. */ +interface Headers { + append(name: string, value: string): void; + delete(name: string): void; + get(name: string): string | null; + has(name: string): boolean; + set(name: string, value: string): void; + forEach( + callbackfn: (value: string, key: string, parent: Headers) => void, + thisArg?: any, + ): void; +} + +declare class Headers implements DomIterable<string, string> { + constructor(init?: HeadersInit); + + /** Appends a new value onto an existing header inside a `Headers` object, or + * adds the header if it does not already exist. + */ + append(name: string, value: string): void; + /** Deletes a header from a `Headers` object. */ + delete(name: string): void; + /** Returns an iterator allowing to go through all key/value pairs + * contained in this Headers object. The both the key and value of each pairs + * are ByteString objects. + */ + entries(): IterableIterator<[string, string]>; + /** Returns a `ByteString` sequence of all the values of a header within a + * `Headers` object with a given name. + */ + get(name: string): string | null; + /** Returns a boolean stating whether a `Headers` object contains a certain + * header. + */ + has(name: string): boolean; + /** Returns an iterator allowing to go through all keys contained in + * this Headers object. The keys are ByteString objects. + */ + keys(): IterableIterator<string>; + /** Sets a new value for an existing header inside a Headers object, or adds + * the header if it does not already exist. + */ + set(name: string, value: string): void; + /** Returns an iterator allowing to go through all values contained in + * this Headers object. The values are ByteString objects. + */ + values(): IterableIterator<string>; + forEach( + callbackfn: (value: string, key: string, parent: this) => void, + thisArg?: any, + ): void; + /** The Symbol.iterator well-known symbol specifies the default + * iterator for this Headers object + */ + [Symbol.iterator](): IterableIterator<[string, string]>; +} + +type RequestInfo = Request | string; +type RequestCache = + | "default" + | "force-cache" + | "no-cache" + | "no-store" + | "only-if-cached" + | "reload"; +type RequestCredentials = "include" | "omit" | "same-origin"; +type RequestMode = "cors" | "navigate" | "no-cors" | "same-origin"; +type RequestRedirect = "error" | "follow" | "manual"; +type ReferrerPolicy = + | "" + | "no-referrer" + | "no-referrer-when-downgrade" + | "origin" + | "origin-when-cross-origin" + | "same-origin" + | "strict-origin" + | "strict-origin-when-cross-origin" + | "unsafe-url"; +type BodyInit = + | Blob + | BufferSource + | FormData + | URLSearchParams + | ReadableStream<Uint8Array> + | string; +type RequestDestination = + | "" + | "audio" + | "audioworklet" + | "document" + | "embed" + | "font" + | "image" + | "manifest" + | "object" + | "paintworklet" + | "report" + | "script" + | "sharedworker" + | "style" + | "track" + | "video" + | "worker" + | "xslt"; + +interface RequestInit { + /** + * A BodyInit object or null to set request's body. + */ + body?: BodyInit | null; + /** + * A string indicating how the request will interact with the browser's cache + * to set request's cache. + */ + cache?: RequestCache; + /** + * A string indicating whether credentials will be sent with the request + * always, never, or only when sent to a same-origin URL. Sets request's + * credentials. + */ + credentials?: RequestCredentials; + /** + * A Headers object, an object literal, or an array of two-item arrays to set + * request's headers. + */ + headers?: HeadersInit; + /** + * A cryptographic hash of the resource to be fetched by request. Sets + * request's integrity. + */ + integrity?: string; + /** + * A boolean to set request's keepalive. + */ + keepalive?: boolean; + /** + * A string to set request's method. + */ + method?: string; + /** + * A string to indicate whether the request will use CORS, or will be + * restricted to same-origin URLs. Sets request's mode. + */ + mode?: RequestMode; + /** + * A string indicating whether request follows redirects, results in an error + * upon encountering a redirect, or returns the redirect (in an opaque + * fashion). Sets request's redirect. + */ + redirect?: RequestRedirect; + /** + * A string whose value is a same-origin URL, "about:client", or the empty + * string, to set request's referrer. + */ + referrer?: string; + /** + * A referrer policy to set request's referrerPolicy. + */ + referrerPolicy?: ReferrerPolicy; + /** + * An AbortSignal to set request's signal. + */ + signal?: AbortSignal | null; + /** + * Can only be null. Used to disassociate request from any Window. + */ + window?: any; +} + +/** This Fetch API interface represents a resource request. */ +declare class Request implements Body { + constructor(input: RequestInfo, init?: RequestInit); + + /** + * Returns the cache mode associated with request, which is a string + * indicating how the request will interact with the browser's cache when + * fetching. + */ + readonly cache: RequestCache; + /** + * Returns the credentials mode associated with request, which is a string + * indicating whether credentials will be sent with the request always, never, + * or only when sent to a same-origin URL. + */ + readonly credentials: RequestCredentials; + /** + * Returns the kind of resource requested by request, e.g., "document" or "script". + */ + readonly destination: RequestDestination; + /** + * Returns a Headers object consisting of the headers associated with request. + * Note that headers added in the network layer by the user agent will not be + * accounted for in this object, e.g., the "Host" header. + */ + readonly headers: Headers; + /** + * Returns request's subresource integrity metadata, which is a cryptographic + * hash of the resource being fetched. Its value consists of multiple hashes + * separated by whitespace. [SRI] + */ + readonly integrity: string; + /** + * Returns a boolean indicating whether or not request is for a history + * navigation (a.k.a. back-forward navigation). + */ + readonly isHistoryNavigation: boolean; + /** + * Returns a boolean indicating whether or not request is for a reload + * navigation. + */ + readonly isReloadNavigation: boolean; + /** + * Returns a boolean indicating whether or not request can outlive the global + * in which it was created. + */ + readonly keepalive: boolean; + /** + * Returns request's HTTP method, which is "GET" by default. + */ + readonly method: string; + /** + * Returns the mode associated with request, which is a string indicating + * whether the request will use CORS, or will be restricted to same-origin + * URLs. + */ + readonly mode: RequestMode; + /** + * Returns the redirect mode associated with request, which is a string + * indicating how redirects for the request will be handled during fetching. A + * request will follow redirects by default. + */ + readonly redirect: RequestRedirect; + /** + * Returns the referrer of request. Its value can be a same-origin URL if + * explicitly set in init, the empty string to indicate no referrer, and + * "about:client" when defaulting to the global's default. This is used during + * fetching to determine the value of the `Referer` header of the request + * being made. + */ + readonly referrer: string; + /** + * Returns the referrer policy associated with request. This is used during + * fetching to compute the value of the request's referrer. + */ + readonly referrerPolicy: ReferrerPolicy; + /** + * Returns the signal associated with request, which is an AbortSignal object + * indicating whether or not request has been aborted, and its abort event + * handler. + */ + readonly signal: AbortSignal; + /** + * Returns the URL of request as a string. + */ + readonly url: string; + clone(): Request; + + /** A simple getter used to expose a `ReadableStream` of the body contents. */ + readonly body: ReadableStream<Uint8Array> | null; + /** Stores a `Boolean` that declares whether the body has been used in a + * response yet. + */ + readonly bodyUsed: boolean; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with an `ArrayBuffer`. + */ + arrayBuffer(): Promise<ArrayBuffer>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `Blob`. + */ + blob(): Promise<Blob>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `FormData` object. + */ + formData(): Promise<FormData>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with the result of parsing the body text as JSON. + */ + json(): Promise<any>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `USVString` (text). + */ + text(): Promise<string>; +} + +interface ResponseInit { + headers?: HeadersInit; + status?: number; + statusText?: string; +} + +type ResponseType = + | "basic" + | "cors" + | "default" + | "error" + | "opaque" + | "opaqueredirect"; + +/** This Fetch API interface represents the response to a request. */ +declare class Response implements Body { + constructor(body?: BodyInit | null, init?: ResponseInit); + static error(): Response; + static redirect(url: string, status?: number): Response; + + readonly headers: Headers; + readonly ok: boolean; + readonly redirected: boolean; + readonly status: number; + readonly statusText: string; + readonly trailer: Promise<Headers>; + readonly type: ResponseType; + readonly url: string; + clone(): Response; + + /** A simple getter used to expose a `ReadableStream` of the body contents. */ + readonly body: ReadableStream<Uint8Array> | null; + /** Stores a `Boolean` that declares whether the body has been used in a + * response yet. + */ + readonly bodyUsed: boolean; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with an `ArrayBuffer`. + */ + arrayBuffer(): Promise<ArrayBuffer>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `Blob`. + */ + blob(): Promise<Blob>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `FormData` object. + */ + formData(): Promise<FormData>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with the result of parsing the body text as JSON. + */ + json(): Promise<any>; + /** Takes a `Response` stream and reads it to completion. It returns a promise + * that resolves with a `USVString` (text). + */ + text(): Promise<string>; +} + +/** Fetch a resource from the network. It returns a Promise that resolves to the + * Response to that request, whether it is successful or not. + * + * const response = await fetch("http://my.json.host/data.json"); + * console.log(response.status); // e.g. 200 + * console.log(response.statusText); // e.g. "OK" + * const jsonData = await response.json(); + */ +declare function fetch( + input: Request | URL | string, + init?: RequestInit, +): Promise<Response>; diff --git a/ext/fetch/lib.rs b/ext/fetch/lib.rs new file mode 100644 index 000000000..e89df470a --- /dev/null +++ b/ext/fetch/lib.rs @@ -0,0 +1,567 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +use data_url::DataUrl; +use deno_core::error::bad_resource_id; +use deno_core::error::null_opbuf; +use deno_core::error::type_error; +use deno_core::error::AnyError; +use deno_core::futures::Future; +use deno_core::futures::Stream; +use deno_core::futures::StreamExt; +use deno_core::include_js_files; +use deno_core::op_async; +use deno_core::op_sync; +use deno_core::url::Url; +use deno_core::AsyncRefCell; +use deno_core::ByteString; +use deno_core::CancelFuture; +use deno_core::CancelHandle; +use deno_core::CancelTryFuture; +use deno_core::Canceled; +use deno_core::Extension; +use deno_core::OpState; +use deno_core::RcRef; +use deno_core::Resource; +use deno_core::ResourceId; +use deno_core::ZeroCopyBuf; +use deno_tls::create_http_client; +use deno_tls::rustls::RootCertStore; +use deno_tls::Proxy; +use deno_web::BlobStore; +use http::header::CONTENT_LENGTH; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use reqwest::header::HOST; +use reqwest::Body; +use reqwest::Client; +use reqwest::Method; +use reqwest::RequestBuilder; +use reqwest::Response; +use serde::Deserialize; +use serde::Serialize; +use std::borrow::Cow; +use std::cell::RefCell; +use std::convert::From; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use std::pin::Pin; +use std::rc::Rc; +use tokio::io::AsyncReadExt; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::io::StreamReader; + +pub use reqwest; // Re-export reqwest + +pub fn init<P: FetchPermissions + 'static>( + user_agent: String, + root_cert_store: Option<RootCertStore>, + proxy: Option<Proxy>, + request_builder_hook: Option<fn(RequestBuilder) -> RequestBuilder>, + unsafely_ignore_certificate_errors: Option<Vec<String>>, +) -> Extension { + Extension::builder() + .js(include_js_files!( + prefix "deno:ext/fetch", + "01_fetch_util.js", + "20_headers.js", + "21_formdata.js", + "22_body.js", + "22_http_client.js", + "23_request.js", + "23_response.js", + "26_fetch.js", + )) + .ops(vec![ + ("op_fetch", op_sync(op_fetch::<P>)), + ("op_fetch_send", op_async(op_fetch_send)), + ("op_fetch_request_write", op_async(op_fetch_request_write)), + ("op_fetch_response_read", op_async(op_fetch_response_read)), + ("op_create_http_client", op_sync(op_create_http_client::<P>)), + ]) + .state(move |state| { + state.put::<reqwest::Client>({ + create_http_client( + user_agent.clone(), + root_cert_store.clone(), + None, + proxy.clone(), + unsafely_ignore_certificate_errors.clone(), + ) + .unwrap() + }); + state.put::<HttpClientDefaults>(HttpClientDefaults { + user_agent: user_agent.clone(), + root_cert_store: root_cert_store.clone(), + proxy: proxy.clone(), + request_builder_hook, + unsafely_ignore_certificate_errors: unsafely_ignore_certificate_errors + .clone(), + }); + Ok(()) + }) + .build() +} + +pub struct HttpClientDefaults { + pub user_agent: String, + pub root_cert_store: Option<RootCertStore>, + pub proxy: Option<Proxy>, + pub request_builder_hook: Option<fn(RequestBuilder) -> RequestBuilder>, + pub unsafely_ignore_certificate_errors: Option<Vec<String>>, +} + +pub trait FetchPermissions { + fn check_net_url(&mut self, _url: &Url) -> Result<(), AnyError>; + fn check_read(&mut self, _p: &Path) -> Result<(), AnyError>; +} + +/// For use with `op_fetch` when the user does not want permissions. +pub struct NoFetchPermissions; + +impl FetchPermissions for NoFetchPermissions { + fn check_net_url(&mut self, _url: &Url) -> Result<(), AnyError> { + Ok(()) + } + + fn check_read(&mut self, _p: &Path) -> Result<(), AnyError> { + Ok(()) + } +} + +pub fn get_declaration() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("lib.deno_fetch.d.ts") +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchArgs { + method: ByteString, + url: String, + headers: Vec<(ByteString, ByteString)>, + client_rid: Option<u32>, + has_body: bool, + body_length: Option<u64>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchReturn { + request_rid: ResourceId, + request_body_rid: Option<ResourceId>, + cancel_handle_rid: Option<ResourceId>, +} + +pub fn op_fetch<FP>( + state: &mut OpState, + args: FetchArgs, + data: Option<ZeroCopyBuf>, +) -> Result<FetchReturn, AnyError> +where + FP: FetchPermissions + 'static, +{ + let client = if let Some(rid) = args.client_rid { + let r = state + .resource_table + .get::<HttpClientResource>(rid) + .ok_or_else(bad_resource_id)?; + r.client.clone() + } else { + let client = state.borrow::<reqwest::Client>(); + client.clone() + }; + + let method = Method::from_bytes(&args.method)?; + let url = Url::parse(&args.url)?; + + // Check scheme before asking for net permission + let scheme = url.scheme(); + let (request_rid, request_body_rid, cancel_handle_rid) = match scheme { + "http" | "https" => { + let permissions = state.borrow_mut::<FP>(); + permissions.check_net_url(&url)?; + + let mut request = client.request(method, url); + + let request_body_rid = if args.has_body { + match data { + None => { + // If no body is passed, we return a writer for streaming the body. + let (tx, rx) = mpsc::channel::<std::io::Result<Vec<u8>>>(1); + + // If the size of the body is known, we include a content-length + // header explicitly. + if let Some(body_size) = args.body_length { + request = + request.header(CONTENT_LENGTH, HeaderValue::from(body_size)) + } + + request = request.body(Body::wrap_stream(ReceiverStream::new(rx))); + + let request_body_rid = + state.resource_table.add(FetchRequestBodyResource { + body: AsyncRefCell::new(tx), + cancel: CancelHandle::default(), + }); + + Some(request_body_rid) + } + Some(data) => { + // If a body is passed, we use it, and don't return a body for streaming. + request = request.body(Vec::from(&*data)); + None + } + } + } else { + None + }; + + for (key, value) in args.headers { + let name = HeaderName::from_bytes(&key).unwrap(); + let v = HeaderValue::from_bytes(&value).unwrap(); + if name != HOST { + request = request.header(name, v); + } + } + + let defaults = state.borrow::<HttpClientDefaults>(); + if let Some(request_builder_hook) = defaults.request_builder_hook { + request = request_builder_hook(request); + } + + let cancel_handle = CancelHandle::new_rc(); + let cancel_handle_ = cancel_handle.clone(); + + let fut = async move { + request + .send() + .or_cancel(cancel_handle_) + .await + .map(|res| res.map_err(|err| type_error(err.to_string()))) + }; + + let request_rid = state + .resource_table + .add(FetchRequestResource(Box::pin(fut))); + + let cancel_handle_rid = + state.resource_table.add(FetchCancelHandle(cancel_handle)); + + (request_rid, request_body_rid, Some(cancel_handle_rid)) + } + "data" => { + let data_url = DataUrl::process(url.as_str()) + .map_err(|e| type_error(format!("{:?}", e)))?; + + let (body, _) = data_url + .decode_to_vec() + .map_err(|e| type_error(format!("{:?}", e)))?; + + let response = http::Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, data_url.mime_type().to_string()) + .body(reqwest::Body::from(body))?; + + let fut = async move { Ok(Ok(Response::from(response))) }; + + let request_rid = state + .resource_table + .add(FetchRequestResource(Box::pin(fut))); + + (request_rid, None, None) + } + "blob" => { + let blob_store = state.try_borrow::<BlobStore>().ok_or_else(|| { + type_error("Blob URLs are not supported in this context.") + })?; + + let blob = blob_store + .get_object_url(url)? + .ok_or_else(|| type_error("Blob for the given URL not found."))?; + + if method != "GET" { + return Err(type_error("Blob URL fetch only supports GET method.")); + } + + let cancel_handle = CancelHandle::new_rc(); + let cancel_handle_ = cancel_handle.clone(); + + let fut = async move { + // TODO(lucacsonato): this should be a stream! + let chunk = match blob.read_all().or_cancel(cancel_handle_).await? { + Ok(chunk) => chunk, + Err(err) => return Ok(Err(err)), + }; + + let res = http::Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_LENGTH, chunk.len()) + .header(http::header::CONTENT_TYPE, blob.media_type.clone()) + .body(reqwest::Body::from(chunk)) + .map_err(|err| type_error(err.to_string())); + + match res { + Ok(response) => Ok(Ok(Response::from(response))), + Err(err) => Ok(Err(err)), + } + }; + + let request_rid = state + .resource_table + .add(FetchRequestResource(Box::pin(fut))); + + let cancel_handle_rid = + state.resource_table.add(FetchCancelHandle(cancel_handle)); + + (request_rid, None, Some(cancel_handle_rid)) + } + _ => return Err(type_error(format!("scheme '{}' not supported", scheme))), + }; + + Ok(FetchReturn { + request_rid, + request_body_rid, + cancel_handle_rid, + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchResponse { + status: u16, + status_text: String, + headers: Vec<(ByteString, ByteString)>, + url: String, + response_rid: ResourceId, +} + +pub async fn op_fetch_send( + state: Rc<RefCell<OpState>>, + rid: ResourceId, + _: (), +) -> Result<FetchResponse, AnyError> { + let request = state + .borrow_mut() + .resource_table + .take::<FetchRequestResource>(rid) + .ok_or_else(bad_resource_id)?; + + let request = Rc::try_unwrap(request) + .ok() + .expect("multiple op_fetch_send ongoing"); + + let res = match request.0.await { + Ok(Ok(res)) => res, + Ok(Err(err)) => return Err(type_error(err.to_string())), + Err(_) => return Err(type_error("request was cancelled")), + }; + + //debug!("Fetch response {}", url); + let status = res.status(); + let url = res.url().to_string(); + let mut res_headers = Vec::new(); + for (key, val) in res.headers().iter() { + let key_bytes: &[u8] = key.as_ref(); + res_headers.push(( + ByteString(key_bytes.to_owned()), + ByteString(val.as_bytes().to_owned()), + )); + } + + let stream: BytesStream = Box::pin(res.bytes_stream().map(|r| { + r.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)) + })); + let stream_reader = StreamReader::new(stream); + let rid = state + .borrow_mut() + .resource_table + .add(FetchResponseBodyResource { + reader: AsyncRefCell::new(stream_reader), + cancel: CancelHandle::default(), + }); + + Ok(FetchResponse { + status: status.as_u16(), + status_text: status.canonical_reason().unwrap_or("").to_string(), + headers: res_headers, + url, + response_rid: rid, + }) +} + +pub async fn op_fetch_request_write( + state: Rc<RefCell<OpState>>, + rid: ResourceId, + data: Option<ZeroCopyBuf>, +) -> Result<(), AnyError> { + let data = data.ok_or_else(null_opbuf)?; + let buf = Vec::from(&*data); + + let resource = state + .borrow() + .resource_table + .get::<FetchRequestBodyResource>(rid) + .ok_or_else(bad_resource_id)?; + let body = RcRef::map(&resource, |r| &r.body).borrow_mut().await; + let cancel = RcRef::map(resource, |r| &r.cancel); + body.send(Ok(buf)).or_cancel(cancel).await?.map_err(|_| { + type_error("request body receiver not connected (request closed)") + })?; + + Ok(()) +} + +pub async fn op_fetch_response_read( + state: Rc<RefCell<OpState>>, + rid: ResourceId, + data: Option<ZeroCopyBuf>, +) -> Result<usize, AnyError> { + let data = data.ok_or_else(null_opbuf)?; + + let resource = state + .borrow() + .resource_table + .get::<FetchResponseBodyResource>(rid) + .ok_or_else(bad_resource_id)?; + let mut reader = RcRef::map(&resource, |r| &r.reader).borrow_mut().await; + let cancel = RcRef::map(resource, |r| &r.cancel); + let mut buf = data.clone(); + let read = reader.read(&mut buf).try_or_cancel(cancel).await?; + Ok(read) +} + +type CancelableResponseResult = Result<Result<Response, AnyError>, Canceled>; + +struct FetchRequestResource( + Pin<Box<dyn Future<Output = CancelableResponseResult>>>, +); + +impl Resource for FetchRequestResource { + fn name(&self) -> Cow<str> { + "fetchRequest".into() + } +} + +struct FetchCancelHandle(Rc<CancelHandle>); + +impl Resource for FetchCancelHandle { + fn name(&self) -> Cow<str> { + "fetchCancelHandle".into() + } + + fn close(self: Rc<Self>) { + self.0.cancel() + } +} + +struct FetchRequestBodyResource { + body: AsyncRefCell<mpsc::Sender<std::io::Result<Vec<u8>>>>, + cancel: CancelHandle, +} + +impl Resource for FetchRequestBodyResource { + fn name(&self) -> Cow<str> { + "fetchRequestBody".into() + } + + fn close(self: Rc<Self>) { + self.cancel.cancel() + } +} + +type BytesStream = + Pin<Box<dyn Stream<Item = Result<bytes::Bytes, std::io::Error>> + Unpin>>; + +struct FetchResponseBodyResource { + reader: AsyncRefCell<StreamReader<BytesStream, bytes::Bytes>>, + cancel: CancelHandle, +} + +impl Resource for FetchResponseBodyResource { + fn name(&self) -> Cow<str> { + "fetchResponseBody".into() + } + + fn close(self: Rc<Self>) { + self.cancel.cancel() + } +} + +struct HttpClientResource { + client: Client, +} + +impl Resource for HttpClientResource { + fn name(&self) -> Cow<str> { + "httpClient".into() + } +} + +impl HttpClientResource { + fn new(client: Client) -> Self { + Self { client } + } +} + +#[derive(Deserialize, Default, Debug)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct CreateHttpClientOptions { + ca_stores: Option<Vec<String>>, + ca_file: Option<String>, + ca_data: Option<ByteString>, + proxy: Option<Proxy>, +} + +pub fn op_create_http_client<FP>( + state: &mut OpState, + args: CreateHttpClientOptions, + _: (), +) -> Result<ResourceId, AnyError> +where + FP: FetchPermissions + 'static, +{ + if let Some(ca_file) = args.ca_file.clone() { + let permissions = state.borrow_mut::<FP>(); + permissions.check_read(&PathBuf::from(ca_file))?; + } + + if let Some(proxy) = args.proxy.clone() { + let permissions = state.borrow_mut::<FP>(); + let url = Url::parse(&proxy.url)?; + permissions.check_net_url(&url)?; + } + + let defaults = state.borrow::<HttpClientDefaults>(); + let cert_data = + get_cert_data(args.ca_file.as_deref(), args.ca_data.as_deref())?; + + let client = create_http_client( + defaults.user_agent.clone(), + defaults.root_cert_store.clone(), + cert_data, + args.proxy, + defaults.unsafely_ignore_certificate_errors.clone(), + ) + .unwrap(); + + let rid = state.resource_table.add(HttpClientResource::new(client)); + Ok(rid) +} + +fn get_cert_data( + ca_file: Option<&str>, + ca_data: Option<&[u8]>, +) -> Result<Option<Vec<u8>>, AnyError> { + if let Some(ca_data) = ca_data { + Ok(Some(ca_data.to_vec())) + } else if let Some(ca_file) = ca_file { + let mut buf = Vec::new(); + File::open(ca_file)?.read_to_end(&mut buf)?; + Ok(Some(buf)) + } else { + Ok(None) + } +} |