Skip to content

File Upload Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the file-uploads-wiring recipe 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 SymboltoBuffer() 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.

typescript
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.