UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,238 lines 44.6 kB
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