Skip to content

feat: add @routup/upload package for multipart/form-data parsing #760

@tada5hi

Description

@tada5hi

Summary

Create a new @routup/upload plugin for streaming multipart/form-data parsing with file upload support, built on busboy.

Motivation

The @routup/body plugin handles JSON, URL-encoded, text, and raw bodies but intentionally does not handle multipart/form-data. The Web Standard request.formData() exists but lacks production-critical features:

  • No streaming file access (buffers entire files in memory)
  • No per-file or total size limits
  • No file/field count limits
  • No ability to abort on limit exceeded

For production file uploads with untrusted input, a streaming parser with limits is required.

Approach

Use busboy under the hood, bridging the Web Standard ReadableStream to a Node.js Readable via Readable.fromWeb():

import { Readable } from 'node:stream';
import busboy from 'busboy';

const bb = busboy({ headers: { 'content-type': event.headers.get('content-type') } });
Readable.fromWeb(event.request.body).pipe(bb);

Note: This package will be Node.js-only due to busboy requiring Node streams.

Proposed API

Plugin

import { upload } from '@routup/upload';

router.use(upload({
    limits: {
        fileSize: '10mb',     // per-file size limit
        files: 5,             // max number of files
        fields: 20,           // max number of non-file fields
        fieldSize: '1mb',     // max field value size
    },
}));

Helpers

import { useRequestFiles, useRequestFile } from '@routup/upload';

router.post('/upload', defineCoreHandler(async (event) => {
    const files = await useRequestFiles(event);      // all uploaded files
    const avatar = await useRequestFile(event, 'avatar'); // single file by field name
}));

Options

Option Type Description
limits.fileSize number | string Max size per file (e.g., '10mb')
limits.files number Max number of file fields
limits.fields number Max number of non-file fields
limits.fieldSize number | string Max size of a field value

Package Structure

Follow standard plugin layout under packages/upload/:

packages/upload/
├── src/
│   ├── index.ts
│   ├── module.ts        # upload() plugin factory
│   ├── handler.ts       # middleware
│   ├── helpers/         # useRequestFiles, useRequestFile
│   ├── types.ts         # UploadOptions, UploadedFile
│   └── utils/           # busboy bridge, size parsing
├── test/
│   └── unit/
├── package.json         # peerDep: routup ^5.0.0-beta.3, dep: busboy
└── tsdown.config.ts

Error Handling

Use createError() from routup with appropriate status codes:

  • 413 — file or total size limit exceeded
  • 413 — too many files or fields
  • 415 — content-type is not multipart/form-data

Considerations

  • Node.js only (Readable.fromWeb + busboy)
  • Files should be streamed, not buffered — expose file streams to handlers
  • Consider whether to store files temporarily on disk or keep in-memory (configurable)
  • Reuse parseSize utility pattern from @routup/body

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions