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 --uiFor 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 devChoose 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:
| 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
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": [] }
}| 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. 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.