Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(FormData/Serializer): Early fix for supporting async blob source #11050

Merged
merged 4 commits into from
Jun 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
205 changes: 40 additions & 165 deletions extensions/fetch/21_formdata.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
((window) => {
const core = window.Deno.core;
const webidl = globalThis.__bootstrap.webidl;
const { Blob, File, _byteSequence } = globalThis.__bootstrap.file;
const { Blob, File } = globalThis.__bootstrap.file;

const entryList = Symbol("entry list");

Expand All @@ -25,10 +25,10 @@
*/
function createEntry(name, value, filename) {
if (value instanceof Blob && !(value instanceof File)) {
value = new File([value[_byteSequence]], "blob", { type: value.type });
value = new File([value], "blob", { type: value.type });
}
if (value instanceof File && filename !== undefined) {
value = new File([value[_byteSequence]], filename, {
value = new File([value], filename, {
type: value.type,
lastModified: value.lastModified,
});
Expand Down Expand Up @@ -242,170 +242,44 @@

webidl.configurePrototype(FormData);

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(core.encode(`\r\n--${this.boundary}--`));
const escape = (str, isFilename) =>
(isFilename ? str : str.replace(/\r?\n|\r/g, "\r\n"))
.replace(/\n/g, "%0A")
.replace(/\r/g, "%0D")
.replace(/"/g, "%22");

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;
/**
* convert FormData to a Blob synchronous without reading all of the files
* @param {globalThis.FormData} formData
*/
function formDataToBlob(formData) {
const boundary = `${Math.random()}${Math.random()}`
.replaceAll(".", "").slice(-28).padStart(32, "-");
const chunks = [];
const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="`;

for (const [name, value] of formData) {
if (typeof value === "string") {
chunks.push(
prefix + escape(name) + '"' + CRLF + CRLF +
value.replace(/\r(?!\n)|(?<!\r)\n/g, CRLF) + CRLF,
);
} else {
chunks.push(
prefix + escape(name) + `"; filename="${escape(value.name, true)}"` +
CRLF +
`Content-Type: ${value.type || "application/octet-stream"}\r\n\r\n`,
value,
CRLF,
);
}

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(core.encode(buf));
}
chunks.push(`--${boundary}--`);

/**
* @param {string} field
* @param {string} filename
* @param {string} [type]
* @returns {void}
*/
#writeFileHeaders(
field,
filename,
type,
) {
const escapedField = this.#headerEscape(field);
const escapedFilename = this.#headerEscape(filename, true);
/** @type {[string, string][]} */
const headers = [
[
"Content-Disposition",
`form-data; name="${escapedField}"; filename="${escapedFilename}"`,
],
["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="${this.#headerEscape(field)}"`,
]];
return this.#writeHeaders(headers);
}

/**
* @param {string} field
* @param {string} value
* @returns {void}
*/
#writeField(field, value) {
this.#writeFieldHeaders(field);
this.chunks.push(core.encode(this.#normalizeNewlines(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 {string} string
* @returns {string}
*/
#normalizeNewlines(string) {
return string.replace(/\r(?!\n)|(?<!\r)\n/g, "\r\n");
}

/**
* Performs the percent-escaping and the normalization required for field
* names and filenames in Content-Disposition headers.
* @param {string} name
* @param {boolean} isFilename Whether we are encoding a filename. This
* skips the newline normalization that takes place for field names.
* @returns {string}
*/
#headerEscape(name, isFilename = false) {
if (!isFilename) {
name = this.#normalizeNewlines(name);
}
return name
.replaceAll("\n", "%0A")
.replaceAll("\r", "%0D")
.replaceAll('"', "%22");
}
}

/**
* @param {FormData} formdata
* @returns {{body: Uint8Array, contentType: string}}
*/
function encodeFormData(formdata) {
const builder = new MultipartBuilder(formdata);
return {
body: builder.getBody(),
contentType: builder.getContentType(),
};
return new Blob(chunks, {
type: "multipart/form-data; boundary=" + boundary,
});
}

/**
Expand All @@ -426,8 +300,9 @@
return params;
}

const LF = "\n".codePointAt(0);
const CR = "\r".codePointAt(0);
const CRLF = "\r\n";
const LF = CRLF.codePointAt(1);
const CR = CRLF.codePointAt(0);

class MultipartParser {
/**
Expand Down Expand Up @@ -575,7 +450,7 @@

globalThis.__bootstrap.formData = {
FormData,
encodeFormData,
formDataToBlob,
parseFormData,
formDataFromEntries,
};
Expand Down
12 changes: 6 additions & 6 deletions extensions/fetch/22_body.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
const core = window.Deno.core;
const webidl = globalThis.__bootstrap.webidl;
const { parseUrlEncoded } = globalThis.__bootstrap.url;
const { parseFormData, formDataFromEntries, encodeFormData } =
const { parseFormData, formDataFromEntries, formDataToBlob } =
globalThis.__bootstrap.formData;
const mimesniff = globalThis.__bootstrap.mimesniff;
const { isReadableStreamDisturbed, errorReadableStream } =
Expand Down Expand Up @@ -311,11 +311,11 @@
const copy = u8.slice(0, u8.byteLength);
source = copy;
} else if (object instanceof FormData) {
const res = encodeFormData(object);
stream = { body: res.body, consumed: false };
source = object;
length = res.body.byteLength;
contentType = res.contentType;
const res = formDataToBlob(object);
stream = res.stream();
source = res;
length = res.size;
contentType = res.type;
} else if (object instanceof URLSearchParams) {
source = core.encode(object.toString());
contentType = "application/x-www-form-urlencoded;charset=UTF-8";
Expand Down
7 changes: 3 additions & 4 deletions extensions/fetch/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,9 @@ declare namespace globalThis {

declare namespace formData {
declare type FormData = typeof FormData;
declare function encodeFormData(formdata: FormData): {
body: Uint8Array;
contentType: string;
};
declare function formDataToBlob(
formData: globalThis.FormData,
): Blob;
declare function parseFormData(
body: Uint8Array,
boundary: string | undefined,
Expand Down