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,
});
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:
| 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, 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/uploads.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.
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.
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.