← 中文资源

简体中文版本

Multipart Form-Data 请求体构建

在无浏览器环境构建 multipart/form-data 请求体,支持自定义边界、文本字段与二进制文件。

阅读英文原文

功能说明

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, " ");
}