--- title: "Storage" description: "Use Beignet's StoragePort for object storage, generated files, imports, exports, attachments, and public asset URLs." url: "https://www.beignetjs.com/storage" markdown_url: "https://www.beignetjs.com/markdown/storage.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "f27ffc691ada2988ce24a71558d3c529d53a9c1d" source_modified: true --- # Storage Storage is an application dependency behind `StoragePort`. Use it when a workflow needs to read or write files, exports, imports, attachments, generated documents, or uploaded objects without coupling use cases to S3, R2, GCS, Vercel Blob, or local disk. The boundary is intentionally small: application code talks to `ctx.ports.storage`; infra chooses the adapter. ## Setup Install the storage port and local provider: ```bash bun add @beignet/core @beignet/provider-storage-local ``` In the [Quickstart](https://www.beignetjs.com/markdown/getting-started.md) app, declare and defer the storage port, then add the local provider to the existing registry. If an upload generator already added this wiring, keep it and skip these patches: ```diff --- a/ports/index.ts +++ b/ports/index.ts @@ -1,1 +1,2 @@ +import type { StoragePort } from "@beignet/core/ports"; import type { ErrorReporterPort } from "@beignet/core/error-reporting"; @@ -1,1 +1,2 @@ export type AppPorts = { + storage: StoragePort; ``` ```diff --- a/infra/port-wiring.ts +++ b/infra/port-wiring.ts @@ -1,1 +1,1 @@ - deferred: ["auth", "idempotency", "logger", "todos", "uow"], + deferred: ["auth", "idempotency", "logger", "todos", "uow", "storage"], ``` ```diff --- a/server/providers.ts +++ b/server/providers.ts @@ -1,1 +1,2 @@ import "@beignet/core/server-only"; +import { createLocalStorageProvider } from "@beignet/provider-storage-local"; @@ -1,2 +1,3 @@ export const providers = [ createDevtoolsProvider(), + createLocalStorageProvider(), ``` Keep the existing providers; the storage provider supplies the new deferred port. Run `bun run typecheck` and `bun beignet doctor --strict` before restarting the app. To test a complete browser upload, follow [Uploads](https://www.beignetjs.com/markdown/uploads.md). Add optional local storage settings to `.env.local`: ```bash STORAGE_ROOT=storage/app STORAGE_PUBLIC_BASE_URL=/storage ``` `STORAGE_ROOT` defaults to `storage/app`. `STORAGE_PUBLIC_BASE_URL` is optional and may be an absolute URL or app-relative path. It only controls the URL returned by `publicUrl(...)`; when using local filesystem storage, add a storage route for that path. Local-storage mutation commits targeting the same root and key are serialized in invocation order within one Node.js process. The provider does not coordinate writers across processes or hosts; use shared object storage for horizontally scaled deployments. The provider stages an incoming body stream before committing it, so the stream may read the previous version of the same key without deadlocking. A successful `get(...)` holds a one-shot snapshot whose metadata and body stay on the same version across later overwrites. Same-key writes stage in invocation order, so a stalled stream does not make every queued body consume temporary disk space. ```typescript // app/storage/[...key]/route.ts import { createStorageRoute } from "@beignet/next"; import { getServer } from "@/server"; export const { GET, HEAD } = createStorageRoute( async () => (await getServer()).ports.storage, { basePath: "/storage", }, ); ``` The route serves public objects only. Missing objects, private objects, invalid keys, and paths outside `basePath` all return 404. Responses include `X-Content-Type-Options: nosniff`. The default `contentDisposition: "auto"` serves active public content types such as HTML, SVG, XML, and JavaScript as downloads while keeping ordinary assets inline. Override the route's `contentDisposition` or `headers` only when the app intentionally serves active public assets from that origin. Use the memory adapter in tests and pure in-memory examples: ```typescript // tests/storage.ts (excerpt) import { createMemoryStorage, definePorts } from "@beignet/core/ports"; export const testPorts = definePorts({ storage: createMemoryStorage(), }); ``` Production apps can swap in the S3-compatible provider or another app-owned storage provider. The application-facing API should stay `ctx.ports.storage` either way. ## Private downloads Keep private files behind an application route that checks the caller's current access. Store objects with `visibility: "private"`, resolve the file through an authorized application record, and call `createStorageDownloadResponse(...)` with that record's key. Do not accept an arbitrary storage key from the browser. The helper is available from `@beignet/next` and `@beignet/web`. It streams the file without buffering it, sets `Content-Disposition: attachment` and `Cache-Control: private, no-store`, and preserves Unicode download filenames. It returns `null` for a missing object so the route can use the app error catalog. Storage failures throw and follow the server's error pipeline. In a feature's route group, use a `handle` callback for the native response. This excerpt assumes your files feature defines `downloadFile`, a `FileNotFound` catalog error, and `getFileDownloadUseCase`. That use case must require a user, look up the file within their tenant, and authorize access before returning `{ storageKey, fileName }`. See [Authorization](https://www.beignetjs.com/markdown/authorization.md) for policy setup. Reuse the app's `auth` helper in `lib/route-auth.ts`, or create it with [HTTP boundary hooks](https://www.beignetjs.com/markdown/authentication.md#http-boundary-hooks) before wiring this route. ```typescript // features/files/routes.ts (excerpt) import { createStorageDownloadResponse } from "@beignet/next"; import { downloadFile } from "@/features/files/contracts"; import { getFileDownloadUseCase } from "@/features/files/use-cases"; import { appError } from "@/features/shared/errors"; import { auth } from "@/lib/route-auth"; import { defineRouteGroup } from "@/lib/routes"; export const fileRoutes = defineRouteGroup({ name: "files", routes: [{ contract: downloadFile, hooks: [auth.required()], handle: async ({ ctx, path }) => { const file = await getFileDownloadUseCase.run({ ctx, input: { id: path.id }, }); const response = await createStorageDownloadResponse({ storage: ctx.ports.storage, key: file.storageKey, fileName: file.fileName, }); if (!response) throw appError("FileNotFound"); return response; }, }], }); ``` Declare the contract's successful response as `.responses({ 200: null })` for this native file response, declare its catalog errors with `.errors(...)`, and register `fileRoutes` in `server/routes.ts`. For a Fetch app, change the helper import to `@beignet/web`; the feature structure and authorization stay the same. Use a browser download link to the authenticated route when you want the browser to save the file. `fileName` defaults to the key's last segment. Empty filenames, path separators, and control characters are rejected before storage is read. Normalize original upload names in the application before passing them as `fileName`, or omit it to use the stored key's basename. The response also sets `Content-Length`, `Last-Modified`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. Its content type comes from storage, falling back to `application/octet-stream`; stored public cache settings cannot override the private response's cache policy. For a separately registered HEAD handler, pass `method: "HEAD"` after the same authorization check. The helper returns headers without a body and releases the unread storage stream. The helper sends full responses; it does not implement range requests or conditional GET. `createStorageRoute(...)` continues to serve public objects only and is not a private-download endpoint. ### Temporary download URLs Use `storage.temporaryUrl(...)` when the browser should download directly from object storage. Authorize access before requesting the URL, as with streaming. Pass an explicit lifetime of 1 through 604800 seconds (seven days): ```typescript // Inside the authorized handler above, after resolving `file`: const result = await ctx.ports.storage.temporaryUrl(file.storageKey, { expiresInSeconds: 300, fileName: file.fileName, }); if (result.status === "ready") { return new Response(null, { status: 302, headers: { location: result.url, "cache-control": "private, no-store", "referrer-policy": "no-referrer", }, }); } if (result.status === "not_found") throw appError("FileNotFound"); // `unsupported`: continue with createStorageDownloadResponse(...) above. ``` If the route can redirect, include `302: null` alongside `200: null` in the contract's `.responses(...)` declaration. | Provider | `temporaryUrl(...)` behavior | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | S3-compatible storage | Checks object existence, then signs a GET URL with the download filename and private cache policy. Returns `ready` with `url` and an ISO `expiresAt`, or `not_found`. | | Local disk, Vercel Blob, memory | Returns `unsupported` without checking existence. Use the authorized streaming route. | Invalid options throw even when a provider does not support temporary URLs. Permission failures and provider outages throw; they are not missing-object or unsupported results. Custom `StoragePort` adapters must implement this method and explicitly return `unsupported` when they cannot issue temporary GET URLs. A signed URL grants anyone holding it access until its deadline, subject to provider permissions. It is reusable, does not recheck application membership, and addresses the key rather than an immutable file version. Use streaming when access must be checked on every request, and use unique keys when replacing files must not change what an existing link downloads. Do not log signed URLs. `expiresAt` is the requested signing deadline; temporary credentials, revoked credentials, and provider policy may end access earlier. On AWS S3, a download started before expiry can continue afterward. Keep the bucket private: Beignet's `visibility` metadata alone does not configure bucket access. See [AWS's presigned URL guidance](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). ## S3-compatible storage Use `@beignet/provider-storage-s3` when storage needs to survive deploys, work across multiple app instances, or run on infrastructure with ephemeral local disk. The provider works with AWS S3 and S3-compatible services such as Cloudflare R2, MinIO, Backblaze B2, and DigitalOcean Spaces. If you configured local storage above, remove the local provider entry and its import from `server/providers.ts` first, keeping the `storage` port and deferred key. Remove the unused `@beignet/provider-storage-local` dependency so doctor no longer expects its registration. The preset registers S3 and adds its SDK dependencies to `package.json`: ```bash bun remove @beignet/provider-storage-local bun beignet provider add storage-s3 bun install ``` ```typescript // server/providers.ts (excerpt) import { createS3StorageProvider } from "@beignet/provider-storage-s3"; export const providers = [createS3StorageProvider()]; ``` For AWS S3: ```bash STORAGE_S3_BUCKET=my-app-assets STORAGE_S3_REGION=us-east-1 STORAGE_S3_PUBLIC_BASE_URL=https://cdn.example.com ``` For Cloudflare R2: ```bash STORAGE_S3_BUCKET=my-app-assets STORAGE_S3_REGION=auto STORAGE_S3_ENDPOINT=https://.r2.cloudflarestorage.com STORAGE_S3_ACCESS_KEY_ID=... STORAGE_S3_SECRET_ACCESS_KEY=... STORAGE_S3_PUBLIC_BASE_URL=https://assets.example.com ``` `STORAGE_S3_KEY_PREFIX` can scope every object key for an app or environment. `STORAGE_S3_FORCE_PATH_STYLE=true` is available for S3-compatible services that need path-style bucket addressing. S3 operations are idempotent, so bounded retry is appropriate. Beignet does not wrap S3 calls in its own retry loop; when the provider creates the AWS SDK client, the SDK retries transient failures itself — `standard` retry mode with 3 attempts, including the first, by default. Tune that with `STORAGE_S3_MAX_ATTEMPTS` and `STORAGE_S3_RETRY_MODE` (`standard` or `adaptive`), or the matching `maxAttempts` and `retryMode` provider options. Choose `adaptive` when the app regularly hits S3 throttling. Injected clients keep their own retry configuration. The S3 provider stores Beignet visibility as reserved object metadata and does not set bucket ACLs. Configure bucket policies, public buckets, custom domains, or a CDN outside the provider when public objects should be reachable. The provider also installs `ctx.ports.s3Storage` as an [escape hatch](https://www.beignetjs.com/markdown/providers.md#escape-hatches) for S3-specific operations that do not belong in `StoragePort`. Use `ctx.ports.s3Storage.objectKey(key)` when a direct S3 call needs to address an object written through `ctx.ports.storage`; use `ctx.ports.s3Storage.objectPrefix(prefix)` for direct S3 list operations. Both helpers apply the configured `STORAGE_S3_KEY_PREFIX`. On AWS, grant `s3:ListBucket` on the bucket in addition to the object actions the app uses. Without it, S3 commonly returns `403 AccessDenied` rather than `404 NotFound` for an absent key, so `get`, `stat`, `exists`, `delete`, and `temporaryUrl` may throw instead of returning their missing-object result. Beignet does not map a generic 403 to “missing” because that would hide real credential or bucket policy failures. Scope object permissions to the configured key prefix where possible. ## Vercel Blob storage Use `@beignet/provider-storage-vercel-blob` on Vercel deployments. A connected Blob store can use `BLOB_READ_WRITE_TOKEN`, or Vercel OIDC with `VERCEL_OIDC_TOKEN` plus `BLOB_STORE_ID`: ```bash bun add @beignet/provider-storage-vercel-blob @vercel/blob@^2.8.0 # or scaffold everything: bun beignet provider add storage-vercel-blob ``` Use `@vercel/blob` 2.8.0 or newer within the v2 line. ```typescript import { createLocalStorageProvider } from "@beignet/provider-storage-local"; import { createVercelBlobStorageProvider } from "@beignet/provider-storage-vercel-blob"; const hasVercelBlobCredentials = Boolean(process.env.BLOB_READ_WRITE_TOKEN) || Boolean(process.env.BLOB_STORE_ID && process.env.VERCEL_OIDC_TOKEN); export const providers = [ // Vercel Blob in deployed environments (local disk does not survive // serverless); the local provider keeps dev working with zero setup. hasVercelBlobCredentials ? createVercelBlobStorageProvider() : createLocalStorageProvider(), ]; ``` The store is uniform-visibility: Vercel Blob does not report per-object access back from the API, so every object shares the configured `BLOB_ACCESS` (default `private`), and writes that request a different `visibility` are rejected instead of silently misreporting visibility on later reads. Serve private objects through app routes that authorize and then call `createStorageDownloadResponse(...)`; run a second provider over a second store when an app genuinely needs both levels. `ctx.ports.vercelBlob` is the [escape hatch](https://www.beignetjs.com/markdown/providers.md#escape-hatches) for raw SDK access, key-prefix helpers, and a health check. For Vercel OIDC authentication, set `BLOB_STORE_ID` or pass `storeId` directly to `createVercelBlobStorage(...)`; the Blob SDK reads `VERCEL_OIDC_TOKEN`. Beignet forwards the store id to every Blob SDK operation, including reads, writes, deletes, lists, and health checks; the escape hatch exposes the resolved value as `ctx.ports.vercelBlob.storeId`. Because either a read-write token or OIDC is valid, doctor, provider audit, and preflight accept either `BLOB_READ_WRITE_TOKEN` or the complete `BLOB_STORE_ID` plus `VERCEL_OIDC_TOKEN` pair. Run `beignet preflight --connect` to verify the selected credential path against the store. ## Port API `StoragePort` models object storage: ```typescript export interface StoragePort { put( key: string, body: StorageBody, options?: { contentType?: string; cacheControl?: string; metadata?: Record; visibility?: "private" | "public"; }, ): Promise; get(key: string): Promise; stat(key: string): Promise; delete(key: string): Promise; exists(key: string): Promise; publicUrl(key: string): Promise; temporaryUrl( key: string, options: StorageTemporaryUrlOptions, ): Promise; } ``` `StorageBody` accepts `string`, `Uint8Array`, `ArrayBuffer`, `Blob`, or a `ReadableStream`. `get(...)` returns object metadata plus helpers for reading the body as bytes, text, an array buffer, or a stream: The S3-compatible adapter sends an exact `Content-Length` for strings, byte arrays, and Blobs. Generic `ReadableStream` bodies remain streaming through the AWS SDK's managed uploader, which holds a bounded set of 5 MiB parts in memory, uses multipart upload for larger streams, and aborts failed multipart uploads. Grant `s3:AbortMultipartUpload` when the app accepts generic streams. Large browser files should still use the signed direct-upload workflow so their bytes do not pass through the application server. ```typescript export interface StorageObjectBody extends StorageObject { readonly bodyUsed: boolean; cancel(reason?: unknown): Promise; stream(): ReadableStream; bytes(): Promise; arrayBuffer(): Promise; text(): Promise; } ``` Object bodies are one-shot reads, similar to Fetch responses. Choose one read method per returned object. Call `get(...)` again if the workflow needs a fresh body. If a workflow inspects only metadata from `get(...)`, call `await object.cancel()` in `finally` to discard the unread body and release provider resources. Prefer `stat(...)` when no body is needed at all. Beignet's storage and upload route helpers release their own unread bodies automatically. ```typescript const object = await ctx.ports.storage.get("reports/latest.csv"); if (!object) throw new Error("Report not found"); try { return await object.text(); } finally { // Safe after consumption, and required when an earlier branch leaves the // body unread. await object.cancel(); } ``` ## Use storage in a workflow Keep storage keys predictable and make ownership explicit: ```typescript export async function exportProject(ctx: AppContext, projectId: string) { const project = await ctx.ports.projects.findById(projectId); const body = JSON.stringify(project, null, 2); const key = `projects/${projectId}/exports/latest.json`; const object = await ctx.ports.storage.put(key, body, { contentType: "application/json", cacheControl: "private, max-age=0", metadata: { projectId }, visibility: "private", }); return { key: object.key, size: object.size, }; } ``` For public assets, write with public visibility and ask the adapter for a URL: ```typescript await ctx.ports.storage.put("avatars/user_123.png", avatarBytes, { contentType: "image/png", visibility: "public", }); const url = await ctx.ports.storage.publicUrl("avatars/user_123.png"); ``` `publicUrl(...)` returns `null` when the object is missing, private, or the adapter does not expose public URLs. For local filesystem storage, `createStorageRoute(...)` streams public objects and preserves `Content-Type`, `Cache-Control`, `Content-Length`, and `Last-Modified` response headers. ## Key conventions Prefer keys that include the resource, owner, and purpose: ```typescript const avatarKey = `users/${userId}/avatar/original.png`; const importKey = `imports/${tenantId}/${importId}/source.csv`; const exportKey = `projects/${projectId}/exports/${exportId}.json`; ``` Keys must be relative object keys: no empty strings, control characters, empty path segments, leading or trailing `/`, backslashes, or `.` / `..` path segments. Avoid putting untrusted file names directly at the front of the key. Normalize names in infra or place them after an app-owned prefix so user input cannot escape the intended namespace. Custom adapters should reuse Beignet's shared storage-key helpers instead of creating provider-specific rules: ```typescript import { assertValidStorageKey, createStoragePublicUrl, normalizeStorageKeyPrefix, prefixStorageKey, } from "@beignet/core/ports"; ``` `assertValidStorageKey(...)` enforces the common contract. `normalizeStorageKeyPrefix(...)` and `prefixStorageKey(...)` apply an optional app or environment namespace, while `createStoragePublicUrl(...)` preserves path separators and encodes each key segment. Adapters may add restrictions for provider-owned internal paths after the shared assertion. ## Handling uploads Use [Uploads](https://www.beignetjs.com/markdown/uploads.md) for browser-upload workflows. Upload definitions own file constraints, metadata validation, authorization, key generation, direct upload signing, and completion hooks. They write accepted files through `StoragePort`, then let app-owned repositories persist attachment ownership, status, display names, scanning state, or moderation state. Use `ctx.ports.storage.put(...)` directly for app-generated files, imports, exports, and other workflows that already have trusted bytes inside the server. ## Testing Use `createMemoryStorage()` in use case tests: ```typescript import { createMemoryStorage } from "@beignet/core/ports"; const storage = createMemoryStorage(); await storage.put("reports/test.txt", "hello"); expect(await (await storage.get("reports/test.txt"))?.text()).toBe("hello"); ``` This keeps storage behavior testable without networked infrastructure. ## API reference - [`createVercelBlobStorageProvider`](https://www.beignetjs.com/typedoc/functions/_beignet_provider-storage-vercel-blob.createVercelBlobStorageProvider.html) ## Web Fetch hosts Use `createStorageRoute` from `@beignet/web` for Bun or another Fetch host. Mount the returned method handlers in the host entrypoint and reuse the app's assembled server or ports. Next apps import the corresponding helper from `@beignet/next`. See [operational HTTP handlers](https://www.beignetjs.com/markdown/server.md#operational-http-handlers) for mounting, context, and lifecycle behavior.