@beignet/core
Version:
Core framework primitives for Beignet
1,238 lines • 44.6 kB
JavaScript
import { createProviderInstrumentation } from "../providers/index.js";
/**
* 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 {
code;
status;
details;
constructor(args) {
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 {
constructor(args) {
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 {
constructor(args) {
super({ code: "INVALID_UPLOAD_ACTION", status: 400, ...args });
this.name = "InvalidUploadActionError";
}
}
/**
* Error thrown when upload metadata fails schema validation.
*/
export class InvalidUploadMetadataError extends UploadError {
constructor(args) {
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 {
constructor(args) {
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 {
constructor(args) {
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 {
constructor(args) {
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 {
constructor(args) {
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 {
constructor(args) {
super({ code: "INVALID_UPLOAD_BODY", status: 400, ...args });
this.name = "InvalidUploadBodyError";
}
}
/**
* Define a typed upload workflow.
*/
export function defineUpload(name, options) {
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 } : {}),
};
}
/**
* 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() {
return {
defineUpload(name, options) {
return defineUpload(name, options);
},
};
}
/**
* Define a nested upload registry while preserving upload names and metadata
* types for client code.
*/
export function defineUploads(uploads) {
return uploads;
}
/**
* Flatten a nested upload registry into the list expected by
* `createUploadRouter(...)`.
*/
export function uploadsFromRegistry(uploads) {
if (Array.isArray(uploads))
return [...uploads];
const result = [];
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) {
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 = {}) {
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(options) {
const uploads = new Map();
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() {
return typeof options.ctx === "function"
? options.ctx()
: options.ctx;
}
function findUpload(name) {
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, input) {
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 = [];
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 = {
...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,
};
}
catch (error) {
recordFailure("upload.prepare.failed", uploadName, startedAt, error);
throw error;
}
}
async function complete(uploadName, input) {
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 = [];
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,
};
}
catch (error) {
recordFailure("upload.complete.failed", uploadName, startedAt, error);
throw error;
}
}
async function upload(uploadName, input) {
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 = [];
const storedKeys = [];
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,
};
}
catch (error) {
recordFailure("upload.server.failed", uploadName, startedAt, error);
throw error;
}
}
async function cleanupRejectedServerUpload(uploadName, keys) {
const failures = [];
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, uploadName, startedAt, error) {
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));
}
if (requestOptions.action === "complete") {
const input = await readJsonBody(request, {
uploadName: requestOptions.uploadName,
action: "complete",
maxBytes: requestLimits.jsonMaxBytes,
});
return jsonResponse(await complete(requestOptions.uploadName, input));
}
const multipartContext = {
uploadName: requestOptions.uploadName,
action: "upload",
maxBytes: requestLimits.multipartMaxBytes,
};
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) {
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, fallback, name) {
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, context) {
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, context) {
assertUploadContentLengthWithinLimit(request.headers, context);
if (!request.body)
return new ArrayBuffer(0);
const reader = request.body.getReader();
const chunks = [];
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, context) {
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, context) {
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.`,
});
}
}
function collectBodyIssues(body, options) {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return [{ message: "Body must be a JSON object.", path: [] }];
}
const files = body.files;
if (!Array.isArray(files)) {
return [{ message: 'Body must include a "files" array.', path: ["files"] }];
}
const issues = [];
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;
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, body) {
const issues = collectBodyIssues(body, {
requireCompletedFileFields: false,
});
if (issues.length > 0) {
throw new InvalidUploadBodyError({
message: `Upload "${uploadName}" prepare body is invalid.`,
details: { issues },
});
}
return body;
}
function parseCompleteInput(uploadName, body) {
const issues = collectBodyIssues(body, {
requireCompletedFileFields: true,
});
if (issues.length > 0) {
throw new InvalidUploadBodyError({
message: `Upload "${uploadName}" complete body is invalid.`,
details: { issues },
});
}
return body;
}
async function parseMetadata(upload, input) {
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;
}
throw new Error("Invalid Standard Schema result: missing value");
}
function assertFiles(upload, files, options = {}) {
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, file, options) {
const requirement = upload.file.checksum;
if (!requirement && !file.checksum)
return;
const configuredAlgorithm = requirement?.algorithm;
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;
function needsUploadBodyVerification(upload, file) {
return (needsUploadChecksumVerification(upload, file) ||
needsUploadContentTypeVerification(upload, file));
}
function needsUploadChecksumVerification(upload, file) {
return Boolean(file.checksum ||
(upload.file.checksum && upload.file.checksum.required !== false));
}
function needsUploadContentTypeVerification(upload, file) {
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, file, blob) {
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, file, object) {
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) {
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, file, bytes) {
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, file, actual) {
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(upload, args) {
if (!upload.verifyFile)
return;
const result = await upload.verifyFile(args);
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, maxBytes) {
const reader = object.stream().getReader();
const chunks = [];
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) {
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) {
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function isSha256Hex(value) {
return /^[a-f0-9]{64}$/i.test(value);
}
function normalizeContentType(contentType) {
return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
}
function hasContentTypeSignature(contentType) {
return SIGNATURE_CONTENT_TYPES.has(normalizeContentType(contentType));
}
function matchesContentTypeSignature(contentType, bytes) {
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) {
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, prefix) {
if (bytes.length < prefix.length)
return false;
return prefix.every((byte, index) => bytes[index] === byte);
}
function startsWithAscii(bytes, prefix) {
return asciiAt(bytes, 0, prefix.length) === prefix;
}
function asciiAt(bytes, offset, length) {
return String.fromCharCode(...bytes.slice(offset, offset + length));
}
function looksLikeSvg(bytes) {
const text = new TextDecoder()
.decode(bytes)
.replace(/^\uFEFF/, "")
.trimStart()
.toLowerCase();
return (text.startsWith("<svg") ||
(text.startsWith("<?xml") && text.includes("<svg")));
}
async function assertAuthorized(upload, args) {
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);
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, file, object) {
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) {
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) {
const values = [...formData.getAll("file"), ...formData.getAll("files")];
const files = values.filter((value) => 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) {
const contentType = normalizeContentType(file.type) || "application/octet-stream";
return {
name: file.name,
contentType,
size: file.size,
};
}
function jsonResponse(body, init) {
const headers = {
"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) {
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() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `upload_${Math.random().toString(36).slice(2)}`;
}
function isUploadDef(value) {
return (typeof value === "object" &&
value !== null &&
"kind" in value &&
value.kind === "upload");
}
//# sourceMappingURL=index.js.map