@broberg/media

中文文档
0.7.0发布于 2026/11/03MITTypeScriptESM + CJS构建来源已验证近 30 天下载 3203

The fleet's provider-agnostic media-storage facade — one createMedia() API (upload · get · signedUrl · delete · publicUrl) over swappable storage providers, so a later move between providers never touches a call-site. Ships a Cloudflare R2 provider (S3-co

原始使用文档
文档目录8 节
  1. Usage
  2. API
  3. Public URLs (v0.2.0)
  4. Attachments on a message (v0.4.0, F006.7)
  5. List and delete a folder (v0.5.0, F006.8)
  6. Size, time, Tigris and bucket lists (0.7.0, F059.4)
  7. Provisioning the bucket
  8. Multipart upload — files too big for one request (0.6.0, F0…

@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

MethodReturnsNotes
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)voididempotent (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 into signedUrl/delete/publicUrl is symmetric. Only observable when you use keyPrefix (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/http contentDisposition, so faktura-æøå.pdf keeps 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 under kunder/1/, never kunder/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); cursor is set only when there is more.
  • deletePrefix("") is refused: it would empty the store. If a delete reports success but the object stays, deletePrefix stops 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 }]
  • objects sits beside keys with size (bytes) and lastModified (ISO, UTC). Every built-in provider fills it; it is typed optional only so a hand-written test double returning { keys } still compiles.
  • tigris is the same S3 code as r2 on another host (endpoint overrides https://fly.storage.tigris.dev), so every MediaStore method works the same. Errors say media(tigris).
  • listBuckets() is a separate function because a bucket list belongs to the account, not a bucket. For R2, jurisdiction picks the host: an EU bucket only shows up on the EU host. It needs an account-level key — a bucket-scoped R2 token gets 403 AccessDenied, and that is thrown, never returned as an empty list.
  • Measured 3/11 2026: list() against the live EU bucket broberg-call-recordings returned sizes, times and a cursor in the same order as keys. Not live-measured: a successful listBuckets() (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.

版本与发布记录

@broberg/media 共发布 9 个版本,首次发布于 2026/06/15;过去 12 个月发布了 9 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.7.0latest2026/11/035 天前
0.6.02026/10/1425 天前
0.5.02026/10/1227 天前
0.4.02026/10/031 个月前
0.3.12026/09/221 个月前
0.3.02026/09/171 个月前

关于 @broberg/media 的常见问题

@broberg/media 是什么?

The fleet's provider-agnostic media-storage facade — one createMedia() API (upload · get · signedUrl · delete · publicUrl) over swappable storage providers, so a later move between providers never touches a call-site. Ships a Cloudflare R2 provider (S3-co

如何安装 @broberg/media?

在项目目录运行 npm install @broberg/media,或使用 pnpm add / yarn add / bun add。

@broberg/media 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@broberg/media 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@broberg/media 可以商用吗?

@broberg/media 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@broberg/media 的下载量有多少?

根据 npm 官方下载统计,@broberg/media 在 2026/10/09 – 2026/11/07 这 30 天内被下载 3,203 次,日均约 107 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@broberg/media 还在维护吗?

最新版本 0.7.0 发布于 2026/11/03(5 天前);至今共发布 9 个版本,其中过去 12 个月发布了 9 个;首次发布于 2026/06/15。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。