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 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-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 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.