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.

Define an upload

Put feature-owned upload definitions under features/<feature>/uploads/:

// features/posts/uploads/attachment.ts
import { defineUpload } from "@beignet/core/uploads";
import { z } from "zod";
import type { AppContext } from "@/app-context";

const Metadata = z.object({
  postSlug: z.string().min(1),
});

export const PostAttachmentUpload = defineUpload<
  "posts.attachment",
  typeof Metadata,
  AppContext,
  { attachmentIds: string[] }
>("posts.attachment", {
  metadata: Metadata,
  file: {
    contentTypes: ["application/pdf", "text/plain"],
    maxSizeBytes: 5 * 1024 * 1024,
    maxFiles: 3,
    visibility: "private",
    cacheControl: "private, max-age=0",
  },
  authorize({ ctx }) {
    return ctx.actor.type === "user";
  },
  key({ ctx, metadata, uploadId, file }) {
    const tenantId = ctx.tenant?.id ?? "default";
    const extension = file.name.split(".").pop();
    return `posts/${tenantId}/${metadata.postSlug}/${uploadId}.${extension}`;
  },
  storageMetadata({ ctx, metadata }) {
    return {
      tenantId: ctx.tenant?.id ?? "default",
      postSlug: metadata.postSlug,
    };
  },
  async onComplete({ ctx, metadata, files }) {
    const attachments = await Promise.all(
      files.map((file) =>
        ctx.ports.postAttachments.create({
          id: file.uploadId,
          tenantId: ctx.tenant?.id ?? "default",
          postSlug: metadata.postSlug,
          key: file.key,
          fileName: file.name,
          contentType: file.contentType,
          size: file.object.size,
        }),
      ),
    );

    await ctx.ports.audit.record({
      action: "posts.attachment.upload",
      actor: ctx.actor,
      tenant: ctx.tenant,
      requestId: ctx.requestId,
      resource: { type: "post", id: metadata.postSlug },
      metadata: { attachmentCount: attachments.length },
    });

    return { attachmentIds: attachments.map((item) => item.id) };
  },
});

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.

Collect feature uploads in a registry:

// features/posts/uploads/index.ts
import { defineUploads } from "@beignet/core/uploads";
import { PostAttachmentUpload } from "./attachment";

export const postUploads = defineUploads({
  postAttachment: PostAttachmentUpload,
});

Names vs registry keys

Upload routes and clients resolve the defineUpload(...) name, such as "posts.attachment" — not the defineUploads({...}) registry key, such as postAttachment. 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.

Expose the route

Use a focused Next.js route for uploads:

// app/api/uploads/[uploadName]/[action]/route.ts
import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
import { createUploadRoute } from "@beignet/next";
import { postUploads } from "@/features/posts/uploads";
import { server } from "@/server";

const uploadRouter = createUploadRouter({
  uploads: uploadsFromRegistry(postUploads),
  ctx: () => server.createContextFromNext(),
  storage: server.ports.storage,
  instrumentation: server.ports.devtools,
});

export const { POST } = createUploadRoute(uploadRouter);

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, then run onComplete.

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/index.ts
import { createUploadClient } from "@beignet/core/uploads/client";
import type { postUploads } from "@/features/posts/uploads";

type AppUploads = typeof postUploads;

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

Upload by route name:

const result = await uploads.upload("posts.attachment", {
  metadata: { postSlug: "hello-world" },
  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
import { createUploadClient } from "@beignet/core/uploads/client";
import { createReactUploads } from "@beignet/react-uploads";
import type { postUploads } from "@/features/posts/uploads";

type AppUploads = typeof postUploads;

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

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

attachment.upload({
  metadata: { postSlug: "hello-world" },
  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:

import { createS3UploadSigner } from "@beignet/provider-storage-s3";

const uploadRouter = createUploadRouter({
  uploads: uploadsFromRegistry(postUploads),
  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.

Server uploads

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

await uploads.server("posts.attachment", {
  metadata: { postSlug: "hello-world" },
  files: [file],
});

The router parses metadata, validates file count, content type, and size, then writes accepted files through ctx.ports.storage.

Error codes

Upload routes return errors as a JSON envelope with an optional details value:

{
  "error": {
    "code": "INVALID_UPLOAD_METADATA",
    "message": "Invalid metadata for upload \"posts.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 (415), or completed-object verification failed (422).
UNAUTHORIZED_UPLOAD403The upload's authorize hook denied the request.
UPLOAD_OBJECT_NOT_FOUND404Completion could not find the direct-uploaded object in storage.

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:

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

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

const prepared = await router.prepare("posts.attachment", {
  metadata: { postSlug: "hello-world" },
  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. Model them as app-owned attachment status, jobs, events, or provider adapters that run after the object exists. The upload primitive keeps the boundary focused on validated object creation and completion hooks.