@nextrush/form-data
v1.0.0
Published
Streaming multipart/form-data parser and file upload middleware for NextRush
Maintainers
Readme
@nextrush/form-data
Zero-dependency multipart/form-data parsing and file-upload middleware for NextRush -- buffers the request body once, then streams each file's already-buffered bytes into a pluggable storage strategy (in-memory or disk).
| | |
| --- | --- |
| Purpose | Parse multipart/form-data request bodies into uploaded files and form fields |
| Package type | Middleware |
| Status | Stable |
| Included in nextrush? | No -- standalone install; not re-exported from nextrush or nextrush/class |
| Support tier | Public -- middleware/registrar (stable) -- see ADR-0005 |
| Maintenance | Active |
| Runtime | Node, Bun, Deno, Edge for parsing/MemoryStorage; DiskStorage is Node/Bun/Deno only -- see Compatibility |
| Requires | Node >=22, ESM-only, TypeScript >=5.x |
| Introduced | v1.0.0 |
Highlights
- Zero runtime dependencies (a types-only dependency on
@nextrush/types, erased at build) - ESM-only, tree-shakable, side-effect-free (
sideEffects: false) - Fully typed, strict TypeScript, zero
any - Boyer-Moore-Horspool boundary scanning, not a naive byte-by-byte search
- Pluggable storage:
MemoryStorage(buffer) orDiskStorage(filesystem) via oneStorageStrategyinterface
The problem . When to use . Installation . Quick start . Capabilities . Mental model . Common tasks . API overview . Options . Performance . Compatibility . Troubleshooting . FAQ . Package relationships . Architecture . Resources
The problem
File uploads sound like a simple body read, but multipart/form-data interleaves binary file
content with form fields inside a single stream, delimited by a boundary string the client
chooses. A body parser written by hand tends to get the happy-path decode right and miss the rest:
// TODAY, without a multipart-aware parser -- quick to write, dangerous to ship:
let raw = Buffer.alloc(0);
req.on('data', (chunk) => {
raw = Buffer.concat([raw, chunk]); // no size ceiling -- a multi-GB upload is buffered in full
});
req.on('end', () => {
// now what? Splitting on a boundary string by hand means re-deriving RFC 7578 part parsing,
// Content-Disposition filename extraction, RFC 5987 encoded filenames, and a filename like
// `../../.env` reaching your filesystem write untouched
});Beyond the missing size ceiling, a by-hand splitter usually also forgets that a boundary can be
split across two network chunks, that __proto__ is a legal (and dangerous) form field name, and
that a client-supplied filename is attacker-controlled input, not a trusted path segment.
When to use
Use @nextrush/form-data if:
- You need to accept file uploads (
multipart/form-data) with enforced size/count limits - You want filename sanitization (path traversal, null bytes, Windows-reserved names) without writing your own
- You need a choice between buffering uploads in memory or streaming them to disk
Reach for something else if:
- You're parsing
application/json,application/x-www-form-urlencoded,text/*, or raw bodies -- see@nextrush/body-parser, which explicitly rejects multipart - You need object storage (S3, GCS, etc.) out of the box -- implement
StorageStrategyyourself; onlyMemoryStorageandDiskStorageship in this package - You're running on an Edge runtime and need on-disk uploads --
DiskStoragerequiresnode:fs/node:path/node:streamand is not available there
Installation
pnpm add @nextrush/form-data
# npm i @nextrush/form-data . yarn add @nextrush/form-data . bun add @nextrush/form-data[!NOTE]
@nextrush/form-datais not re-exported by thenextrushmeta package -- install and import it directly, as shown above.
Quick start
import { createApp, listen } from 'nextrush';
import { formData } from '@nextrush/form-data';
const app = createApp();
app.use(formData({ limits: { maxFileSize: '5mb', maxFiles: 3 } }));
app.post('/upload', async (ctx) => {
const { files, fields } = ctx.state as { files: unknown[]; fields: Record<string, string> };
ctx.status = 201;
ctx.json({ uploaded: files.length, fields });
});
listen(app, 8080);formData() skips requests with a bodyless method or a non-multipart Content-Type, buffers
the body (up to maxBodySize, default 10mb), and populates ctx.state.files /
ctx.state.fields before your handler runs.
Capabilities
Parsing
formData()-- middleware factory; parsesmultipart/form-databodies intoctx.state.filesandctx.state.fieldsparseFormData()-- the underlying parser, exported for advanced/direct use without the middleware wrapperBoundaryScanner-- the Boyer-Moore-Horspool byte scanner, exported for custom parsing built on the same primitive- Handles boundaries split across the header/body regions, quoted and unquoted
Content-Dispositionvalues, and RFC 5987 (filename*=UTF-8''...) encoded filenames
Storage
MemoryStorage-- buffers each file's bytes into aUint8Array(ctxfiles carry.buffer); works on every runtimeDiskStorage-- streams each file to the filesystem viaReadable.fromWeb()+pipeline()(ctxfiles carry.path); Node/Bun/Deno only, not Edge- Any custom storage strategy that implements
StorageStrategy.handle()(and optionally.remove()for cleanup)
Security
- Filenames run through a multi-step sanitizer (
sanitizeFilename()): strips path components, null bytes and control characters, leading dots, Windows-reserved device names (CON,NUL,COM1-9,LPT1-9), and truncates to 255 bytes - Form-field and file-field names are rejected outright when they equal
__proto__,constructor, orprototype(FORBIDDEN_KEYS) DiskStorageresolves the generated filename against its destination directory and rejects a resolved path that doesn't start with that directory, before writing- The total request body is bounded by
maxBodySize(default10mb) while it is being collected from the stream -- independent of any per-file limit
Performance
- Boundary search inside the body uses Boyer-Moore-Horspool (
BoundaryScanner), not a linear byte-by-byte comparison, for every part after the first abortOnError: falselets a request continue past a single bad part (oversized file, disallowed type, limit overrun) instead of failing the whole upload
Mental model
formData() collects the whole (size-bounded) request body into memory first, then walks it
part by part -- it does not stream individual parts live off the network into storage.
request body (stream) --> collected into one Uint8Array (bounded by maxBodySize)
|
v
boundary-scanned, part by part
| |
file part field part
| |
storage.handle(bytes) fields[name] = value
|
ctx.state.files / ctx.state.fieldsRule: by the time storage.handle() runs, the file's bytes are already fully in memory --
DiskStorage writing to disk reduces long-term memory retention (the bytes aren't kept in the
result object), but it does not reduce the peak memory used while the request body itself is
being collected.
[!TIP] The full collect-scan-parse-store sequence and the per-part limit checks (with diagrams) are in
ARCHITECTURE.md.
Common tasks
Accept uploads with size and count limits
import { formData } from '@nextrush/form-data';
app.use(
multipart({
limits: {
maxFileSize: '10mb',
maxFiles: 5,
maxFields: 20,
maxBodySize: '50mb',
},
})
);Restrict accepted file types
app.use(
multipart({
allowedTypes: ['image/*', 'application/pdf'],
})
);
app.post('/avatar', async (ctx) => {
const { files } = ctx.state as { files: Array<{ mimeType: string; size: number }> };
// any file whose Content-Type didn't match 'image/*' or 'application/pdf'
// already caused the request to be rejected before this handler ran
});Stream large uploads to disk instead of buffering in memory
import { formData, DiskStorage } from '@nextrush/form-data';
app.use(
multipart({
storage: new DiskStorage({ dest: './uploads' }),
limits: { maxFileSize: '200mb' },
})
);
app.post('/upload', async (ctx) => {
const { files } = ctx.state as { files: Array<{ path?: string; sanitizedName: string }> };
// files[i].path holds the on-disk location; files[i].buffer is undefined for DiskStorage
});Continue an upload past a single bad part
app.use(
multipart({
abortOnError: false, // don't throw on the first oversized/disallowed part
limits: { maxFileSize: '2mb' },
})
);
app.post('/bulk-upload', async (ctx) => {
const { files } = ctx.state as { files: Array<{ truncated: boolean }> };
const rejected = files.filter((f) => f.truncated);
// an oversized file is included with truncated: true and its bytes cut off at maxFileSize;
// a file/field/parts-count overrun, forbidden field name, or disallowed type is skipped entirely
});Use the parser directly, without the middleware
import { parseFormData } from '@nextrush/form-data';
const boundary = '----WebKitFormBoundaryABC123';
const { files, fields } = await parseFormData(requestBodyStream, boundary, {
limits: { maxFileSize: '5mb' },
});API overview
The sealed public surface (ADR-0005).
| Export | Signature | Since | Stability | Description |
| ------ | --------- | ----- | --------- | ----------- |
| formData | (options?: FormDataOptions) => Middleware | 1.0.0 | Stable | Middleware factory; parses the request into ctx.state.files/ctx.state.fields. |
| parseFormData | (body: ReadableStream<Uint8Array> \| Uint8Array, boundary: string, options?: FormDataOptions) => Promise<ParsedResult> | 1.0.0 | Stable | The underlying parser, usable without the middleware wrapper. |
| type ParsedResult | -- | 1.0.0 | Stable | { files: UploadedFile[]; fields: Record<string, string> }. |
| BoundaryScanner | class | 1.0.0 | Stable | Boyer-Moore-Horspool boundary scanner, for custom parsing. |
| type ScanResult | -- | 1.0.0 | Stable | { index: number; isFinal: boolean }. |
| MemoryStorage | class implements StorageStrategy | 1.0.0 | Stable | Buffers file bytes into a Uint8Array. |
| DiskStorage | class implements StorageStrategy | 1.0.0 | Stable | Streams file bytes to the filesystem (Node/Bun/Deno only). |
| type DiskStorageOptions | -- | 1.0.0 | Stable | { dest: string; filename?: (info: FileInfo) => string }. |
| FormDataError | class | 1.0.0 | Stable | Thrown on any parse/limit/security failure; carries status and code. |
| type FileInfo / FormDataErrorCode / FormDataField / FormDataLimits / FormDataOptions / FormDataState / StorageResult / StorageStrategy / UploadedFile | -- | 1.0.0 | Stable | Public option and data contracts. |
Options
Every default below is read directly from src/constants.ts and each module's destructuring defaults.
multipart(options?) / parseFormData(body, boundary, options?)
| Option | Type | Required | Default | Security-sensitive | Description |
| ------ | ---- | -------- | ------- | ------------------- | ----------- |
| storage | StorageStrategy | No | new MemoryStorage() | No | Where uploaded file bytes end up. |
| limits | FormDataLimits | No | see below | Yes | Size/count ceilings for the whole request. |
| allowedTypes | string[] | No | undefined (all types accepted) | Yes | MIME allowlist; supports type/* wildcards. |
| filename | (info: FileInfo) => string | No | undefined | No | Custom filename generator (used by DiskStorage if not overridden there). |
| abortOnError | boolean | No | true | Yes | true: throw on the first limit/type/name violation. false: skip the offending part (or mark a file truncated: true) and continue. |
limits (FormDataLimits)
| Option | Type | Required | Default | Security-sensitive | Description |
| ------ | ---- | -------- | ------- | ------------------- | ----------- |
| maxFileSize | number \| string | No | '5mb' (5,242,880 bytes) | Yes | Per-file size ceiling; a part over this is either rejected or truncated (see abortOnError). |
| maxFiles | number | No | 10 | Yes | Maximum file parts per request. |
| maxFields | number | No | 50 | Yes | Maximum non-file field parts per request. |
| maxParts | number | No | 100 | Yes | Maximum total parts (files + fields) per request. |
| maxFieldNameSize | number | No | 200 (bytes) | Yes | Maximum field-name length. |
| maxFieldSize | number \| string | No | '1mb' (1,048,576 bytes) | Yes | Maximum non-file field value size; always rejects (not truncated) when exceeded. |
| maxHeaderPairs | number | No | 2000 | Yes | Maximum header lines parsed per part; excess lines are silently ignored, not rejected. |
| maxBodySize | number \| string | No | '10mb' (10,485,760 bytes) | Yes | Ceiling on the entire request body, enforced while it is being read off the stream. |
DiskStorage constructor options (DiskStorageOptions)
| Option | Type | Required | Default | Security-sensitive | Description |
| ------ | ---- | -------- | ------- | ------------------- | ----------- |
| dest | string | Yes | -- | Yes | Destination directory; created (recursively) on first write if missing. |
| filename | (info: FileInfo) => string | No | UUID + sanitized name (${crypto.randomUUID()}-${info.sanitizedName}) | No | Overrides the on-disk filename. |
Size limits
maxFileSize, maxFieldSize, and maxBodySize accept a byte count or a human-readable string
(parseLimit() in src/utils/limit.ts, the same pattern as @nextrush/body-parser):
app.use(formData({ limits: { maxFileSize: 5242880 } })); // equivalent
app.use(formData({ limits: { maxFileSize: '5mb' } })); // equivalentWhat happens when a limit is exceeded depends on which limit and abortOnError:
maxBodySize-- always throwsFormDataError(BODY_SIZE_EXCEEDED, 413) the moment the running total crosses the ceiling while the stream is still being read;abortOnErrorhas no effect on this check.maxFileSize-- withabortOnError: true(default), throwsFILE_TOO_LARGE(413) and cleans up any files already stored for this request; withabortOnError: false, the file is kept with its bytes cut off at the limit andtruncated: true.maxFiles/maxFields/maxParts-- withabortOnError: true, throws the matching*_LIMIT_EXCEEDEDerror (413); withabortOnError: false, the offending part is skipped and parsing continues.maxFieldSize-- always throwsPARSE_ERROR-coded viaErrors.parseError(400), regardless ofabortOnError-- there is no truncation path for over-limit field values.
Performance
Multipart parsing sits on the request hot path for upload endpoints, so the parser is built around one primitive: bounded, single-pass, in-memory boundary scanning.
- The whole body is collected before parsing starts.
streamToUint8Array()(src/parser.ts) reads theReadableStreaminto one contiguousUint8Array, checking the running total againstmaxBodySizeon every chunk and throwingBODY_SIZE_EXCEEDEDthe moment it's crossed -- an oversized upload never reaches the boundary scanner. - Boundary search is Boyer-Moore-Horspool, not linear, for the repeated case.
BoundaryScanner(src/scanner.ts) precomputes a 256-entry skip table once per parse call and reuses it for every part boundary in the body; only the very first boundary and each part's header terminator (\r\n\r\n) use the simpler linearfindBytes(), since those each run once (or a bounded few times) per part rather than being the repeated hot loop. - A file's bytes are wrapped, not re-read, before storage.
uint8ArrayToReadableStream()creates a single-chunkReadableStreamover the already-in-memoryUint8Arrayslice for that part --storage.handle()sees a stream interface for API consistency with a true network stream, but reads no additional bytes off any socket. DiskStoragestreams to the filesystem, not through a second in-memory copy.Readable.fromWeb()+pipeline()pipe the wrapped stream directly intocreateWriteStream(); the file's bytes are held once (in theUint8Arrayfrom the initial body collection) plus whatever Node's own stream buffering does internally.
[!IMPORTANT] "Streaming" here describes how a file's bytes move from the already-collected body buffer into storage -- it does not mean the parser processes the request body without fully buffering it first.
maxBodySize(default10mb) is the real ceiling on peak memory use per request, notmaxFileSizealone.
Numbers move with hardware and load -- run
pnpm bench:compare --profile standard(pinned) inapps/benchmarkon your own machine.
Compatibility
Requirements
| Requirement | Version | | ----------- | ------- | | NextRush | 3.x | | Node.js | >=22 | | TypeScript | >=5.x |
Runtimes
| Runtime | Parsing + MemoryStorage | DiskStorage | Notes |
| ------- | :---: | :---: | ----- |
| Node.js >=22 | Yes | Yes | ESM-only |
| Bun | Yes | Yes | DiskStorage uses node:fs/node:path/node:stream, available under Bun's Node compatibility layer |
| Deno | Yes | Yes | Same Node-compatibility caveat as Bun |
| Edge | Yes | No | DiskStorage imports node:fs, node:path, and node:stream directly -- there is no Edge-safe fallback; use MemoryStorage or a custom StorageStrategy |
Integration
- Peer dependencies: none -- depends only on
@nextrush/types(types, erased at build). - Works with: any NextRush middleware chain; register before route handlers so
ctx.state.files/ctx.state.fieldsare populated when they run. - Incompatible with: none directly, but registering
formData()after a body parser that has already consumed the body (ctx.bodySource.consumed) leaves multipart with nothing to read.
[!IMPORTANT] NextRush is ESM-only, permanently -- no CommonJS build. On Node >=22, CommonJS consumers can
require()this ESM package natively. See the Module Format Policy.
Troubleshooting
Cause: the total request body -- all files and fields combined -- crossed maxBodySize
(default 10mb), which is checked independently of any per-file limit while the body is being
read. Fix: raise maxBodySize to accommodate the combined size of everything the client may
send in one request.
app.use(formData({ limits: { maxBodySize: '100mb', maxFileSize: '20mb' } }));Cause: the request method was in BODYLESS_METHODS (GET, HEAD, DELETE, OPTIONS), or
the Content-Type header didn't start with multipart/form-data, or a prior middleware already
consumed ctx.bodySource. Fix: confirm the client sends a POST/PUT/PATCH with the
correct Content-Type including a boundary= parameter, and that no earlier middleware read the
body first.
Cause: this is the enforced prototype-pollution guard -- any field or file name equal to
__proto__, constructor, or prototype is rejected outright, by design. Fix: rename the
form field; there is no opt-out, because disabling this check would reopen a prototype-pollution
vector.
Cause: the filename returned by your custom filename callback (or DiskStorage's default
generator) resolved to a path outside the configured dest directory. Fix: don't build the
returned filename from unsanitized input containing ../ segments -- use the sanitized name
NextRush already computed (info.sanitizedName) as your callback's base, or omit filename
entirely to use the built-in UUID-prefixed default.
FAQ
Does @nextrush/form-data stream file uploads without buffering them?
No. The full request body is collected into one in-memory Uint8Array (bounded by
maxBodySize) before any part is parsed. "Streaming" in this package's API refers to how a
file's already-buffered bytes move into a StorageStrategy (e.g. piped to disk), not to
processing the incoming network stream without buffering it first.
Why ESM-only? See the Module Format Policy.
Does it work on Bun / Deno / Edge?
Parsing and MemoryStorage work on every runtime -- the parser, scanner, and middleware import
no Node built-ins. DiskStorage requires node:fs/node:path/node:stream and does not run on
Edge; use MemoryStorage or a custom StorageStrategy there.
Can I write my own storage backend (e.g. S3)?
Yes -- implement StorageStrategy: an async handle(stream, info) that returns a StorageResult,
and optionally remove(result) for cleanup on error. multipart({ storage: new MyStorage() })
accepts anything satisfying the interface.
Package relationships
depends on @nextrush/types (Middleware contract, types only)
@nextrush/form-data -------------->
often used with @nextrush/validation (validate ctx.state.fields after parsing)
usually used after @nextrush/body-parser (JSON/form/text bodies this package doesn't handle)- Depends on:
@nextrush/types-- theMiddleware/Contexttype contracts (types only, erased at build). - Often used with:
@nextrush/validation-- validate the shape ofctx.state.fieldsonce parsed. - Usually used alongside:
@nextrush/body-parser-- for the JSON/URL-encoded/text/raw traffic this package'sformData()doesn't parse (it only matchesmultipart/form-data). - Alternative: a custom
StorageStrategywhen you need object storage (S3, GCS, etc.) instead of memory or disk.
Architecture
Maintaining or contributing to this package? The internal design -- the boundary-scan/parse
pipeline, the collect-then-store sequence, the module layout, and the decisions and trade-offs
behind them (with diagrams) -- is in ARCHITECTURE.md.
Resources
- Learn -- Documentation . Architecture . RFCs
- Changelog -- CHANGELOG.md
- Report an issue -- GitHub Issues
- Contribute -- CONTRIBUTING.md
MIT (c) Tanzim Hossain
