Multipart & File Upload
Guide 14
Multipart in Ndigo is server-side acceptance of multipart/form-data uploads: the
adapter streams an incoming upload straight through busboy,
and a handler binds each file part to a MultipartFile. The client half — building a
multipart/form-data body with ndigo-http’s FormData — already exists and is untouched;
this guide covers the receiving side.
The design mirrors the reference MVC layer’s MultipartFile + early resolver split, adapted to
Ndigo’s resolver/interceptor model: parsing happens before argument binding, so resolvers
see parts directly rather than a buffered body. The one new runtime dependency is busboy —
the canonical zero-dependency streaming parser; Ndigo owns the contract and wraps it.
Audience: Node application developers · Prerequisites: Guides 01 and 06 · Reference app: apps/05-ndigo-api
What you’ll learn
Section titled “What you’ll learn”- Accept a
multipart/form-dataupload withfileValue. - Bind multiple files and text fields.
- Choose buffered bytes or a temp file.
- Map size limits to 413 and sanitize filenames.
- Avoid the common streaming and cleanup pitfalls.
1. The contract
Section titled “1. The contract”A MultipartFile (@ndigo/platform-node) is one part of the request. It is backed either by
an in-memory Buffer (small uploads) or a temp file on disk (large uploads streamed above a
threshold); both are served through the same interface, so callers never care which.
import type { MultipartFile } from '@ndigo/platform-node';
async function handle(file: MultipartFile) { file.name; // form field name ("avatar") file.originalFilename; // client-supplied filename — untrusted, never written to disk as-is file.contentType; // declared Content-Type, when present file.size; // bytes file.isEmpty(); // true when no bytes were received
const bytes: Uint8Array = await file.bytes(); // buffers a temp part into memory const stream = file.stream(); // streaming read (Node Readable) await file.transferTo('/srv/uploads/avatar.png'); // copy off temp; creates parent dirs}The parsed request lives on request.multipart as a MultipartContext: { fields } for text
parts and files, a Map<string, MultipartFile[]> keyed by form field name. The adapter
populates this before dispatch — you never construct it.
2. Accepting an upload
Section titled “2. Accepting an upload”Binding is driven by an explicit marker, not the declared type (parameter types are erased at
runtime). Declare file parts with the fileValue extractor inside @parameters, exactly as
you would for a validated body:
import { fileValue, postMapping, parameters, requestMapping } from '@ndigo/dispatcher';import { ResponseEntity } from '@ndigo/platform-node';import type { MultipartFile } from '@ndigo/platform-node';
@requestMapping('/api')export class ProfileController { @postMapping('/upload') @parameters({ file: fileValue() }) async upload(file: MultipartFile) { if (file.isEmpty()) { return ResponseEntity.badRequest({ error: 'no_file_uploaded' }); } const dest = `/uploads/${randomBytes(8).toString('hex')}`; // never originalFilename await file.transferTo(dest); return { size: file.size, name: file.originalFilename }; }}The part name defaults to the parameter name — fileValue binds client field file here.
Pass an explicit name only when the client form field differs from the JS variable:
@parameters({ avatar: fileValue('avatar_url') }) // client field "avatar_url" → JS `avatar`async uploadProfile(avatar: MultipartFile) { … }3. Multiple files and text fields
Section titled “3. Multiple files and text fields”A repeated client field name binds to an array — declare the parameter as MultipartFile[]:
@parameters({ photos: fileValue('photos') })async uploadGallery(photos: MultipartFile[]) { // One client field "photos" sent three times → three parts. const sizes = await Promise.all(photos.map(async (p) => p.size)); return { count: photos.length, sizes };}Text fields are not files — they keep binding through the existing body resolver (request.body),
so a mixed form mixes @parameters extractors and plain body binding. There is no @RequestPart
JSON+file structured binding in v1; JSON parts bind via the body resolver, file parts as
MultipartFile.
4. Reading bytes: buffer vs. temp
Section titled “4. Reading bytes: buffer vs. temp”The parser thresholds each part (fileThreshold, default 128 KiB): at or below it stays in
memory; above it the remainder streams to a temp file under tmpDir (default os.tmpdir). The
handle records which and serves both access styles:
bytes— read everything into memory (materializes a temp part); simplest for small uploads.stream— a NodeReadable; use for large parts or when piping straight to an object store.transferTo(dest)— copy the part off its temp location to a caller-chosen path (creating parent directories). This is the recommended way to persist: it never writesoriginalFilenameto disk, so a traversal payload can’t escape the upload dir.
Temp files are cleaned up on read-completion or request close/error; there is no daemon — cleanup is per-request.
5. Limits and errors
Section titled “5. Limits and errors”Uploads carry their own bounds (MultipartLimits), separate from the global body cap, so a single
upload can’t exhaust memory or disk. Defaults are bounded but generous:
| Limit | Default | Meaning |
|---|---|---|
maxFileSize |
10 MiB | bytes per file part |
maxRequestSize |
10 MiB | combined bytes for the whole request |
maxFiles |
100 | number of file parts |
maxFields |
100 | number of text-field parts |
fileThreshold |
128 KiB | at/below this, a part stays in memory; above it spills to disk |
Exceeding any limit throws a RequestLimitError(413, …) — the same class the body-limit path maps,
so an oversized upload returns the identical RFC-9457 problem+json shape as an over-large JSON body.
A malformed boundary (or a non-multipart body reaching the parser) rejects with 400. The adapter
bypasses #readBody for multipart, so the global maxBodyBytes cap never applies to uploads — tune
the upload caps independently via the framework’s configuration-properties mechanism.
6. Filename safety
Section titled “6. Filename safety”busboy passes the client-supplied filename through verbatim, so treat it as untrusted. Ndigo
sanitizes at the edge: any name containing a path separator (/ or \), a bare . / .. segment,
or a NUL byte is rejected before any temp file is created. The demo controller persists under a
random filename and echoes only the reported originalFilename — never resolving it to disk.
Virus scanning, content allow-listing, and storage backend (local vs. S3/GCS) are out of contract:
the contract exposes bytes/stream so a caller pipes to their store unchanged. Writing
originalFilename yourself is the documented residual risk.
7. Pitfalls
Section titled “7. Pitfalls”- Don’t write
originalFilenameto disk. It’s untrusted; usetransferToto a path you control, or rename under a random name (as the demo does). Path traversal lives in the filename. bytesmaterializes temp parts into memory. For large uploads preferstream/transferTo; reservebytesfor small parts already in memory.- A missing part on a required parameter fails like any other missing value — no special-casing;
guard with
isEmptywhen an empty (but present) part should be rejected explicitly. - The global body cap doesn’t govern uploads. Multipart parses before
#readBody; tune the upload caps (maxFileSize/maxRequestSize) separately so one knob doesn’t masquerade as both.
Reference: apps/05-ndigo-api (POST /api/upload demo gate in upload-controller.ts, round-trip +
empty-file spec in upload.spec.ts). Design rationale: .
See also
Section titled “See also”- Guide 06 — Argument Binding — the extractor pipeline
- Guide 10 — Static Files — serving the uploaded result
@ndigo/platform-node— the multipart parser