diff options
author | Luca Casonato <lucacasonato@yahoo.com> | 2021-04-14 22:49:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-14 22:49:16 +0200 |
commit | 353e79c796efc6c6aa328abb4d7cef5e642944af (patch) | |
tree | 5ae6d1778c0a1dfa53ac62e409d6da8cdb31285f /op_crates/fetch | |
parent | 5214acd3d9dec56ee159544f0f6bf9834a62c097 (diff) |
chore: align FormData to spec (#10169)
This PR aligns `FormData` to spec. All WPT tests are passing.
Diffstat (limited to 'op_crates/fetch')
-rw-r--r-- | op_crates/fetch/21_formdata.js | 529 | ||||
-rw-r--r-- | op_crates/fetch/26_fetch.js | 483 | ||||
-rw-r--r-- | op_crates/fetch/internal.d.ts | 9 | ||||
-rw-r--r-- | op_crates/fetch/lib.rs | 4 |
4 files changed, 586 insertions, 439 deletions
diff --git a/op_crates/fetch/21_formdata.js b/op_crates/fetch/21_formdata.js new file mode 100644 index 000000000..106b67da4 --- /dev/null +++ b/op_crates/fetch/21_formdata.js @@ -0,0 +1,529 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// <reference path="../webidl/internal.d.ts" /> +/// <reference path="../web/internal.d.ts" /> +/// <reference path="../file/internal.d.ts" /> +/// <reference path="../file/lib.deno_file.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="./11_streams_types.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"use strict"; + +((window) => { + const webidl = globalThis.__bootstrap.webidl; + const { Blob, File, _byteSequence } = globalThis.__bootstrap.file; + + 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[_byteSequence]], "blob", { type: value.type }); + } + if (value instanceof File && filename !== undefined) { + value = new File([value[_byteSequence]], 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 [Symbol.toStringTag]() { + 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); + + this[entryList].push(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) { + list.splice(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) returnList.push(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 { + list.splice(i, 1); + i--; + } + } + } + if (!added) { + list.push(entry); + } + } + } + + webidl.mixinPairIterable("FormData", FormData, entryList, "name", "value"); + + const encoder = new TextEncoder(); + + class MultipartBuilder { + /** + * @param {FormData} formData + */ + constructor(formData) { + this.entryList = formData[entryList]; + this.boundary = this.#createBoundary(); + /** @type {Uint8Array[]} */ + this.chunks = []; + } + + /** + * @returns {string} + */ + getContentType() { + return `multipart/form-data; boundary=${this.boundary}`; + } + + /** + * @returns {Uint8Array} + */ + getBody() { + for (const { name, value } of this.entryList) { + if (value instanceof File) { + this.#writeFile(name, value); + } else this.#writeField(name, value); + } + + this.chunks.push(encoder.encode(`\r\n--${this.boundary}--`)); + + let totalLength = 0; + for (const chunk of this.chunks) { + totalLength += chunk.byteLength; + } + + const finalBuffer = new Uint8Array(totalLength); + let i = 0; + for (const chunk of this.chunks) { + finalBuffer.set(chunk, i); + i += chunk.byteLength; + } + + return finalBuffer; + } + + #createBoundary = () => { + return ( + "----------" + + Array.from(Array(32)) + .map(() => Math.random().toString(36)[2] || 0) + .join("") + ); + }; + + /** + * @param {[string, string][]} headers + * @returns {void} + */ + #writeHeaders = (headers) => { + let buf = (this.chunks.length === 0) ? "" : "\r\n"; + + buf += `--${this.boundary}\r\n`; + for (const [key, value] of headers) { + buf += `${key}: ${value}\r\n`; + } + buf += `\r\n`; + + this.chunks.push(encoder.encode(buf)); + }; + + /** + * @param {string} field + * @param {string} filename + * @param {string} [type] + * @returns {void} + */ + #writeFileHeaders = ( + field, + filename, + type, + ) => { + /** @type {[string, string][]} */ + const headers = [ + [ + "Content-Disposition", + `form-data; name="${field}"; filename="${filename}"`, + ], + ["Content-Type", type || "application/octet-stream"], + ]; + return this.#writeHeaders(headers); + }; + + /** + * @param {string} field + * @returns {void} + */ + #writeFieldHeaders = (field) => { + /** @type {[string, string][]} */ + const headers = [["Content-Disposition", `form-data; name="${field}"`]]; + return this.#writeHeaders(headers); + }; + + /** + * @param {string} field + * @param {string} value + * @returns {void} + */ + #writeField = (field, value) => { + this.#writeFieldHeaders(field); + this.chunks.push(encoder.encode(value)); + }; + + /** + * @param {string} field + * @param {File} value + * @returns {void} + */ + #writeFile = (field, value) => { + this.#writeFileHeaders(field, value.name, value.type); + this.chunks.push(value[_byteSequence]); + }; + } + + /** + * @param {FormData} formdata + * @returns {{body: Uint8Array, contentType: string}} + */ + function encodeFormData(formdata) { + const builder = new MultipartBuilder(formdata); + return { + body: builder.getBody(), + contentType: builder.getContentType(), + }; + } + + /** + * @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 + value + .split(";") + .slice(1) + .map((s) => s.trim().split("=")) + .filter((arr) => arr.length > 1) + .map(([k, v]) => [k, v.replace(/^"([^"]*)"$/, "$1")]) + .forEach(([k, v]) => params.set(k, v)); + return params; + } + + const LF = "\n".codePointAt(0); + const CR = "\r".codePointAt(0); + const decoder = new TextDecoder("utf-8"); + + 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 = encoder.encode(this.boundary); + } + + /** + * @param {string} headersText + * @returns {{ headers: Headers, disposition: Map<string, string> }} + */ + #parseHeaders = (headersText) => { + const headers = new Headers(); + const rawHeaders = headersText.split("\r\n"); + for (const rawHeader of rawHeaders) { + const sepIndex = rawHeader.indexOf(":"); + if (sepIndex < 0) { + continue; // Skip this header + } + const key = rawHeader.slice(0, sepIndex); + const value = rawHeader.slice(sepIndex + 1); + headers.set(key, value); + } + + const disposition = parseContentDisposition( + headers.get("Content-Disposition") ?? "", + ); + + return { headers, disposition }; + }; + + /** + * @returns {FormData} + */ + parse() { + 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 += String.fromCharCode(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 = this.body.subarray( + fileStart, + i - boundaryIndex - 1, + ); + // https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata + const filename = disposition.get("filename"); + const name = disposition.get("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, decoder.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(); + } + + globalThis.__bootstrap.formData = { FormData, encodeFormData, parseFormData }; +})(globalThis); diff --git a/op_crates/fetch/26_fetch.js b/op_crates/fetch/26_fetch.js index e713b48ed..d07121e86 100644 --- a/op_crates/fetch/26_fetch.js +++ b/op_crates/fetch/26_fetch.js @@ -3,6 +3,7 @@ // @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="./11_streams_types.d.ts" /> /// <reference path="./internal.d.ts" /> @@ -16,11 +17,12 @@ // 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 { requiredArguments } = window.__bootstrap.fetchUtil; const { ReadableStream, isReadableStreamDisturbed } = window.__bootstrap.streams; - const { DomIterableMixin } = window.__bootstrap.domIterable; const { Headers } = window.__bootstrap.headers; const { Blob, _byteSequence, File } = window.__bootstrap.file; @@ -203,395 +205,6 @@ } /** - * @param {string} value - * @returns {Map<string, string>} - */ - function getHeaderValueParams(value) { - /** @type {Map<string, string>} */ - const params = new Map(); - // Forced to do so for some Map constructor param mismatch - value - .split(";") - .slice(1) - .map((s) => s.trim().split("=")) - .filter((arr) => arr.length > 1) - .map(([k, v]) => [k, v.replace(/^"([^"]*)"$/, "$1")]) - .forEach(([k, v]) => params.set(k, v)); - return params; - } - - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - const CR = "\r".charCodeAt(0); - const LF = "\n".charCodeAt(0); - - const dataSymbol = Symbol("data"); - - /** - * @param {Blob | string} value - * @param {string | undefined} filename - * @returns {FormDataEntryValue} - */ - function parseFormDataValue(value, filename) { - if (value instanceof File) { - return new File([value], filename || value.name, { - type: value.type, - lastModified: value.lastModified, - }); - } else if (value instanceof Blob) { - return new File([value], filename || "blob", { - type: value.type, - }); - } else { - return String(value); - } - } - - class FormDataBase { - /** @type {[name: string, entry: FormDataEntryValue][]} */ - [dataSymbol] = []; - - /** - * @param {string} name - * @param {string | Blob} value - * @param {string} [filename] - * @returns {void} - */ - append(name, value, filename) { - requiredArguments("FormData.append", arguments.length, 2); - name = String(name); - this[dataSymbol].push([name, parseFormDataValue(value, filename)]); - } - - /** - * @param {string} name - * @returns {void} - */ - delete(name) { - requiredArguments("FormData.delete", arguments.length, 1); - name = String(name); - let i = 0; - while (i < this[dataSymbol].length) { - if (this[dataSymbol][i][0] === name) { - this[dataSymbol].splice(i, 1); - } else { - i++; - } - } - } - - /** - * @param {string} name - * @returns {FormDataEntryValue[]} - */ - getAll(name) { - requiredArguments("FormData.getAll", arguments.length, 1); - name = String(name); - const values = []; - for (const entry of this[dataSymbol]) { - if (entry[0] === name) { - values.push(entry[1]); - } - } - - return values; - } - - /** - * @param {string} name - * @returns {FormDataEntryValue | null} - */ - get(name) { - requiredArguments("FormData.get", arguments.length, 1); - name = String(name); - for (const entry of this[dataSymbol]) { - if (entry[0] === name) { - return entry[1]; - } - } - - return null; - } - - /** - * @param {string} name - * @returns {boolean} - */ - has(name) { - requiredArguments("FormData.has", arguments.length, 1); - name = String(name); - return this[dataSymbol].some((entry) => entry[0] === name); - } - - /** - * @param {string} name - * @param {string | Blob} value - * @param {string} [filename] - * @returns {void} - */ - set(name, value, filename) { - requiredArguments("FormData.set", arguments.length, 2); - name = String(name); - - // If there are any entries in the context object’s entry list whose name - // is name, replace the first such entry with entry and remove the others - let found = false; - let i = 0; - while (i < this[dataSymbol].length) { - if (this[dataSymbol][i][0] === name) { - if (!found) { - this[dataSymbol][i][1] = parseFormDataValue(value, filename); - found = true; - } else { - this[dataSymbol].splice(i, 1); - continue; - } - } - i++; - } - - // Otherwise, append entry to the context object’s entry list. - if (!found) { - this[dataSymbol].push([name, parseFormDataValue(value, filename)]); - } - } - - get [Symbol.toStringTag]() { - return "FormData"; - } - } - - class FormData extends DomIterableMixin(FormDataBase, dataSymbol) {} - - class MultipartBuilder { - /** - * @param {FormData} formData - * @param {string} [boundary] - */ - constructor(formData, boundary) { - this.formData = formData; - this.boundary = boundary ?? this.#createBoundary(); - this.writer = new Buffer(); - } - - /** - * @returns {string} - */ - getContentType() { - return `multipart/form-data; boundary=${this.boundary}`; - } - - /** - * @returns {Uint8Array} - */ - getBody() { - for (const [fieldName, fieldValue] of this.formData.entries()) { - if (fieldValue instanceof File) { - this.#writeFile(fieldName, fieldValue); - } else this.#writeField(fieldName, fieldValue); - } - - this.writer.writeSync(encoder.encode(`\r\n--${this.boundary}--`)); - - return this.writer.bytes(); - } - - #createBoundary = () => { - return ( - "----------" + - Array.from(Array(32)) - .map(() => Math.random().toString(36)[2] || 0) - .join("") - ); - }; - - /** - * @param {[string, string][]} headers - * @returns {void} - */ - #writeHeaders = (headers) => { - let buf = this.writer.empty() ? "" : "\r\n"; - - buf += `--${this.boundary}\r\n`; - for (const [key, value] of headers) { - buf += `${key}: ${value}\r\n`; - } - buf += `\r\n`; - - this.writer.writeSync(encoder.encode(buf)); - }; - - /** - * @param {string} field - * @param {string} filename - * @param {string} [type] - * @returns {void} - */ - #writeFileHeaders = ( - field, - filename, - type, - ) => { - /** @type {[string, string][]} */ - const headers = [ - [ - "Content-Disposition", - `form-data; name="${field}"; filename="${filename}"`, - ], - ["Content-Type", type || "application/octet-stream"], - ]; - return this.#writeHeaders(headers); - }; - - /** - * @param {string} field - * @returns {void} - */ - #writeFieldHeaders = (field) => { - /** @type {[string, string][]} */ - const headers = [["Content-Disposition", `form-data; name="${field}"`]]; - return this.#writeHeaders(headers); - }; - - /** - * @param {string} field - * @param {string} value - * @returns {void} - */ - #writeField = (field, value) => { - this.#writeFieldHeaders(field); - this.writer.writeSync(encoder.encode(value)); - }; - - /** - * @param {string} field - * @param {File} value - * @returns {void} - */ - #writeFile = (field, value) => { - this.#writeFileHeaders(field, value.name, value.type); - this.writer.writeSync(value[_byteSequence]); - }; - } - - 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 = encoder.encode(this.boundary); - } - - /** - * @param {string} headersText - * @returns {{ headers: Headers, disposition: Map<string, string> }} - */ - #parseHeaders = (headersText) => { - const headers = new Headers(); - const rawHeaders = headersText.split("\r\n"); - for (const rawHeader of rawHeaders) { - const sepIndex = rawHeader.indexOf(":"); - if (sepIndex < 0) { - continue; // Skip this header - } - const key = rawHeader.slice(0, sepIndex); - const value = rawHeader.slice(sepIndex + 1); - headers.set(key, value); - } - - return { - headers, - disposition: getHeaderValueParams( - headers.get("Content-Disposition") ?? "", - ), - }; - }; - - /** - * @returns {FormData} - */ - parse() { - 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 += String.fromCharCode(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 = this.body.subarray( - fileStart, - i - boundaryIndex - 1, - ); - // https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata - const filename = disposition.get("filename"); - const name = disposition.get("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, decoder.decode(content)); - } - } - } else if (state === 5 && isNewLine) { - state = 1; - } - } - - return formData; - } - } - - /** * @param {string} name * @param {BodyInit | null} bodySource */ @@ -785,46 +398,46 @@ /** @returns {Promise<FormData>} */ async formData() { const formData = new FormData(); - if (hasHeaderValueOf(this.#contentType, "multipart/form-data")) { - const params = getHeaderValueParams(this.#contentType); - - // ref: https://tools.ietf.org/html/rfc2046#section-5.1 - const boundary = params.get("boundary"); - const body = new Uint8Array(await this.arrayBuffer()); - const multipartParser = new MultipartParser(body, boundary); - - return multipartParser.parse(); - } else if ( - hasHeaderValueOf(this.#contentType, "application/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), - ); + 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"); + }); + } catch (e) { + throw new TypeError("Invalid form urlencoded format"); + } + return formData; } - return formData; - } else { - throw new TypeError("Invalid form data"); } + + throw new TypeError("Invalid form data"); } /** @returns {Promise<string>} */ @@ -1374,17 +987,9 @@ body = init.body[_byteSequence]; contentType = init.body.type; } else if (init.body instanceof FormData) { - let boundary; - if (headers.has("content-type")) { - const params = getHeaderValueParams("content-type"); - boundary = params.get("boundary"); - } - const multipartBuilder = new MultipartBuilder( - init.body, - boundary, - ); - body = multipartBuilder.getBody(); - contentType = multipartBuilder.getContentType(); + const res = encodeFormData(init.body); + body = res.body; + contentType = res.contentType; } else if (init.body instanceof ReadableStream) { body = init.body; } diff --git a/op_crates/fetch/internal.d.ts b/op_crates/fetch/internal.d.ts index e02bc6ed2..3206008c5 100644 --- a/op_crates/fetch/internal.d.ts +++ b/op_crates/fetch/internal.d.ts @@ -19,6 +19,15 @@ declare namespace globalThis { Headers: typeof Headers; }; + declare var formData: { + FormData: typeof FormData; + encodeFormData(formdata: FormData): { + body: Uint8Array; + contentType: string; + }; + parseFormData(body: Uint8Array, boundary: string | undefined): FormData; + }; + declare var streams: { ReadableStream: typeof ReadableStream; isReadableStreamDisturbed(stream: ReadableStream): boolean; diff --git a/op_crates/fetch/lib.rs b/op_crates/fetch/lib.rs index ae716af41..9eeda059a 100644 --- a/op_crates/fetch/lib.rs +++ b/op_crates/fetch/lib.rs @@ -71,6 +71,10 @@ pub fn init(isolate: &mut JsRuntime) { include_str!("20_headers.js"), ), ( + "deno:op_crates/fetch/21_formdata.js", + include_str!("21_formdata.js"), + ), + ( "deno:op_crates/fetch/26_fetch.js", include_str!("26_fetch.js"), ), |