Multipart uploads in Warlock follow the same shape as everything else: declare the schema, attach it to the controller, read typed UploadedFile instances from request.validated(), save them with one call. The framework’s multipart plugin does the parsing; the UploadedFile class does the rest.
This page covers the full lifecycle: from receiving a multipart/form-data body to having the bytes on disk (or S3, R2, anywhere) with a StorageFile reference you can persist.
When a multipart body arrives, the framework’s Fastify multipart plugin attaches each file field as an UploadedFile instance on request.body. The validation layer treats files like any other field — v.file() validators apply size, mime, dimension checks. The controller pulls files out of request.validated() or request.file(key), then calls .save(directory) to persist them through the storage layer.
Changed in 5.21: multipart uploads now have default limits — 10 MB per file, 10 files and 100 fields per request. Exceeding any of them returns 413. Tune them with fileUploadLimit (per-file bytes) and the http.multipart keys files, fields and fieldSize:
const httpConfigurations:HttpConfigurations = {
fileUploadLimit: 20 * 1024 * 1024,
multipart: { files: 20, fields: 200 },
};
This is a Fastify plugin limit — the multipart parser rejects bodies that exceed it before the request ever hits a controller. Set it generously above your largest expected file, then validate stricter limits in the schema per-field.
const single = request.file("avatar"); // UploadedFile | undefined
const many = request.files("attachments"); // UploadedFile[]
Use files(key) when the multipart field is repeated (<input name="files" multiple>). It returns an array; [] if nothing arrived.
In schema-driven controllers, always prefer request.validated() — the schema runs first, so by the time you read files, size and mime checks already passed:
toJSON() includes the file content as base64 — only call it when you actually want to ship the bytes inline. For most server-side flows you’ll save() the file and persist the resulting path/hash instead.
If you don’t validate via the schema (rare, but sometimes for ad-hoc admin endpoints), use file.validate(...):
await file.validate({
allowedMimeTypes: ["image/jpeg", "image/png"],
allowedExtensions: ["jpg", "png"],
maxSize: 5*1024*1024,
});
This throws if validation fails (it’s not the framework’s gentle 400 path — that’s what schemas are for). Wrap in try/catch or just let it propagate to the framework’s error handler.
Here’s the actual upload service from the reference codebase — saves to R2, creates an orphaned uploads row, and lets the controller link it to an entity later:
The expires_at is the project’s “orphaned uploads are cleaned up after 24h” policy — a tip you may want to copy for any side-uploaded file that hasn’t been linked to its parent entity yet.
v.file().saveTo("path/...") is a built-in transformer that calls .save() after validation and replaces the file value with the resulting path. Handy for one-shot endpoints where the path is the only thing you persist — though most apps prefer to call .save() explicitly in a service so they can also store the hash, size, original name, etc.
Uploads sometimes happen out-of-band — you accept a file, return its id, and the user attaches it to a chat message minutes later. If the user abandons the flow, you don’t want orphaned blobs sitting in storage.
The pattern: store an expires_at timestamp when you save, and run a scheduled job to delete uploads where expires_at < now()and the parent linkage is still null. The reference codebase does this for chat uploads:
const expiresAt = dayjs().add(1, "day").toDate();
return Upload.create({
// ...
entity_id: undefined, // null — orphaned until attached
expires_at: expiresAt,
});
Then a scheduled task scans for orphans and calls .destroy(). The actual storage file is deleted via the Upload model’s onDeleted event hook (deleting the row tears down the file too).
Schema-driven controllers should always read files via request.validated(). It returns the typed UploadedFile from the schema, with all size/MIME checks already done. request.file(key) is for ad-hoc endpoints without a schema.
file.buffer() reads the whole file into memory. Fine for images and PDFs; for video uploads, consider streaming directly to the storage driver instead. The first call buffers; subsequent calls return the cached buffer.
fileUploadLimit is a per-file limit at the multipart layer. Once exceeded, Fastify rejects the body before your code sees it. Per-field stricter limits go in the schema with v.file().maxSize(...).
save() returns a StorageFile; that’s what you persist. Store storageFile.path, the file’s hash, MIME type, and original name. Don’t persist the UploadedFile instance itself.
format("webp") rewrites the path extension. If your DB stores file paths, capture them from the returned StorageFile.path, not from file.name + file.extension before save — those still reflect the original upload.
Calling .save() twice creates two files. The class doesn’t memoise. If you want a single saved file with multiple references, save once and pass the StorageFile around.
Non-image transforms are ignored, not errors..resize(800) on a PDF silently does nothing. The transforms are queued; only applied if file.isImage is true at save time.