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:
bun add @beignet/core @beignet/provider-storage-localIn the Quickstart 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:
1212import type { StoragePort } from "@beignet/core/ports";import type { ErrorReporterPort } from "@beignet/core/error-reporting";export type AppPorts = { storage: StoragePort;11 deferred: ["auth", "idempotency", "logger", "todos", "uow"], deferred: ["auth", "idempotency", "logger", "todos", "uow", "storage"],12123import "@beignet/core/server-only";import { createLocalStorageProvider } from "@beignet/provider-storage-local";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.
Add optional local storage settings to .env.local:
STORAGE_ROOT=storage/app
STORAGE_PUBLIC_BASE_URL=/storageSTORAGE_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.
// 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:
// 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 for
policy setup. Reuse the app's auth helper in lib/route-auth.ts, or create it
with HTTP boundary hooks before wiring
this route.
// 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):
// 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.
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:
bun remove @beignet/provider-storage-local
bun beignet provider add storage-s3
bun install// server/providers.ts (excerpt)
import { createS3StorageProvider } from "@beignet/provider-storage-s3";
export const providers = [createS3StorageProvider()];For AWS S3:
STORAGE_S3_BUCKET=my-app-assets
STORAGE_S3_REGION=us-east-1
STORAGE_S3_PUBLIC_BASE_URL=https://cdn.example.comFor Cloudflare R2:
STORAGE_S3_BUCKET=my-app-assets
STORAGE_S3_REGION=auto
STORAGE_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
STORAGE_S3_ACCESS_KEY_ID=...
STORAGE_S3_SECRET_ACCESS_KEY=...
STORAGE_S3_PUBLIC_BASE_URL=https://assets.example.comSTORAGE_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 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:
bun add @beignet/provider-storage-vercel-blob @vercel/blob@^2.8.0
# or scaffold everything:
bun beignet provider add storage-vercel-blobUse @vercel/blob 2.8.0 or newer within the v2 line.
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 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:
export interface StoragePort {
put(
key: string,
body: StorageBody,
options?: {
contentType?: string;
cacheControl?: string;
metadata?: Record<string, string>;
visibility?: "private" | "public";
},
): Promise<StorageObject>;
get(key: string): Promise<StorageObjectBody | null>;
stat(key: string): Promise<StorageObject | null>;
delete(key: string): Promise<boolean>;
exists(key: string): Promise<boolean>;
publicUrl(key: string): Promise<string | null>;
temporaryUrl(
key: string,
options: StorageTemporaryUrlOptions,
): Promise<StorageTemporaryUrlResult>;
}StorageBody accepts string, Uint8Array, ArrayBuffer, Blob, or a
ReadableStream<Uint8Array>. 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.
export interface StorageObjectBody extends StorageObject {
readonly bodyUsed: boolean;
cancel(reason?: unknown): Promise<void>;
stream(): ReadableStream<Uint8Array>;
bytes(): Promise<Uint8Array>;
arrayBuffer(): Promise<ArrayBuffer>;
text(): Promise<string>;
}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.
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:
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:
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:
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:
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 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:
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
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
for mounting, context, and lifecycle behavior.