Documentation status — public API reference
This page consolidates the detailed Subatom reference material supplied with the framework implementation examples. Treat the exported/public API surface as authoritative; internal implementation details are intentionally not presented as application APIs.
Subatom File Upload
Subatom provides built-in support for handling multipart/form-data file uploads.
The file-upload API is designed to feel simple: choose how many files you expect, choose where files should be stored, optionally validate them, and access the uploaded files from ctx.file or ctx.files.
Quick start
import { file, type IContext, type IFileUpload, type IRouter, Router } from "subatom";
const router: IRouter = new Router();
router.post("/avatar", {
middleware: [
file.single("avatar", {
storage: "memory",
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
}),
],
controller: async (ctx: IContext) => {
const avatar = ctx.file;
if (!avatar) {
return ctx.res.json({ error: "Avatar is required" });
}
const buffer = await avatar.buffer();
return ctx.res.json({
filename: avatar.filename,
mimetype: avatar.mimetype,
size: avatar.size,
bytes: buffer.length,
});
},
});
The upload middleware parses the multipart request and creates a FileUpload object for each uploaded file.
How file uploads work
A typical upload flow looks like this:
- The client sends a
multipart/form-datarequest. - Subatom parses the form.
- Text fields are placed in
req.body. - Uploaded files are stored in memory or on disk.
- Each file is represented by an
IFileUpload. - The upload middleware places files on
req.file/req.files. - With the context API, you can access them through
ctx.file/ctx.files. - You can read, stream, validate, or destroy the uploaded file.
Upload middleware
Subatom exposes five upload modes:
| Method | Use when |
|---|---|
file.single() |
One file is expected from one field |
file.array() |
Multiple files are expected from one field |
file.fields() |
Different file fields have different limits |
file.any() |
Any file field is accepted |
file.none() |
Files must not be uploaded |
These APIs are intentionally similar to the familiar Express/Multer-style upload model.
file.single()
Use file.single() when an endpoint accepts one file from a specific form field.
file.single("avatar")
Example:
router.post("/profile/avatar", {
middleware: [
file.single("avatar", {
storage: "memory",
}),
],
controller: async (ctx) => {
const avatar = ctx.file;
if (!avatar) {
return ctx.res.json({ error: "No avatar uploaded" });
}
return ctx.res.json({
filename: avatar.filename,
size: avatar.size,
mimetype: avatar.mimetype,
});
},
});
The uploaded file is available as:
ctx.file
It is also available through the named field in:
ctx.files?.avatar
When a file is uploaded, req.files is also populated.
Signature
file.single(
fieldname: string,
options?: FileUploadPipelineOptions
)
Example with MIME restrictions
file.single("avatar", {
storage: "memory",
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/webp",
],
})
file.array()
Use file.array() when several files are uploaded using the same field name.
file.array("documents", 5)
The second argument is the maximum number of files accepted for that field.
Example:
router.post("/documents", {
middleware: [
file.array("documents", 5, {
storage: "memory",
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/svg+xml",
"application/pdf",
],
}),
],
controller: async (ctx) => {
const documents = Array.isArray(ctx.files)
? ctx.files
: ctx.files?.documents || [];
return ctx.res.json({
count: documents.length,
files: documents.map((file) => ({
filename: file.filename,
mimetype: file.mimetype,
size: file.size,
})),
});
},
});
The files are available through:
ctx.files
or, depending on the resulting request shape:
ctx.files.documents
Signature
file.array(
fieldname: string,
maxCount?: number,
options?: FileUploadPipelineOptions
)
If more files than maxCount are received, the upload is rejected with HTTP 413.
file.fields()
Use file.fields() when an endpoint accepts several named file fields.
For example:
- one
avatar - up to four
galleryimages
file.fields(
[
{ name: "avatar", maxCount: 1 },
{ name: "gallery", maxCount: 4 },
],
{
storage: "memory",
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/webp",
],
},
)
Example:
router.post("/gallery", {
middleware: [
file.fields(
[
{ name: "avatar", maxCount: 1 },
{ name: "gallery", maxCount: 4 },
],
{
storage: "memory",
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/webp",
],
},
),
],
controller: async (ctx) => {
const avatar = ctx.files?.avatar;
const gallery = ctx.files?.gallery || [];
return ctx.res.json({
avatar,
gallery,
});
},
});
The resulting ctx.files is a field map.
For example:
ctx.files.avatar
ctx.files.gallery
Signature
file.fields(
fields: Array<{
name: string;
maxCount?: number;
}>,
options?: FileUploadPipelineOptions
)
file.any()
Use file.any() when the endpoint accepts files from any field name.
file.any({
storage: "memory",
allowedMimeTypes: ["*/*"],
})
Example:
router.post("/upload", {
middleware: [
file.any({
storage: "memory",
allowedMimeTypes: ["*/*"],
}),
],
controller: async (ctx) => {
const files = ctx.req.files;
return ctx.res.json({
files,
});
},
});
With file.any(), files are grouped by their multipart field name.
For example, a request containing:
avatar
document
gallery
gallery
produces a map conceptually like:
{
avatar: [/* file */],
document: [/* file */],
gallery: [/* file */, /* file */],
}
Use file.any() carefully on public endpoints because it accepts arbitrary file field names.
Signature
file.any(options?: FileUploadPipelineOptions)
file.none()
Use file.none() when an endpoint accepts form fields but must reject uploaded files.
file.none()
Example:
router.post("/profile", {
middleware: [
file.none(),
],
controller: async (ctx) => {
const { title, description } = ctx.body;
return ctx.res.json({
title,
description,
});
},
});
Text fields are still allowed.
If a multipart request contains a file, Subatom rejects the request with HTTP 400.
The error message is:
File uploads are not permitted on this endpoint
Storage
Subatom supports two storage strategies:
storage: "memory"
and:
storage: "disk"
Memory storage
Memory storage keeps the uploaded file in a Buffer.
file.single("avatar", {
storage: "memory",
})
You can read the content with:
const buffer = await file.buffer();
You can also access the currently stored buffer through:
file.bufferContent
Memory storage is convenient when the application needs to process the file immediately.
Be careful with large uploads because the file contents are held in memory.
Disk storage
Disk storage writes the uploaded file to the filesystem.
file.single("document", {
storage: "disk",
})
You can access its path:
console.log(file.path);
You can read it as a buffer:
const buffer = await file.buffer();
Or create a readable stream:
const stream = file.stream();
Custom destination
You can provide a destination directory:
file.single("document", {
storage: "disk",
dest: "./uploads",
})
If no destination is provided, the parser uses the operating system temporary directory.
The destination directory is created when necessary.
IFileUpload
Every uploaded file is represented by an IFileUpload.
interface IFileUpload {
readonly filename: string;
readonly encoding: string;
readonly mimetype: string;
readonly storageType: "memory" | "disk";
readonly path: string | undefined;
readonly size: number | undefined;
readonly destroyed: boolean;
readonly bufferContent: Buffer | undefined;
buffer(): Promise<Buffer>;
stream(): Readable;
destroy(): Promise<void>;
}
File properties
filename
The original uploaded filename.
console.log(file.filename);
Example:
profile-picture.png
encoding
The encoding reported for the uploaded multipart file.
console.log(file.encoding);
mimetype
The resolved MIME type of the uploaded file.
console.log(file.mimetype);
Examples:
image/png
image/jpeg
application/pdf
Subatom resolves generic MIME types using the filename extension when it can determine a known type.
For example, a generic binary upload whose filename ends in .png can be resolved to:
image/png
SVG MIME values are normalized to:
image/svg+xml
storageType
Tells you where the file content is stored.
file.storageType
Possible values:
"memory"
or:
"disk"
path
The filesystem path used for disk storage.
console.log(file.path);
For memory storage, this is:
undefined
size
The uploaded file size in bytes.
console.log(file.size);
Example:
204800
which represents 200 KiB.
destroyed
Checks whether the file has already been destroyed.
if (file.destroyed) {
// file is no longer available
}
bufferContent
Returns the in-memory Buffer, when one exists.
const buffer = file.bufferContent;
For disk-backed files, this value is normally:
undefined
Use buffer() when you need the file contents regardless of the storage strategy.
Reading file content
buffer()
Reads the complete file into a Buffer.
const buffer = await file.buffer();
This works for both memory and disk storage.
For memory storage, Subatom returns the existing in-memory buffer.
For disk storage, Subatom reads the file from its path.
Example:
const avatar = ctx.file;
if (!avatar) {
return ctx.res.status(400).json({
error: "Avatar is required",
});
}
const buffer = await avatar.buffer();
console.log(buffer.length);
If the file has already been destroyed, buffer() throws.
Streaming a file
stream()
Creates a readable stream for the file.
const stream = file.stream();
For memory storage, Subatom creates a readable stream from the stored buffer.
For disk storage, Subatom creates a filesystem read stream.
Example:
const uploadedFile = ctx.file;
if (!uploadedFile) {
return ctx.res.status(400).json({
error: "File is required",
});
}
const stream = uploadedFile.stream();
ctx.res.sendStream(stream);
Streaming is useful when you do not want to manually load a disk-backed file into a buffer first.
If the file has been destroyed, stream() throws.
Destroying an uploaded file
destroy()
Use destroy() when the uploaded file is no longer needed.
await file.destroy();
For memory storage, Subatom releases the stored buffer.
For disk storage, Subatom removes the uploaded file from the filesystem.
Example:
const uploadedFile = ctx.file;
if (!uploadedFile) {
return ctx.res.status(400).json({
error: "File is required",
});
}
try {
await processFile(uploadedFile);
} finally {
await uploadedFile.destroy();
}
destroy() is safe to call more than once.
For disk files, an already-missing file does not cause the cleanup operation to fail.
File validation
Subatom's request validator supports file validation.
File schemas are defined under:
schema.files
For example:
const schema = {
files: {
avatar: infer
.file()
.max(2 * 1024 * 1024)
.mime([
"image/jpeg",
"image/png",
"image/webp",
])
.extension([
"jpg",
"jpeg",
"png",
"webp",
]),
},
};
A file schema can validate things such as:
- maximum file size
- MIME type
- file extension
- single-file vs multiple-file expectations
- minimum/maximum number of files
- per-file size limits
The exact fluent validation API comes from subatom-infer.
MIME type restrictions
Upload middleware can restrict MIME types directly:
file.single("avatar", {
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/webp",
],
})
Wildcard MIME types are supported:
allowedMimeTypes: ["image/*"]
Allow everything:
allowedMimeTypes: ["*/*"]
MIME rules can also use file extensions:
allowedMimeTypes: [
"jpg",
"jpeg",
"png",
"pdf",
]
Upload limits
The parser supports limits for:
limits: {
fileSize: 5 * 1024 * 1024,
files: 5,
fields: 20,
fieldSize: 1024 * 100,
parts: 25,
}
Available limits are:
| Option | Meaning |
|---|---|
fileSize |
Maximum size of an individual uploaded file |
files |
Maximum number of uploaded files |
fields |
Maximum number of non-file form fields |
fieldSize |
Maximum size of a form field |
parts |
Maximum number of multipart parts |
For example:
file.any({
storage: "disk",
dest: "./uploads",
limits: {
fileSize: 10 * 1024 * 1024,
files: 10,
},
})
Upload-size violations result in HTTP 413.
Form fields
Multipart text fields are parsed along with files.
For example, a request containing:
title=My photo
published=true
count=10
can produce values such as:
{
title: "My photo",
published: true,
count: 10,
}
The multipart parser also handles repeated field names as arrays.
JSON-looking field values can be parsed when they contain valid JSON.
Accessing uploads from req
The low-level request object exposes:
req.file
and:
req.files
req.file represents a single file:
const file = req.file;
req.files can represent either:
IFileUpload[]
or a field map:
{
avatar: IFileUpload[],
gallery: IFileUpload[],
}
The exact shape depends on the upload middleware you use.
Accessing uploads from ctx
When using Subatom's context API, the same upload data is available through:
ctx.file
and:
ctx.files
Example:
controller: async (ctx) => {
const file = ctx.file;
if (!file) {
return ctx.res.status(400).json({
error: "No file uploaded",
});
}
console.log(file.filename);
return ctx.res.json({
filename: file.filename,
});
}
For named file fields:
const avatar = ctx.files?.avatar;
const gallery = ctx.files?.gallery;
Complete example
Here is a complete example accepting one avatar and multiple gallery images:
import {
file,
type IContext,
type IRouter,
Router,
} from "subatom";
const router: IRouter = new Router();
router.post("/profile", {
middleware: [
file.fields(
[
{ name: "avatar", maxCount: 1 },
{ name: "gallery", maxCount: 4 },
],
{
storage: "memory",
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/webp",
],
limits: {
fileSize: 5 * 1024 * 1024,
},
},
),
],
controller: async (ctx: IContext) => {
const avatar = ctx.files?.avatar;
const gallery = ctx.files?.gallery || [];
return ctx.res.json({
success: true,
avatar: avatar
? {
filename: avatar.filename,
mimetype: avatar.mimetype,
size: avatar.size,
}
: null,
gallery: gallery.map((image) => ({
filename: image.filename,
mimetype: image.mimetype,
size: image.size,
})),
});
},
});
export default router;
Sending an uploaded file back to the client
A FileUpload can provide a readable stream, which can then be sent through Subatom's response API.
const uploadedFile = ctx.file;
if (!uploadedFile) {
return ctx.res.status(404).json({
error: "File not found",
});
}
ctx.res.sendStream(uploadedFile.stream());
For disk-backed files, you can also work with the stored path and Subatom's response file helpers:
if (!uploadedFile.path) {
return ctx.res.status(500).json({
error: "File does not have a disk path",
});
}
ctx.res.sendFile(uploadedFile.path);
sendFile() sends a local file inline, while download() sends it as a download.
Error handling
Upload parsing errors are converted into HTTP responses.
The upload pipeline currently maps errors as follows:
| Error | HTTP status |
|---|---|
| File/request payload too large | 413 |
| Unprocessable entity | 422 |
| Bad request | 400 |
| Unknown upload error | 500 |
The response body is normally:
{
"error": "..."
}
For example, exceeding a configured file count or file-size limit results in 413.
Upload cleanup
Uploaded files may create temporary resources, especially when using disk storage.
Subatom cleans up files when upload processing fails.
After successfully processing a file, application code can explicitly release it:
await file.destroy();
A useful pattern is:
try {
await processFile(file);
} finally {
await file.destroy();
}
This is especially important when you use disk storage and no longer need the uploaded file.
Choosing the right upload method
Use the simplest method that matches your endpoint.
One file
file.single("avatar")
Good for:
- profile pictures
- document uploads
- one CSV file
- one PDF
Multiple files with one field
file.array("photos", 10)
Good for:
- photo galleries
- multiple documents
- batch uploads
Multiple named fields
file.fields([
{ name: "avatar", maxCount: 1 },
{ name: "gallery", maxCount: 4 },
])
Good for:
- profile + gallery
- document + attachments
- forms with different upload sections
Unknown field names
file.any()
Use when the endpoint intentionally accepts arbitrary file fields.
No files
file.none()
Use when the endpoint accepts multipart form fields but must reject attachments.
Recommended production pattern
For a public upload endpoint, combine:
- an explicit upload mode
- a storage strategy
- MIME restrictions
- file-count limits
- file-size limits
- schema validation when needed
- cleanup after processing
Example:
file.array("documents", 5, {
storage: "disk",
dest: "./uploads",
allowedMimeTypes: [
"application/pdf",
"image/jpeg",
"image/png",
],
limits: {
fileSize: 5 * 1024 * 1024,
files: 5,
},
})
Then validate the uploaded files with your route schema when you need application-level rules.
API reference
file.single()
file.single(
fieldname: string,
options?: FileUploadPipelineOptions,
)
Accepts a single file from one multipart field.
file.array()
file.array(
fieldname: string,
maxCount?: number,
options?: FileUploadPipelineOptions,
)
Accepts multiple files from one multipart field.
file.fields()
file.fields(
fields: Array<{
name: string;
maxCount?: number;
}>,
options?: FileUploadPipelineOptions,
)
Accepts multiple named file fields.
file.any()
file.any(
options?: FileUploadPipelineOptions,
)
Accepts files from any field.
file.none()
file.none(
options?: FileUploadPipelineOptions,
)
Rejects file uploads while still allowing form fields.
FileUploadPipelineOptions
The upload pipeline accepts:
interface FileUploadPipelineOptions {
storage: "memory" | "disk";
dest?: string;
allowedMimeTypes?: string[];
limits?: {
fileSize?: number;
files?: number;
fields?: number;
fieldSize?: number;
parts?: number;
};
}
fieldname is also part of the internal upload option type.
IFileUpload API
| Property / method | Description |
|---|---|
filename |
Original uploaded filename |
encoding |
Multipart file encoding |
mimetype |
Resolved MIME type |
storageType |
"memory" or "disk" |
path |
Disk path, when disk storage is used |
size |
File size in bytes |
destroyed |
Whether the file has been destroyed |
bufferContent |
In-memory buffer, when available |
buffer() |
Reads the complete file as a Buffer |
stream() |
Returns a readable stream |
destroy() |
Releases memory or removes the disk file |
Important notes
file middleware only processes multipart requests
The upload middleware checks whether the request content type is multipart/form-data.
For non-multipart requests, the upload middleware continues to the next middleware.
file.none() is different
file.none() allows form fields but rejects file parts.
Memory vs disk
Choose memory storage for files that are small enough to safely process in memory.
Choose disk storage when you want the upload to exist as a filesystem file or want to avoid keeping the entire uploaded file in application memory.
Always validate public uploads
Do not rely only on a client-provided filename or MIME type. Configure upload restrictions and application-level validation appropriate for your endpoint.
Quick reference
// One file
file.single("avatar");
// Multiple files from one field
file.array("photos", 10);
// Multiple named fields
file.fields([
{ name: "avatar", maxCount: 1 },
{ name: "gallery", maxCount: 4 },
]);
// Any file fields
file.any();
// No files
file.none();
Storage:
{
storage: "memory",
}
or:
{
storage: "disk",
dest: "./uploads",
}
Restrictions:
{
allowedMimeTypes: [
"image/jpeg",
"image/png",
],
limits: {
fileSize: 5 * 1024 * 1024,
files: 5,
},
}
Access:
ctx.file
ctx.files
Read:
await ctx.file?.buffer();
ctx.file?.stream();
Cleanup:
await ctx.file?.destroy();
Summary
Subatom's file-upload API gives you a small set of predictable building blocks:
file.single() → one named file
file.array() → many files from one field
file.fields() → multiple named file fields
file.any() → arbitrary file fields
file.none() → no files allowed
Every uploaded file is represented by IFileUpload, giving you:
metadata → filename, mimetype, size, storageType
content → buffer()
stream → stream()
cleanup → destroy()
For most applications, start with file.single() or file.array(), add MIME and size limits, and use destroy() when a temporary uploaded file is no longer needed.