storage
| Package | @theoven/storage |
| Adds to context | ctx.storage |
| Endpoints | none |
| Creates files | in development, whatever you upload, under the directory you name |
| Creates tables | none |
| Status | shipped |
Install
Section titled “Install”bun add @theoven/storageimport { createApp, env } from '@theoven/core'import { diskStorage, s3Storage, storage } from '@theoven/storage'
export const app = createApp().use( storage( env.has('S3_BUCKET') ? s3Storage({ bucket: env.string('S3_BUCKET') }) : diskStorage({ dir: './storage' }), ),)import { defineRoute } from '@theoven/core'import { z } from 'zod'
export default defineRoute( { auth: true, body: z.object({ file: z.file().max(5_000_000) }) }, (ctx) => ctx.storage.upload(`avatars/${ctx.user.id}`, ctx.body.file),)No SDK. Bun.S3Client is in the runtime and signs its own requests, which is why this brick is a
few hundred lines rather than a dependency tree.
What it does
Section titled “What it does”Gives you ctx.storage — upload, download, list, delete, stat, and presigned URLs — over four
drivers: S3 (and anything S3-compatible), local disk, Bunny and
ImageKit. One interface, so the driver is a config change.
Uploads arrive in your handler as web File objects because core parses multipart bodies
already — there is no multer equivalent to install or order correctly.
Endpoints
Section titled “Endpoints”This brick adds no endpoints. Files are served from your storage provider or through your own routes; nothing is mounted automatically, because a storage brick that silently exposed a public file route would be a security decision made on your behalf.
What it creates
Section titled “What it creates”Files: with diskStorage, the directory at dir and everything uploaded into it — add it to
.gitignore. With the other drivers, nothing locally.
Tables: none. This brick stores no index of what you uploaded; the bucket is the source of truth.
await ctx.storage.upload('reports/q3.pdf', file) // returns { key, size, type, bucket }await ctx.storage.exists('reports/q3.pdf') // booleanawait ctx.storage.stat('reports/q3.pdf') // metadata, or nullawait ctx.storage.delete('reports/q3.pdf') // deleting something absent is fine
const { objects, next } = await ctx.storage.list({ prefix: 'reports/', limit: 50 })const more = await ctx.storage.list({ prefix: 'reports/', after: next })Downloads stream
Section titled “Downloads stream”app.get('/files/[...key]', (ctx) => ctx.storage.download(ctx.params.key))download() returns a Blob — a lazy handle, not bytes. Nothing is fetched until something
reads it, and returning it from a handler streams it to the client without the body ever being
held in memory. Both Bun.file() and Bun’s S3File are Blobs, so this is the same line whichever
driver is configured.
Direct uploads from the browser
Section titled “Direct uploads from the browser”For anything large, do not proxy the bytes through your server:
export default defineRoute({ auth: true }, (ctx) => ctx.storage.directUpload(`users/${ctx.user.id}/${crypto.randomUUID()}`, { type: 'image/png', expiresIn: 600, }),)const ticket = await (await fetch('/uploads', { method: 'POST' })).json()await fetch(ticket.url, { method: ticket.method, headers: ticket.headers, body: file })// then tell your API about ticket.keyA 2GB file never touches your server: no request timeout to raise, no memory to budget, no bandwidth paid for twice.
The ticket is an object rather than a bare URL because the content type is part of the signature. Send a different one and S3 answers with a signature mismatch whose message explains nothing — so the header you must send comes back with the URL.
| Field | |
|---|---|
url |
the presigned URL |
method |
always PUT |
headers |
what the browser must send; content type when you set one |
key |
store this — the URL is temporary, the key is not |
expiresAt |
ISO timestamp |
Large files
Section titled “Large files”There is no separate multipart API. Bun switches to a multipart upload above partSize
automatically, so a 5MB file and a 5GB file are the same call. Tune it if you need to:
s3Storage({ bucket: 'uploads', partSize: 16 * 1024 * 1024, queueSize: 8 })An upload from a form arrives as a File that Bun already spilled to a temporary file while
parsing the request. ctx.storage.upload() hands that straight to the driver — nothing calls
arrayBuffer(), so the bytes go from spill file to bucket without passing through your heap.
Configuration
Section titled “Configuration”s3Storage
Section titled “s3Storage”| Option | Default | Purpose |
|---|---|---|
bucket |
— | required |
accessKeyId, secretAccessKey |
AWS env vars | credentials |
sessionToken |
— | for temporary credentials |
region |
us-east-1 |
|
endpoint |
AWS | R2, MinIO, Spaces, B2 |
virtualHostedStyle |
driver default | MinIO and most self-hosted gateways need false |
partSize |
5 MiB | bytes per multipart part |
queueSize |
5 | parts uploaded in parallel |
retry |
3 | retries per part |
// Cloudflare R2s3Storage({ bucket: 'uploads', endpoint: `https://${accountId}.r2.cloudflarestorage.com` })
// MinIOs3Storage({ bucket: 'uploads', endpoint: 'http://localhost:9000', virtualHostedStyle: false })diskStorage
Section titled “diskStorage”| Option | Default | Purpose |
|---|---|---|
dir |
— | required; created on first write |
bucket |
local |
the name reported on stored objects |
Several buckets
Section titled “Several buckets”storage(s3Storage({ bucket: 'uploads' }), { buckets: { avatars: s3Storage({ bucket: 'avatars' }) },})
await ctx.storage.bucket('avatars').upload(key, file)Capabilities
Section titled “Capabilities”presign is declared, not assumed:
if (ctx.storage.canPresign) { /* … */ }S3 can hand a browser a URL that uploads straight into the bucket. A local directory cannot —
there is no signing authority and no endpoint — so presignUpload throws on the disk driver
rather than returning something that does not work. Check canPresign, or serve the object
through a route in development.
Object keys
Section titled “Object keys”Keys are usually built from something a user typed, so they are checked before they reach a
backend. Traversal (../, both separators), absolute paths, empty keys and null bytes are all
refused. The disk driver checks a second time after resolving, because the consequence there is
a write outside the directory you chose.
The key is always what you passed. A browser’s filename is never used as one — that is how
../../etc/passwd becomes a file path.
Limitations
Section titled “Limitations”- No content-type or size enforcement on direct uploads. The type is signed, so a mismatch
is rejected; the size is not. A presigned
PUTcannot cap it. If that matters, upload through your server withz.file().max(n), or set a bucket lifecycle policy. - No image processing. No resizing, no thumbnails, no transforms.
- The disk driver cannot presign, and infers content types from the key’s extension rather than storing them.
liston the disk driver reads the whole directory tree before paging. Fine for development; it is not an index.- No copy or move. Read and write, or reach through
ctx.storage.raw— which ties that code to one driver.
How it is verified
Section titled “How it is verified”Key handling, both drivers, paging, capability refusals and the production guard run everywhere.
Presigning is pure computation, so those tests need no server: they assert the URL is signed,
carries the expiry, differs between PUT and GET, and never contains the secret.
Real I/O — round trips, a 5MB streamed upload, a 12MB multipart upload, and a direct-upload
ticket used exactly as issued — runs against MinIO in CI, gated on S3_ENDPOINT. Locally
those skip and say so. CI fails if they skip.