1
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2024-11-25 15:29:32 -05:00

chore: align FormData to spec (#10169)

This PR aligns `FormData` to spec. All WPT tests are passing.
This commit is contained in:
Luca Casonato 2021-04-14 22:49:16 +02:00 committed by GitHub
parent 5214acd3d9
commit 353e79c796
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 742 additions and 698 deletions

View file

@ -1,212 +0,0 @@
// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
import {
assert,
assertEquals,
assertStringIncludes,
unitTest,
} from "./test_util.ts";
unitTest(function formDataHasCorrectNameProp(): void {
assertEquals(FormData.name, "FormData");
});
unitTest(function formDataParamsAppendSuccess(): void {
const formData = new FormData();
formData.append("a", "true");
assertEquals(formData.get("a"), "true");
});
unitTest(function formDataParamsDeleteSuccess(): void {
const formData = new FormData();
formData.append("a", "true");
formData.append("b", "false");
assertEquals(formData.get("b"), "false");
formData.delete("b");
assertEquals(formData.get("a"), "true");
assertEquals(formData.get("b"), null);
});
unitTest(function formDataParamsGetAllSuccess(): void {
const formData = new FormData();
formData.append("a", "true");
formData.append("b", "false");
formData.append("a", "null");
assertEquals(formData.getAll("a"), ["true", "null"]);
assertEquals(formData.getAll("b"), ["false"]);
assertEquals(formData.getAll("c"), []);
});
unitTest(function formDataParamsGetSuccess(): void {
const formData = new FormData();
formData.append("a", "true");
formData.append("b", "false");
formData.append("a", "null");
// deno-lint-ignore no-explicit-any
formData.append("d", undefined as any);
// deno-lint-ignore no-explicit-any
formData.append("e", null as any);
assertEquals(formData.get("a"), "true");
assertEquals(formData.get("b"), "false");
assertEquals(formData.get("c"), null);
assertEquals(formData.get("d"), "undefined");
assertEquals(formData.get("e"), "null");
});
unitTest(function formDataParamsHasSuccess(): void {
const formData = new FormData();
formData.append("a", "true");
formData.append("b", "false");
assert(formData.has("a"));
assert(formData.has("b"));
assert(!formData.has("c"));
});
unitTest(function formDataParamsSetSuccess(): void {
const formData = new FormData();
formData.append("a", "true");
formData.append("b", "false");
formData.append("a", "null");
assertEquals(formData.getAll("a"), ["true", "null"]);
assertEquals(formData.getAll("b"), ["false"]);
formData.set("a", "false");
assertEquals(formData.getAll("a"), ["false"]);
// deno-lint-ignore no-explicit-any
formData.set("d", undefined as any);
assertEquals(formData.get("d"), "undefined");
// deno-lint-ignore no-explicit-any
formData.set("e", null as any);
assertEquals(formData.get("e"), "null");
});
unitTest(function fromDataUseFile(): void {
const formData = new FormData();
const file = new File(["foo"], "bar", {
type: "text/plain",
});
formData.append("file", file);
assertEquals(formData.get("file"), file);
});
unitTest(function formDataSetEmptyBlobSuccess(): void {
const formData = new FormData();
formData.set("a", new Blob([]), "blank.txt");
formData.get("a");
/* TODO Fix this test.
assert(file instanceof File);
if (typeof file !== "string") {
assertEquals(file.name, "blank.txt");
}
*/
});
unitTest(function formDataBlobFilename(): void {
const formData = new FormData();
const content = new TextEncoder().encode("deno");
formData.set("a", new Blob([content]));
const file = formData.get("a");
assert(file instanceof File);
assertEquals(file.name, "blob");
});
unitTest(function formDataParamsForEachSuccess(): void {
const init = [
["a", "54"],
["b", "true"],
];
const formData = new FormData();
for (const [name, value] of init) {
formData.append(name, value);
}
let callNum = 0;
formData.forEach((value, key, parent): void => {
assertEquals(formData, parent);
assertEquals(value, init[callNum][1]);
assertEquals(key, init[callNum][0]);
callNum++;
});
assertEquals(callNum, init.length);
});
unitTest(function formDataParamsArgumentsCheck(): void {
const methodRequireOneParam = [
"delete",
"getAll",
"get",
"has",
"forEach",
] as const;
const methodRequireTwoParams = ["append", "set"] as const;
methodRequireOneParam.forEach((method): void => {
const formData = new FormData();
let hasThrown = 0;
let errMsg = "";
try {
// deno-lint-ignore no-explicit-any
(formData as any)[method]();
hasThrown = 1;
} catch (err) {
errMsg = err.message;
if (err instanceof TypeError) {
hasThrown = 2;
} else {
hasThrown = 3;
}
}
assertEquals(hasThrown, 2);
assertStringIncludes(
errMsg,
`${method} requires at least 1 argument, but only 0 present`,
);
});
methodRequireTwoParams.forEach((method: string): void => {
const formData = new FormData();
let hasThrown = 0;
let errMsg = "";
try {
// deno-lint-ignore no-explicit-any
(formData as any)[method]();
hasThrown = 1;
} catch (err) {
errMsg = err.message;
if (err instanceof TypeError) {
hasThrown = 2;
} else {
hasThrown = 3;
}
}
assertEquals(hasThrown, 2);
assertStringIncludes(
errMsg,
`${method} requires at least 2 arguments, but only 0 present`,
);
hasThrown = 0;
errMsg = "";
try {
// deno-lint-ignore no-explicit-any
(formData as any)[method]("foo");
hasThrown = 1;
} catch (err) {
errMsg = err.message;
if (err instanceof TypeError) {
hasThrown = 2;
} else {
hasThrown = 3;
}
}
assertEquals(hasThrown, 2);
assertStringIncludes(
errMsg,
`${method} requires at least 2 arguments, but only 1 present`,
);
});
});
unitTest(function toStringShouldBeWebCompatibility(): void {
const formData = new FormData();
assertEquals(formData.toString(), "[object FormData]");
});

View file

@ -24,7 +24,6 @@ import "./file_test.ts";
import "./filereader_test.ts";
import "./files_test.ts";
import "./filter_function_test.ts";
import "./form_data_test.ts";
import "./format_error_test.ts";
import "./fs_events_test.ts";
import "./get_random_values_test.ts";

View file

@ -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);

View file

@ -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;
@ -202,395 +204,6 @@
return new RegExp(`^${value}(?:[\\s;]|$)`).test(s);
}
/**
* @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 objects 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 objects 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;
}

View file

@ -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;

View file

@ -70,6 +70,10 @@ pub fn init(isolate: &mut JsRuntime) {
"deno:op_crates/fetch/20_headers.js",
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"),

View file

@ -139,6 +139,10 @@
const _byteSequence = Symbol("[[ByteSequence]]");
class Blob {
get [Symbol.toStringTag]() {
return "Blob";
}
/** @type {string} */
#type;
@ -286,10 +290,6 @@
}
return bytes.buffer;
}
get [Symbol.toStringTag]() {
return "Blob";
}
}
webidl.converters["Blob"] = webidl.createInterfaceConverter("Blob", Blob);
@ -336,6 +336,10 @@
const _LastModfied = Symbol("[[LastModified]]");
class File extends Blob {
get [Symbol.toStringTag]() {
return "File";
}
/** @type {string} */
[_Name];
/** @type {number} */

View file

@ -24,6 +24,10 @@
const aborted = Symbol("[[aborted]]");
class FileReader extends EventTarget {
get [Symbol.toStringTag]() {
return "FileReader";
}
/** @type {"empty" | "loading" | "done"} */
[state] = "empty";
/** @type {null | string | ArrayBuffer} */

View file

@ -9,7 +9,7 @@ declare namespace globalThis {
Blob: typeof Blob & {
[globalThis.__bootstrap.file._byteSequence]: Uint8Array;
};
_byteSequence: unique symbol;
readonly _byteSequence: unique symbol;
File: typeof File & {
[globalThis.__bootstrap.file._byteSequence]: Uint8Array;
};

View file

@ -10,6 +10,25 @@
((window) => {
const { collectSequenceOfCodepoints } = window.__bootstrap.infra;
/**
* @param {string[]} chars
* @returns {string}
*/
function regexMatcher(chars) {
const matchers = chars.map((char) => {
if (char.length === 1) {
return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
} else if (char.length === 3 && char[1] === "-") {
return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}-\\u${
char.charCodeAt(2).toString(16).padStart(4, "0")
}`;
} else {
throw TypeError("unreachable");
}
});
return matchers.join("");
}
const HTTP_TAB_OR_SPACE = ["\u0009", "\u0020"];
const HTTP_WHITESPACE = ["\u000A", "\u000D", ...HTTP_TAB_OR_SPACE];
@ -35,14 +54,25 @@
"\u007E",
...ASCII_ALPHANUMERIC,
];
const HTTP_TOKEN_CODE_POINT_RE = new RegExp(`^[${HTTP_TOKEN_CODE_POINT}]+$`);
const HTTP_TOKEN_CODE_POINT_RE = new RegExp(
`^[${regexMatcher(HTTP_TOKEN_CODE_POINT)}]+$`,
);
const HTTP_QUOTED_STRING_TOKEN_POINT = [
"\u0009",
"\u0020-\u007E",
"\u0080-\u00FF",
];
const HTTP_QUOTED_STRING_TOKEN_POINT_RE = new RegExp(
`^[${HTTP_QUOTED_STRING_TOKEN_POINT}]+$`,
`^[${regexMatcher(HTTP_QUOTED_STRING_TOKEN_POINT)}]+$`,
);
const HTTP_WHITESPACE_MATCHER = regexMatcher(HTTP_WHITESPACE);
const HTTP_WHITESPACE_PREFIX_RE = new RegExp(
`^[${HTTP_WHITESPACE_MATCHER}]+`,
"g",
);
const HTTP_WHITESPACE_SUFFIX_RE = new RegExp(
`[${HTTP_WHITESPACE_MATCHER}]+$`,
"g",
);
/**
@ -106,8 +136,8 @@
*/
function parseMimeType(input) {
// 1.
input = input.replaceAll(new RegExp(`^[${HTTP_WHITESPACE}]+`, "g"), "");
input = input.replaceAll(new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"), "");
input = input.replaceAll(HTTP_WHITESPACE_PREFIX_RE, "");
input = input.replaceAll(HTTP_WHITESPACE_SUFFIX_RE, "");
// 2.
let position = 0;
@ -123,9 +153,7 @@
position = res1.position;
// 4.
if (type === "" || !HTTP_TOKEN_CODE_POINT_RE.test(type)) {
return null;
}
if (type === "" || !HTTP_TOKEN_CODE_POINT_RE.test(type)) return null;
// 5.
if (position >= endOfInput) return null;
@ -143,12 +171,10 @@
position = res2.position;
// 8.
subtype = subtype.replaceAll(new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"), "");
subtype = subtype.replaceAll(HTTP_WHITESPACE_SUFFIX_RE, "");
// 9.
if (subtype === "" || !HTTP_TOKEN_CODE_POINT_RE.test(subtype)) {
return null;
}
if (subtype === "" || !HTTP_TOKEN_CODE_POINT_RE.test(subtype)) return null;
// 10.
const mimeType = {
@ -216,7 +242,7 @@
// 11.9.2.
parameterValue = parameterValue.replaceAll(
new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"),
HTTP_WHITESPACE_SUFFIX_RE,
"",
);
@ -224,7 +250,7 @@
if (parameterValue === "") continue;
}
// 11.9.
// 11.10.
if (
parameterName !== "" && HTTP_TOKEN_CODE_POINT_RE.test(parameterName) &&
HTTP_QUOTED_STRING_TOKEN_POINT_RE.test(parameterValue) &&

View file

@ -4,6 +4,9 @@
/// <reference lib="esnext" />
declare namespace globalThis {
declare var TextEncoder: typeof TextEncoder;
declare var TextDecoder: typeof TextDecoder;
declare namespace __bootstrap {
declare var infra: {
collectSequenceOfCodepoints(

View file

@ -87,45 +87,44 @@ declare class Event {
*/
declare class EventTarget {
/** Appends an event listener for events whose type attribute value is type.
* The callback argument sets the callback that will be invoked when the event
* is dispatched.
*
* The options argument sets listener-specific options. For compatibility this
* can be a boolean, in which case the method behaves exactly as if the value
* was specified as options's capture.
*
* When set to true, options's capture prevents callback from being invoked
* when the event's eventPhase attribute value is BUBBLING_PHASE. When false
* (or not present), callback will not be invoked when event's eventPhase
* attribute value is CAPTURING_PHASE. Either way, callback will be invoked if
* event's eventPhase attribute value is AT_TARGET.
*
* When set to true, options's passive indicates that the callback will not
* cancel the event by invoking preventDefault(). This is used to enable
* performance optimizations described in § 2.8 Observing event listeners.
*
* When set to true, options's once indicates that the callback will only be
* invoked once after which the event listener will be removed.
*
* The event listener is appended to target's event listener list and is not
* appended if it has the same type, callback, and capture. */
* The callback argument sets the callback that will be invoked when the event
* is dispatched.
*
* The options argument sets listener-specific options. For compatibility this
* can be a boolean, in which case the method behaves exactly as if the value
* was specified as options's capture.
*
* When set to true, options's capture prevents callback from being invoked
* when the event's eventPhase attribute value is BUBBLING_PHASE. When false
* (or not present), callback will not be invoked when event's eventPhase
* attribute value is CAPTURING_PHASE. Either way, callback will be invoked if
* event's eventPhase attribute value is AT_TARGET.
*
* When set to true, options's passive indicates that the callback will not
* cancel the event by invoking preventDefault(). This is used to enable
* performance optimizations described in § 2.8 Observing event listeners.
*
* When set to true, options's once indicates that the callback will only be
* invoked once after which the event listener will be removed.
*
* The event listener is appended to target's event listener list and is not
* appended if it has the same type, callback, and capture. */
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
/** Dispatches a synthetic event event to target and returns true if either
* event's cancelable attribute value is false or its preventDefault() method
* was not invoked, and false otherwise. */
* event's cancelable attribute value is false or its preventDefault() method
* was not invoked, and false otherwise. */
dispatchEvent(event: Event): boolean;
/** Removes the event listener in target's event listener list with the same
* type, callback, and options. */
* type, callback, and options. */
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean,
): void;
[Symbol.toStringTag]: string;
}
interface EventListener {

View file

@ -802,6 +802,50 @@
throw new TypeError("Illegal constructor");
}
function mixinPairIterable(name, prototype, dataSymbol, keyKey, valueKey) {
const methods = {
*entries() {
assertBranded(this, prototype);
for (const entry of this[dataSymbol]) {
yield [entry[keyKey], entry[valueKey]];
}
},
[Symbol.iterator]() {
assertBranded(this, prototype);
return this.entries();
},
*keys() {
assertBranded(this, prototype);
for (const entry of this[dataSymbol]) {
yield entry[keyKey];
}
},
*values() {
assertBranded(this, prototype);
for (const entry of this[dataSymbol]) {
yield entry[valueKey];
}
},
forEach(idlCallback, thisArg) {
assertBranded(this, prototype);
const prefix = `Failed to execute 'forEach' on '${name}'`;
requiredArguments(arguments.length, 1, { prefix });
idlCallback = converters["Function"](idlCallback, {
prefix,
context: "Argument 1",
});
idlCallback = idlCallback.bind(thisArg ?? globalThis);
const pairs = this[dataSymbol];
for (let i = 0; i < pairs.length; i++) {
const entry = pairs[i];
idlCallback(entry[valueKey], entry[keyKey], this);
}
},
};
return Object.assign(prototype.prototype, methods);
}
window.__bootstrap ??= {};
window.__bootstrap.webidl = {
makeException,
@ -817,5 +861,6 @@
createBranded,
assertBranded,
illegalConstructor,
mixinPairIterable,
};
})(this);

View file

@ -286,6 +286,18 @@ declare namespace globalThis {
v: Record<K, V>,
opts: ValueConverterOpts,
) => any;
/**
* Mix in the iterable declarations defined in WebIDL.
* https://heycam.github.io/webidl/#es-iterable
*/
declare function mixinPairIterable(
name: string,
prototype: any,
dataSymbol: symbol,
keyKey: string | number | symbol,
valueKey: string | number | symbol,
): void;
}
}
}

View file

@ -29,6 +29,7 @@ delete Object.prototype.__proto__;
const webgpu = window.__bootstrap.webgpu;
const webSocket = window.__bootstrap.webSocket;
const file = window.__bootstrap.file;
const formData = window.__bootstrap.formData;
const fetch = window.__bootstrap.fetch;
const prompt = window.__bootstrap.prompt;
const denoNs = window.__bootstrap.denoNs;
@ -261,7 +262,7 @@ delete Object.prototype.__proto__;
EventTarget: util.nonEnumerable(EventTarget),
File: util.nonEnumerable(file.File),
FileReader: util.nonEnumerable(fileReader.FileReader),
FormData: util.nonEnumerable(fetch.FormData),
FormData: util.nonEnumerable(formData.FormData),
Headers: util.nonEnumerable(headers.Headers),
MessageEvent: util.nonEnumerable(MessageEvent),
Performance: util.nonEnumerable(performance.Performance),

@ -1 +1 @@
Subproject commit a522daf78a71c2252d10c978f09cf0575aceb794
Subproject commit e19bdbe96243f2ba548c1fd01c0812d645ba0c6f

View file

@ -578,6 +578,10 @@
"Parsing: <file://example.net/C:/> against <about:blank>",
"Parsing: <file://1.2.3.4/C:/> against <about:blank>",
"Parsing: <file://[1::8]/C:/> against <about:blank>",
"Parsing: <C|/> against <file://host/>",
"Parsing: </C:/> against <file://host/>",
"Parsing: <file:C:/> against <file://host/>",
"Parsing: <file:/C:/> against <file://host/>",
"Parsing: <file://localhost//a//../..//foo> against <about:blank>",
"Parsing: <file://localhost////foo> against <about:blank>",
"Parsing: <file:////foo> against <about:blank>",
@ -753,5 +757,17 @@
"queue-microtask.any.js": true
}
}
},
"xhr": {
"formdata": {
"append.any.js": true,
"constructor.any.js": true,
"delete.any.js": true,
"foreach.any.js": true,
"get.any.js": true,
"has.any.js": true,
"set-blob.any.js": true,
"set.any.js": true
}
}
}