@broberg/media
The fleet's provider-agnostic media-storage facade — one createMedia() API
(upload · signedUrl · delete) over swappable storage providers, so a later
move between backends never touches a call-site (the @broberg/ai-sdk pattern,
for object storage). Ships with Cloudflare R2; the config grows to S3 /
Supabase / GCS without changing your code.
npm i @broberg/media
The R2 provider speaks the S3 API and signs with aws4fetch
(tiny, zero-dep, runs in Node · Bun · edge / Workers) — no AWS SDK.
Usage
import { createMedia } from "@broberg/media";
const media = createMedia({
provider: "r2",
accountId: process.env.R2_ACCOUNT_ID!,
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
bucket: "assets",
jurisdiction: "eu", // pin EU data-residency (GDPR); must match how the bucket was created
keyPrefix: "tenants/acme/", // optional multi-tenant isolation
publicBaseUrl: "https://media.example.com", // optional — enables publicUrl() (public bucket)
});
const { key } = await media.upload("logo.png", bytes, { contentType: "image/png" });
const signed = await media.signedUrl(key, { expiresIn: 600 }); // presigned GET, no public bucket
const stable = media.publicUrl(key); // stable public URL (needs publicBaseUrl)
await media.delete(key); // idempotent — a missing key is not an error
API
| Method | Returns | Notes |
|---|---|---|
upload(key, body, opts?) | { key } | returns the logical key you passed (safe to feed back into the others); opts.contentType / opts.cacheControl |
signedUrl(key, opts?) | string (async) | time-limited presigned GET (opts.expiresIn seconds, default 3600) — no public bucket needed |
publicUrl(key) | string (sync) | stable, non-expiring public URL; needs publicBaseUrl + a public bucket; throws if publicBaseUrl is unset |
delete(key) | void | idempotent (404 tolerated) |
Every method takes the logical key; keyPrefix is applied internally (with a
single /, leading slashes stripped), so keyPrefix:"tenants/acme" + "/logo.png"
→ tenants/acme/logo.png. Because upload() returns the logical key, storing it and
passing it back never double-prefixes.
v0.2.0 (behavior):
upload()now returns the logical (un-prefixed) key, not the prefixed one — so the round-trip intosignedUrl/delete/publicUrlis symmetric. Only observable when you usekeyPrefix(and that round-trip was broken before). Also new:publicUrl(key)+publicBaseUrl.
Public URLs (v0.2.0)
signedUrl() expires — wrong for an image embedded in already-published content
(news richtext, a sent email). For those, set publicBaseUrl (the bucket's R2
custom-domain or r2.dev URL) and use publicUrl(key) — a stable, non-expiring
URL, built synchronously (no signing/IO) so it drops straight into a template.
The package only constructs the URL — the bucket must actually be publicly
readable. Bind a custom domain (via dns-mcp, e.g. media.example.com) or enable
the bucket's r2.dev public URL, then set that as publicBaseUrl. Until it is set,
publicUrl() throws (public stays off — nothing is exposed by accident).
Attachments on a message (v0.4.0, F006.7)
@broberg/media/attachments is the part that makes a stored file a safe
attachment: what the file really is, whether it may come in, where it lives,
and how to serve it. Pure functions, no I/O; lifted from cardmem's upload path.
import { createMedia } from "@broberg/media";
import { validateAttachment, attachmentKey, attachmentHeaders, sha256Hex, type AttachmentRecord } from "@broberg/media/attachments";
const store = createMedia({ provider: "r2", /* … */ jurisdiction: "eu", keyPrefix: `tenants/${tenantId}/` });
const v = validateAttachment({ bytes, filename }); // the BYTES decide
if (!v.ok) return new Response(v.reason, { status: 400 });
const id = crypto.randomUUID();
const key = attachmentKey(id); // attachments/<id> — never the file name
await store.upload(key, bytes, { contentType: v.contentType });
const row: AttachmentRecord = { id, messageId, key, contentType: v.contentType, bytes: v.bytes, filename, sha256: await sha256Hex(bytes) };
// serving it through your app:
const obj = await store.get(row.key);
return new Response(obj!.bytes, { headers: attachmentHeaders(row.contentType, row.bytes, { filename: row.filename }) });
- The bytes decide the type. The first bytes are sniffed (png, jpeg, gif, webp,
heic, pdf, office). Store
v.contentType, never the browser's. A name that promises an image or a PDF the bytes do not deliver is refused, and so is a program, whatever it is called. There is deliberately no parameter for the claimed type. - Default list: images, pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv, md, json.
No svg/html (they can carry script) and no archives. Widen per app with
allowedExt. The forbidden floor (exe, sh, js, dmg, …) can never be readmitted. - Limit 25 MB by default (
maxBytes). - Serving: images and PDF render inline; everything else is a download behind
CSP
sandbox+nosniff. The file name goes through@broberg/httpcontentDisposition, sofaktura-æøå.pdfkeeps its name. - Not here: the HTTP route and the database table (yours), PDF thumbnails,
virus scanning, image re-encoding (
@broberg/media-transform).
List and delete a folder (v0.5.0, F006.8)
const { keys, cursor } = await media.list("kunder/42", { limit: 1000 }); // logical keys, sorted
const { deleted } = await media.deletePrefix("kunder/42"); // e.g. a GDPR erasure
- A prefix is a folder.
"kunder/1"means everything underkunder/1/, neverkunder/10/…— as a plain string match, a GDPR delete would take another customer's files. - Keys come back logical (keyPrefix stripped), like
upload()returns them. Page size 1–1000 (default 1000);cursoris set only when there is more. deletePrefix("")is refused: it would empty the store. If a delete reports success but the object stays,deletePrefixstops with an error instead of looping.- r2 uses S3 ListObjectsV2; volume walks the folder. Since 0.7.0 r2's
list()is measured against a live EU bucket (see below).
Size, time, Tigris and bucket lists (0.7.0, F059.4)
const { objects = [] } = await media.list("db"); // [{ key, size, lastModified }] — same order as keys
const newest = [...objects].sort((a, b) => b.lastModified.localeCompare(a.lastModified))[0];
const tigris = createMedia({ provider: "tigris", accessKeyId, secretAccessKey, bucket: "backups" }); // fly.storage.tigris.dev
const buckets = await listBuckets({ provider: "r2", accountId, jurisdiction: "eu", accessKeyId, secretAccessKey });
// [{ name, createdAt }]
objectssits besidekeyswithsize(bytes) andlastModified(ISO, UTC). Every built-in provider fills it; it is typed optional only so a hand-written test double returning{ keys }still compiles.tigrisis the same S3 code asr2on another host (endpointoverrideshttps://fly.storage.tigris.dev), so everyMediaStoremethod works the same. Errors saymedia(tigris).listBuckets()is a separate function because a bucket list belongs to the account, not a bucket. For R2,jurisdictionpicks the host: an EU bucket only shows up on the EU host. It needs an account-level key — a bucket-scoped R2 token gets403 AccessDenied, and that is thrown, never returned as an empty list.- Measured 3/11 2026:
list()against the live EU bucketbroberg-call-recordingsreturned sizes, times and a cursor in the same order askeys. Not live-measured: a successfullistBuckets()(our key is bucket-scoped and got the 403 above) and Tigris (we hold no Tigris key) — both are tested against real S3 XML with a stubbed fetch.
Provisioning the bucket
This package consumes an existing bucket + S3 creds. To create an
EU-jurisdiction R2 bucket + scoped creds 100% programmatically (no dashboard),
use the fleet's dns-mcp R2Client / MCP tools (r2_create_bucket,
r2_create_scoped_token) — see the Cloudflare card on
discovery.broberg.ai.
EU jurisdiction is set at creation and is immutable.
Part of the broberg.ai shared inventory. Search
before you build: GET https://discovery.broberg.ai/api/search?q=storage.
Multipart upload — files too big for one request (0.6.0, F099.20)
For a 1 GB video the bytes should go from the client straight to the bucket, in parts, never through your server. r2 stores have store.multipart (volume does not — a client cannot PUT to a disk):
const { key, uploadId } = await media.multipart!.create("clips/film.mp4", { contentType: "video/mp4" });
const urls = await Promise.all([1, 2, 3].map((n) => media.multipart!.presignPart(key, uploadId, n, { expiresIn: 900 })));
// the client PUTs each part (≥ 5 MiB except the last) to its url and keeps the ETag header of each answer
await media.multipart!.complete(key, uploadId, [{ partNumber: 1, etag }, /* … ascending */]);
// or, on failure: await media.multipart!.abort(key, uploadId);
The server keeps the R2 keys; the client only sees short-lived presigned part addresses. For a browser client the bucket's CORS must allow PUT from the client's origin and expose ETag. complete treats a 200 that carries an <Error> as the failure it is.