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-localUse the local filesystem provider in development:
import { createLocalStorageProvider } from "@beignet/provider-storage-local";
export const providers = [createLocalStorageProvider()];The provider reads STORAGE_ config:
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.
// 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:
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.
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.
bun add @beignet/provider-storage-s3import { 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.
Vercel Blob storage
Use @beignet/provider-storage-vercel-blob on Vercel deployments, where a
connected Blob store provisions BLOB_READ_WRITE_TOKEN automatically:
bun add @beignet/provider-storage-vercel-blob @vercel/blob
# or scaffold everything:
bun beignet providers add vercel-blob-storageimport { createLocalStorageProvider } from "@beignet/provider-storage-local";
import { createVercelBlobStorageProvider } from "@beignet/provider-storage-vercel-blob";
export const providers = [
// Vercel Blob in deployed environments (local disk does not survive
// serverless); the local provider keeps dev working with zero setup.
process.env.BLOB_READ_WRITE_TOKEN
? 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 stream
ctx.ports.storage.get(...); 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.
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>;
}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:
export interface StorageObjectBody extends StorageObject {
readonly bodyUsed: boolean;
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.
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, 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.
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.