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
Generate the backend definition, registry, route, local storage wiring, and a client-safe constraint manifest:
beignet make upload posts.attachmentIn a full-stack app, add --ui to generate the connected browser workflow as
well:
beignet make upload posts.attachment --uiThe UI option adds features/posts/client/attachment-upload.ts,
features/posts/components/attachment-uploader.tsx, and
features/posts/tests/attachment-uploader.test.tsx. The uploader derives its
file input hints from the same feature-root manifest used by the server
definition, and includes progress, cancellation, error, reset, and completion
states. API-only apps support the backend command but reject --ui before
writing files.
Define an upload
Create the app-bound defineUpload builder once in lib/uploads.ts with
createUploads<AppContext>() (see
app-bound builders), then put feature-owned
upload definitions under features/<feature>/uploads/:
// features/posts/uploads/attachment.ts
import { z } from "zod";
import { defineUpload } from "@/lib/uploads";
const Metadata = z.object({
postSlug: z.string().min(1),
});
export const PostAttachmentUpload = defineUpload("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 actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
const extension = file.name.split(".").pop();
return `posts/${tenantId}/${actorId}/${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.upsertByUploadId({
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.
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.
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 { getServer } from "@/server";
export const { POST } = createUploadRoute(async () => {
const server = await getServer();
return createUploadRouter({
uploads: uploadsFromRegistry(postUploads),
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:
| Action | Purpose |
|---|---|
prepare | Validate metadata and file intent, authorize the upload, compute keys, and return direct-upload instructions when a signer is configured. |
upload | Accept a server-handled multipart upload and write files through StoragePort. |
complete | Verify direct-uploaded objects exist and match the prepared file before running verifyFile and onComplete. |
Verification
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.
export const PostImageUpload = defineUpload("posts.image", {
metadata: Metadata,
file: {
contentTypes: ["image/png", "image/jpeg", "image/webp"],
maxSizeBytes: 5 * 1024 * 1024,
contentTypeVerification: "signature",
},
authorize({ ctx }) {
return ctx.actor.type === "user";
},
key({ ctx, metadata, uploadId }) {
const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
return `posts/${actorId}/${metadata.postSlug}/images/${uploadId}`;
},
});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.
export const PostAttachmentUpload = defineUpload("posts.attachment", {
metadata: Metadata,
file: {
contentTypes: ["application/pdf", "text/plain"],
maxSizeBytes: 5 * 1024 * 1024,
checksum: { algorithm: "sha256" },
},
authorize({ ctx }) {
return ctx.actor.type === "user";
},
key({ ctx, metadata, uploadId }) {
const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
return `posts/${actorId}/${metadata.postSlug}/attachments/${uploadId}`;
},
});Checksum-enabled browser clients need a client-safe manifest so Beignet can
know which uploads require a digest before prepare:
// client/upload-manifest.ts
import type { UploadManifestEntry } from "@beignet/core/uploads";
export const uploadManifest = [
{
name: "posts.attachment",
file: {
contentTypes: ["application/pdf", "text/plain"],
maxSizeBytes: 5 * 1024 * 1024,
checksum: { algorithm: "sha256" },
},
},
] satisfies UploadManifestEntry[];// client/index.ts
import { createUploadClient } from "@beignet/core/uploads/client";
import type { postUploads } from "@/features/posts/uploads";
import { uploadManifest } from "./upload-manifest";
type AppUploads = typeof postUploads;
export const uploads = createUploadClient<AppUploads>({
baseUrl: "/api/uploads",
manifest: uploadManifest,
});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.
export const PostAttachmentUpload = defineUpload("posts.attachment", {
metadata: Metadata,
file: {
contentTypes: ["application/pdf"],
maxSizeBytes: 5 * 1024 * 1024,
},
authorize({ ctx }) {
return ctx.actor.type === "user";
},
key({ ctx, metadata, uploadId }) {
const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
return `posts/${actorId}/${metadata.postSlug}/attachments/${uploadId}`;
},
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 },
};
},
async onComplete({ ctx, files }) {
await ctx.ports.postAttachments.upsertByUploadId({
id: files[0]!.uploadId,
key: files[0]!.key,
});
},
});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 { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
import { createUploadRoute } from "@beignet/next";
import { createS3UploadSigner } from "@beignet/provider-storage-s3";
import { postUploads } from "@/features/posts/uploads";
import { getServer } from "@/server";
export const { POST } = createUploadRoute(async () => {
const server = await getServer();
return 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. 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("posts.attachment", {
metadata: { postSlug: "hello-world" },
files: [file],
});The router parses metadata, validates file count, content type, size, supported
content signatures, and configured checksums, then writes accepted files 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.
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 \"posts.attachment\".",
"details": { "issues": [] }
}| Code | Status | Meaning |
|---|---|---|
UPLOAD_NOT_FOUND | 404 | No upload is registered under the requested defineUpload(...) name. The message lists the registered names. |
INVALID_UPLOAD_ACTION | 400 | The route action segment is not prepare, upload, or complete. |
INVALID_UPLOAD_BODY | 400 | The 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_METADATA | 422 | Metadata failed the upload's schema. details.issues carries the schema issues. |
INVALID_UPLOAD_FILE | 413, 415, or 422 | File count, size (413), content type/signature (415), checksum, scanner, or completed-object verification failed (422). |
UNAUTHORIZED_UPLOAD | 403 | The upload omitted authorization, or its authorize hook denied the request. |
UPLOAD_OBJECT_NOT_FOUND | 404 | Completion could not find the direct-uploaded object in storage. |
UPLOAD_BODY_TOO_LARGE | 413 | The 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:
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. Use verifyFile(...) to block completion synchronously when
that is appropriate. If verification rejects a server-handled multipart
upload, Beignet deletes all objects stored by that request before returning the
error; cleanup failures appear as upload.server.cleanup.failed
instrumentation. 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.