Uploads

Uploads are typed application workflows above StoragePort. Use them when a route needs file constraints, authorization, metadata validation, storage keys, and completion behavior in one predictable place.

StoragePort stores objects. Upload definitions decide who may upload, what files are accepted, where objects are written, and what app records or audit events are created after upload completion.

Generate an upload workflow

Start with the Todos app from Quickstart. Generate an upload backend and its connected React component with one command:

bun beignet make upload todos.attachment --ui

For an API-only app, omit --ui. Do not run both forms for the same upload. The command creates the definition, registry, route, local storage wiring, and features/todos/attachment-upload-manifest.ts. With --ui, it also adds the client helper, uploader component, and component test.

The generated client sends { resourceId } metadata and receives { objectKeys }. Keep those shapes while following this walkthrough.

Define an upload

Replace features/todos/uploads/attachment.ts with this complete file. It checks that the signed-in user owns the Todo before accepting an attachment. The shared manifest keeps the server constraints and generated file input in sync:

// features/todos/uploads/attachment.ts
import { z } from "zod";
import { attachmentUploadFile } from "../attachment-upload-manifest";
import { defineUpload } from "@/lib/uploads";

export const AttachmentUploadMetadataSchema = z.object({
  resourceId: z.string().min(1),
});

export type AttachmentUploadMetadata = z.infer<typeof AttachmentUploadMetadataSchema>;

export const AttachmentUpload = defineUpload("todos.attachment", {
  metadata: AttachmentUploadMetadataSchema,
  file: attachmentUploadFile,
  async authorize({ ctx, metadata }) {
    if (ctx.actor.type !== "user") return false;
    const todo = await ctx.ports.todos.findById(metadata.resourceId);
    return todo?.userId === ctx.actor.id;
  },
  key({ ctx, metadata, uploadId, file }) {
    const tenantId = ctx.tenant?.id ?? "default";
    const extension = file.name.includes(".") ? file.name.split(".").pop() : undefined;
    const suffix = extension ? `.${extension}` : "";
    return "todos/" + tenantId + "/" + metadata.resourceId + "/" + uploadId + suffix;
  },
  storageMetadata({ ctx, metadata }) {
    return {
      tenantId: ctx.tenant?.id ?? "default",
      resourceId: metadata.resourceId,
    };
  },
  async onComplete({ ctx, metadata, files }) {
    ctx.ports.logger.info("Upload completed", {
      uploadName: "todos.attachment",
      resourceId: metadata.resourceId,
      objectKeys: files.map((file) => file.key),
    });

    return {
      objectKeys: files.map((file) => file.key),
    };
  },
});

This workflow stores a private object and returns its key. It does not create an attachment table or a download screen. Add an app-owned repository and an authorized download route when your product needs persistent attachment links. Keep objectKeys in the result while the generated component consumes it.

The completion hook is where app-owned database records, audit entries, domain events, jobs, notifications, and scanning state belong. Beignet does not create a framework upload table.

Direct-upload completion is intentionally stateless: Beignet does not retain an issuance record or single-use marker between prepare and complete. Authorize every request, include the relevant actor, tenant, or resource owner in key(...), and make onComplete(...) idempotent by upload ID or object key. Back the app record with a unique constraint or upsert so replaying complete does not duplicate records or side effects. Apps that require single-use issuance or revocation should store that state in an app-owned table.

Uploads are protected by default. A definition must either provide authorize(...) or explicitly opt into public preparation with access: "public". Use public access only for workflows where anonymous callers are allowed to write the configured object keys.

The generator already collects feature uploads in this registry (excerpt):

// features/todos/uploads/index.ts (excerpt)
import { defineUploads } from "@beignet/core/uploads";
import { AttachmentUpload } from "./attachment";

export const todoUploads = defineUploads({
  attachment: AttachmentUpload,
});

Names vs registry keys

Upload routes and clients resolve the defineUpload(...) name, such as "todos.attachment" — not the defineUploads({...}) registry key, such as attachment. Registry keys only organize the registry object. Requesting an unknown name returns UPLOAD_NOT_FOUND with the registered names listed, and createUploadRouter(...) throws at construction when two definitions share the same name.

Verify the generated workflow

Render <AttachmentUploader resourceId={todo.id} /> from @/features/todos/components/attachment-uploader beside a Todo in your signed-in UI. Pass the ID of a Todo owned by that user.

bun run typecheck
bun run test
bun beignet doctor --strict
bun run dev

Choose a plain-text file under 5 MiB. Expect “Upload complete,” an Upload completed server log, and the returned object key in the component's onComplete callback. The file is stored under storage/app by default. Another account must receive UNAUTHORIZED_UPLOAD if it submits the owner's Todo ID. The shared manifest allows one file; edit that manifest, the component, and tests together if you want a different selection flow.

Expose the route

The generator creates this focused Next.js route. This excerpt shows one feature; keep other generated registry entries when your app has more uploads:

// app/api/uploads/[uploadName]/[action]/route.ts (excerpt)
import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
import { createUploadRoute } from "@beignet/next";
import { todoUploads } from "@/features/todos/uploads";
import { getServer } from "@/server";

export const { POST } = createUploadRoute(async () => {
  const server = await getServer();

  return createUploadRouter({
    uploads: uploadsFromRegistry(todoUploads),
    ctx: () => server.createContextFromNext(),
    storage: server.ports.storage,
    limits: {
      jsonMaxBytes: 256 * 1024,
      multipartMaxBytes: 25 * 1024 * 1024,
    },
    instrumentation: server.ports.devtools,
  });
});

The action segment is one of:

ActionPurpose
prepareValidate metadata and file intent, authorize the upload, compute keys, and return direct-upload instructions when a signer is configured.
uploadAccept a server-handled multipart upload and write files through StoragePort.
completeVerify direct-uploaded objects exist and match the prepared file before running verifyFile and onComplete.

Verification

The examples below are optional excerpts for the upload definition above. Keep constraints in features/todos/attachment-upload-manifest.ts and import the same values in the definition and browser client.

Upload definitions always validate file count, content type, size, and authorization before writing app records. For supported binary types, Beignet also checks the declared content type against the file signature instead of trusting only browser metadata. Signature verification currently covers PDF, ZIP, GIF, JPEG, PNG, SVG, and WebP. Set contentTypeVerification: false only when a workflow intentionally accepts one of those media types without matching bytes.

For an image-only workflow, change the shared manifest's contentTypes to ["image/png", "image/jpeg", "image/webp"]. Keep the definition's ownership check and completion result. The generated input reads its accept value from the manifest; update its descriptive text and test expectations too.

Direct uploads can require a browser-computed SHA-256 checksum. When a signer is configured and checksum.required is not false, prepare and complete require the checksum, and completion reads the stored object bytes before running onComplete.

To require a checksum for the attachment workflow, replace the shared manifest with this complete file. Both the definition and generated client already import it, so they will agree on the checksum requirement:

// features/todos/attachment-upload-manifest.ts
import type { UploadFileConstraints, UploadManifestEntry } from "@beignet/core/uploads";

export const attachmentUploadFile = {
  contentTypes: ["application/pdf", "text/plain"],
  maxSizeBytes: 5 * 1024 * 1024,
  maxFiles: 1,
  visibility: "private",
  cacheControl: "private, max-age=0",
  checksum: { algorithm: "sha256" },
} satisfies UploadFileConstraints;

export const attachmentUploadManifest = [
  {
    name: "todos.attachment",
    description: "Attachment upload",
    file: attachmentUploadFile,
  },
] satisfies readonly UploadManifestEntry[];

Use verifyFile(...) for app-owned scanning, moderation, or quarantine decisions that need the object to exist in storage before records are created. The hook runs after Beignet's built-in object verification and before onComplete. This excerpt assumes your app has already declared and wired a fileScanner port whose scanObject(key) method returns the result shown. Add this hook to the existing definition; keep its other options:

// features/todos/uploads/attachment.ts (excerpt)
async verifyFile({ ctx, file }) {
  const scan = await ctx.ports.fileScanner.scanObject(file.key);

  return scan.clean
    ? true
    : {
        valid: false,
        reason: "Upload did not pass scanning.",
        details: { scanner: scan.provider, finding: scan.finding },
      };
},

Use the upload client

Create a browser client typed by the upload registry. Import the registry as a type so client code does not bundle server-only upload hooks:

// client/uploads.ts (excerpt)
import { createUploadClient } from "@beignet/core/uploads/client";
import type { todoUploads } from "@/features/todos/uploads";
import { attachmentUploadManifest } from "@/features/todos/attachment-upload-manifest";

type AppUploads = typeof todoUploads;

export const uploads = createUploadClient<AppUploads>({
  baseUrl: "/api/uploads",
  manifest: attachmentUploadManifest,
});

Upload by route name:

const result = await uploads.upload("todos.attachment", {
  metadata: { resourceId: todo.id },
  files: [file],
  strategy: "auto",
  onProgress({ progress }) {
    console.log(Math.round(progress * 100));
  },
});

upload(...) uses direct upload instructions when the route returns them and falls back to server-handled multipart upload otherwise. Use direct(...) when direct upload is required, or server(...) when a form should always stream through the app server.

Progress reporting depends on the transport. Direct uploads report real per-file progress from the browser's XMLHttpRequest upload events. Server-handled uploads stream the whole multipart request through the app server and only report request completion, so onProgress fires once with progress: 1 when the request finishes.

React components

React apps can wrap the typed upload client with @beignet/react-uploads to track status, progress, errors, aborts, and completion results:

// client/uploads.ts (excerpt)
import { createUploadClient } from "@beignet/core/uploads/client";
import { createReactUploads } from "@beignet/react-uploads";
import type { todoUploads } from "@/features/todos/uploads";
import { attachmentUploadManifest } from "@/features/todos/attachment-upload-manifest";

type AppUploads = typeof todoUploads;

export const uploads = createUploadClient<AppUploads>({
  baseUrl: "/api/uploads",
  manifest: attachmentUploadManifest,
});

export const reactUploads = createReactUploads({
  uploads,
});
const attachment = reactUploads.useUpload("todos.attachment");

attachment.upload({
  metadata: { resourceId: todo.id },
  files,
});

upload(...) is fire-and-forget and never rejects; failures land in hook state and onError. Use uploadAsync(...) when the caller needs the completion result or a rejecting promise.

See React uploads for hook state and callback details.

Direct uploads

Direct uploads use an UploadSignerPort. The S3-compatible provider includes a signer for AWS S3, Cloudflare R2, MinIO, Spaces, and similar services. Follow S3-compatible storage first to install the provider and configure its bucket and credentials. This route uses the same settings from the generated lib/env.ts:

// app/api/uploads/[uploadName]/[action]/route.ts
import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
import { createUploadRoute } from "@beignet/next";
import { createS3UploadSigner } from "@beignet/provider-storage-s3";
import { env } from "@/lib/env";
import { todoUploads } from "@/features/todos/uploads";
import { getServer } from "@/server";

export const { POST } = createUploadRoute(async () => {
  const server = await getServer();

  return createUploadRouter({
    uploads: uploadsFromRegistry(todoUploads),
    ctx: () => server.createContextFromNext(),
    storage: server.ports.storage,
    signer: createS3UploadSigner({
      bucket: env.STORAGE_S3_BUCKET,
      region: env.STORAGE_S3_REGION,
      endpoint: env.STORAGE_S3_ENDPOINT,
      credentials: {
        accessKeyId: env.STORAGE_S3_ACCESS_KEY_ID,
        secretAccessKey: env.STORAGE_S3_SECRET_ACCESS_KEY,
      },
      keyPrefix: env.STORAGE_S3_KEY_PREFIX,
    }),
  });
});

The upload client handles the direct flow for browser code: it calls prepare, PUTs each file to the returned provider URL with the returned headers, then calls complete with the prepared file metadata. Completion re-checks the object key, content type, declared size, maxSizeBytes, supported content signatures, and configured checksums before running verifyFile and onComplete.

The upload router limits prepare and complete JSON bodies to 256 KiB by default. Override limits.jsonMaxBytes if metadata is larger.

Server uploads

Server uploads use multipart/form-data and are useful for small forms, local development, and tests:

await uploads.server("todos.attachment", {
  metadata: { resourceId: todo.id },
  files: [file],
});

The router parses metadata and validates file count, content type, and size, then authorizes each file before reading its bytes for supported content signatures or configured checksums. Accepted files are written through ctx.ports.storage. Server-handled multipart uploads reject declared Content-Length values over limits.multipartMaxBytes, then enforce the same limit while reading the actual request stream before calling formData(). The limit therefore applies to chunked requests and requests without a length header. The default is 25 MiB. If key derivation, storage, or verification fails before app-owned completion begins, the router deletes every object already written by that request before returning the original error.

Error codes

On the server, upload failures throw condition-specific subclasses of UploadError — UploadNotFoundError, InvalidUploadActionError, InvalidUploadMetadataError, InvalidUploadFileError, UnauthorizedUploadError, UploadObjectNotFoundError, UploadBodyTooLargeError, and InvalidUploadBodyError — each carrying its code literal and HTTP status. Catch the base UploadError for blanket handling or a subclass for one condition; UploadError itself is not constructed directly.

Upload routes use the standard flat Beignet error body with an optional details value:

{
  "code": "INVALID_UPLOAD_METADATA",
  "message": "Invalid metadata for upload \"todos.attachment\".",
  "details": { "issues": [] }
}
CodeStatusMeaning
UPLOAD_NOT_FOUND404No upload is registered under the requested defineUpload(...) name. The message lists the registered names.
INVALID_UPLOAD_ACTION400The route action segment is not prepare, upload, or complete.
INVALID_UPLOAD_BODY400The request body is not valid JSON, is missing a files array, contains non-object file entries, omits uploadId or key on completed files, or a multipart upload has no file field. Shape problems include details.issues.
INVALID_UPLOAD_METADATA422Metadata failed the upload's schema. details.issues carries the schema issues.
INVALID_UPLOAD_FILE413, 415, or 422File count, size (413), content type/signature (415), checksum, scanner, or completed-object verification failed (422).
UNAUTHORIZED_UPLOAD403The upload omitted authorization, or its authorize hook denied the request.
UPLOAD_OBJECT_NOT_FOUND404Completion could not find the direct-uploaded object in storage.
UPLOAD_BODY_TOO_LARGE413The upload route body exceeded the configured JSON or multipart body limit.

The typed upload client and @beignet/react-uploads surface these as UploadClientError values with the same code, status, and details.

Testing

Use memory storage and the memory signer in tests. This excerpt assumes ctx contains a user actor and a todos repository with a Todo owned by that user:

import {
  createMemoryUploadSigner,
  createUploadRouter,
  uploadsFromRegistry,
} from "@beignet/core/uploads";
import { createMemoryStorage } from "@beignet/core/ports";
import { todoUploads } from "@/features/todos/uploads";

const router = createUploadRouter({
  uploads: uploadsFromRegistry(todoUploads),
  ctx,
  storage: createMemoryStorage(),
  signer: createMemoryUploadSigner(),
  id: () => "upload_1",
});

const prepared = await router.prepare("todos.attachment", {
  metadata: { resourceId: todo.id },
  files: [{ name: "note.txt", contentType: "text/plain", size: 5 }],
});

Use app-owned fake repositories to assert attachment rows, audit entries, and events created by onComplete.

Scanning and quarantine

Virus scanning, malware detection, moderation, and quarantine are app or provider concerns. Use verifyFile(...) to block completion synchronously when that is appropriate. Before onComplete(...) begins, a failed server-handled multipart batch deletes all objects stored earlier by that request; cleanup failures appear as upload.server.cleanup.failed instrumentation without masking the original error. Once onComplete(...) begins, the app may have persisted durable references, so Beignet leaves the objects app-owned and the app owns transaction or compensation for a later completion failure. Direct-upload completion does not delete pre-existing objects, so lifecycle expiration remains necessary for abandoned or rejected direct uploads. For asynchronous scanners, create an attachment row with a pending or quarantined status in onComplete, dispatch a job, and publish the object only after the app-owned scanner marks it clean.