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.