Skip to content

storage-bunny

Package @theoven/storage-bunny
Adds to context ctx.storage — it is a driver for storage
Endpoints none
Creates files whatever you upload, in your storage zone
Creates tables none
Status shipped
Terminal window
bun add @theoven/storage @theoven/storage-bunny
src/app.ts
import { storage } from '@theoven/storage'
import { bunnyStorage } from '@theoven/storage-bunny'
export const app = createApp().use(
storage(
bunnyStorage({
zone: env.string('BUNNY_ZONE'),
accessKey: env.string('BUNNY_ACCESS_KEY'),
pullZone: 'cdn.example.com',
}),
),
)

Puts Bunny.net Storage behind the storage contract. Everything on ctx.storage works exactly as it does on S3 or disk — the driver is the only line that changes. What Bunny adds is cheap egress through its pull zones, which is the reason to pick it.

This brick adds no endpoints.

Uploading is ctx.storage.upload() — the same call as every other driver:

src/routes/upload.post.ts
import { z } from 'zod'
export const body = z.object({ file: z.file() })
export default async ({ body, storage }) => {
const object = await storage.upload(`uploads/${body.file.name}`, body.file)
return { key: object.key, size: object.size }
}

upload takes anything web-shaped — a File, Blob, ArrayBuffer, ReadableStream or string — and returns { key, bucket, size, type, lastModified }.

await storage.upload('reports/q3.pdf', pdfBytes, { type: 'application/pdf' })
const blob = storage.download('reports/q3.pdf') // lazy — no request until read
const text = await blob.text()
await storage.exists('reports/q3.pdf')
await storage.stat('reports/q3.pdf')
await storage.list({ prefix: 'reports/', limit: 50 })
await storage.delete('reports/q3.pdf')

Full detail on each of these is on the storage page; they behave the same here.

Nothing in your repository. Objects go to the storage zone you configured.

With pullZone set, download() fetches from the CDN. Without it, reads fall back to the storage API — which works, and is slower and costs more.

The zone password is never sent to the pull zone host; a test asserts that, because leaking a storage credential to a public edge is not a mistake you want to make once.

Option Default
zone — required; the storage zone name
accessKey — required; the zone password
region storage.bunnycdn.com ny., la., sg., syd., uk., se., br., jh.
pullZone — hostname for public URLs
tokenKey — token-authentication key, if enabled on the pull zone

A zone created in one region is not reachable through another’s host.

ctx.storage.presignDownload('reports/q3.pdf', { expiresIn: 300 })

Available only once both pullZone and tokenKey are set — presign is undefined otherwise, so canPresign tells you before it matters.

upload, download, delete, exists, stat, list ✓
presignDownload ✓, once pullZone and tokenKey are set — check storage.canPresign
presignUpload ✗ — throws; Bunny has no signed-upload equivalent
directUpload ✗ — depends on presignUpload
Multiple buckets via storage.bucket() ✗ — one zone per driver; register a second driver instead
  • No browser-direct uploads — see above; server-side upload() is unaffected.
  • Uploads are buffered. Bunny’s PUT needs a known length, so a stream is collected before sending. S3 streams; this does not.
  • list is directory-based. A prefix is split into a directory and a “starts with”, so a prefix spanning directories does not work the way it does on S3.
  • Paging is client-side. Bunny returns a whole directory; limit and after slice it here.

Request shapes, key encoding, pull-zone routing, paging and the URL signing are tested against an injected fetcher — deterministic, and where the bugs are. The token is checked across forty keys to be URL-safe, since base64’s + and / do not survive a query string.

That Bunny accepts those requests is not verified here: live tests are gated on BUNNY_ZONE and skip with a printed notice.