File Upload Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
file-uploads-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
@modularityjs/http-upload is the abstract upload contract — it owns HttpUploadConfig and the sanitizeFilename(...) helper. No driver-specific code. Apps consume it directly; bridges like http-storage depend only on the abstract.
HttpUploadConfig caps every dimension of a multipart request, not just per-file size: maxFileSize (10 MB), maxFiles (10), maxParts (1000), maxFields (100), maxFieldSize (1 MB), and maxTotalUploadSize (50 MB) — the aggregate cap across all files in one request. That last one exists because @fastify/multipart installs its own body parser, so HttpModuleConfig.bodyLimit does not apply to multipart/form-data; without it a request could buffer maxFileSize × maxFiles in heap before any handler runs. It also exposes isMimeTypeAllowed(mimetype) (boolean) and assertMimeTypeAllowed(mimetype) (throws a 422) for decisions of your own.
@modularityjs/http-fastify-upload is the Fastify driver — it reaches into HttpServer.getInstance() to register @fastify/multipart and to bind the @UploadedFile() / @UploadedFiles() parameter resolvers. Load HttpFastifyModule first; loading this package against any other HTTP driver will throw at afterLoad.
Two parameter decorators ship from @modularityjs/http (not the upload package): @UploadedFile(fieldname?) returns one FileUpload | undefined, @UploadedFiles() returns FileUpload[]. FileUpload is the abstract shape — { readonly filename, readonly mimetype, readonly size, toBuffer(): Promise<Buffer> }. Body is not populated from multipart on request.body — use the decorators.
HttpUploadModule.forRoot({ maxFileSize, allowedMimeTypes }) configures the upload limits, and both are enforced for you. maxFileSize is read by HttpFastifyUploadModule and passed to Fastify at parse time — over-limit requests fail before your handler runs. allowedMimeTypes is checked per part as the file parser streams it, so a disallowed mimetype throws a ValidationException (422) before the offending part is buffered and a flood of forbidden files cannot fill the heap first. Matching is case-insensitive and supports subtype wildcards (image/*).
allowedMimeTypes is required and default-closed. Leaving it unset fails boot — accepting arbitrary content types has to be a decision, never a forgotten default. Pass the explicit ['*/*'] wildcard to opt into accepting everything.
The declared mimetype is checked against the bytes. part.mimetype is the client's claim, and it is what gets stored and later served — an allowlist checked against it alone admits an HTML document labelled image/png and serves stored XSS from your origin. Where the declared type has a recognisable signature the parser requires the bytes to carry it, and a mismatch is a 422 naming what the content actually looks like. Types with no magic bytes (text/*, JSON, SVG, OOXML) are unjudgeable and pass the sniff — treat them as untrusted content and serve them from a separate origin or with Content-Disposition: attachment.
The driver consumes the entire multipart stream into Buffers on first decorator access and caches the result on the request via a Symbol — toBuffer() is repeat-safe and @UploadedFile('a') + @UploadedFile('b') on the same handler don't re-parse. Large uploads sit in memory; for streaming or disk-spool, reach into the underlying Fastify request (request as FastifyRequest) and use @fastify/multipart directly instead of this module.
import { Injectable } from '@modularityjs/di';
import {
Controller,
type FileUpload,
HttpControllersPool,
HttpModule,
Post,
UploadedFile,
UploadedFiles,
} from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpFastifyUploadModule } from '@modularityjs/http-fastify-upload';
import { HttpUploadModule } from '@modularityjs/http-upload';
import { Module } from '@modularityjs/modularity';
@Injectable()
@Controller('/files')
class FilesController {
// Single file — selects by field name when given, else the first part.
// A disallowed mimetype never reaches here: the parser already rejected it
// with a 422, and `file.filename` is already sanitized. Inject
// HttpUploadConfig only if you need isMimeTypeAllowed(...) /
// assertMimeTypeAllowed(...) for a decision of your own.
@Post('/avatar')
async avatar(@UploadedFile('avatar') file: FileUpload | undefined) {
if (!file) return { error: 'missing avatar' };
const buffer = await file.toBuffer();
return {
filename: file.filename,
mimetype: file.mimetype,
size: buffer.byteLength, // === file.size
};
}
// All parts — order matches the order fields appear in the request.
@Post('/bulk')
async bulk(@UploadedFiles() files: FileUpload[]) {
return { count: files.length, names: files.map((f) => f.filename) };
}
}
@Module({
name: 'files',
imports: [HttpModule, HttpUploadModule], // depend on the abstract, not the driver
providers: [FilesController],
pools: [
{ pool: HttpControllersPool, key: 'files', useClass: FilesController },
],
})
class FilesModule {}
// modules: [
// ModularityModule,
// HttpModule.forRoot({ port: 3000 }),
// HttpFastifyModule,
// HttpUploadModule.forRoot({
// maxFileSize: 5 * 1024 * 1024, // 5 MB
// allowedMimeTypes: ['image/png', 'image/*'], // 'type/*' wildcards supported
// }),
// HttpFastifyUploadModule, // wires @fastify/multipart + parameter resolvers
// FilesModule,
// ]FileUpload.filename arrives sanitized. The parser runs sanitizeFilename as it builds each part, so nothing downstream ever sees the raw client-supplied name — earlier only http-storage sanitized, and anything that read the FileUpload and wrote it itself got the raw name. Call the exported sanitizeFilename(...) yourself only for names you derive elsewhere (a form field, a URL segment).
@UploadedFile() with no argument picks the first part — fine for single-file endpoints, ambiguous when the client sends multiple fields. Always pass the field name (@UploadedFile('avatar')) when the endpoint expects a specific upload. A missing part is rejected with a ValidationException (422) naming the field; use @UploadedFiles() when zero-or-many parts are legitimate.