@nextrush/multipart
Zero-dependency multipart/form-data file upload middleware with streaming parser, pluggable storage, and security-by-default.
Handle file uploads in multipart/form-data requests.
HTTP file uploads arrive as multipart-encoded streams with mixed files and form fields. This middleware parses them using a zero-dependency Web Streams parser, sanitizes filenames, enforces size limits, and delivers structured results on ctx.state. Built-in protections guard against path traversal, prototype pollution, oversized payloads, and platform-specific filesystem attacks.
Default Behavior
With default options, multipart() uses MemoryStorage and applies these defaults:
| Limit | Default |
|---|---|
| Max file size | 5 MB |
| Max files | 10 |
| Max fields | 50 |
| Max parts | 100 |
| Max field size | 1 MB |
| Max body size | 10 MB |
| Max boundary length | 70 (RFC 2046) |
Bodyless HTTP methods (GET, HEAD, DELETE, OPTIONS) are skipped automatically. Non-multipart requests pass through to the next middleware.
Installation
$ pnpm add @nextrush/multipart
Minimal Usage
import { createApp } from '@nextrush/core';
import { createRouter } from '@nextrush/router';
import { multipart } from '@nextrush/multipart';
const app = createApp();
const router = createRouter();
app.use(multipart());
router.post('/upload', (ctx) => {
const { files, fields } = ctx.state;
ctx.json({
uploaded: files.map((f) => ({
name: f.sanitizedName,
size: f.size,
type: f.mimeType,
})),
fields,
});
});
app.route('/', router);
app.listen(3000);Storage Strategies
Buffers file contents in memory as Uint8Array. Use for small uploads or when you need the full buffer in memory.
import { multipart, MemoryStorage } from '@nextrush/multipart';
app.use(multipart({
storage: new MemoryStorage(),
}));
router.post('/upload', (ctx) => {
const file = ctx.state.files[0];
console.log(file.buffer); // Uint8Array
console.log(file.size); // bytes
});Production Warning
MemoryStorage holds entire files in memory. For production workloads with large uploads, use DiskStorage or a custom strategy. Always configure limits.maxFileSize and limits.maxBodySize to cap memory usage.
Runtime: Node.js, Bun, Deno, Edge
Streams files directly to the filesystem. Use for large uploads or when you want disk-backed storage.
import { multipart, DiskStorage } from '@nextrush/multipart';
app.use(multipart({
storage: new DiskStorage({
dest: './uploads',
filename: (info) => `${Date.now()}-${info.sanitizedName}`,
}),
limits: { maxFileSize: '50mb' },
}));
router.post('/upload', (ctx) => {
const file = ctx.state.files[0];
console.log(file.path); // './uploads/1709721600000-photo.jpg'
});The destination directory is created automatically. Default filename uses crypto.randomUUID() prefix for collision resistance.
Runtime: Node.js, Bun, Deno only (requires filesystem access)
Implement the StorageStrategy interface for S3, GCS, or any backend:
import type { StorageStrategy, StorageResult, FileInfo } from '@nextrush/multipart';
class S3Storage implements StorageStrategy {
async handle(
stream: ReadableStream<Uint8Array>,
info: FileInfo
): Promise<StorageResult> {
// Stream to S3...
return { size: uploadedBytes, path: s3Key };
}
async remove(result: StorageResult): Promise<void> {
// Delete from S3...
}
}
app.use(multipart({ storage: new S3Storage() }));| Method | Required | Description |
|---|---|---|
handle(stream, info) | Yes | Process the upload stream and return StorageResult |
remove(result) | No | Cleanup on error (called automatically if abortOnError is true) |
Configuration Options
multipart(options?)
function multipart(options?: MultipartOptions): Middleware;MultipartOptions
| Property | Type | Description |
|---|---|---|
storage | StorageStrategy= MemoryStorage | Where to store uploaded files |
limits | MultipartLimits= See limits table above | Upload size and count limits |
allowedTypes | string[]= undefined (all types accepted) | Allowed MIME types — supports wildcards (e.g., "image/*") |
filename? | (info: FileInfo) => string | Custom filename generator for DiskStorage |
abortOnError | boolean= true | Stop processing on first error. If false, truncated files are included with truncated=true |
Limits
MultipartLimits
| Property | Type | Description |
|---|---|---|
maxFileSize | number | string= '5mb' (5,242,880 bytes) | Maximum size per file in bytes or human-readable (e.g., "10mb") |
maxFiles | number= 10 | Maximum number of files per request |
maxFields | number= 50 | Maximum number of non-file form fields |
maxParts | number= 100 | Maximum total parts (files + fields) |
maxFieldNameSize | number= 200 | Maximum field name length in bytes |
maxFieldSize | number | string= '1mb' (1,048,576 bytes) | Maximum value size per non-file field |
maxHeaderPairs | number= 2000 | Maximum header pairs per MIME part |
maxBodySize | number | string= '10mb' (10,485,760 bytes) | Maximum total request body size |
Accessing Uploaded Data
After the middleware runs, ctx.state contains the parsed results:
router.post('/upload', (ctx) => {
// Files
for (const file of ctx.state.files) {
file.fieldName; // Form field name (e.g., "avatar")
file.originalName; // Client-provided filename (unsanitized)
file.sanitizedName; // Safe filename for storage
file.mimeType; // MIME type (e.g., "image/png")
file.encoding; // Transfer encoding
file.size; // File size in bytes
file.truncated; // Whether file was truncated (exceeded size limit)
file.buffer; // Uint8Array (MemoryStorage only)
file.path; // Filesystem path (DiskStorage only)
}
// Fields
const { name, description } = ctx.state.fields;
});Type Definitions
interface UploadedFile {
readonly fieldName: string;
readonly originalName: string;
readonly sanitizedName: string;
readonly encoding: string;
readonly mimeType: string;
readonly size: number;
readonly truncated: boolean;
readonly buffer?: Uint8Array;
readonly path?: string;
}
interface MultipartState {
files: UploadedFile[];
fields: Record<string, string>;
}MIME Type Filtering
Restrict allowed file types using exact matches or wildcards:
app.use(multipart({
allowedTypes: [
'image/png',
'image/jpeg',
'image/*', // All image types
'application/pdf',
],
}));Files with disallowed types throw INVALID_FILE_TYPE (415).
Error Handling
The middleware throws MultipartError with specific codes and HTTP status:
import { MultipartError } from '@nextrush/multipart';
app.use(async (ctx) => {
try {
await ctx.next();
} catch (error) {
if (error instanceof MultipartError) {
ctx.status = error.status;
ctx.json({
error: error.code,
message: error.message,
});
}
}
});Error Codes
| Code | Status | When |
|---|---|---|
FILE_TOO_LARGE | 413 | File exceeds maxFileSize |
BODY_SIZE_EXCEEDED | 413 | Total body exceeds maxBodySize |
FILES_LIMIT_EXCEEDED | 413 | Too many files |
FIELDS_LIMIT_EXCEEDED | 413 | Too many form fields |
PARTS_LIMIT_EXCEEDED | 413 | Total parts exceeded |
INVALID_CONTENT_TYPE | 415 | Not a multipart/form-data request |
INVALID_FILE_TYPE | 415 | MIME type not in allowedTypes |
INVALID_FIELD_NAME | 400 | Prototype pollution attempt (__proto__, constructor) |
PARSE_ERROR | 400 | Malformed multipart data or missing boundary |
REQUEST_ABORTED | 400 | Client disconnected during upload |
STORAGE_ERROR | 500 | Storage strategy failure |
The expose property controls whether the error message is safe for clients: true for 4xx errors, false for 5xx errors.
Security
All protections are enabled by default with no opt-in required.
Filename Sanitization Pipeline
Every uploaded filename passes through a multi-step sanitization:
- Path traversal prevention — strips directory components (
../../etc/passwd→passwd) - Null byte and control character removal — replaces
\x00–\x1Fwith_ - Hidden file prevention — strips leading dots (
.htaccess→htaccess) - Windows reserved name detection — prefixes
CON,PRN,AUX,NUL,COM1–COM9,LPT1–LPT9with_ - Length truncation — caps at safe filesystem length (preserves extension)
- Fallback generation — uses
crypto.randomUUID()if nothing remains
Prototype Pollution Protection
Field names __proto__, constructor, and prototype are rejected with INVALID_FIELD_NAME (400).
Body Size Enforcement
The total request body is tracked cumulatively against maxBodySize (default: 10 MB). Exceeding it aborts parsing immediately — prevents memory exhaustion from large payloads.
Boundary Validation
Boundaries exceeding 70 characters are rejected per RFC 2046 §5.1.1.
Error Message Sanitization
User-supplied values in error messages are truncated and stripped of control characters to prevent log injection.
DiskStorage Options
new DiskStorage(options: DiskStorageOptions)DiskStorageOptions
| Property | Type | Description |
|---|---|---|
dest | string | Destination directory for uploaded files. Created automatically if it does not exist. |
filename | (info: FileInfo) => string= `${crypto.randomUUID()}-${info.sanitizedName}` | Custom filename generator |
DiskStorage includes path traversal protection — generated filenames are resolved and verified to stay within the destination directory.
Advanced Usage
Direct Parser Access
Use parseMultipart directly for custom processing pipelines:
import { parseMultipart } from '@nextrush/multipart';
const result = await parseMultipart(body, boundary, {
storage: new MemoryStorage(),
limits: { maxFileSize: '1mb' },
});
// result.files: UploadedFile[]
// result.fields: Record<string, string>BoundaryScanner
Low-level boundary scanner for custom streaming parsers:
import { BoundaryScanner } from '@nextrush/multipart';
const scanner = new BoundaryScanner(boundaryBytes);
const result = scanner.scan(chunk);
// result.parts: extracted parts
// result.remainder: leftover bytes for next chunkRuntime Compatibility
| Runtime | MemoryStorage | DiskStorage | Notes |
|---|---|---|---|
| Node.js 22+ | ✅ | ✅ | Full support |
| Bun | ✅ | ✅ | Full support |
| Deno | ✅ | ✅ | Via Node compat layer |
| Cloudflare Workers | ✅ | ❌ | No filesystem access |
| Vercel Edge | ✅ | ❌ | No filesystem access |
The parser uses Web Streams API (ReadableStream) and Web Crypto API (crypto.randomUUID()) — both available across all modern runtimes. DiskStorage requires node:fs and node:stream, limiting it to server runtimes.
Complete Example
import { createApp } from '@nextrush/core';
import { createRouter } from '@nextrush/router';
import { multipart, DiskStorage, MultipartError } from '@nextrush/multipart';
const app = createApp();
const router = createRouter();
// Error handler
app.use(async (ctx) => {
try {
await ctx.next();
} catch (error) {
if (error instanceof MultipartError) {
ctx.status = error.status;
ctx.json({ error: error.code, message: error.message });
return;
}
throw error;
}
});
// Multipart middleware with disk storage
app.use(multipart({
storage: new DiskStorage({ dest: './uploads' }),
allowedTypes: ['image/*', 'application/pdf'],
limits: {
maxFileSize: '10mb',
maxFiles: 5,
maxBodySize: '50mb',
},
}));
router.post('/upload', (ctx) => {
ctx.json({
files: ctx.state.files.map((f) => ({
name: f.sanitizedName,
size: f.size,
type: f.mimeType,
path: f.path,
})),
fields: ctx.state.fields,
});
});
app.route('/', router);
app.listen(3000);