diff options
author | Luca Casonato <lucacasonato@yahoo.com> | 2021-04-20 14:47:22 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-20 14:47:22 +0200 |
commit | 9e6cd91014ac4a0d34556b0d09cbe25e4e0930c6 (patch) | |
tree | 4523790510a17676c987039feb03f208a258dc16 /op_crates/fetch/26_fetch.js | |
parent | 115197ffb06aad2a3045e8478980ab911b5a5eeb (diff) |
chore: align fetch to spec (#10203)
This commit aligns the `fetch` API and the `Request` / `Response`
classes belonging to it to the spec. This commit enables all the
relevant `fetch` WPT tests. Spec compliance is now at around 90%.
Performance is essentially identical now (within 1% of 1.9.0).
Diffstat (limited to 'op_crates/fetch/26_fetch.js')
-rw-r--r-- | op_crates/fetch/26_fetch.js | 1296 |
1 files changed, 229 insertions, 1067 deletions
diff --git a/op_crates/fetch/26_fetch.js b/op_crates/fetch/26_fetch.js index d07121e86..a6d1608d0 100644 --- a/op_crates/fetch/26_fetch.js +++ b/op_crates/fetch/26_fetch.js @@ -13,497 +13,36 @@ ((window) => { const core = window.Deno.core; - - // provided by "deno_web" - const { URLSearchParams } = window.__bootstrap.url; - const { getLocationHref } = window.__bootstrap.location; - const { FormData, parseFormData, encodeFormData } = - window.__bootstrap.formData; - const { parseMimeType } = window.__bootstrap.mimesniff; - - const { ReadableStream, isReadableStreamDisturbed } = - window.__bootstrap.streams; - const { Headers } = window.__bootstrap.headers; - const { Blob, _byteSequence, File } = window.__bootstrap.file; - - const MAX_SIZE = 2 ** 32 - 2; - - /** - * @param {Uint8Array} src - * @param {Uint8Array} dst - * @param {number} off the offset into `dst` where it will at which to begin writing values from `src` - * - * @returns {number} number of bytes copied - */ - function copyBytes(src, dst, off = 0) { - const r = dst.byteLength - off; - if (src.byteLength > r) { - src = src.subarray(0, r); - } - dst.set(src, off); - return src.byteLength; - } - - class Buffer { - /** @type {Uint8Array} */ - #buf; // contents are the bytes buf[off : len(buf)] - #off = 0; // read at buf[off], write at buf[buf.byteLength] - - /** @param {ArrayBuffer} [ab] */ - constructor(ab) { - if (ab == null) { - this.#buf = new Uint8Array(0); - return; - } - - this.#buf = new Uint8Array(ab); - } - - /** - * @returns {Uint8Array} - */ - bytes(options = { copy: true }) { - if (options.copy === false) return this.#buf.subarray(this.#off); - return this.#buf.slice(this.#off); - } - - /** - * @returns {boolean} - */ - empty() { - return this.#buf.byteLength <= this.#off; - } - - /** - * @returns {number} - */ - get length() { - return this.#buf.byteLength - this.#off; - } - - /** - * @returns {number} - */ - get capacity() { - return this.#buf.buffer.byteLength; - } - - /** - * @returns {void} - */ - reset() { - this.#reslice(0); - this.#off = 0; - } - - /** - * @param {number} n - * @returns {number} - */ - #tryGrowByReslice = (n) => { - const l = this.#buf.byteLength; - if (n <= this.capacity - l) { - this.#reslice(l + n); - return l; - } - return -1; - }; - - /** - * @param {number} len - * @returns {void} - */ - #reslice = (len) => { - if (!(len <= this.#buf.buffer.byteLength)) { - throw new Error("assert"); - } - this.#buf = new Uint8Array(this.#buf.buffer, 0, len); - }; - - /** - * @param {Uint8Array} p - * @returns {number} - */ - writeSync(p) { - const m = this.#grow(p.byteLength); - return copyBytes(p, this.#buf, m); - } - - /** - * @param {Uint8Array} p - * @returns {Promise<number>} - */ - write(p) { - const n = this.writeSync(p); - return Promise.resolve(n); - } - - /** - * @param {number} n - * @returns {number} - */ - #grow = (n) => { - const m = this.length; - // If buffer is empty, reset to recover space. - if (m === 0 && this.#off !== 0) { - this.reset(); - } - // Fast: Try to grow by means of a reslice. - const i = this.#tryGrowByReslice(n); - if (i >= 0) { - return i; - } - const c = this.capacity; - if (n <= Math.floor(c / 2) - m) { - // We can slide things down instead of allocating a new - // ArrayBuffer. We only need m+n <= c to slide, but - // we instead let capacity get twice as large so we - // don't spend all our time copying. - copyBytes(this.#buf.subarray(this.#off), this.#buf); - } else if (c + n > MAX_SIZE) { - throw new Error("The buffer cannot be grown beyond the maximum size."); - } else { - // Not enough space anywhere, we need to allocate. - const buf = new Uint8Array(Math.min(2 * c + n, MAX_SIZE)); - copyBytes(this.#buf.subarray(this.#off), buf); - this.#buf = buf; - } - // Restore this.#off and len(this.#buf). - this.#off = 0; - this.#reslice(Math.min(m + n, MAX_SIZE)); - return m; - }; - - /** - * @param {number} n - * @returns {void} - */ - grow(n) { - if (n < 0) { - throw Error("Buffer.grow: negative count"); - } - const m = this.#grow(n); - this.#reslice(m); - } - } - - /** - * @param {unknown} x - * @returns {x is ArrayBufferView} - */ - function isTypedArray(x) { - return ArrayBuffer.isView(x) && !(x instanceof DataView); - } - - /** - * @param {string} s - * @param {string} value - * @returns {boolean} - */ - function hasHeaderValueOf(s, value) { - return new RegExp(`^${value}(?:[\\s;]|$)`).test(s); - } - - /** - * @param {string} name - * @param {BodyInit | null} bodySource - */ - function validateBodyType(name, bodySource) { - if (isTypedArray(bodySource)) { - return true; - } else if (bodySource instanceof ArrayBuffer) { - return true; - } else if (typeof bodySource === "string") { - return true; - } else if (bodySource instanceof ReadableStream) { - return true; - } else if (bodySource instanceof FormData) { - return true; - } else if (bodySource instanceof URLSearchParams) { - return true; - } else if (!bodySource) { - return true; // null body is fine - } - throw new TypeError( - `Bad ${name} body type: ${bodySource.constructor.name}`, - ); - } - - /** - * @param {ReadableStreamReader<Uint8Array>} stream - * @param {number} [size] - */ - async function bufferFromStream( - stream, - size, - ) { - const encoder = new TextEncoder(); - const buffer = new Buffer(); - - if (size) { - // grow to avoid unnecessary allocations & copies - buffer.grow(size); - } - - while (true) { - const { done, value } = await stream.read(); - - if (done) break; - - if (typeof value === "string") { - buffer.writeSync(encoder.encode(value)); - } else if (value instanceof ArrayBuffer) { - buffer.writeSync(new Uint8Array(value)); - } else if (value instanceof Uint8Array) { - buffer.writeSync(value); - } else if (!value) { - // noop for undefined - } else { - throw new Error("unhandled type on stream read"); - } - } - - return buffer.bytes().buffer; - } - - /** - * @param {Exclude<BodyInit, ReadableStream> | null} bodySource - */ - function bodyToArrayBuffer(bodySource) { - if (isTypedArray(bodySource)) { - return bodySource.buffer; - } else if (bodySource instanceof ArrayBuffer) { - return bodySource; - } else if (typeof bodySource === "string") { - const enc = new TextEncoder(); - return enc.encode(bodySource).buffer; - } else if ( - bodySource instanceof FormData || - bodySource instanceof URLSearchParams - ) { - const enc = new TextEncoder(); - return enc.encode(bodySource.toString()).buffer; - } else if (!bodySource) { - return new ArrayBuffer(0); - } - throw new Error( - `Body type not implemented: ${bodySource.constructor.name}`, - ); - } - - const BodyUsedError = - "Failed to execute 'clone' on 'Body': body is already used"; - - const teeBody = Symbol("Body#tee"); - - // fastBody and dontValidateUrl allow users to opt out of certain behaviors - const fastBody = Symbol("Body#fast"); - const dontValidateUrl = Symbol("dontValidateUrl"); - const lazyHeaders = Symbol("lazyHeaders"); - - class Body { - #contentType = ""; - #size; - /** @type {BodyInit | null} */ - #bodySource; - /** @type {ReadableStream<Uint8Array> | null} */ - #stream = null; - - /** - * @param {BodyInit| null} bodySource - * @param {{contentType: string, size?: number}} meta - */ - constructor(bodySource, meta) { - validateBodyType(this.constructor.name, bodySource); - this.#bodySource = bodySource; - this.#contentType = meta.contentType; - this.#size = meta.size; - } - - get body() { - if (!this.#stream) { - if (!this.#bodySource) { - return null; - } else if (this.#bodySource instanceof ReadableStream) { - this.#stream = this.#bodySource; - } else { - const buf = bodyToArrayBuffer(this.#bodySource); - if (!(buf instanceof ArrayBuffer)) { - throw new Error( - `Expected ArrayBuffer from body`, - ); - } - - this.#stream = new ReadableStream({ - /** - * @param {ReadableStreamDefaultController<Uint8Array>} controller - */ - start(controller) { - controller.enqueue(new Uint8Array(buf)); - controller.close(); - }, - }); - } - } - - return this.#stream; - } - - // Optimization that allows caller to bypass expensive ReadableStream. - [fastBody]() { - if (!this.#bodySource) { - return null; - } else if (!(this.#bodySource instanceof ReadableStream)) { - return bodyToArrayBuffer(this.#bodySource); - } else { - return this.body; - } - } - - /** @returns {BodyInit | null} */ - [teeBody]() { - if (this.#stream || this.#bodySource instanceof ReadableStream) { - const body = this.body; - if (body) { - const [stream1, stream2] = body.tee(); - this.#stream = stream1; - return stream2; - } else { - return null; - } - } - - return this.#bodySource; - } - - get bodyUsed() { - if (this.body && isReadableStreamDisturbed(this.body)) { - return true; - } - return false; - } - - set bodyUsed(_) { - // this is a noop per spec - } - - /** @returns {Promise<Blob>} */ - async blob() { - return new Blob([await this.arrayBuffer()], { - type: this.#contentType, - }); - } - - // ref: https://fetch.spec.whatwg.org/#body-mixin - /** @returns {Promise<FormData>} */ - async formData() { - const formData = new FormData(); - const mimeType = parseMimeType(this.#contentType); - if (mimeType) { - if (mimeType.type === "multipart" && mimeType.subtype === "form-data") { - // ref: https://tools.ietf.org/html/rfc2046#section-5.1 - const boundary = mimeType.parameters.get("boundary"); - const body = new Uint8Array(await this.arrayBuffer()); - return parseFormData(body, boundary); - } else if ( - mimeType.type === "application" && - mimeType.subtype === "x-www-form-urlencoded" - ) { - // From https://github.com/github/fetch/blob/master/fetch.js - // Copyright (c) 2014-2016 GitHub, Inc. MIT License - const body = await this.text(); - try { - body - .trim() - .split("&") - .forEach((bytes) => { - if (bytes) { - const split = bytes.split("="); - if (split.length >= 2) { - // @ts-expect-error this is safe because of the above check - const name = split.shift().replace(/\+/g, " "); - const value = split.join("=").replace(/\+/g, " "); - formData.append( - decodeURIComponent(name), - decodeURIComponent(value), - ); - } - } - }); - } catch (e) { - throw new TypeError("Invalid form urlencoded format"); - } - return formData; - } - } - - throw new TypeError("Invalid form data"); - } - - /** @returns {Promise<string>} */ - async text() { - if (typeof this.#bodySource === "string") { - return this.#bodySource; - } - - const ab = await this.arrayBuffer(); - const decoder = new TextDecoder("utf-8"); - return decoder.decode(ab); - } - - /** @returns {Promise<any>} */ - async json() { - const raw = await this.text(); - return JSON.parse(raw); - } - - /** @returns {Promise<ArrayBuffer>} */ - arrayBuffer() { - if (this.#bodySource instanceof ReadableStream) { - const body = this.body; - if (!body) throw new TypeError("Unreachable state (no body)"); - return bufferFromStream(body.getReader(), this.#size); - } - return Promise.resolve(bodyToArrayBuffer(this.#bodySource)); - } - } + const webidl = window.__bootstrap.webidl; + const { byteLowerCase } = window.__bootstrap.infra; + const { InnerBody, extractBody } = window.__bootstrap.fetchBody; + const { + toInnerRequest, + fromInnerResponse, + redirectStatus, + nullBodyStatus, + networkError, + } = window.__bootstrap.fetch; + + const REQUEST_BODY_HEADER_NAMES = [ + "content-encoding", + "content-language", + "content-location", + "content-type", + ]; /** - * @param {Deno.CreateHttpClientOptions} options - * @returns {HttpClient} - */ - function createHttpClient(options) { - return new HttpClient(core.opSync("op_create_http_client", options)); - } - - class HttpClient { - /** - * @param {number} rid - */ - constructor(rid) { - this.rid = rid; - } - close() { - core.close(this.rid); - } - } - - /** - * @param {{ headers: [string,string][], method: string, url: string, baseUrl: string | null, clientRid: number | null, hasBody: boolean }} args + * @param {{ method: string, url: string, headers: [string, string][], clientRid: number | null, hasBody: boolean }} args * @param {Uint8Array | null} body - * @returns {{requestRid: number, requestBodyRid: number | null}} + * @returns {{ requestRid: number, requestBodyRid: number | null }} */ function opFetch(args, body) { - let zeroCopy; - if (body != null) { - zeroCopy = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); - } - return core.opSync("op_fetch", args, zeroCopy); + return core.opSync("op_fetch", args, body); } /** - * @param {number} rid - * @returns {Promise<{status: number, statusText: string, headers: Record<string,string[]>, url: string, responseRid: number}>} + * @param {number} rid + * @returns {Promise<{ status: number, statusText: string, headers: [string, string][], url: string, responseRid: number }>} */ function opFetchSend(rid) { return core.opAsync("op_fetch_send", rid); @@ -515,631 +54,254 @@ * @returns {Promise<void>} */ function opFetchRequestWrite(rid, body) { - const zeroCopy = new Uint8Array( - body.buffer, - body.byteOffset, - body.byteLength, - ); - return core.opAsync("op_fetch_request_write", rid, zeroCopy); + return core.opAsync("op_fetch_request_write", rid, body); } - const NULL_BODY_STATUS = [101, 204, 205, 304]; - const REDIRECT_STATUS = [301, 302, 303, 307, 308]; - /** - * @param {string} s - * @returns {string} + * @param {number} rid + * @param {Uint8Array} body + * @returns {Promise<number>} */ - function byteUpperCase(s) { - return String(s).replace(/[a-z]/g, function byteUpperCaseReplace(c) { - return c.toUpperCase(); - }); + function opFetchResponseRead(rid, body) { + return core.opAsync("op_fetch_response_read", rid, body); } /** - * @param {string} m - * @returns {boolean} + * @param {number} responseBodyRid + * @returns {ReadableStream<Uint8Array>} */ - function isKnownMethod(m) { - return ( - m === "DELETE" || - m === "GET" || - m === "HEAD" || - m === "OPTIONS" || - m === "POST" || - m === "PUT" - ); + function createResponseBodyStream(responseBodyRid) { + return new ReadableStream({ + type: "bytes", + async pull(controller) { + try { + // This is the largest possible size for a single packet on a TLS + // stream. + const chunk = new Uint8Array(16 * 1024 + 256); + const read = await opFetchResponseRead( + responseBodyRid, + chunk, + ); + if (read > 0) { + // We read some data. Enqueue it onto the stream. + controller.enqueue(chunk.subarray(0, read)); + } else { + // We have reached the end of the body, so we close the stream. + controller.close(); + core.close(responseBodyRid); + } + } catch (err) { + // There was an error while reading a chunk of the body, so we + // error. + controller.error(err); + controller.close(); + core.close(responseBodyRid); + } + }, + cancel() { + core.close(responseBodyRid); + }, + }); } /** - * @param {string} m - * @returns {string} + * @param {InnerRequest} req + * @param {boolean} recursive + * @returns {Promise<InnerResponse>} */ - function normalizeMethod(m) { - // Fast path for already valid methods - if (isKnownMethod(m)) { - return m; - } - // Normalize lower case (slowpath and should be avoided ...) - const u = byteUpperCase(m); - if (isKnownMethod(u)) { - return u; - } - // Otherwise passthrough - return m; - } - - class Request extends Body { - /** @type {string} */ - #method = "GET"; - /** @type {string} */ - #url = ""; - /** @type {Headers | string[][]} */ - #headers; - /** @type {"include" | "omit" | "same-origin" | undefined} */ - #credentials = "omit"; - - /** - * @param {RequestInfo} input - * @param {RequestInit} init - */ - // @ts-expect-error because the use of super in this constructor is valid. - constructor(input, init) { - if (arguments.length < 1) { - throw TypeError("Not enough arguments"); - } - - if (!init) { - init = {}; - } - - let b; - - // prefer body from init - if (init.body) { - b = init.body; - } else if (input instanceof Request) { - if (input.bodyUsed) { - throw TypeError(BodyUsedError); - } - b = input[teeBody](); - } else if (typeof input === "object" && "body" in input && input.body) { - if (input.bodyUsed) { - throw TypeError(BodyUsedError); - } - b = input.body; - } else { - b = ""; - } - - let headers; - let contentType = ""; - // prefer headers from init - if (init.headers) { - if (init[lazyHeaders] && Array.isArray(init.headers)) { - // Trust the headers are valid, and only put them into the `Headers` - // strucutre when the user accesses the property. We also assume that - // all passed headers are lower-case (as is the case when they come - // from hyper in Rust), and that headers are of type - // `[string, string][]`. - headers = init.headers; - for (const tuple of headers) { - if (tuple[0] === "content-type") { - contentType = tuple[1]; - } - } + async function mainFetch(req, recursive) { + /** @type {ReadableStream<Uint8Array> | Uint8Array | null} */ + let reqBody = null; + if (req.body !== null) { + if (req.body.streamOrStatic instanceof ReadableStream) { + if (req.body.length === null) { + reqBody = req.body.stream; } else { - headers = new Headers(init.headers); - contentType = headers.get("content-type") || ""; + const reader = req.body.stream.getReader(); + const r1 = await reader.read(); + if (r1.done) throw new TypeError("Unreachable"); + reqBody = r1.value; + const r2 = await reader.read(); + if (!r2.done) throw new TypeError("Unreachable"); } - } else if (input instanceof Request) { - headers = input.headers; - contentType = headers.get("content-type") || ""; } else { - headers = new Headers(); - } - - super(b, { contentType }); - this.#headers = headers; - - if (input instanceof Request) { - if (input.bodyUsed) { - throw TypeError(BodyUsedError); - } - // headers are already set above. no reason to do it again - this.#method = input.method; - this.#url = input.url; - this.#credentials = input.credentials; - } else { - // Constructing a URL just for validation is known to be expensive. - // dontValidateUrl allows one to opt out. - if (init[dontValidateUrl]) { - this.#url = input; - } else { - const baseUrl = getLocationHref(); - this.#url = baseUrl != null - ? new URL(String(input), baseUrl).href - : new URL(String(input)).href; - } - } - - if (init && "method" in init && init.method) { - this.#method = normalizeMethod(init.method); - } - - if ( - init && - "credentials" in init && - init.credentials && - ["omit", "same-origin", "include"].indexOf(init.credentials) !== -1 - ) { - this.credentials = init.credentials; - } - } - - clone() { - if (this.bodyUsed) { - throw TypeError(BodyUsedError); - } - - const iterators = this.headers.entries(); - const headersList = []; - for (const header of iterators) { - headersList.push(header); - } - - const body = this[teeBody](); - - return new Request(this.url, { - body, - method: this.method, - headers: new Headers(headersList), - credentials: this.credentials, - }); - } - - get method() { - return this.#method; - } - - set method(_) { - // can not set method - } - - get url() { - return this.#url; - } - - set url(_) { - // can not set url - } - - get headers() { - if (!(this.#headers instanceof Headers)) { - this.#headers = new Headers(this.#headers); - } - return this.#headers; - } - - set headers(_) { - // can not set headers - } - - get credentials() { - return this.#credentials; - } - - set credentials(_) { - // can not set credentials - } - } - - const responseData = new WeakMap(); - class Response extends Body { - /** - * @param {BodyInit | null} body - * @param {ResponseInit} [init] - */ - constructor(body = null, init) { - init = init ?? {}; - - if (typeof init !== "object") { - throw new TypeError(`'init' is not an object`); - } - - const extraInit = responseData.get(init) || {}; - let { type = "default", url = "" } = extraInit; - - let status = init.status === undefined ? 200 : Number(init.status || 0); - let statusText = init.statusText ?? ""; - let headers = init.headers instanceof Headers - ? init.headers - : new Headers(init.headers); - - if (init.status !== undefined && (status < 200 || status > 599)) { - throw new RangeError( - `The status provided (${init.status}) is outside the range [200, 599]`, - ); - } - - // null body status - if (body && NULL_BODY_STATUS.includes(status)) { - throw new TypeError("Response with null body status cannot have body"); - } - - if (!type) { - type = "default"; - } else { - if (type == "error") { - // spec: https://fetch.spec.whatwg.org/#concept-network-error - status = 0; - statusText = ""; - headers = new Headers(); - body = null; - /* spec for other Response types: - https://fetch.spec.whatwg.org/#concept-filtered-response-basic - Please note that type "basic" is not the same thing as "default".*/ - } else if (type == "basic") { - for (const h of headers) { - /* Forbidden Response-Header Names: - https://fetch.spec.whatwg.org/#forbidden-response-header-name */ - if (["set-cookie", "set-cookie2"].includes(h[0].toLowerCase())) { - headers.delete(h[0]); - } + req.body.streamOrStatic.consumed = true; + reqBody = req.body.streamOrStatic.body; + } + } + + const { requestRid, requestBodyRid } = opFetch({ + method: req.method, + url: req.currentUrl(), + headers: req.headerList, + clientRid: req.clientRid, + hasBody: reqBody !== null, + }, reqBody instanceof Uint8Array ? reqBody : null); + + if (requestBodyRid !== null) { + if (reqBody === null || !(reqBody instanceof ReadableStream)) { + throw new TypeError("Unreachable"); + } + const reader = reqBody.getReader(); + (async () => { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + await reader.cancel("value not a Uint8Array"); + break; } - } else if (type == "cors") { - /* CORS-safelisted Response-Header Names: - https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name */ - const allowedHeaders = [ - "Cache-Control", - "Content-Language", - "Content-Length", - "Content-Type", - "Expires", - "Last-Modified", - "Pragma", - ].map((c) => c.toLowerCase()); - for (const h of headers) { - /* Technically this is still not standards compliant because we are - supposed to allow headers allowed in the - 'Access-Control-Expose-Headers' header in the 'internal response' - However, this implementation of response doesn't seem to have an - easy way to access the internal response, so we ignore that - header. - TODO(serverhiccups): change how internal responses are handled - so we can do this properly. */ - if (!allowedHeaders.includes(h[0].toLowerCase())) { - headers.delete(h[0]); - } + try { + await opFetchRequestWrite(requestBodyRid, value); + } catch (err) { + await reader.cancel(err); + break; } - /* TODO(serverhiccups): Once I fix the 'internal response' thing, - these actually need to treat the internal response differently */ - } else if (type == "opaque" || type == "opaqueredirect") { - url = ""; - status = 0; - statusText = ""; - headers = new Headers(); - body = null; } - } - - const contentType = headers.get("content-type") || ""; - const size = Number(headers.get("content-length")) || undefined; - - super(body, { contentType, size }); - - this.url = url; - this.statusText = statusText; - this.status = extraInit.status || status; - this.headers = headers; - this.redirected = extraInit.redirected || false; - this.type = type; - } - - get ok() { - return 200 <= this.status && this.status < 300; + core.close(requestBodyRid); + })(); + } + + const resp = await opFetchSend(requestRid); + /** @type {InnerResponse} */ + const response = { + headerList: resp.headers, + status: resp.status, + body: null, + statusMessage: resp.statusText, + type: "basic", + url() { + if (this.urlList.length == 0) return null; + return this.urlList[this.urlList.length - 1]; + }, + urlList: req.urlList, + }; + if (redirectStatus(resp.status)) { + switch (req.redirectMode) { + case "error": + core.close(resp.responseRid); + return networkError( + "Encountered redirect while redirect mode is set to 'error'", + ); + case "follow": + core.close(resp.responseRid); + return httpRedirectFetch(req, response); + case "manual": + break; + } + } + + if (nullBodyStatus(response.status)) { + core.close(resp.responseRid); + } else { + response.body = new InnerBody(createResponseBodyStream(resp.responseRid)); } - clone() { - if (this.bodyUsed) { - throw TypeError(BodyUsedError); - } - - const iterators = this.headers.entries(); - const headersList = []; - for (const header of iterators) { - headersList.push(header); - } - - const body = this[teeBody](); + if (recursive) return response; - return new Response(body, { - status: this.status, - statusText: this.statusText, - headers: new Headers(headersList), - }); + if (response.urlList.length === 0) { + response.urlList = [...req.urlList]; } - /** - * @param {string } url - * @param {number} status - */ - static redirect(url, status = 302) { - if (![301, 302, 303, 307, 308].includes(status)) { - throw new RangeError( - "The redirection status must be one of 301, 302, 303, 307 and 308.", - ); - } - return new Response(null, { - status, - statusText: "", - headers: [["Location", String(url)]], - }); - } - } - - /** @type {string | null} */ - let baseUrl = null; - - /** @param {string} href */ - function setBaseUrl(href) { - baseUrl = href; + return response; } /** - * @param {string} url - * @param {string} method - * @param {Headers} headers - * @param {ReadableStream<Uint8Array> | ArrayBufferView | undefined} body - * @param {number | null} clientRid - * @returns {Promise<{status: number, statusText: string, headers: Record<string,string[]>, url: string, responseRid: number}>} + * @param {InnerRequest} request + * @param {InnerResponse} response + * @returns {Promise<InnerResponse>} */ - async function sendFetchReq(url, method, headers, body, clientRid) { - /** @type {[string, string][]} */ - let headerArray = []; - if (headers) { - headerArray = Array.from(headers.entries()); + function httpRedirectFetch(request, response) { + const locationHeaders = response.headerList.filter((entry) => + byteLowerCase(entry[0]) === "location" + ); + if (locationHeaders.length === 0) { + return response; } - - const { requestRid, requestBodyRid } = opFetch( - { - method, - url, - baseUrl, - headers: headerArray, - clientRid, - hasBody: !!body, - }, - body instanceof Uint8Array ? body : null, + const locationURL = new URL( + locationHeaders[0][1], + response.url() ?? undefined, ); - if (requestBodyRid) { - if (!(body instanceof ReadableStream)) { - throw new TypeError("Unreachable state (body is not ReadableStream)."); + if (locationURL.hash === "") { + locationURL.hash = request.currentUrl().hash; + } + if (locationURL.protocol !== "https:" && locationURL.protocol !== "http:") { + return networkError("Can not redirect to a non HTTP(s) url"); + } + if (request.redirectCount === 20) { + return networkError("Maximum number of redirects (20) reached"); + } + request.redirectCount++; + if ( + response.status !== 303 && request.body !== null && + request.body.source === null + ) { + return networkError( + "Can not redeliver a streaming request body after a redirect", + ); + } + if ( + ((response.status === 301 || response.status === 302) && + request.method === "POST") || + (response.status === 303 && + (request.method !== "GET" && request.method !== "HEAD")) + ) { + request.method = "GET"; + request.body = null; + for (let i = 0; i < request.headerList.length; i++) { + if ( + REQUEST_BODY_HEADER_NAMES.includes( + byteLowerCase(request.headerList[i][0]), + ) + ) { + request.headerList.splice(i, 1); + i--; + } } - const writer = new WritableStream({ - /** - * @param {Uint8Array} chunk - * @param {WritableStreamDefaultController} controller - */ - async write(chunk, controller) { - try { - await opFetchRequestWrite(requestBodyRid, chunk); - } catch (err) { - controller.error(err); - } - }, - close() { - core.close(requestBodyRid); - }, - }); - body.pipeTo(writer); } - - return await opFetchSend(requestRid); + if (request.body !== null) { + const res = extractBody(request.body.source); + request.body = res.body; + } + request.urlList.push(locationURL.href); + return mainFetch(request, true); } /** - * @param {Request | URL | string} input - * @param {RequestInit & {client: Deno.HttpClient}} [init] - * @returns {Promise<Response>} + * @param {RequestInfo} input + * @param {RequestInit} init */ - async function fetch(input, init) { - /** @type {string | null} */ - let url; - let method = null; - let headers = null; - let body; - let clientRid = null; - let redirected = false; - let remRedirectCount = 20; // TODO(bartlomieju): use a better way to handle - - if (typeof input === "string" || input instanceof URL) { - url = typeof input === "string" ? input : input.href; - if (init != null) { - method = init.method || null; - if (init.headers) { - headers = init.headers instanceof Headers - ? init.headers - : new Headers(init.headers); - } else { - headers = null; - } - - // ref: https://fetch.spec.whatwg.org/#body-mixin - // Body should have been a mixin - // but we are treating it as a separate class - if (init.body) { - if (!headers) { - headers = new Headers(); - } - let contentType = ""; - if (typeof init.body === "string") { - body = new TextEncoder().encode(init.body); - contentType = "text/plain;charset=UTF-8"; - } else if (isTypedArray(init.body)) { - body = init.body; - } else if (init.body instanceof ArrayBuffer) { - body = new Uint8Array(init.body); - } else if (init.body instanceof URLSearchParams) { - body = new TextEncoder().encode(init.body.toString()); - contentType = "application/x-www-form-urlencoded;charset=UTF-8"; - } else if (init.body instanceof Blob) { - body = init.body[_byteSequence]; - contentType = init.body.type; - } else if (init.body instanceof FormData) { - const res = encodeFormData(init.body); - body = res.body; - contentType = res.contentType; - } else if (init.body instanceof ReadableStream) { - body = init.body; - } - if (contentType && !headers.has("content-type")) { - headers.set("content-type", contentType); - } - } - - if (init.client instanceof HttpClient) { - clientRid = init.client.rid; - } - } - } else { - url = input.url; - method = input.method; - headers = input.headers; + async function fetch(input, init = {}) { + const prefix = "Failed to call 'fetch'"; + input = webidl.converters["RequestInfo"](input, { + prefix, + context: "Argument 1", + }); + init = webidl.converters["RequestInit"](init, { + prefix, + context: "Argument 2", + }); - if (input.body) { - body = input.body; - } + // 1. + const requestObject = new Request(input, init); + // 2. + const request = toInnerRequest(requestObject); + // 10. + if (!requestObject.headers.has("Accept")) { + request.headerList.push(["Accept", "*/*"]); } - let responseBody; - let responseInit = {}; - while (remRedirectCount) { - const fetchResp = await sendFetchReq( - url, - method ?? "GET", - headers ?? new Headers(), - body, - clientRid, + // 12. + const response = await mainFetch(request, false); + if (response.type === "error") { + throw new TypeError( + "Fetch failed: " + (response.error ?? "unknown error"), ); - const rid = fetchResp.responseRid; - - if ( - NULL_BODY_STATUS.includes(fetchResp.status) || - REDIRECT_STATUS.includes(fetchResp.status) - ) { - // We won't use body of received response, so close it now - // otherwise it will be kept in resource table. - core.close(rid); - responseBody = null; - } else { - responseBody = new ReadableStream({ - type: "bytes", - /** @param {ReadableStreamDefaultController<Uint8Array>} controller */ - async pull(controller) { - try { - const chunk = new Uint8Array(16 * 1024 + 256); - const read = await core.opAsync( - "op_fetch_response_read", - rid, - chunk, - ); - if (read != 0) { - if (chunk.length == read) { - controller.enqueue(chunk); - } else { - controller.enqueue(chunk.subarray(0, read)); - } - } else { - controller.close(); - core.close(rid); - } - } catch (e) { - controller.error(e); - controller.close(); - core.close(rid); - } - }, - cancel() { - // When reader.cancel() is called - core.close(rid); - }, - }); - } - - responseInit = { - status: 200, - statusText: fetchResp.statusText, - headers: fetchResp.headers, - }; - - responseData.set(responseInit, { - redirected, - rid: fetchResp.responseRid, - status: fetchResp.status, - url: fetchResp.url, - }); - - const response = new Response(responseBody, responseInit); - - if (REDIRECT_STATUS.includes(fetchResp.status)) { - // We're in a redirect status - switch ((init && init.redirect) || "follow") { - case "error": - responseInit = {}; - responseData.set(responseInit, { - type: "error", - redirected: false, - url: "", - }); - return new Response(null, responseInit); - case "manual": - // On the web this would return a `opaqueredirect` response, but - // those don't make sense server side. See denoland/deno#8351. - return response; - case "follow": - // fallthrough - default: { - /** @type {string | null} */ - let redirectUrl = response.headers.get("Location"); - if (redirectUrl == null) { - return response; // Unspecified - } - if ( - !redirectUrl.startsWith("http://") && - !redirectUrl.startsWith("https://") - ) { - redirectUrl = new URL(redirectUrl, fetchResp.url).href; - } - url = redirectUrl; - redirected = true; - remRedirectCount--; - } - } - } else { - return response; - } } - responseData.set(responseInit, { - type: "error", - redirected: false, - url: "", - }); - - return new Response(null, responseInit); + return fromInnerResponse(response, "immutable"); } - window.__bootstrap.fetch = { - FormData, - setBaseUrl, - fetch, - Request, - Response, - HttpClient, - createHttpClient, - fastBody, - dontValidateUrl, - lazyHeaders, - }; + window.__bootstrap.fetch ??= {}; + window.__bootstrap.fetch.fetch = fetch; })(this); |