Skip to content

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:

bash
bun add @beignet/core @beignet/provider-storage-local

In the Quickstart app, declare and defer the storage port, then add the local provider to the existing registry. If an upload generator already added this wiring, keep it and skip these patches:

ports/index.ts
infra/port-wiring.ts
server/providers.ts

Keep the existing providers; the storage provider supplies the new deferred port. Run bun run typecheck and bun beignet doctor --strict before restarting the app. To test a complete browser upload, follow Uploads.

Add optional local storage settings to .env.local:

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

typescript
// 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:

typescript
// tests/storage.ts (excerpt)
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.

Private downloads

Keep private files behind an application route that checks the caller's current access. Store objects with visibility: "private", resolve the file through an authorized application record, and call createStorageDownloadResponse(...) with that record's key. Do not accept an arbitrary storage key from the browser.

The helper is available from @beignet/next and @beignet/web. It streams the file without buffering it, sets Content-Disposition: attachment and Cache-Control: private, no-store, and preserves Unicode download filenames. It returns null for a missing object so the route can use the app error catalog. Storage failures throw and follow the server's error pipeline.

In a feature's route group, use a handle callback for the native response. This excerpt assumes your files feature defines downloadFile, a FileNotFound catalog error, and getFileDownloadUseCase. That use case must require a user, look up the file within their tenant, and authorize access before returning { storageKey, fileName }. See Authorization for policy setup. Reuse the app's auth helper in lib/route-auth.ts, or create it with HTTP boundary hooks before wiring this route.

typescript
// features/files/routes.ts (excerpt)
import { createStorageDownloadResponse } from "@beignet/next";
import { downloadFile } from "@/features/files/contracts";
import { getFileDownloadUseCase } from "@/features/files/use-cases";
import { appError } from "@/features/shared/errors";
import { auth } from "@/lib/route-auth";
import { defineRouteGroup } from "@/lib/routes";

export const fileRoutes = defineRouteGroup({
  name: "files",
  routes: [{
    contract: downloadFile,
    hooks: [auth.required()],
    handle: async ({ ctx, path }) => {
      const file = await getFileDownloadUseCase.run({
        ctx,
        input: { id: path.id },
      });
      const response = await createStorageDownloadResponse({
        storage: ctx.ports.storage,
        key: file.storageKey,
        fileName: file.fileName,
      });
      if (!response) throw appError("FileNotFound");
      return response;
    },
  }],
});

Declare the contract's successful response as .responses({ 200: null }) for this native file response, declare its catalog errors with .errors(...), and register fileRoutes in server/routes.ts. For a Fetch app, change the helper import to @beignet/web; the feature structure and authorization stay the same. Use a browser download link to the authenticated route when you want the browser to save the file.

fileName defaults to the key's last segment. Empty filenames, path separators, and control characters are rejected before storage is read. Normalize original upload names in the application before passing them as fileName, or omit it to use the stored key's basename. The response also sets Content-Length, Last-Modified, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer. Its content type comes from storage, falling back to application/octet-stream; stored public cache settings cannot override the private response's cache policy.

For a separately registered HEAD handler, pass method: "HEAD" after the same authorization check. The helper returns headers without a body and releases the unread storage stream. The helper sends full responses; it does not implement range requests or conditional GET. createStorageRoute(...) continues to serve public objects only and is not a private-download endpoint.

Temporary download URLs

Use storage.temporaryUrl(...) when the browser should download directly from object storage. Authorize access before requesting the URL, as with streaming. Pass an explicit lifetime of 1 through 604800 seconds (seven days):

typescript
// Inside the authorized handler above, after resolving `file`:
const result = await ctx.ports.storage.temporaryUrl(file.storageKey, {
  expiresInSeconds: 300,
  fileName: file.fileName,
});

if (result.status === "ready") {
  return new Response(null, {
    status: 302,
    headers: {
      location: result.url,
      "cache-control": "private, no-store",
      "referrer-policy": "no-referrer",
    },
  });
}
if (result.status === "not_found") throw appError("FileNotFound");
// `unsupported`: continue with createStorageDownloadResponse(...) above.

If the route can redirect, include 302: null alongside 200: null in the contract's .responses(...) declaration.

ProvidertemporaryUrl(...) behavior
S3-compatible storageChecks object existence, then signs a GET URL with the download filename and private cache policy. Returns ready with url and an ISO expiresAt, or not_found.
Local disk, Vercel Blob, memoryReturns unsupported without checking existence. Use the authorized streaming route.

Invalid options throw even when a provider does not support temporary URLs. Permission failures and provider outages throw; they are not missing-object or unsupported results. Custom StoragePort adapters must implement this method and explicitly return unsupported when they cannot issue temporary GET URLs.

A signed URL grants anyone holding it access until its deadline, subject to provider permissions. It is reusable, does not recheck application membership, and addresses the key rather than an immutable file version. Use streaming when access must be checked on every request, and use unique keys when replacing files must not change what an existing link downloads. Do not log signed URLs.

expiresAt is the requested signing deadline; temporary credentials, revoked credentials, and provider policy may end access earlier. On AWS S3, a download started before expiry can continue afterward. Keep the bucket private: Beignet's visibility metadata alone does not configure bucket access. See AWS's presigned URL guidance.

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.

If you configured local storage above, remove the local provider entry and its import from server/providers.ts first, keeping the storage port and deferred key. Remove the unused @beignet/provider-storage-local dependency so doctor no longer expects its registration. The preset registers S3 and adds its SDK dependencies to package.json:

bash
bun remove @beignet/provider-storage-local
bun beignet provider add storage-s3
bun install
typescript
// server/providers.ts (excerpt)
import { createS3StorageProvider } from "@beignet/provider-storage-s3";

export const providers = [createS3StorageProvider()];

For AWS S3:

bash
STORAGE_S3_BUCKET=my-app-assets
STORAGE_S3_REGION=us-east-1
STORAGE_S3_PUBLIC_BASE_URL=https://cdn.example.com

For Cloudflare R2:

bash
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, delete, and temporaryUrl 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:

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

typescript
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 call createStorageDownloadResponse(...); 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:

typescript
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>;
  temporaryUrl(
    key: string,
    options: StorageTemporaryUrlOptions,
  ): Promise<StorageTemporaryUrlResult>;
}

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.

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

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

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

API reference

Web Fetch hosts

Use createStorageRoute from @beignet/web for Bun or another Fetch host. Mount the returned method handlers in the host entrypoint and reuse the app's assembled server or ports. Next apps import the corresponding helper from @beignet/next. See operational HTTP handlers for mounting, context, and lifecycle behavior.