@beignet/core
Version:
Core framework primitives for Beignet
2,142 lines • 57.7 kB
text/typescript
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type {
StorageMetadata,
StorageObject,
StorageObjectBody,
StoragePort,
} from "../ports/index.js";
import type { ProviderInstrumentationTarget } from "../providers/index.js";
import { createProviderInstrumentation } from "../providers/index.js";
/**
* Any Standard Schema compatible validator.
*/
export type StandardSchema = StandardSchemaV1<unknown, unknown>;
/**
* Value or promise of that value.
*/
export type MaybePromise<T> = T | Promise<T>;
/**
* Infer the parsed output type from a Standard Schema.
*/
export type InferSchemaOutput<T extends StandardSchemaV1> =
StandardSchemaV1.InferOutput<T>;
/**
* File metadata declared by a browser before an upload starts.
*/
export interface UploadFileIntent {
/**
* Original file name supplied by the client.
*/
name: string;
/**
* MIME content type supplied by the client.
*/
contentType: string;
/**
* File size in bytes.
*/
size: number;
/**
* Optional expected checksum for this file. Direct uploads use this to
* verify the object bytes during completion.
*/
checksum?: UploadFileChecksum;
}
/**
* Checksum algorithms supported by Beignet upload verification.
*/
export type UploadChecksumAlgorithm = "sha256";
/**
* Expected or computed file checksum.
*/
export interface UploadFileChecksum {
/**
* Hash algorithm used for the checksum.
*/
algorithm: UploadChecksumAlgorithm;
/**
* Lowercase hex digest.
*/
value: string;
}
/**
* Checksum verification requested by an upload definition.
*/
export interface UploadChecksumRequirement {
/**
* Hash algorithm to compute.
*/
algorithm: UploadChecksumAlgorithm;
/**
* Whether direct upload preparation/completion must include a checksum.
*
* @default true
*/
required?: boolean;
}
/**
* Constraints for files accepted by an upload definition.
*/
export interface UploadFileConstraints {
/**
* Accepted MIME content types. Omit to accept any content type.
*/
contentTypes?: readonly string[];
/**
* Maximum accepted file size in bytes.
*/
maxSizeBytes?: number;
/**
* Maximum files accepted by one request.
*
* @default 1
*/
maxFiles?: number;
/**
* Object visibility written to storage.
*
* @default "private"
*/
visibility?: "private" | "public";
/**
* Cache-Control value written to storage.
*/
cacheControl?: string;
/**
* Verify supported MIME types against file signatures instead of trusting
* only the client-declared content type.
*
* @default "signature"
*/
contentTypeVerification?: "signature" | false;
/**
* Request checksum verification for direct uploads and expose the requirement
* through the upload manifest so browser clients can send expected digests.
*/
checksum?: UploadChecksumRequirement;
}
/**
* Parsed file intent with a generated upload id and storage key.
*/
export interface PreparedUploadFile extends UploadFileIntent {
/**
* Stable upload id used to correlate prepare, direct upload, and complete.
*/
uploadId: string;
/**
* Storage key that should receive the file.
*/
key: string;
}
/**
* Direct upload instruction returned by an upload signer.
*/
export interface DirectUploadInstruction {
method: "PUT";
url: string;
headers?: Record<string, string>;
expiresAt: string;
}
/**
* Arguments passed to an upload signer.
*/
export interface SignUploadArgs {
uploadName: string;
uploadId: string;
key: string;
file: UploadFileIntent;
metadata: unknown;
storage: {
visibility: "private" | "public";
cacheControl?: string;
metadata: StorageMetadata;
};
}
/**
* Port for providers that can create direct-upload instructions.
*/
export interface UploadSignerPort {
/**
* Create direct upload instructions for one prepared file.
*/
sign(args: SignUploadArgs): MaybePromise<DirectUploadInstruction>;
}
/**
* Prepared upload file returned to clients.
*/
export interface PreparedUploadResultFile extends PreparedUploadFile {
/**
* Direct upload instruction when a signer is configured.
*/
direct?: DirectUploadInstruction;
}
/**
* Result returned by `prepare(...)`.
*/
export interface PrepareUploadResult {
uploadName: string;
mode: "direct" | "server";
files: PreparedUploadResultFile[];
}
/**
* File object completed by direct upload or server upload.
*/
export interface CompletedUploadFile extends PreparedUploadFile {
object: StorageObject;
}
/**
* Result returned by upload completion.
*/
export interface CompleteUploadResult<Result = unknown> {
uploadName: string;
files: CompletedUploadFile[];
result: Result;
}
/**
* Input for `prepare(...)`.
*/
export interface PrepareUploadInput {
metadata: unknown;
files: readonly UploadFileIntent[];
}
/**
* Input for `complete(...)`.
*/
export interface CompleteUploadInput {
metadata: unknown;
files: readonly PreparedUploadFile[];
}
/**
* Input for server-handled multipart uploads.
*/
export interface ServerUploadInput {
formData: FormData;
}
/**
* Authorization result accepted by upload definitions.
*/
export type UploadAuthorizeResult =
| boolean
| undefined
| {
allowed: boolean;
reason?: string;
};
/**
* Upload access mode.
*
* Protected uploads require an `authorize(...)` hook. Public uploads are
* intentionally reachable without that hook.
*/
export type UploadAccess = "protected" | "public";
/**
* Arguments passed to upload definition hooks.
*/
export interface UploadHookArgs<Metadata, Ctx> {
ctx: Ctx;
metadata: Metadata;
}
/**
* Arguments passed to file-specific upload hooks.
*/
export interface UploadFileHookArgs<Metadata, Ctx>
extends UploadHookArgs<Metadata, Ctx> {
file: UploadFileIntent;
uploadId: string;
}
/**
* Arguments passed to `onComplete(...)`.
*/
export interface UploadCompleteHookArgs<Metadata, Ctx>
extends UploadHookArgs<Metadata, Ctx> {
files: CompletedUploadFile[];
}
/**
* Arguments passed to `verifyFile(...)` after a file exists in storage and
* before `onComplete(...)` runs.
*/
export interface UploadVerifyFileHookArgs<Metadata, Ctx>
extends UploadHookArgs<Metadata, Ctx> {
file: CompletedUploadFile;
storage: StoragePort;
}
/**
* Verification result accepted by `verifyFile(...)`.
*/
export type UploadFileVerificationResult =
| boolean
| undefined
| {
valid: boolean;
reason?: string;
details?: unknown;
};
/**
* Upload definition options.
*/
export interface DefineUploadOptions<
MetadataSchema extends StandardSchema,
Ctx,
Result,
> {
/**
* Metadata schema submitted with prepare, server upload, and complete calls.
*/
metadata: MetadataSchema;
/**
* File constraints for this upload workflow.
*/
file: UploadFileConstraints;
/**
* Whether this upload may be prepared without an authorize hook.
*
* @default "protected"
*/
access?: UploadAccess;
/**
* Optional human-readable description for docs and tooling.
*/
description?: string;
/**
* Check whether the current actor may start this upload.
*/
authorize?(
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<UploadAuthorizeResult>;
/**
* Build the storage key for one file.
*/
key(
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<string>;
/**
* Add storage metadata for one file.
*/
storageMetadata?(
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<StorageMetadata>;
/**
* Run after a file exists in storage and before `onComplete(...)`. Use this
* for app-owned scanning, moderation, or quarantine decisions.
*/
verifyFile?(
args: UploadVerifyFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<UploadFileVerificationResult>;
/**
* Run after files exist in storage.
*/
onComplete?(
args: UploadCompleteHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<Result>;
}
/**
* Upload definition created by `defineUpload(...)`.
*/
export interface UploadDef<
Name extends string = string,
MetadataSchema extends StandardSchema = StandardSchema,
Ctx = unknown,
Result = unknown,
> {
readonly kind: "upload";
readonly name: Name;
readonly metadata: MetadataSchema;
readonly file: UploadFileConstraints;
readonly access?: UploadAccess;
readonly description?: string;
authorize?(
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<UploadAuthorizeResult>;
key(
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<string>;
storageMetadata?(
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<StorageMetadata>;
verifyFile?(
args: UploadVerifyFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<UploadFileVerificationResult>;
onComplete?(
args: UploadCompleteHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
): MaybePromise<Result>;
}
/**
* Infer the parsed metadata type for an upload definition.
*/
export type InferUploadMetadata<U extends UploadDef> =
U["metadata"] extends StandardSchemaV1<unknown, infer Output>
? Output
: never;
/**
* Infer the result returned by an upload definition's completion hook.
*/
export type InferUploadResult<U extends UploadDef> =
U extends UploadDef<string, StandardSchema, unknown, infer Result>
? Result
: unknown;
/**
* Nested upload registry shape used by server registration and typed clients.
*/
export interface UploadRegistry {
/**
* Upload definition or nested upload registry.
*/
readonly [key: string]: UploadDef | UploadRegistry;
}
/**
* Infer every upload definition contained in a nested upload registry.
*/
export type UploadFromRegistry<Registry> = Registry extends UploadDef
? Registry
: Registry extends readonly (infer Upload)[]
? Upload extends UploadDef
? Upload
: never
: Registry extends object
? {
[Key in keyof Registry]: UploadFromRegistry<Registry[Key]>;
}[keyof Registry]
: never;
/**
* Client-safe upload metadata generated from server upload definitions.
*/
export interface UploadManifestEntry {
/**
* Upload route name.
*/
name: string;
/**
* Optional human-readable upload description.
*/
description?: string;
/**
* File constraints safe to expose to browser UI code.
*/
file: UploadFileConstraints;
}
/**
* Request body limits for upload route actions.
*/
export interface UploadRequestLimits {
/**
* Maximum JSON body size for prepare and complete actions.
*
* @default 262144
*/
jsonMaxBytes?: number;
/**
* Maximum multipart body size for server-handled upload actions when
* Content-Length is present. File constraints still enforce per-file limits.
*
* @default 26214400
*/
multipartMaxBytes?: number;
}
/**
* Options for `createUploadRouter(...)`.
*/
export interface CreateUploadRouterOptions<Ctx> {
/**
* Upload definitions registered with this router.
*/
uploads: readonly UploadDef<string, StandardSchema, Ctx, unknown>[];
/**
* Request context value or lazy context factory.
*/
ctx: Ctx | (() => MaybePromise<Ctx>);
/**
* Storage port used for server uploads and direct upload completion checks.
*/
storage: StoragePort;
/**
* Optional signer used to prepare direct upload instructions.
*/
signer?: UploadSignerPort;
/**
* Request body limits for upload actions.
*/
limits?: UploadRequestLimits;
/**
* Optional instrumentation target used by devtools/provider watchers.
*/
instrumentation?: ProviderInstrumentationTarget;
/**
* Optional upload id generator for tests or custom id policies.
*/
id?: () => string;
}
/**
* Framework-neutral upload router.
*/
export interface UploadRouter {
prepare(
uploadName: string,
input: PrepareUploadInput,
): Promise<PrepareUploadResult>;
complete(
uploadName: string,
input: CompleteUploadInput,
): Promise<CompleteUploadResult>;
upload(
uploadName: string,
input: ServerUploadInput,
): Promise<CompleteUploadResult>;
handleRequest(
request: Request,
options: { uploadName: string; action: "prepare" | "complete" | "upload" },
): Promise<Response>;
}
/**
* Machine-readable upload error codes.
*/
export type UploadErrorCode =
| "UPLOAD_NOT_FOUND"
| "INVALID_UPLOAD_ACTION"
| "INVALID_UPLOAD_METADATA"
| "INVALID_UPLOAD_FILE"
| "UNAUTHORIZED_UPLOAD"
| "UPLOAD_OBJECT_NOT_FOUND"
| "UPLOAD_BODY_TOO_LARGE"
| "INVALID_UPLOAD_BODY";
/**
* Base error for expected upload failures.
*
* Upload workflows throw one of the specific subclasses, such as
* {@link UploadNotFoundError} or {@link UnauthorizedUploadError}. Catch this
* base class to handle any expected upload failure; the upload router maps it
* to an HTTP response using `code`, `status`, and `details`.
*/
export class UploadError extends Error {
readonly code: UploadErrorCode;
readonly status: number;
readonly details?: unknown;
protected constructor(args: {
code: UploadErrorCode;
message: string;
status?: number;
details?: unknown;
}) {
super(args.message);
this.name = "UploadError";
this.code = args.code;
this.status = args.status ?? 400;
this.details = args.details;
}
}
/**
* Error thrown when an upload name is not registered on the router.
*/
export class UploadNotFoundError extends UploadError {
declare readonly code: "UPLOAD_NOT_FOUND";
constructor(args: { message: string; details?: unknown }) {
super({ code: "UPLOAD_NOT_FOUND", status: 404, ...args });
this.name = "UploadNotFoundError";
}
}
/**
* Error thrown when an upload request targets an unknown upload action.
*/
export class InvalidUploadActionError extends UploadError {
declare readonly code: "INVALID_UPLOAD_ACTION";
constructor(args: { message: string; details?: unknown }) {
super({ code: "INVALID_UPLOAD_ACTION", status: 400, ...args });
this.name = "InvalidUploadActionError";
}
}
/**
* Error thrown when upload metadata fails schema validation.
*/
export class InvalidUploadMetadataError extends UploadError {
declare readonly code: "INVALID_UPLOAD_METADATA";
constructor(args: { message: string; details?: unknown }) {
super({ code: "INVALID_UPLOAD_METADATA", status: 422, ...args });
this.name = "InvalidUploadMetadataError";
}
}
/**
* Error thrown when a file fails upload validation, such as file count,
* content type (415), size limits (413), or prepared-file mismatches.
*/
export class InvalidUploadFileError extends UploadError {
declare readonly code: "INVALID_UPLOAD_FILE";
constructor(args: { message: string; details?: unknown; status?: number }) {
super({
code: "INVALID_UPLOAD_FILE",
message: args.message,
status: args.status ?? 422,
details: args.details,
});
this.name = "InvalidUploadFileError";
}
}
/**
* Error thrown when an upload is denied by authorize(...) or lacks
* authorization configuration.
*/
export class UnauthorizedUploadError extends UploadError {
declare readonly code: "UNAUTHORIZED_UPLOAD";
constructor(args: { message: string; details?: unknown }) {
super({ code: "UNAUTHORIZED_UPLOAD", status: 403, ...args });
this.name = "UnauthorizedUploadError";
}
}
/**
* Error thrown when a completed upload references a stored object that does
* not exist.
*/
export class UploadObjectNotFoundError extends UploadError {
declare readonly code: "UPLOAD_OBJECT_NOT_FOUND";
constructor(args: { message: string; details?: unknown }) {
super({ code: "UPLOAD_OBJECT_NOT_FOUND", status: 404, ...args });
this.name = "UploadObjectNotFoundError";
}
}
/**
* Error thrown when an upload request body exceeds the configured size limit.
*/
export class UploadBodyTooLargeError extends UploadError {
declare readonly code: "UPLOAD_BODY_TOO_LARGE";
constructor(args: { message: string; details?: unknown }) {
super({ code: "UPLOAD_BODY_TOO_LARGE", status: 413, ...args });
this.name = "UploadBodyTooLargeError";
}
}
/**
* Error thrown when an upload request body cannot be parsed or has an
* invalid shape.
*/
export class InvalidUploadBodyError extends UploadError {
declare readonly code: "INVALID_UPLOAD_BODY";
constructor(args: { message: string; details?: unknown }) {
super({ code: "INVALID_UPLOAD_BODY", status: 400, ...args });
this.name = "InvalidUploadBodyError";
}
}
/**
* Define a typed upload workflow.
*/
export function defineUpload<
const Name extends string,
MetadataSchema extends StandardSchema,
Ctx = unknown,
Result = unknown,
>(
name: Name,
options: DefineUploadOptions<MetadataSchema, Ctx, Result>,
): UploadDef<Name, MetadataSchema, Ctx, Result> {
return {
kind: "upload",
name,
metadata: options.metadata,
file: {
...options.file,
maxFiles: options.file.maxFiles ?? 1,
visibility: options.file.visibility ?? "private",
},
access: options.access ?? "protected",
...(options.description !== undefined
? { description: options.description }
: {}),
...(options.authorize ? { authorize: options.authorize } : {}),
key: options.key,
...(options.storageMetadata
? { storageMetadata: options.storageMetadata }
: {}),
...(options.verifyFile ? { verifyFile: options.verifyFile } : {}),
...(options.onComplete ? { onComplete: options.onComplete } : {}),
};
}
/**
* Context-bound upload helper factory.
*/
export interface Uploads<Ctx> {
/**
* Define an upload workflow with the bound context type.
*/
defineUpload<
const Name extends string,
MetadataSchema extends StandardSchema,
Result = unknown,
>(
name: Name,
options: DefineUploadOptions<MetadataSchema, Ctx, Result>,
): UploadDef<Name, MetadataSchema, Ctx, Result>;
}
/**
* Create upload helper methods bound to an application context type.
*
* Call it once in `lib/uploads.ts`:
*
* ```ts
* export const { defineUpload } = createUploads<AppContext>();
* ```
*/
export function createUploads<Ctx>(): Uploads<Ctx> {
return {
defineUpload<
const Name extends string,
MetadataSchema extends StandardSchema,
Result = unknown,
>(
name: Name,
options: DefineUploadOptions<MetadataSchema, Ctx, Result>,
): UploadDef<Name, MetadataSchema, Ctx, Result> {
return defineUpload(name, options);
},
};
}
/**
* Define a nested upload registry while preserving upload names and metadata
* types for client code.
*/
export function defineUploads<const Uploads extends UploadRegistry>(
uploads: Uploads,
): Uploads {
return uploads;
}
/**
* Flatten a nested upload registry into the list expected by
* `createUploadRouter(...)`.
*/
export function uploadsFromRegistry(
uploads: UploadRegistry | readonly UploadDef[],
): UploadDef[] {
if (Array.isArray(uploads)) return [...uploads];
const result: UploadDef[] = [];
for (const value of Object.values(uploads)) {
if (isUploadDef(value)) {
result.push(value);
} else {
result.push(...uploadsFromRegistry(value));
}
}
return result;
}
/**
* Create client-safe upload metadata for browser helpers.
*/
export function createUploadManifest(
uploads: UploadRegistry | readonly UploadDef[],
): UploadManifestEntry[] {
return uploadsFromRegistry(uploads).map((upload) => ({
name: upload.name,
...(upload.description !== undefined
? { description: upload.description }
: {}),
file: upload.file,
}));
}
/**
* Create deterministic direct upload instructions for tests.
*/
export function createMemoryUploadSigner(
options: { baseUrl?: string; expiresAt?: string } = {},
): UploadSignerPort {
const baseUrl = options.baseUrl ?? "https://uploads.beignet.test";
const expiresAt = options.expiresAt ?? "2100-01-01T00:00:00.000Z";
return {
sign(args) {
return {
method: "PUT",
url: `${baseUrl}/${encodeURIComponent(args.key)}`,
headers: {
"content-type": args.file.contentType,
},
expiresAt,
};
},
};
}
/**
* Create a framework-neutral upload router.
*/
export function createUploadRouter<Ctx>(
options: CreateUploadRouterOptions<Ctx>,
): UploadRouter {
const uploads = new Map<string, UploadDef<string, StandardSchema, Ctx>>();
for (const upload of options.uploads) {
if (uploads.has(upload.name)) {
throw new Error(
`createUploadRouter received duplicate upload name "${upload.name}". Each defineUpload(...) name must be unique.`,
);
}
uploads.set(upload.name, upload);
}
const id = options.id ?? randomUploadId;
const instrumentation = createProviderInstrumentation(
options.instrumentation,
{
providerName: "uploads",
watcher: "uploads",
},
);
const requestLimits = uploadRequestLimits(options.limits);
async function resolveCtx(): Promise<Ctx> {
return typeof options.ctx === "function"
? (options.ctx as () => MaybePromise<Ctx>)()
: options.ctx;
}
function findUpload(name: string) {
const upload = uploads.get(name);
if (!upload) {
const registered = [...uploads.keys()]
.map((registeredName) => `"${registeredName}"`)
.join(", ");
throw new UploadNotFoundError({
message: `Upload "${name}" is not registered. Registered uploads: ${registered || "none"}. Upload routes resolve the defineUpload(...) name, not the defineUploads({...}) registry key.`,
});
}
return upload;
}
async function prepare(uploadName: string, input: PrepareUploadInput) {
const startedAt = Date.now();
instrumentation.custom({
name: "upload.prepare.started",
label: "Upload prepare started",
summary: uploadName,
});
try {
const upload = findUpload(uploadName);
const parsed = parsePrepareInput(uploadName, input);
const ctx = await resolveCtx();
const metadata = await parseMetadata(upload, parsed.metadata);
assertFiles(upload, parsed.files, {
requireChecksum: Boolean(options.signer),
});
const files: PreparedUploadResultFile[] = [];
for (const file of parsed.files) {
const uploadId = id();
await assertAuthorized(upload, { ctx, metadata, file, uploadId });
const key = await upload.key({ ctx, metadata, file, uploadId });
const storageMetadata =
(await upload.storageMetadata?.({ ctx, metadata, file, uploadId })) ??
{};
const prepared: PreparedUploadResultFile = {
...file,
uploadId,
key,
};
if (options.signer) {
prepared.direct = await options.signer.sign({
uploadName,
uploadId,
key,
file,
metadata,
storage: {
visibility: upload.file.visibility ?? "private",
...(upload.file.cacheControl !== undefined
? { cacheControl: upload.file.cacheControl }
: {}),
metadata: storageMetadata,
},
});
}
files.push(prepared);
}
instrumentation.custom({
name: "upload.prepare.completed",
label: "Upload prepare completed",
summary: `${uploadName} (${files.length} file${files.length === 1 ? "" : "s"})`,
details: {
uploadName,
mode: options.signer ? "direct" : "server",
fileCount: files.length,
durationMs: Date.now() - startedAt,
},
});
return {
uploadName,
mode: options.signer ? "direct" : "server",
files,
} satisfies PrepareUploadResult;
} catch (error) {
recordFailure("upload.prepare.failed", uploadName, startedAt, error);
throw error;
}
}
async function complete(uploadName: string, input: CompleteUploadInput) {
const startedAt = Date.now();
instrumentation.custom({
name: "upload.complete.started",
label: "Upload complete started",
summary: uploadName,
});
try {
const upload = findUpload(uploadName);
const parsed = parseCompleteInput(uploadName, input);
const ctx = await resolveCtx();
const metadata = await parseMetadata(upload, parsed.metadata);
assertFiles(upload, parsed.files, { requireChecksum: true });
const files: CompletedUploadFile[] = [];
for (const file of parsed.files) {
await assertAuthorized(upload, {
ctx,
metadata,
file,
uploadId: file.uploadId,
});
const expectedKey = await upload.key({
ctx,
metadata,
file,
uploadId: file.uploadId,
});
if (file.key !== expectedKey) {
throw new InvalidUploadFileError({
message: `Uploaded object key does not match upload "${upload.name}".`,
details: {
expectedKey,
actualKey: file.key,
},
});
}
const object = needsUploadBodyVerification(upload, file)
? await options.storage.get(file.key)
: await options.storage.stat(file.key);
if (!object) {
throw new UploadObjectNotFoundError({
message: `Uploaded object "${file.key}" was not found.`,
});
}
assertStoredObject(upload, file, object);
const verified = await verifyStoredUploadFile(upload, file, object);
const completedObject = storageObjectMetadata(object);
const completedFile = {
...file,
...(verified.checksum ? { checksum: verified.checksum } : {}),
object: completedObject,
};
await assertVerifiedFile(upload, {
ctx,
metadata,
file: completedFile,
storage: options.storage,
});
files.push(completedFile);
}
const result = await upload.onComplete?.({ ctx, metadata, files });
instrumentation.custom({
name: "upload.complete.completed",
label: "Upload complete completed",
summary: `${uploadName} (${files.length} file${files.length === 1 ? "" : "s"})`,
details: {
uploadName,
fileCount: files.length,
durationMs: Date.now() - startedAt,
},
});
return {
uploadName,
files,
result,
} satisfies CompleteUploadResult;
} catch (error) {
recordFailure("upload.complete.failed", uploadName, startedAt, error);
throw error;
}
}
async function upload(uploadName: string, input: ServerUploadInput) {
const startedAt = Date.now();
instrumentation.custom({
name: "upload.server.started",
label: "Server upload started",
summary: uploadName,
});
try {
const definition = findUpload(uploadName);
const ctx = await resolveCtx();
const metadata = await parseMetadata(
definition,
metadataFromFormData(input.formData),
);
const webFiles = filesFromFormData(input.formData);
const intents = webFiles.map(fileIntentFromFile);
assertFiles(definition, intents, { requireChecksum: false });
const completed: CompletedUploadFile[] = [];
const storedKeys: string[] = [];
try {
for (const [index, file] of webFiles.entries()) {
const intent = intents[index];
if (!intent) continue;
const uploadId = id();
await assertAuthorized(definition, {
ctx,
metadata,
file: intent,
uploadId,
});
const verified = await verifyBlobUploadFile(definition, intent, file);
const verifiedIntent = {
...intent,
...(verified.checksum ? { checksum: verified.checksum } : {}),
};
const key = await definition.key({
ctx,
metadata,
file: verifiedIntent,
uploadId,
});
const storageMetadata =
(await definition.storageMetadata?.({
ctx,
metadata,
file: verifiedIntent,
uploadId,
})) ?? {};
const object = await options.storage.put(key, file, {
contentType: verifiedIntent.contentType,
...(definition.file.cacheControl !== undefined
? { cacheControl: definition.file.cacheControl }
: {}),
metadata: storageMetadata,
visibility: definition.file.visibility ?? "private",
});
storedKeys.push(key);
const completedFile = {
...verifiedIntent,
uploadId,
key,
object,
};
await assertVerifiedFile(definition, {
ctx,
metadata,
file: completedFile,
storage: options.storage,
});
completed.push(completedFile);
}
} catch (error) {
if (storedKeys.length > 0) {
await cleanupRejectedServerUpload(uploadName, storedKeys);
}
throw error;
}
// Once app-owned completion begins, the app may persist durable references
// to these objects. The framework can no longer delete them safely if a
// later completion step fails; transaction or compensation belongs to the
// app from this point forward.
const result = await definition.onComplete?.({
ctx,
metadata,
files: completed,
});
instrumentation.custom({
name: "upload.server.completed",
label: "Server upload completed",
summary: `${uploadName} (${completed.length} file${completed.length === 1 ? "" : "s"})`,
details: {
uploadName,
fileCount: completed.length,
durationMs: Date.now() - startedAt,
},
});
return {
uploadName,
files: completed,
result,
} satisfies CompleteUploadResult;
} catch (error) {
recordFailure("upload.server.failed", uploadName, startedAt, error);
throw error;
}
}
async function cleanupRejectedServerUpload(
uploadName: string,
keys: readonly string[],
): Promise<void> {
const failures: Array<{ key: string; error: string }> = [];
await Promise.all(
keys.map(async (key) => {
try {
await options.storage.delete(key);
} catch (error) {
failures.push({
key,
error: error instanceof Error ? error.message : String(error),
});
}
}),
);
if (failures.length === 0) return;
instrumentation.custom({
name: "upload.server.cleanup.failed",
label: "Rejected upload cleanup failed",
summary: uploadName,
details: {
uploadName,
failures,
},
});
}
function recordFailure(
name: string,
uploadName: string,
startedAt: number,
error: unknown,
) {
instrumentation.custom({
name,
label: "Upload failed",
summary: uploadName,
details: {
uploadName,
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
},
});
}
return {
prepare,
complete,
upload,
async handleRequest(request, requestOptions) {
try {
if (requestOptions.action === "prepare") {
const input = await readJsonBody(request, {
uploadName: requestOptions.uploadName,
action: "prepare",
maxBytes: requestLimits.jsonMaxBytes,
});
return jsonResponse(
await prepare(
requestOptions.uploadName,
input as PrepareUploadInput,
),
);
}
if (requestOptions.action === "complete") {
const input = await readJsonBody(request, {
uploadName: requestOptions.uploadName,
action: "complete",
maxBytes: requestLimits.jsonMaxBytes,
});
return jsonResponse(
await complete(
requestOptions.uploadName,
input as CompleteUploadInput,
),
);
}
const multipartContext = {
uploadName: requestOptions.uploadName,
action: "upload",
maxBytes: requestLimits.multipartMaxBytes,
} as const;
const multipartBody = await readLimitedRequestBytes(
request,
multipartContext,
);
const multipartHeaders = new Headers(request.headers);
multipartHeaders.delete("content-length");
const formData = await new Request(request.url, {
method: request.method,
headers: multipartHeaders,
body: multipartBody,
signal: request.signal,
}).formData();
return jsonResponse(
await upload(requestOptions.uploadName, {
formData,
}),
);
} catch (error) {
return uploadErrorResponse(error);
}
},
};
}
const DEFAULT_UPLOAD_JSON_MAX_BYTES = 256 * 1024;
const DEFAULT_UPLOAD_MULTIPART_MAX_BYTES = 25 * 1024 * 1024;
function uploadRequestLimits(
limits: UploadRequestLimits | undefined,
): Required<UploadRequestLimits> {
return {
jsonMaxBytes: positiveLimit(
limits?.jsonMaxBytes,
DEFAULT_UPLOAD_JSON_MAX_BYTES,
"limits.jsonMaxBytes",
),
multipartMaxBytes: positiveLimit(
limits?.multipartMaxBytes,
DEFAULT_UPLOAD_MULTIPART_MAX_BYTES,
"limits.multipartMaxBytes",
),
};
}
function positiveLimit(
value: number | undefined,
fallback: number,
name: string,
): number {
const limit = value ?? fallback;
if (!Number.isFinite(limit) || limit <= 0) {
throw new Error(`createUploadRouter ${name} must be a positive number.`);
}
return limit;
}
function assertUploadContentLengthWithinLimit(
headers: Headers,
context: {
uploadName: string;
action: "prepare" | "complete" | "upload";
maxBytes: number;
},
): void {
const contentLength = headers.get("content-length");
if (contentLength === null) return;
const actualBytes = Number(contentLength);
if (!Number.isFinite(actualBytes) || actualBytes < 0) return;
if (actualBytes > context.maxBytes) {
throw new UploadBodyTooLargeError({
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
details: {
maxBytes: context.maxBytes,
actualBytes,
},
});
}
}
async function readLimitedRequestBytes(
request: Request,
context: {
uploadName: string;
action: "upload";
maxBytes: number;
},
): Promise<ArrayBuffer> {
assertUploadContentLengthWithinLimit(request.headers, context);
if (!request.body) return new ArrayBuffer(0);
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let actualBytes = 0;
try {
while (true) {
const result = await reader.read();
if (result.done) break;
actualBytes += result.value.byteLength;
if (actualBytes > context.maxBytes) {
try {
await reader.cancel();
} catch {
// Preserve the size-limit error when the request source rejects cancellation.
}
throw new UploadBodyTooLargeError({
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
details: {
maxBytes: context.maxBytes,
actualBytes,
},
});
}
chunks.push(result.value);
}
} finally {
reader.releaseLock();
}
const body = new ArrayBuffer(actualBytes);
const bytes = new Uint8Array(body);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return body;
}
async function readLimitedRequestText(
request: Request,
context: {
uploadName: string;
action: "prepare" | "complete";
maxBytes: number;
},
): Promise<string> {
assertUploadContentLengthWithinLimit(request.headers, context);
if (!request.body) {
const text = await request.text();
const actualBytes = new TextEncoder().encode(text).byteLength;
if (actualBytes > context.maxBytes) {
throw new UploadBodyTooLargeError({
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
details: {
maxBytes: context.maxBytes,
actualBytes,
},
});
}
return text;
}
const reader = request.body.getReader();
const decoder = new TextDecoder();
let actualBytes = 0;
let text = "";
try {
while (true) {
const result = await reader.read();
if (result.done) break;
actualBytes += result.value.byteLength;
if (actualBytes > context.maxBytes) {
throw new UploadBodyTooLargeError({
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
details: {
maxBytes: context.maxBytes,
actualBytes,
},
});
}
text += decoder.decode(result.value, { stream: true });
}
text += decoder.decode();
} finally {
reader.releaseLock();
}
return text;
}
async function readJsonBody(
request: Request,
context: {
uploadName: string;
action: "prepare" | "complete";
maxBytes: number;
},
): Promise<unknown> {
try {
return JSON.parse(await readLimitedRequestText(request, context));
} catch (error) {
if (error instanceof UploadError) throw error;
throw new InvalidUploadBodyError({
message: `Upload "${context.uploadName}" ${context.action} body must be valid JSON.`,
});
}
}
interface UploadBodyIssue {
message: string;
path: (string | number)[];
}
function collectBodyIssues(
body: unknown,
options: { requireCompletedFileFields: boolean },
): UploadBodyIssue[] {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return [{ message: "Body must be a JSON object.", path: [] }];
}
const files = (body as { files?: unknown }).files;
if (!Array.isArray(files)) {
return [{ message: 'Body must include a "files" array.', path: ["files"] }];
}
const issues: UploadBodyIssue[] = [];
for (const [index, file] of files.entries()) {
if (typeof file !== "object" || file === null || Array.isArray(file)) {
issues.push({
message: "Each file must be an object.",
path: ["files", index],
});
continue;
}
if (!options.requireCompletedFileFields) continue;
const completed = file as { uploadId?: unknown; key?: unknown };
if (typeof completed.uploadId !== "string") {
issues.push({
message: 'Each completed file must include a string "uploadId".',
path: ["files", index, "uploadId"],
});
}
if (typeof completed.key !== "string") {
issues.push({
message: 'Each completed file must include a string "key".',
path: ["files", index, "key"],
});
}
}
return issues;
}
function parsePrepareInput(
uploadName: string,
body: unknown,
): PrepareUploadInput {
const issues = collectBodyIssues(body, {
requireCompletedFileFields: false,
});
if (issues.length > 0) {
throw new InvalidUploadBodyError({
message: `Upload "${uploadName}" prepare body is invalid.`,
details: { issues },
});
}
return body as PrepareUploadInput;
}
function parseCompleteInput(
uploadName: string,
body: unknown,
): CompleteUploadInput {
const issues = collectBodyIssues(body, {
requireCompletedFileFields: true,
});
if (issues.length > 0) {
throw new InvalidUploadBodyError({
message: `Upload "${uploadName}" complete body is invalid.`,
details: { issues },
});
}
return body as CompleteUploadInput;
}
async function parseMetadata<U extends UploadDef>(
upload: U,
input: unknown,
): Promise<InferUploadMetadata<U>> {
const result = await upload.metadata["~standard"].validate(input);
if (result.issues?.length) {
throw new InvalidUploadMetadataError({
message: `Invalid metadata for upload "${upload.name}".`,
details: { issues: result.issues },
});
}
if ("value" in result) {
return result.value as InferUploadMetadata<U>;
}
throw new Error("Invalid Standard Schema result: missing value");
}
function assertFiles(
upload: UploadDef,
files: readonly UploadFileIntent[],
options: { requireChecksum?: boolean } = {},
): void {
const maxFiles = upload.file.maxFiles ?? 1;
if (files.length === 0 || files.length > maxFiles) {
throw new InvalidUploadFileError({
message: `Upload "${upload.name}" requires between 1 and ${maxFiles} file${maxFiles === 1 ? "" : "s"}.`,
details: { fileCount: files.length, maxFiles },
});
}
for (const file of files) {
if (!file.name || !file.contentType || !Number.isFinite(file.size)) {
throw new InvalidUploadFileError({
message: `Upload "${upload.name}" received invalid file metadata.`,
details: { file },
});
}
assertFileChecksum(upload, file, options);
if (
upload.file.contentTypes?.length &&
!upload.file.contentTypes
.map(normalizeContentType)
.includes(normalizeContentType(file.contentType))
) {
throw new InvalidUploadFileError({
status: 415,
message: `Upload "${upload.name}" does not accept "${file.contentType}".`,
details: {
contentType: file.contentType,
acceptedContentTypes: upload.file.contentTypes,
},
});
}
if (
upload.file.maxSizeBytes !== undefined &&
file.size > upload.file.maxSizeBytes
) {
throw new InvalidUploadFileError({
status: 413,
message: `Upload "${upload.name}" exceeds the maximum file size.`,
details: {
size: file.size,
maxSizeBytes: upload.file.maxSizeBytes,
},
});
}
}
}
function assertFileChecksum(
upload: UploadDef,
file: UploadFileIntent,
options: { requireChecksum?: boolean },
): void {
const requirement = upload.file.checksum;
if (!requirement && !file.checksum) return;
const configuredAlgorithm = requirement?.algorithm as string | undefined;
if (configuredAlgorithm && configuredAlgorithm !== "sha256") {
throw new InvalidUploadFileError({
message: `Upload "${upload.name}" uses an unsupported checksum algorithm.`,
details: {
algorithm: configuredAlgorithm,
},
});
}
const required = requirement
? requirement.required !== false && options.requireChecksum === true
: false;
if (!file.checksum) {
if (!required) return;
throw new InvalidUploadFileError({
message: `Upload "${upload.name}" requires a sha256 checksum for "${file.name}".`,
details: {
fileName: file.name,
algorithm: "sha256",
},
});
}
if (
file.checksum.algorithm !== "sha256" ||
!isSha256Hex(file.checksum.value)
) {
throw new InvalidUploadFileError({
message: `Upload "${upload.name}" received an invalid checksum for "${file.name}".`,
details: {
fileName: file.name,
checksum: file.checksum,
},
});
}
}
const UPLOAD_SIGNATURE_MAX_BYTES = 512;
type UploadFileBodyVerification = {
checksum?: UploadFileChecksum;
};
function needsUploadBodyVerification(
upload: UploadDef,
file: UploadFileIntent,
): boolean {
return (
needsUploadChecksumVerification(upload, file) ||
needsUploadContentTypeVerification(upload, file)
);
}
function needsUploadChecksumVerification(
upload: UploadDef,
file: UploadFileIntent,
): boolean {
return Boolean(
file.checksum ||
(upload.file.checksum && upload.file.checksum.required !== false),
);
}
function needsUploadContentTypeVerification(
upload: UploadDef,
file: UploadFileIntent,
): boolean {
if (upload.file.contentTypeVerification === false) return false;
if (hasContentTypeSignature(file.contentType)) return true;
return Boolean(
upload.file.contentTypes?.some((contentType) =>
hasContentTypeSignature(contentType),
),
);
}
async function verifyBlobUploadFile(
upload: UploadDef,
file: UploadFileIntent,
blob: Blob,
): Promise<UploadFileBodyVerification> {
if (!needsUploadBodyVerification(upload, file)) return {};
const needsChecksum = needsUploadChecksumVerification(upload, file);
const bytes = needsChecksum
? new Uint8Array(await blob.arrayBuffer())
: new Uint8Array(
await blob.slice(0, UPLOAD_SIGNATURE_MAX_BYTES).arrayBuffer(),
);
assertUploadContentTypeSignature(upload, file, bytes);
if (!needsChecksum) return {};
const checksum = await createUploadChecksum(bytes);
assertUploadChecksum(upload, file, checksum);
return { checksum };
}
async function verifyStoredUploadFile(
upload: UploadDef,
file: UploadFileIntent,
object: StorageObject | StorageObjectBody,
): Promise<UploadFileBodyVerification> {
if (!needsUploadBodyVerification(upload, file)) return {};
if (!("bytes" in object)) {
throw new InvalidUploadFileError({
message: `Uploaded object "${object.key}" must be readable for verification.`,
details: {
key: object.key,
},
});
}
const needsChecksum = needsUploadChecksumVerification(upload, file);
const bytes = needsChecksum
? await object.bytes()
: await readObjectPrefix(object, UPLOAD_SIGNATURE_MAX_BYTES);
assertUploadContentTypeSignature(upload, file, bytes);
if (!needsChecksum) return {};
const checksum = await createUploadChecksum(bytes);
assertUploadChecksum(upload, file, checksum);
return { checksum };
}
function storageObjectMetadata(object: StorageObject): StorageObject {
return {
key: object.key,
size: object.size,
...(object.contentType !== undefined
? { contentType: object.contentType }
: {}),
...(object.cacheControl !== undefined
? { cacheControl: object.cacheControl }
: {}),
metadata: object.metadata,
visibility: object.visibility,
lastModified: object.lastModified,
};
}
function assertUploadContentTypeSignature(
upload: UploadDef,
file: UploadFileIntent,
bytes: Uint8Array,
): void {
if (upload.file.contentTypeVerification === false) return;
const declaredContentType = normalizeContentType(file.contentType);
const acceptedContentTypes =
upload.file.contentTypes?.map(normalizeContentType) ?? [];
const detectedContentType = detectSupportedContentType(bytes);
if (
detectedContentType &&
acceptedContentTypes.length > 0 &&
!acceptedContentTypes.includes(detectedContentType)
) {
throw new InvalidUploadFileError({
status: 415,
message: `Upload "${upload.name}" does not accept detected content type "${detectedContentType}".`,
details: {
fileName: file.name,
declaredContentType,
detectedContentType,
acceptedContentTypes,
},
});
}
if (
hasContentTypeSignature(declaredContentType) &&
!matchesContentTypeSignature(declaredContentType, bytes)
) {
throw new InvalidUploadFileError({
status: 415,
message: `Upload "${upload.name}" content type "${declaredContentType}" does not match the file bytes.`,
details: {
fileName: file.name,
expectedContentType: declaredContentType,
detectedContentType: detectedContentType ?? "unknown",
},
});
}
}
function assertUploadChecksum(
upload: UploadDef,
file: UploadFileIntent,
actual: UploadFileChecksum,
): void {
if (!file.checksum) return;
if (file.checksum.value.toLowerCase() === actual.value) return;
throw new InvalidUploadFileError({
message: `Upload "${upload.name}" checksum does not match "${file.name}".`,
details: {
fileName: file.name,
algorithm: "sha256",
expected: file.checksum.value.toLowerCase(),
actual: actual.value,
},
});
}
async function assertVerifiedFile<Metadata, Ctx>(
upload: UploadDef<string, StandardSchema, Ctx>,
args: UploadVerifyFileHookArgs<Metadata, Ctx>,
): Promise<void> {
if (!upload.verifyFile) return;
const result = await upload.verifyFile(
args as UploadVerifyFileHookArgs<InferUploadMetadata<typeof upload>, Ctx>,
);
const invalid =
result === false ||
(typeof result === "object" &&
result !== null &&
"valid" in result &&
result.valid === false);
if (!invalid) return;
throw new InvalidUploadFileError({
message:
typeof result === "object" && result?.reason
? result.reason
: `Upload "${upload.name}" file "${args.file.name}" did not pass verification.`,
details:
typeof result === "object" && "details" in result
? result.details
: {
fileName: args.file.name,
key: args.file.key,
},
});
}
async function readObjectPrefix(
object: StorageObjectBody,
maxBytes: number,
): Promise<Uint8Array> {
const reader = object.stream().getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (total < maxBytes) {
const result = await reader.read();
if (result.done) break;
const remaining = maxBytes - total;
const chunk =
result.value.byteLength > remaining
? result.value.slice(0, remaining)
: result.value;
chunks.push(chunk);
total += chunk.byteLength;
if (result.value.byteLength > remaining) break;
}
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
async function createUploadChecksum(
bytes: Uint8Array,
): Promise<UploadFileChecksum> {
if (!globalThis.crypto?.subtle) {
throw new InvalidUploadFileError({
message: "Upload checksum verification requires Web Crypto.",
});
}
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
return {
algorithm: "sha256",
value: bytesToHex(new Uint8Array(digest)),
};
}
function bytesToHex(bytes: Uint8Array): string {
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function isSha256Hex(value: string): boolean {
return /^[a-f0-9]{64}$/i.test(value);
}
function normalizeContentType(contentType: string): string {
return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
}
function hasContentTypeSignature(contentType: string): boolean {
return SIGNATURE_CONTENT_TYPES.has(normalizeContentType(contentType));
}
function matchesContentTypeSignature(
contentType: string,
bytes: Uint8Array,
): boolean {
const normalized = normalizeContentType(contentType);
switch (normalized) {
case "application/pdf":
return startsWithBytes(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]);
case "application/zip":
return (
startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]) ||
startsWithBytes(bytes, [0x50, 0x4b, 0x05, 0x06]) ||
startsWithBytes(bytes, [0x50, 0x4b, 0x07, 0x08])
);
case "image/gif":
return (
startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a")
);
case "image/jpeg":
return startsWithBytes(bytes, [0xff, 0xd8, 0xff]);
case "image/png":
return startsWithBytes(
bytes,
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
);
case "image/svg+xml":
return looksLikeSvg(bytes);
case "image/webp":
return (
startsWithAscii(bytes, "RIFF") &&
bytes.length >= 12 &&
asciiAt(bytes, 8, 4) === "WEBP"
);
default:
return false;
}
}
function detectSupportedContentType(bytes: Uint8Array): string | undefined {
for (const contentType of SIGNATURE_CONTENT_TYPES) {
if (matchesContentTypeSignature(contentType, bytes)) return contentType;
}
return undefined;
}
const SIGNATURE_CONTENT_TYPES = new Set([
"application/pdf",
"application/zip",
"image/gif",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/webp",
]);
function startsWithBytes(
bytes: Uint8Array,
prefix: readonly number[],
): boolean {
if (bytes.length < prefix.length) return false;
return prefix.every((byte, index) => bytes[index] === byte);
}
function startsWithAscii(bytes: Uint8Array, prefix: string): boolean {
return asciiAt(bytes, 0, prefix.length) === prefix;
}
function asciiAt(bytes: Uint8Array, offset: number, length: number): string {
return String.fromCharCode(...bytes.slice(offset, offset + length));
}
function looksLikeSvg(bytes: Uint8Array): boolean {
const text = new TextDecoder()
.decode(bytes)
.replace(/^\uFEFF/, "")
.trimStart()
.toLowerCase();
return (
text.startsWith("<svg") ||
(text.startsWith("<?xml") && text.includes("<svg"))
);
}
async function assertAuthorized<Metadata, Ctx>(
upload: UploadDef<string, StandardSchema, Ctx>,
args: UploadFileHookArgs<Metadata, Ctx>,
): Promise<void> {
if (!upload.authorize) {
if ((upload.access ?? "protected") === "public") return;
throw new UnauthorizedUploadError({
message: `Upload "${upload.name}" must declare authorize(...) or set access: "public".`,
});
}
const result = await upload.authorize?.(
args as UploadFileHookArgs<InferUploadMetadata<typeof upload>, Ctx>,
);
const denied =
result === false ||
(typeof result === "object" &&
result !== null &&
"allowed" in result &&
result.allowed === false);
if (!denied) return;
throw new UnauthorizedUploadError({
message:
typeof result === "object" && result?.reason
? result.reason
: `Upload "${upload.name}" is not authorized.`,
});
}
function assertStoredObject(
upload: UploadDef,
file: UploadFileIntent,
object: StorageObject,
): void {
assertFiles(upload, [file]);
if (
upload.file.maxSizeBytes !== undefined &&
object.size > upload.file.maxSizeBytes
) {
throw new InvalidUploadFileError({
status: 413,
message: `Uploaded object "${object.key}" exceeds the maximum file size.`,
details: {
size: object.size,
maxSizeBytes: upload.file.maxSizeBytes,
},
});
}
if (object.size !== file.size) {
throw new InvalidUploadFileError({
message: `Uploaded object "${object.key}" size does not match the prepared file.`,
details: {
expected: file.size,
actual: object.size,
},
});
}
if (
object.contentType &&
file.contentType &&
normalizeContentType(object.contentType) !==
normalizeContentType(file.contentType)
) {
throw new InvalidUploadFileError({
message: `Uploaded object "${object.key}" content type does not match the prepared file.`,
details: {
expected: file.contentType,
actual: object.contentType,
},
});
}
}
function metadataFromFormData(formData: FormData): unknown {
const metadata = formData.get("metadata");
if (typeof metadata !== "string") return {};
try {
return JSON.parse(metadata);
} catch {
throw new InvalidUploadBodyError({
message: "Upload metadata must be valid JSON.",
});
}
}
function filesFromFormData(formData: FormData): File[] {
const values = [...formData.getAll("file"), ...formData.getAll("files")];
const files = values.filter((value): value is File => value instanceof File);
if (files.length === 0) {
throw new InvalidUploadBodyError({
message: 'Multipart upload must include at least one "file" field.',
});
}
return files;
}
function fileIntentFromFile(file: File): UploadFileIntent {
const contentType =
normalizeContentType(file.type) || "application/octet-stream";
return {
name: file.name,
contentType,
size: file.size,
};
}
function jsonResponse(body: unknown, init?: ResponseInit): Response {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => {
headers[key] = value;
});
}
return new Response(JSON.stringify(body), {
status: init?.status ?? 200,
headers,
});
}
function uploadErrorResponse(error: unknown): Response {
if (error instanceof UploadError) {
return jsonResponse(
{
code: error.code,
message: error.message,
...(error.details !== undefined ? { details: error.details } : {}),
},
{ status: error.status },
);
}
return jsonResponse(
{
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
},
{ status: 500 },
);
}
function randomUploadId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `upload_${Math.random().toString(36).slice(2)}`;
}
function isUploadDef(value: UploadDef | UploadRegistry): value is UploadDef {
return (
typeof value === "object" &&
value !== null &&
"kind" in value &&
value.kind === "upload"
);
}