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-local
Use 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=/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.
// 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 an app-owned storage
provider later. 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-s3
import { 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.com
For 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.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.
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 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
Treat uploads as application workflows. The route that receives the file should
own request-specific concerns such as authentication, authorization, file size
limits, accepted content types, and domain metadata. Once the file is accepted,
write the object through ctx.ports.storage:
const acceptedAvatarTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
export async function saveAvatar(
ctx: AppContext,
userId: string,
file: File,
) {
if (!acceptedAvatarTypes.has(file.type)) {
throw appError("InvalidUpload", {
details: { contentType: file.type },
});
}
const key = `users/${userId}/avatar/original`;
const object = await ctx.ports.storage.put(key, file, {
contentType: file.type,
metadata: { userId },
visibility: "public",
});
const url = await ctx.ports.storage.publicUrl(object.key);
return {
key: object.key,
url,
};
}
The example assumes InvalidUpload is declared in your app error catalog and
on any route contract that can return it.
Keep durable application state, such as attachment ownership, upload status, display names, or moderation state, in your database. Storage metadata is best for object-store concerns and lightweight lookup hints, not as the source of truth for product behavior.
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.