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:
| Runtime | Multipart driver | Notes |
|---|---|---|
| Express (default) | multer | Used by the upload.single/array/none() middleware too |
| Fastify | @fastify/multipart | Parts are buffered into the same Multer file shape |
| h3 | built-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:
import { upload } from '@forinda/kickjs'Single File
Attaches one file to ctx.file:
@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):
@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:
@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.
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.
| Option | Type | Default | Description |
|---|---|---|---|
mode | 'single' | 'array' | 'none' | required | Upload mode |
fieldName | string | 'file' | Form field name |
maxCount | number | 10 | Max files (array mode only) |
maxSize | number | 5MB | Max file size in bytes |
allowedTypes | string[] | FileTypeFilter | all | String array or filter function |
customMimeMap | Record<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:
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.
UploadOptions
All middleware methods accept an UploadOptions object:
| Option | Type | Default | Description |
|---|---|---|---|
maxSize | number | 5 * 1024 * 1024 (5 MB) | Maximum file size in bytes |
allowedTypes | string[] | FileFilterFn | all | String array or filter function (see below) |
customMimeMap | Record<string, string> | — | Extend the built-in extension-to-MIME map |
storage | Multer StorageEngine | memory | Custom Multer storage engine (Express only) |
dest | string | — | Disk storage destination dir, Express only |
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:
| Violation | Status | Body |
|---|---|---|
Larger than maxSize | 413 Payload Too Large | { "message": "File … exceeds the N-byte limit" } |
Type not in allowedTypes | 415 Unsupported Media Type | { "message": "File type … is not allowed" } |
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)
// 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:
// 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:
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:
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:
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:
@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:
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:
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]:
@Post('/photos')
@FileUpload({ mode: 'array', fieldName: 'photos', maxCount: 5 })curl -X POST http://localhost:3000/api/v1/media/photos \
-F 'photos=@./a.png' \
-F 'photos=@./b.png'const form = new FormData()
for (const file of input.files) form.append('photos', file) // same key each timeNode / tests — supertest's .attach() takes the field name first, and .field() adds text parts:
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 usingsinglemode)ctx.files— an array of uploaded files (when usingarraymode)
Each file object follows the standard Multer file shape: originalname, mimetype, size, buffer (memory storage), or path and filename (disk 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.
@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.