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 { localStorageProvider } from "@beignet/provider-storage-local";
export const providers = [localStorageProvider];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 { server } from "@/server";
export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
basePath: "/storage",
});The route serves public objects only. Missing objects, private objects, invalid
keys, and paths outside basePath all return 404.
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 { s3StorageProvider } from "@beignet/provider-storage-s3";
export const providers = [s3StorageProvider];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.
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.
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.