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/21_formdata.js | |
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/21_formdata.js')
-rw-r--r-- | op_crates/fetch/21_formdata.js | 529 |
1 files changed, 529 insertions, 0 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); |