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. 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:
| 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 |
rename | (file) => string | UUID + extension | The 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:
| 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" } |
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)
// 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, filename, mimetype, size, and buffer (memory storage) or path (disk storage).
File names
Every file carries two names, on every runtime:
filenameis the server-side name: a random UUID plus the client name's extension when that extension names the file's type (3f2b9c1e-….pngforphoto.pngsent asimage/png). A mismatched one is dropped:evil.htmlsent asimage/pngis 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. Withdest(disk storage, Express), it is the name the file was written under.originalnameis the name the client sent, made safe to show: Unicode NFC (soétyped two ways compares equal), without a directory part (C:\fakepath\photo.pngbecomesphoto.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.
@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:
@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.
@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.