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 { createLocalStorageProvider } from "@beignet/provider-storage-local";

export const providers = [createLocalStorageProvider()];

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.

Local-storage mutation commits targeting the same root and key are serialized in invocation order within one Node.js process. The provider does not coordinate writers across processes or hosts; use shared object storage for horizontally scaled deployments.

The provider stages an incoming body stream before committing it, so the stream may read the previous version of the same key without deadlocking. A successful get(...) holds a one-shot snapshot whose metadata and body stay on the same version across later overwrites. Same-key writes stage in invocation order, so a stalled stream does not make every queued body consume temporary disk space.

// 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-s3 @aws-sdk/client-s3 @aws-sdk/lib-storage@3.1050.0 @aws-sdk/s3-request-presigner
import { 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.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.

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.

On AWS, grant s3:ListBucket on the bucket in addition to the object actions the app uses. Without it, S3 commonly returns 403 AccessDenied rather than 404 NotFound for an absent key, so get, stat, exists, and delete may throw instead of returning their missing-object result. Beignet does not map a generic 403 to “missing” because that would hide real credential or bucket policy failures. Scope object permissions to the configured key prefix where possible.

Vercel Blob storage

Use @beignet/provider-storage-vercel-blob on Vercel deployments. A connected Blob store can use BLOB_READ_WRITE_TOKEN, or Vercel OIDC with VERCEL_OIDC_TOKEN plus BLOB_STORE_ID:

bun add @beignet/provider-storage-vercel-blob @vercel/blob@^2.8.0
# or scaffold everything:
bun beignet provider add storage-vercel-blob

Use @vercel/blob 2.8.0 or newer within the v2 line.

import { createLocalStorageProvider } from "@beignet/provider-storage-local";
import { createVercelBlobStorageProvider } from "@beignet/provider-storage-vercel-blob";

const hasVercelBlobCredentials =
  Boolean(process.env.BLOB_READ_WRITE_TOKEN) ||
  Boolean(process.env.BLOB_STORE_ID && process.env.VERCEL_OIDC_TOKEN);

export const providers = [
  // Vercel Blob in deployed environments (local disk does not survive
  // serverless); the local provider keeps dev working with zero setup.
  hasVercelBlobCredentials
    ? 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.

For Vercel OIDC authentication, set BLOB_STORE_ID or pass storeId directly to createVercelBlobStorage(...); the Blob SDK reads VERCEL_OIDC_TOKEN. Beignet forwards the store id to every Blob SDK operation, including reads, writes, deletes, lists, and health checks; the escape hatch exposes the resolved value as ctx.ports.vercelBlob.storeId. Because either a read-write token or OIDC is valid, doctor, provider audit, and preflight accept either BLOB_READ_WRITE_TOKEN or the complete BLOB_STORE_ID plus VERCEL_OIDC_TOKEN pair. Run beignet preflight --connect to verify the selected credential path against the store.

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:

The S3-compatible adapter sends an exact Content-Length for strings, byte arrays, and Blobs. Generic ReadableStream bodies remain streaming through the AWS SDK's managed uploader, which holds a bounded set of 5 MiB parts in memory, uses multipart upload for larger streams, and aborts failed multipart uploads. Grant s3:AbortMultipartUpload when the app accepts generic streams. Large browser files should still use the signed direct-upload workflow so their bytes do not pass through the application server.

export interface StorageObjectBody extends StorageObject {
  readonly bodyUsed: boolean;
  cancel(reason?: unknown): Promise<void>;
  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. If a workflow inspects only metadata from get(...), call await object.cancel() in finally to discard the unread body and release provider resources. Prefer stat(...) when no body is needed at all. Beignet's storage and upload route helpers release their own unread bodies automatically.

const object = await ctx.ports.storage.get("reports/latest.csv");
if (!object) throw new Error("Report not found");

try {
  return await object.text();
} finally {
  // Safe after consumption, and required when an earlier branch leaves the
  // body unread.
  await object.cancel();
}

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, control characters, 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.

Custom adapters should reuse Beignet's shared storage-key helpers instead of creating provider-specific rules:

import {
  assertValidStorageKey,
  createStoragePublicUrl,
  normalizeStorageKeyPrefix,
  prefixStorageKey,
} from "@beignet/core/ports";

assertValidStorageKey(...) enforces the common contract. normalizeStorageKeyPrefix(...) and prefixStorageKey(...) apply an optional app or environment namespace, while createStoragePublicUrl(...) preserves path separators and encodes each key segment. Adapters may add restrictions for provider-owned internal paths after the shared assertion.

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.