← All @molecule/* packages · App templates
@molecule/api-uploadsCore interface · storage · API (Node) · v1.0.1 · Apache-2.0
File uploads and storage
npm install @molecule/api-uploads@molecule/api-uploads is the storage core interface on the API (Node) side: the API your app calls, with no vendor inside.
Choose the implementation by bonding one of its 2 providers: @molecule/api-uploads-filesystem, @molecule/api-uploads-s3.
import { getProvider } from '@molecule/api-uploads'
// a bond (e.g. `@molecule/api-uploads-s3`) called setProvider() at startup
router.post('/files', async (req, res) => {
const userId = getUserId(res)
if (!userId) return res.status(401).json({ error: 'Authentication required.' })
// busboy/multer yields (fieldname, stream, info) — validate BEFORE trusting it.
if (!ALLOWED_TYPES.has(info.mimeType)) return res.status(415).json({ error: 'Unsupported type.' })
const file = getProvider().upload(fieldname, stream, info, (e) =>
res.status(500).json({ error: e.message }),
)
await saveFileRow({ id: file.id, userId, name: info.filename }) // own it
res.json({ id: file.id })
})
router.get('/files/:id', async (req, res) => {
const row = await getFileRow(req.params.id)
if (!row || row.userId !== getUserId(res)) return res.status(404).end() // ownership → no IDOR
const stream = await getProvider().getFile?.(row.id)
if (!stream) return res.status(404).end()
stream.pipe(res)
})Providers (2): @molecule/api-uploads-filesystem, @molecule/api-uploads-s3
Works with: @molecule/api-bond, @molecule/api-i18n
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Uploads core interface for molecule.dev.
Defines the standard interface for upload providers.
import { getProvider } from '@molecule/api-uploads'
// a bond (e.g. `@molecule/api-uploads-s3`) called setProvider() at startup
router.post('/files', async (req, res) => {
const userId = getUserId(res)
if (!userId) return res.status(401).json({ error: 'Authentication required.' })
// busboy/multer yields (fieldname, stream, info) — validate BEFORE trusting it.
if (!ALLOWED_TYPES.has(info.mimeType)) return res.status(415).json({ error: 'Unsupported type.' })
const file = getProvider().upload(fieldname, stream, info, (e) =>
res.status(500).json({ error: e.message }),
)
await saveFileRow({ id: file.id, userId, name: info.filename }) // own it
res.json({ id: file.id })
})
router.get('/files/:id', async (req, res) => {
const row = await getFileRow(req.params.id)
if (!row || row.userId !== getUserId(res)) return res.status(404).end() // ownership → no IDOR
const stream = await getProvider().getFile?.(row.id)
if (!stream) return res.status(404).end()
stream.pipe(res)
})
core
npm install @molecule/api-uploads @molecule/api-bond @molecule/api-i18n
FileInfoInformation about a file being uploaded.
This interface is provider-agnostic. Implementations using busboy or other multipart parsers should adapt to this interface.
interface FileInfo {
/**
* The original filename - e.g., `some-image.jpg`.
*/
filename: string
/**
* The file's encoding - e.g., `7bit`, `binary`.
*/
encoding: string
/**
* The file's MIME type - e.g., `image/jpeg`.
*/
mimeType: string
}
UploadedFileProperties describing an uploading/uploaded file.
interface UploadedFile {
/**
* The unique file identifier.
*/
id: string
/**
* The file's fieldname from the form.
* Used as the key for the file within `req.files` - e.g., `req.files[fieldname] = file`.
*/
fieldname: string
/**
* The original filename - e.g., `some-image.jpg`.
*/
filename: string
/**
* The file's encoding - e.g., `binary`.
*/
encoding: string
/**
* The file's mimetype - e.g., `image/jpeg`.
*/
mimetype: string
/**
* The file's size in bytes.
*/
size: number
/**
* The source stream (available during upload).
*/
stream?: NodeJS.ReadableStream
/**
* A promise that resolves when the upload completes.
*/
uploadPromise?: Promise<void>
/**
* Whether the upload has completed.
*/
uploaded: boolean
/**
* The URL/location of the uploaded file (if available).
*/
location?: string
}
UploadProviderUpload provider interface.
All upload providers must implement this interface.
interface UploadProvider {
/**
* Uploads a file from a stream.
*/
upload: UploadHandler
/**
* Aborts an in-progress upload.
*/
abortUpload: AbortHandler
/**
* Deletes a file.
*/
deleteFile: DeleteHandler
/**
* Gets a file stream for reading.
*/
getFile?: GetFileHandler
}
AbortHandlerCallback invoked to abort an in-progress upload and clean up any partially written data in the storage backend.
type AbortHandler = (file: UploadedFile) => void | Promise<void>
DeleteHandlerCallback invoked to permanently delete an uploaded file from storage.
type DeleteHandler = (id: string) => Promise<void>
GetFileHandlerCallback invoked to retrieve a readable stream for an uploaded file,
or null if the file does not exist.
type GetFileHandler = (id: string) => Promise<NodeJS.ReadableStream | null>
UploadHandlerCallback invoked by the multipart parser for each incoming file. Implementations
pipe the stream to a storage backend and return an UploadedFile descriptor.
type UploadHandler = (
fieldname: string,
stream: NodeJS.ReadableStream,
info: FileInfo,
onError: (error: Error) => void,
) => UploadedFile
UploadAbortedErrorThrown by conforming upload providers to reject a file's uploadPromise when
abortUpload() is called on it — signals a deliberate cancellation, distinguishable
from both a successful upload and a real transport/storage failure.
getProvider()Retrieves the bonded upload provider, throwing if none is configured.
function getProvider(): UploadProvider
Returns: The bonded upload provider.
hasProvider()Checks whether an upload provider is currently bonded.
function hasProvider(): boolean
Returns: true if an upload provider is bonded.
setProvider(provider)Registers an upload provider as the active singleton. Called by bond packages during application startup.
function setProvider(provider: UploadProvider): void
provider — The upload provider implementation to bond.| Provider | Package |
|---|---|
| Local Filesystem | @molecule/api-uploads-filesystem |
| AWS S3 / S3-compatible | @molecule/api-uploads-s3 |
Peer dependencies:
@molecule/api-bond ^1.0.1@molecule/api-i18n ^1.0.1@molecule/api-bond@molecule/api-i18nA weak upload integration leaks files or trusts the client. Enforce these in your
handler around {@link UploadProvider.upload} / {@link UploadProvider.getFile} /
deleteFile:
user_id, and on read/delete load the row and 404 if it isn't the caller's — an
unscoped getFile(id) is an IDOR (anyone enumerates everyone's files).Content-Type.UploadHandler takes a plain NodeJS.ReadableStream — the bundled bonds enforce a size
cap ONLY by listening for the multipart parser's 'limit' event on that stream (busboy's
fileSize option triggers it) and reporting it to onError as 'Stream limit reached.'.
Configure the cap on your multipart parser (e.g. busboy's limits.fileSize) — a plain
stream that never emits 'limit' is NEVER size-limited by these bonds.file.id (or a serve URL you build from
it, e.g. /api/files/<id>) — NEVER the raw file.location. A bucket URL 403s the moment
storage is private (the default here), so a rendered location works only on the local
filesystem bond and breaks on S3; and only the id lets a later route serve, delete, or
REPLACE the object. Persisting location strands the file — uncleanable.deleteFile(id) — a row-only delete orphans the object in storage forever. On REPLACE (a
new upload overwriting a stored id) deleteFile the OLD id too, or every change leaks the
previous blob. Both are best-effort (log, don't fail the request) and run AFTER the row
write so a storage hiccup never leaves a row pointing at a gone object.NodeJS.ReadableStream); never buffer a whole upload
in memory.abortUpload() rejects the
file's uploadPromise with {@link UploadAbortedError} — it never resolves uploadPromise
and never invokes the upload() call's onError for the abort itself. This holds across
every bundled provider, so swapping providers never changes what a consumer observes on
abort. See {@link UploadAbortedError} for the full contract.Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
Translation strings are provided by @molecule/api-locales-uploads.