Storage
File storage with local disk, S3-compatible, and Ostra backends. Two classes:Storage for file operations and Upload for validated ingestion.
Quick start
import { Storage, Upload } from '@strav/kernel'
// Store a file
const path = await Storage.put('avatars', file) // 'avatars/a7f3c9d2.jpg'
const path = await Storage.putAs('avatars', file, 'me.jpg')
// Retrieve, check, delete
const blob = await Storage.get('avatars/a7f3c9d2.jpg') // Blob | null
const exists = await Storage.exists('avatars/a7f3c9d2.jpg')
await Storage.delete('avatars/a7f3c9d2.jpg')
// URL generation
const url = Storage.url('avatars/a7f3c9d2.jpg')
// Local: '/storage/avatars/a7f3c9d2.jpg'
// S3: presigned URL (default 1h expiry)
// Ostra: 'http://localhost:9000/buckets/my-bucket/avatars/a7f3c9d2.jpg'
Upload (validated ingestion)
Upload wraps a File, validates it, and stores it via Storage:
const { avatar } = await ctx.files('avatar')
const { path, url } = await Upload.file(avatar)
.maxSize('5mb')
.types(['image/jpeg', 'image/png', 'image/webp'])
.store('avatars')
Both maxSize() and types() are optional. If validation fails, a typed error is thrown:
import { FileTooLargeError, InvalidFileTypeError } from '@strav/kernel'
try {
await Upload.file(file).maxSize('2mb').store('docs')
} catch (e) {
if (e instanceof FileTooLargeError) { /* ... */ }
if (e instanceof InvalidFileTypeError) { /* ... */ }
}
Size format
Accepts a number (bytes) or a string:'500b', '5kb', '10mb', '1gb'.
Custom filename
Pass a name tostore():
const { path } = await Upload.file(avatar).store('avatars', 'profile.jpg')
// 'avatars/profile.jpg'
Without a name, a random hex filename is generated with the original extension preserved.
Configuration
Createconfig/storage.ts:
import { env } from '@strav/kernel'
export default {
default: env('STORAGE_DRIVER', 'local'),
local: {
root: env('STORAGE_LOCAL_ROOT', 'storage'),
baseUrl: env('STORAGE_BASE_URL', '/storage'),
},
s3: {
bucket: env('S3_BUCKET', ''),
region: env('S3_REGION', 'us-east-1'),
endpoint: env('S3_ENDPOINT', null),
accessKeyId: env('S3_ACCESS_KEY_ID', ''),
secretAccessKey: env('S3_SECRET_ACCESS_KEY', ''),
baseUrl: env('S3_BASE_URL', null),
},
ostra: {
url: env('OSTRA_URL', 'http://localhost:9000'),
token: env('OSTRA_TOKEN', ''),
bucket: env('OSTRA_BUCKET', ''),
},
}
Local driver
Stores files under theroot directory using Bun.write(). URLs are path-based using the baseUrl prefix. Serve stored files via the static middleware or a custom route.
S3 driver
Uses Bun's nativeS3Client — works with AWS S3, Cloudflare R2, DigitalOcean Spaces, MinIO, and any S3-compatible service.
# .env for AWS S3
STORAGE_DRIVER=s3
S3_BUCKET=my-app
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=AKIA...
S3_SECRET_ACCESS_KEY=...
# .env for Cloudflare R2
STORAGE_DRIVER=s3
S3_BUCKET=my-r2-bucket
S3_REGION=auto
S3_ENDPOINT=https://account-id.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BASE_URL=https://cdn.example.com
# .env for MinIO (local dev)
STORAGE_DRIVER=s3
S3_BUCKET=stravigor-test
S3_REGION=us-east-1
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
When S3_BASE_URL is set (e.g. a CDN), Storage.url() returns a direct URL. Otherwise it returns a presigned URL with configurable expiry:
Storage.url('avatars/photo.jpg') // presigned, 1h default
Storage.url('avatars/photo.jpg', 86400) // presigned, 24h
Ostra driver
Stores files on a Stravigor Ostra server — a standalone object storage service with an HTTP API.# .env
STORAGE_DRIVER=ostra
OSTRA_URL=http://localhost:9000
OSTRA_TOKEN=otk_...
OSTRA_BUCKET=my-bucket
Storage.url() returns a direct URL to the ostra server (http://localhost:9000/buckets/my-bucket/path). For public-read buckets this works without authentication. For private buckets, use the OstraClient to generate signed URLs (see below).
Ostra client (advanced)
Beyond theStorage facade, you can use OstraClient directly for the full Ostra API — buckets, versions, multipart uploads, signed URLs, and tokens:
import { OstraClient } from '@strav/kernel'
const ostra = new OstraClient({ url: 'http://localhost:9000', token: 'otk_...' })
// Bucket operations
await ostra.createBucket('photos', { visibility: 'public-read' })
const buckets = await ostra.listBuckets()
// Scoped bucket handle
const bucket = ostra.bucket('photos')
await bucket.info()
await bucket.update({ versioning: 'enabled' })
await bucket.destroy()
// Object operations
await bucket.put('avatars/me.png', file)
const blob = await bucket.get('avatars/me.png')
const meta = await bucket.head('avatars/me.png')
await bucket.delete('avatars/me.png')
const { objects } = await bucket.list({ prefix: 'avatars/', limit: 50 })
await bucket.deleteMany(['a.png', 'b.png'])
await bucket.copy('dest-key', { bucket: 'source-bucket', key: 'source-key' })
// Versions
const blob = await bucket.get('me.png', { versionId: 'ver_abc' })
await bucket.delete('me.png', { versionId: 'ver_abc' })
await bucket.delete('me.png', { allVersions: true })
const { versions } = await bucket.versions('me.png')
// Signed URLs
const { url, expires_at } = await bucket.signedUrl('me.png', 'GET', 3600)
// Multipart uploads
const upload = await bucket.multipart('videos/big.mp4', 'video/mp4')
await upload.part(1, chunk1)
await upload.part(2, chunk2)
await upload.complete() // or upload.abort()
// Token management (requires root scope)
await ostra.createToken({ scope: 'read-write', buckets: ['photos'] })
const tokens = await ostra.listTokens()
await ostra.deleteToken('tok_abc')
You can also access the OstraClient from the driver when using the Storage facade:
import { StorageManager, OstraDriver } from '@strav/kernel'
const driver = StorageManager.driver as OstraDriver
const { url } = await driver.client.bucket('my-bucket').signedUrl('private/doc.pdf', 'GET', 3600)
Errors from the ostra server throw OstraError with code, message, and statusCode:
import { OstraError } from '@strav/kernel'
try {
await bucket.get('missing.txt')
} catch (e) {
if (e instanceof OstraError) {
e.code // 'OBJECT_NOT_FOUND'
e.statusCode // 404
e.message // 'Object not found'
}
}
Bootstrap
Using a service provider (recommended)
import { StorageProvider } from '@strav/kernel'
app.use(new StorageProvider())
The StorageProvider registers StorageManager as a singleton. It depends on the config provider.
Manual setup
import { StorageManager } from '@strav/kernel'
app.singleton(StorageManager)
app.resolve(StorageManager)
Controller example
import { Storage, Upload, FileTooLargeError } from '@strav/kernel'
export default class ProfileController {
async updateAvatar(ctx: Context) {
const session = ctx.get<Session>('session')
const user = ctx.get<User>('user')
const { avatar } = await ctx.files('avatar')
if (!avatar) {
session.flash('error', 'No file selected.')
return ctx.redirect('/profile')
}
try {
// Delete old avatar if exists
if (user.avatarPath) await Storage.delete(user.avatarPath)
const { path } = await Upload.file(avatar)
.maxSize('5mb')
.types(['image/jpeg', 'image/png', 'image/webp'])
.store('avatars')
await BaseModel.db.sql`
UPDATE "user_profile" SET "avatar_url" = ${path} WHERE "user_pid" = ${user.pid}
`
session.flash('success', 'Avatar updated.')
} catch (e) {
if (e instanceof FileTooLargeError) {
session.flash('error', 'Image must be under 5MB.')
} else {
session.flash('error', 'Invalid file type.')
}
}
return ctx.redirect('/profile')
}
}