Skip to content

File Uploads ​

KickJS provides file upload handling via both middleware and a decorator, exposing every uploaded file on ctx.file / ctx.files in the standard Multer shape regardless of the HTTP runtime.

Runtime support

The @FileUpload decorator works on all three runtimes; each uses its engine's multipart backend, which you install with kick add upload:

RuntimeMultipart driverNotes
Express (default)multerUsed by the upload.single/array/none() middleware too
Fastify@fastify/multipartParts are buffered into the same Multer file shape
h3built-in (readMultipartFormData)No driver to install

The upload middleware factory and cleanupFiles() (disk storage, custom StorageEngine) are Multer-specific and therefore Express-only. On Fastify / h3, use the @FileUpload decorator. kick doctor flags a missing driver when it detects upload usage.

Middleware Approach ​

Import the upload factory from @forinda/kickjs:

ts
import { upload } from '@forinda/kickjs'

Single File ​

Attaches one file to ctx.file:

ts
@Post('/avatar')
@Middleware(upload.single('avatar', { maxSize: 2 * 1024 * 1024, allowedTypes: ['jpg', 'png'] }))
async uploadAvatar(ctx: RequestContext) {
  ctx.json({ filename: ctx.file.originalname, size: ctx.file.size })
}

Multiple Files ​

Attaches an array to ctx.files. The second argument is maxCount (default 10):

ts
@Post('/gallery')
@Middleware(upload.array('photos', 5, { allowedTypes: ['png', 'jpeg', 'webp'] }))
async uploadGallery(ctx: RequestContext) {
  ctx.json({ count: ctx.files?.length })
}

No Files (Multipart Body Only) ​

Parses multipart form data without accepting file fields:

ts
@Post('/form')
@Middleware(upload.none())
async handleForm(ctx: RequestContext) {
  ctx.json(ctx.body)
}

@FileUpload Decorator ​

The @FileUpload decorator is a declarative alternative. The router builder automatically attaches the upload middleware from the decorator metadata — no manual @Middleware(upload.single(...)) needed.

ts
import { Controller, Post, FileUpload } from '@forinda/kickjs'
import { RequestContext } from '@forinda/kickjs'

@Controller()
class FileController {
  @Post('/upload')
  @FileUpload({
    mode: 'single',
    fieldName: 'document',
    maxSize: 10_000_000,
    allowedTypes: ['pdf', 'docx'],
  })
  async handleUpload(ctx: RequestContext) {
    ctx.json({ file: ctx.file.originalname, size: ctx.file.size })
  }

  @Post('/photos')
  @FileUpload({
    mode: 'array',
    fieldName: 'photos',
    maxCount: 5,
    allowedTypes: ['jpg', 'png', 'webp'],
  })
  async handlePhotos(ctx: RequestContext) {
    ctx.json({ count: ctx.files?.length })
  }

  @Post('/avatar')
  @FileUpload({
    mode: 'single',
    fieldName: 'avatar',
    allowedTypes: (mime, filename) => mime.startsWith('image/') || filename.endsWith('.heic'),
    customMimeMap: { heic: 'image/heic' },
  })
  async handleAvatar(ctx: RequestContext) {
    ctx.json({ file: ctx.file.originalname })
  }
}

FileUploadConfig ​

The @FileUpload decorator and the upload.*() middleware share the same base options (BaseUploadOptions). The decorator adds mode, fieldName, and maxCount.

OptionTypeDefaultDescription
mode'single' | 'array' | 'none'requiredUpload mode
fieldNamestring'file'Form field name
maxCountnumber10Max files (array mode only)
maxSizenumber5MBMax file size in bytes
allowedTypesstring[] | FileTypeFilterallString array or filter function
customMimeMapRecord<string, string>—Extend the built-in MIME map

Decorator is memory-only — for disk/custom storage use the middleware

@FileUpload has no storage / dest option on purpose: it buffers to memory so the same decorator works identically on Express, Fastify, and h3 (ctx.file.buffer). Multer's StorageEngine is Express-specific and can't cross engines.

To vary the storage engine per route — memory on one, disk on another, S3 on a third — use the upload.single/array() middleware (Express only), which exposes storage + dest:

ts
import multer from 'multer'
import { upload } from '@forinda/kickjs'

// memory → ctx.file.buffer
@Post('/avatar') @Middleware(upload.single('avatar'))
avatar(ctx: RequestContext) {}

// disk → ctx.file.path
@Post('/import') @Middleware(upload.single('csv', { dest: '/tmp/uploads' }))
importCsv(ctx: RequestContext) {}

// any multer StorageEngine (multer-s3, gridfs, …)
@Post('/doc') @Middleware(upload.single('doc', { storage: multer.diskStorage({ destination: '/var/data' }) }))
doc(ctx: RequestContext) {}

Rule of thumb: portable + memory → @FileUpload; Express-specific storage engine → upload.*() middleware. To keep the file after the request, on disk or in an S3 / R2 bucket on any runtime, pass ctx.file.buffer to File Storage.

UploadOptions ​

All middleware methods accept an UploadOptions object:

OptionTypeDefaultDescription
maxSizenumber5 * 1024 * 1024 (5 MB)Maximum file size in bytes
allowedTypesstring[] | FileFilterFnallString array or filter function (see below)
customMimeMapRecord<string, string>—Extend the built-in extension-to-MIME map
storageMulter StorageEnginememoryCustom Multer storage engine (Express only)
deststring—Disk storage destination dir, Express only
rename(file) => stringUUID + extensionThe file's server-side name (file.filename); see File names

What a rejected upload returns ​

A violation is the client's, so it answers 4xx — identically on every runtime, even though each engine detects it in its own backend:

ViolationStatusBody
Larger than maxSize413 Payload Too Large{ "message": "File … exceeds the N-byte limit" }
Type not in allowedTypes415 Unsupported Media Type{ "message": "File type … is not allowed" }
More files than the route takes (single: 1, array: maxCount)413 Payload Too Large{ "message": "Too many files: at most N" }
A field that sets a prototype (__proto__, constructor[prototype])400 Bad Request{ "message": "A body key that sets a prototype … is not allowed" }

Limits are enforced while the request is read, not after: a route never buffers more than maxSize per file, maxCount files, and about 1 MB of form fields, whatever the client sends. A body past that is refused with 413 on every runtime.

Express names the field, the others name the file

On a 413, Express reports the form field (doc) where Fastify and h3 report the filename (big.txt) — Multer's LIMIT_FILE_SIZE error carries no filename to pass on. The status and the limit are identical, so branch on those rather than parsing the message.

Both are HttpExceptions, so onError sees them like any other and can reshape the body.

Allowed Types — Value or Function ​

allowedTypes follows a Vue-style pattern: pass a value (string array) or a function for full control.

String Array (short extensions, MIME types, or wildcards) ​

ts
// Short extensions — resolved via built-in MIME map
upload.single('file', { allowedTypes: ['jpg', 'png', 'pdf'] })

// Full MIME types
upload.single('file', { allowedTypes: ['image/jpeg', 'image/png', 'application/pdf'] })

// Wildcards
upload.single('file', { allowedTypes: ['image/*'] })

// Mix all three
upload.single('file', { allowedTypes: ['jpg', 'application/pdf', 'video/*'] })

Filter Function ​

For full control, pass a function that receives the MIME type and original filename:

ts
// Accept images and HEIC files by extension
upload.single('file', {
  allowedTypes: (mime, filename) => mime.startsWith('image/') || filename.endsWith('.heic'),
})

// Accept anything under 2MB that isn't executable
upload.single('file', {
  allowedTypes: (mime) => !mime.includes('executable') && !mime.includes('x-msdownload'),
})

Custom MIME Map ​

Extend the built-in extension map with your own mappings. Your entries take precedence over defaults:

ts
upload.single('file', {
  allowedTypes: ['heic', 'jxl', 'jpg'],
  customMimeMap: {
    heic: 'image/heic',
    jxl: 'image/jxl',
  },
})

Short Extension Support ​

The built-in MIME map covers 40+ common extensions. Use resolveMimeTypes() to inspect how extensions are mapped:

ts
import { resolveMimeTypes } from '@forinda/kickjs'

resolveMimeTypes(['jpg', 'pdf', 'image/*'])
// → ['image/jpeg', 'application/pdf', 'image/*']

Automatic Cleanup ​

For disk-stored uploads, use cleanupFiles() to delete temporary files after the response finishes:

ts
import { upload, cleanupFiles } from '@forinda/kickjs'

@Post('/process')
@Middleware(upload.single('document', { dest: '/tmp/uploads' }), cleanupFiles())
async processDocument(ctx: RequestContext) {
  // Work with ctx.file.path
  // File is automatically deleted after the response is sent
  ctx.json({ ok: true })
}

cleanupFiles() listens to the finish event on the response. It only attempts to delete files that have a path property (disk-stored files). If the file was already moved or deleted by your handler, the cleanup silently ignores the missing file.

Sending the request ​

Everything above is the server half. The client half is a multipart/form-data body whose field name matches fieldName — that match is the whole contract, and a mismatch is the most common reason ctx.file is undefined on a request that otherwise looks fine.

For this handler:

ts
@Post('/upload')
@FileUpload({ mode: 'single', fieldName: 'document' })
handle(ctx: RequestContext) {
  ctx.json({ name: ctx.file.originalname })
}

curl — -F sets the multipart encoding for you; @ reads a file:

bash
curl -X POST http://localhost:3000/api/v1/files/upload \
  -F 'document=@./report.pdf'

# extra text fields ride along and arrive on ctx.body
curl -X POST http://localhost:3000/api/v1/files/upload \
  -F 'document=@./report.pdf' \
  -F 'title=Q3 report'

Browser / fetch — build a FormData and pass it as the body:

ts
const form = new FormData()
form.append('document', fileInput.files[0]) // key === fieldName
form.append('title', 'Q3 report') // → ctx.body.title

await fetch('/api/v1/files/upload', { method: 'POST', body: form })

Don't set Content-Type yourself

fetch derives multipart/form-data; boundary=… from the FormData body. Set the header by hand and you overwrite the generated boundary, the parser finds no parts, and ctx.file is undefined — with no error to explain it. Omit headers entirely, or set only headers unrelated to the body (auth, tracing).

Array mode repeats the same field name once per file — it is not documents[] or documents[0]:

ts
@Post('/photos')
@FileUpload({ mode: 'array', fieldName: 'photos', maxCount: 5 })
bash
curl -X POST http://localhost:3000/api/v1/media/photos \
  -F 'photos=@./a.png' \
  -F 'photos=@./b.png'
ts
const form = new FormData()
for (const file of input.files) form.append('photos', file) // same key each time

Node / tests — supertest's .attach() takes the field name first, and .field() adds text parts:

ts
const res = await request(app.handle.bind(app))
  .post('/api/v1/files/upload')
  .field('title', 'Q3 report')
  .attach('document', Buffer.from('…'), 'report.pdf')

The typed client doesn't do multipart

@forinda/kickjs-client serializes bodies as JSON. Upload endpoints go through plain fetch with a FormData body as above — you keep the typed client for the rest of the API.

Accessing Uploaded Files ​

Uploaded files are available on the RequestContext:

  • ctx.file — the single uploaded file object (when using single mode)
  • ctx.files — an array of uploaded files (when using array mode)

Each file object follows the standard Multer file shape: originalname, filename, mimetype, size, and buffer (memory storage) or path (disk storage).

File names ​

Every file carries two names, on every runtime:

  • filename is the server-side name: a random UUID plus the client name's extension when that extension names the file's type (3f2b9c1e-….png for photo.png sent as image/png). A mismatched one is dropped: evil.html sent as image/png is stored without an extension, so it can't later be served as HTML. Use it as the storage key or the file name on disk. With dest (disk storage, Express), it is the name the file was written under.
  • originalname is the name the client sent, made safe to show: Unicode NFC (so é typed two ways compares equal), without a directory part (C:\fakepath\photo.png becomes photo.png), and without control characters. Chinese, Arabic, emoji and the rest arrive intact. It is still the client's choice, so display it, but never build a path from it.
ts
@Post('/avatar')
@FileUpload({ mode: 'single', fieldName: 'avatar', maxSize: 2_000_000, allowedTypes: ['image/*'] })
async avatar(ctx: RequestContext) {
  const file = ctx.file!
  await this.storage.put(`avatars/${file.filename}`, file.buffer, { contentType: file.mimetype })
  ctx.json({ key: file.filename, name: file.originalname })
}

To choose the name yourself, pass rename. It gets the file's normalized originalname, mimetype, fieldname and size:

ts
@FileUpload({
  mode: 'single',
  fieldName: 'avatar',
  rename: (file) => `${crypto.randomUUID()}-${file.size}.webp`,
})

The result must not contain a slash or backslash, nor be . or ..; an unsafe name fails the request (500) rather than reaching your storage.

Sending a file back out ​

The other direction is ctx.download(buffer, filename, type?) — the mirror of @FileUpload, and runtime-neutral for the same reason: it writes through the response driver rather than the engine's own response object.

ts
@Get('/students.xlsx')
async export(ctx: RequestContext) {
  const file = await this.reports.buildStudentsWorkbook()
  return ctx.download(
    file,
    'students.xlsx',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  )
}

Setting the headers by hand on ctx.res works on Express and breaks on the others — FastifyReply has no setHeader, and h3's event has no end.

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.