Multipart Form-Data Body Builder

Build multipart/form-data request bodies with custom boundaries, text fields and binary file parts without a browser.

TypescriptFile Upload

Code

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