hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses File Uploads and Storage with @hectoday/http

The Basics

  • Why File Uploads Are Hard
  • Project Setup

Receiving Files

  • Multipart Form Data
  • Validating Uploads
  • Saving to Disk

Serving Files

  • Serving Static Files
  • Range Requests and Resumable Downloads
  • Access Control on Files

Production Patterns

  • Streaming Uploads
  • Image Processing
  • Upload Progress and Cancellation

Cloud Storage

  • Presigned URLs
  • Moving from Local to Cloud

Putting It All Together

  • File Upload Checklist
  • Capstone: File Sharing API

Multipart Form Data

How files travel over HTTP

When a browser submits a form with a file input, it does not send JSON. It sends multipart/form-data: a format that splits the body into parts, each with its own headers and content.

A multipart body looks like this:

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="description"

My vacation photo
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg

<binary data>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

Each part is separated by a boundary string. Text fields (like description) are sent as plain text. File fields include the filename and MIME type, followed by the raw binary content.

Why not JSON?

JSON is text-based. Binary files (images, PDFs, videos) would need to be base64-encoded, increasing the size by ~33%. Multipart sends raw bytes, which is more efficient.

JSON also cannot represent multiple fields with different content types in one payload. Multipart can: a text description and a binary file in the same request.

Parsing with busboy

busboy is a streaming multipart parser. It processes the request body chunk by chunk, emitting events for each field and file. It never loads the entire file into memory.

// src/upload.ts
import Busboy from "busboy";
import { Readable } from "node:stream";

interface ParsedUpload {
  fields: Record<string, string>;
  file?: {
    fieldName: string;
    filename: string;
    mimeType: string;
    stream: Readable;
  };
}

export function parseMultipart(request: Request): Promise<ParsedUpload> {
  return new Promise(async (resolve, reject) => {
    const contentType = request.headers.get("content-type");
    if (!contentType?.includes("multipart/form-data")) {
      reject(new Error("Expected multipart/form-data"));
      return;
    }

    const busboy = Busboy({ headers: { "content-type": contentType } });
    const fields: Record<string, string> = {};
    let fileResult: ParsedUpload["file"];

    // "field" fires for each text field in the form (like "description")
    busboy.on("field", (name, value) => {
      fields[name] = value;
    });

    // "file" fires for each file field. stream is a Readable of the file's bytes.
    busboy.on("file", (fieldName, stream, info) => {
      fileResult = {
        fieldName,
        filename: info.filename,
        mimeType: info.mimeType,
        stream,
      };
    });

    // "close" fires when the entire multipart body has been parsed
    busboy.on("close", () => {
      resolve({ fields, file: fileResult });
    });

    // "error" fires if the multipart body is malformed
    busboy.on("error", (err) => {
      reject(err);
    });

    // Pipe the request body into busboy
    const body = request.body;
    if (!body) {
      reject(new Error("No request body"));
      return;
    }

    const reader = body.getReader();
    const nodeStream = new Readable({
      async read() {
        const { done, value } = await reader.read();
        if (done) {
          this.push(null);
        } else {
          this.push(Buffer.from(value));
        }
      },
    });

    nodeStream.pipe(busboy);
  });
}

The key: busboy processes the multipart body as a stream. The file data arrives as a Readable stream — we can pipe it directly to disk without ever holding the full file in memory.

Using it in a route

route.post("/upload", {
  resolve: async (c) => {
    const { fields, file } = await parseMultipart(c.request);

    if (!file) {
      return Response.json({ error: "No file uploaded" }, { status: 400 });
    }

    console.log("Fields:", fields);
    console.log("File:", file.filename, file.mimeType);

    // Next lesson: validate and save the file
    // For now, consume the stream (discard)
    for await (const chunk of file.stream) {
      // process chunk...
    }

    return Response.json({ message: "File received", filename: file.filename });
  },
});

Try it

curl -X POST http://localhost:3000/upload \
  -F "description=My photo" \
  -F "[email protected]"
# { "message": "File received", "filename": "photo.jpg" }

The -F flag tells curl to send multipart form data. @photo.jpg reads the file from disk.

Exercises

Exercise 1: Implement the parseMultipart function and the upload route. Upload a file with curl and verify the filename and MIME type are printed.

Exercise 2: Upload a form with multiple text fields (like description and tags). Verify they appear in the fields object.

Exercise 3: What happens if you send a JSON body to the upload endpoint? (The multipart parser rejects it because the Content-Type is wrong.)

Why does busboy process files as a stream instead of loading them into memory?

← Project Setup Validating Uploads →

© 2026 hectoday. All rights reserved.