功能说明
MultipartFormDataBuilder 通过链式调用添加文本字段与二进制文件,最终生成 Uint8Array 请求体、边界与 Content-Type。它适合 Node 脚本上传构建产物、CLI 调用文件接口、测试中构造原始请求以及在受限运行时替代 FormData。生成的字节可直接交给 fetch、http 模块或对象存储客户端。
关键代码
构造函数允许自定义边界,默认使用随机字符串加时间戳,并限制长度与禁止引号和换行。每个字段按 RFC 7578 写入边界、Content-Disposition 与空行,文件部分额外写入 filename 与 Content-Type,再追加原始字节和 CRLF。build 先计算所有块与结束边界的总长度,一次性分配缓冲区并依次复制,避免多次数组拼接。
使用注意
默认边界来自 Math.random,不适合作为安全随机数;并发上传时建议传入包含足够熵的自定义边界。字段名与文件名中的引号、反斜杠和换行会被转义或替换,但不会做完整的 RFC 5987 文件名编码,非 ASCII 文件名可能被服务端误解。build 不会修改内部块,可重复调用,但之后继续添加字段会产生不同结果。请求体较大时应流式写入而不是整体驻留内存。
验证方式
构建包含两个文本字段与一个二进制文件的请求体,按边界拆分后断言各部分头与内容。使用包含引号、反斜杠与换行的字段名,检查转义结果。验证自定义边界、超长边界、含引号或换行的边界会抛错。重复调用 build 断言字节一致,并用真实 HTTP 服务端解析器做一次端到端上传测试。
代码示例
const encoder = new TextEncoder();
export interface MultipartFile {
field: string;
filename: string;
contentType: string;
data: Uint8Array;
}
export interface MultipartBody {
body: Uint8Array;
boundary: string;
contentType: string;
}
export class MultipartFormDataBuilder {
private readonly boundary: string;
private readonly chunks: Uint8Array[] = [];
constructor(boundary?: string) {
this.boundary = boundary ?? "----MultipartBoundary"
+ Math.random().toString(36).slice(2)
+ Date.now().toString(36);
if (this.boundary.length < 1 || this.boundary.length > 70) {
throw new RangeError("boundary length must be between 1 and 70");
}
if (/[\r\n"]/.test(this.boundary)) {
throw new TypeError("boundary must not contain quotes or newlines");
}
}
field(name: string, value: string): this {
this.chunks.push(encoder.encode(
"--" + this.boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"" + escapeName(name) + "\"\r\n\r\n"
+ value + "\r\n",
));
return this;
}
file(file: MultipartFile): this {
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+*-]+$/i.test(file.contentType)) {
throw new TypeError("invalid file contentType");
}
this.chunks.push(encoder.encode(
"--" + this.boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"" + escapeName(file.field)
+ "\"; filename=\"" + escapeName(file.filename) + "\"\r\n"
+ "Content-Type: " + file.contentType + "\r\n\r\n",
));
this.chunks.push(file.data);
this.chunks.push(encoder.encode("\r\n"));
return this;
}
build(): MultipartBody {
const closing = encoder.encode("--" + this.boundary + "--\r\n");
const length = this.chunks.reduce((total, chunk) => total + chunk.length, closing.length);
const body = new Uint8Array(length);
let offset = 0;
for (const chunk of this.chunks) {
body.set(chunk, offset);
offset += chunk.length;
}
body.set(closing, offset);
return {
body,
boundary: this.boundary,
contentType: "multipart/form-data; boundary=" + this.boundary,
};
}
}
function escapeName(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\r\n]/g, " ");
}