Skip to content

@mantlejs/storage

File upload plugin for Mantle JS. Parses multipart/form-data requests via busboy, writes files to local disk (or a custom adapter), validates MIME types and file sizes, and attaches uploaded file metadata to context.data through a before hook.


Terminal window
npm install @mantlejs/storage busboy

upload() is a Mantle plugin that stores upload configuration on the app. It does not parse uploads by itself — parsing happens in the handleUpload() before hook, which you attach to whichever service methods accept files.

The built-in diskStorage() adapter writes files to a local directory. The StorageAdapter interface covers the full file lifecycle — store(), retrieve(), delete(), and an optional getSignedUrl() — so cloud adapters can be plugged in via the storage option. See @mantlejs/storage-s3 and @mantlejs/storage-gcs for cloud-backed implementations.

@mantlejs/express and @mantlejs/koa both put the raw Node IncomingMessage at params.request. handleUpload() reads from that to parse the multipart body. This means the hook only runs in HTTP contexts — internal service calls with no provider receive no upload processing (and no errors).

@mantlejs/http is not currently supported. Its router never sets params.request, so handleUpload() treats every request on that transport the same as an internal call — uploads silently pass through (or throw BadRequest("Upload field '<field>' is required") if required: true), indistinguishable from a genuine internal call. If you need file uploads, configure @mantlejs/express or @mantlejs/koa.


import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { upload, diskStorage, handleUpload } from "@mantlejs/storage";
const app = mantle()
.configure(express())
.configure(
upload({
maxFileSize: 5 * 1024 * 1024, // 5 MB
allowedMimeTypes: ["image/jpeg", "image/png"],
storage: diskStorage({ destination: "./uploads" }),
}),
);
app.use("photos", new PhotoService(new PhotoRepository(app)));
app.service("photos").hooks({
before: {
create: [handleUpload("photo", { required: true })],
},
});
app.listen(3030);

Upload a file

POST /photos
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="photo"; filename="avatar.jpg"
Content-Type: image/jpeg
<binary file data>
------boundary--

The service’s create method receives context.data.photo as an UploadedFile object:

{
"fieldname": "photo",
"originalname": "avatar.jpg",
"mimetype": "image/jpeg",
"size": 42317,
"path": "./uploads/1718900000000-avatar.jpg",
"key": "1718900000000-avatar.jpg"
}

Returns a MantlePlugin. Call via app.configure(upload(config)).

app.configure(
upload({
maxFileSize: 10 * 1024 * 1024, // optional — default 10 MB
allowedMimeTypes: ["image/jpeg"], // optional — default [] (all types allowed)
storage: diskStorage({ destination: "./uploads" }), // optional — default local disk at ./uploads
}),
);

Side effects:

  • Stores the UploadEngine at app.get("upload")
Field Type Default Description
maxFileSize number 10485760 (10 MB) Maximum file size in bytes. Files that exceed this limit cause handleUpload() to throw BadRequest.
allowedMimeTypes string[] [] Allowed MIME types. An empty array means all types are accepted.
storage StorageAdapter diskStorage({ destination: "./uploads" }) Where files are written.

Built-in storage adapter that writes uploaded files to a local directory.

import { diskStorage } from "@mantlejs/storage";
const storage = diskStorage({
destination: "./uploads",
// Optional: custom filename. Receives the file info; returns the filename string.
// Default: `${Date.now()}-${originalname}`
filename: (info) => `${info.fieldname}-${Date.now()}.${info.originalname.split(".").pop()}`,
});
  • Creates destination (including parent directories) if it does not exist.
  • Returns the absolute path of the saved file in UploadedFile.path, and the filename (relative to destination) in UploadedFile.key.
  • retrieve(key) and delete(key) take that same key value and resolve it against destination; both reject with BadRequest if the resolved path would escape destination (e.g. a key containing ../), and delete() rejects with NotFound if the file does not exist.
  • getSignedUrl is intentionally omitted — there is no direct-download concept for local disk.
Field Type Default Description
destination string — Directory to write files into (required)
filename (info: UploadFileInfo) => string ${Date.now()}-${originalname} Filename transform

Every StorageAdapter supports reading back and removing a previously stored file via the key returned from store():

const engine = app.get<UploadEngine>("upload");
const stream = await engine.storage.retrieve(uploadedFile.key); // Readable
await engine.storage.delete(uploadedFile.key);
if (engine.storage.getSignedUrl) {
const url = await engine.storage.getSignedUrl(uploadedFile.key, { expiresIn: 300 });
}

getSignedUrl is optional on the interface — disk storage does not implement it, so always feature-detect before calling it.

Field Type Default Description
expiresIn number adapter-specific (S3/GCS default to 900s) URL validity window, in seconds

A before hook factory. Parses the multipart body, validates the file, stores it via the configured adapter, writes an UploadedFile to context.data[field], and merges any other ordinary (non-file) form fields sent alongside it into context.data too — e.g. a hidden articleId field on the same upload form, so the record a file belongs to can travel with it in one request.

app.service("photos").hooks({
before: {
create: [handleUpload("photo", { required: true })],
patch: [handleUpload("photo")], // optional — silently skips if absent
},
});
POST /photos
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="albumId"
42
------boundary
Content-Disposition: form-data; name="photo"; filename="avatar.jpg"
Content-Type: image/jpeg
<binary file data>
------boundary--

create receives context.data.albumId === "42" (a string — same as a URL-encoded form or query string; coerce it yourself, e.g. Number(context.data.albumId)) alongside context.data.photo.

Condition Result
params.request is absent (internal call, or an unsupported transport) and required: false Returns context unchanged
params.request is absent (internal call, or an unsupported transport) and required: true Throws BadRequest
File field not present in form and required: false Merges any other form fields into context.data; the target field stays absent
File field not present in form and required: true Throws BadRequest
MIME type not in allowedMimeTypes Throws BadRequest
File exceeds maxFileSize Throws BadRequest
File valid Writes UploadedFile to context.data[field] and merges other form fields into context.data

Unrelated file fields are discarded — when multiple files share the same field name, or a file is sent under a different field name than the one handleUpload() was called with, only the targeted field’s file is captured. Ordinary text fields are always captured, regardless of where they fall relative to the file part in the multipart body.

Field Type Default Description
required boolean false Whether the upload field must be present

The shape of an uploaded file after handleUpload() processes it.

interface UploadedFile {
fieldname: string; // the form field name (e.g. "photo")
originalname: string; // original filename from the client
mimetype: string; // MIME type (e.g. "image/jpeg")
size: number; // file size in bytes
path: string; // resolved path on disk (local) or display URL (cloud adapter)
key: string; // the adapter-native key — pass this back to retrieve()/delete()/getSignedUrl()
}

Implement StorageAdapter to redirect uploads to cloud storage, a database, or any other destination:

import type { GetSignedUrlOptions, StorageAdapter, UploadFileInfo, UploadedFile } from "@mantlejs/storage";
import type { Readable } from "node:stream";
const customStorage: StorageAdapter = {
async store(stream: Readable, info: UploadFileInfo): Promise<UploadedFile> {
const key = `uploads/${Date.now()}-${info.originalname}`;
// stream to the backend ...
return {
fieldname: info.fieldname,
originalname: info.originalname,
mimetype: info.mimetype,
size: bytesUploaded,
path: `https://my-bucket.example.com/${key}`,
key,
};
},
async retrieve(key: string): Promise<Readable> {
// return a Readable for the stored object
},
async delete(key: string): Promise<void> {
// remove the stored object
},
// optional — omit entirely if the backend has no direct-download concept
async getSignedUrl(key: string, options?: GetSignedUrlOptions): Promise<string> {
// return a time-limited download URL
},
};
app.configure(upload({ storage: customStorage }));

@mantlejs/storage-s3 and @mantlejs/storage-gcs are reference implementations of this interface backed by AWS S3 and Google Cloud Storage respectively.


import type {
UploadConfig,
UploadedFile,
UploadFileInfo,
StorageAdapter,
DiskStorageConfig,
HandleUploadOptions,
GetSignedUrlOptions,
UploadEngine,
} from "@mantlejs/storage";
Type Description
UploadConfig Options passed to upload()
UploadedFile Metadata returned after a file is stored
UploadFileInfo Subset of file info available before storage (fieldname, originalname, mimetype)
StorageAdapter Interface for custom storage backends (store, retrieve, delete, optional getSignedUrl)
DiskStorageConfig Options for diskStorage()
HandleUploadOptions Options for handleUpload()
GetSignedUrlOptions Options for StorageAdapter.getSignedUrl()
UploadEngine The { maxFileSize, allowedMimeTypes, storage } object stored at app.get("upload")

Error Code When thrown
BadRequest 400 File field required but absent
BadRequest 400 MIME type not in allowedMimeTypes
BadRequest 400 File exceeds maxFileSize
BadRequest 400 diskStorage() retrieve/delete called with a key that resolves outside destination
NotFound 404 diskStorage() delete() called with a key that does not exist

Terminal window
npx nx build storage # compile
npx nx test storage # run tests
npx nx lint storage # lint

Build before publishing:

Terminal window
npx nx build storage

First publish (scoped packages require --access public):

Terminal window
cd packages/storage
npm publish --access public

Subsequent releases — bump version in packages/storage/package.json, then:

Terminal window
cd packages/storage
npm publish
Terminal window
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/storage
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/storage --registry http://localhost:4873