UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

463 lines 16.2 kB
/** * Error thrown by upload client route requests or direct provider uploads. */ export class UploadClientError extends Error { /** * Client operation that failed. */ operation; /** * Upload route name involved in the failure. */ uploadName; /** * HTTP status when a route or provider response was received. */ status; /** * Machine-readable error code. */ code; /** * Structured error details from the upload route, when available. */ details; /** * Create an upload client error. */ constructor(options) { super(options.message, { cause: options.cause }); this.name = "UploadClientError"; this.operation = options.operation; this.uploadName = options.uploadName; this.status = options.status; this.code = options.code; this.details = options.details; } } /** * Create a typed browser upload client for a Beignet upload route. */ export function createUploadClient(options = {}) { const baseUrl = normalizeBaseUrl(options.baseUrl ?? "/api/uploads"); const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis); if (!fetchImpl) { throw new Error("createUploadClient requires a fetch implementation."); } async function routeHeaders(routeOptions, contentType) { const headers = new Headers(); if (contentType) headers.set("content-type", contentType); const shared = await resolveHeaders(options.headers); const local = await resolveHeaders(routeOptions?.headers); mergeHeaders(headers, shared); mergeHeaders(headers, local); return headers; } function routeRequest(routeOptions) { return { ...options.request, ...routeOptions?.request, }; } async function prepare(uploadName, prepareOptions) { const fileConstraints = constraints(uploadName); const files = await Promise.all(prepareOptions.files.map((file) => fileIntentFromFile({ file, constraints: fileConstraints, uploadName, }))); return requestJson({ fetchImpl, url: actionUrl(baseUrl, uploadName, "prepare"), operation: "prepare upload", uploadName, init: { ...routeRequest(prepareOptions), method: "POST", headers: await routeHeaders(prepareOptions, "application/json"), signal: prepareOptions.signal, body: JSON.stringify({ metadata: prepareOptions.metadata, files, }), }, }); } async function complete(uploadName, completeOptions) { return requestJson({ fetchImpl, url: actionUrl(baseUrl, uploadName, "complete"), operation: "complete upload", uploadName, init: { ...routeRequest(completeOptions), method: "POST", headers: await routeHeaders(completeOptions, "application/json"), signal: completeOptions.signal, body: JSON.stringify({ metadata: completeOptions.metadata, files: completeOptions.files.map(completeFileInput), }), }, }); } async function server(uploadName, uploadOptions) { const formData = new FormData(); formData.set("metadata", JSON.stringify(uploadOptions.metadata)); uploadOptions.files.forEach((file) => { formData.append("file", file); }); uploadOptions.files.forEach((file, index) => { uploadOptions.onFileBegin?.({ file, fileName: file.name, index, }); }); const result = await requestJson({ fetchImpl, url: actionUrl(baseUrl, uploadName, "upload"), operation: "server upload", uploadName, init: { ...routeRequest(uploadOptions), method: "POST", headers: await routeHeaders(uploadOptions), signal: uploadOptions.signal, body: formData, }, }); uploadOptions.files.forEach((file, index) => { uploadOptions.onProgress?.({ file, fileName: file.name, index, loaded: file.size, total: file.size, progress: 1, }); }); return result; } async function direct(uploadName, uploadOptions) { const prepared = await prepare(uploadName, uploadOptions); return directFromPrepared(uploadName, uploadOptions, prepared); } async function directFromPrepared(uploadName, uploadOptions, prepared) { if (prepared.mode !== "direct") { throw new UploadClientError({ operation: "direct upload", uploadName, code: "DIRECT_UPLOAD_UNAVAILABLE", message: `Upload "${uploadName}" did not return direct upload instructions.`, }); } await Promise.all(prepared.files.map((preparedFile, index) => uploadDirectFile({ fetchImpl, uploadName, preparedFile, file: uploadOptions.files[index], index, signal: uploadOptions.signal, onFileBegin: uploadOptions.onFileBegin, onProgress: uploadOptions.onProgress, }))); return complete(uploadName, { metadata: uploadOptions.metadata, files: prepared.files, headers: uploadOptions.headers, request: uploadOptions.request, signal: uploadOptions.signal, }); } async function upload(uploadName, uploadOptions) { const strategy = uploadOptions.strategy ?? "auto"; if (strategy === "server") return server(uploadName, uploadOptions); if (strategy === "direct") return direct(uploadName, uploadOptions); const prepared = await prepare(uploadName, uploadOptions); if (prepared.mode === "direct") { return directFromPrepared(uploadName, uploadOptions, prepared); } return server(uploadName, uploadOptions); } function constraints(uploadName) { return options.manifest?.find((entry) => entry.name === uploadName)?.file; } function accept(uploadName) { return constraints(uploadName)?.contentTypes?.join(","); } return { prepare, complete, server, direct, upload, constraints, accept, }; } async function fileIntentFromFile(args) { const contentType = normalizeContentType(args.file.type) || "application/octet-stream"; const intent = { name: args.file.name, contentType, size: args.file.size, }; const checksumRequirement = args.constraints?.checksum; if (checksumRequirement?.algorithm === "sha256") { intent.checksum = await createClientUploadChecksum(args.file, { uploadName: args.uploadName, required: checksumRequirement.required !== false, }); } return intent; } function completeFileInput(file) { return { name: file.name, contentType: file.contentType, size: file.size, ...(file.checksum ? { checksum: file.checksum } : {}), uploadId: file.uploadId, key: file.key, }; } async function createClientUploadChecksum(file, options) { if (!globalThis.crypto?.subtle) { if (!options.required) return undefined; throw new UploadClientError({ operation: "prepare upload", uploadName: options.uploadName, code: "UPLOAD_CHECKSUM_UNAVAILABLE", message: `Upload "${options.uploadName}" requires Web Crypto to compute checksums.`, }); } const digest = await globalThis.crypto.subtle.digest("SHA-256", await file.arrayBuffer()); return { algorithm: "sha256", value: [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, "0")) .join(""), }; } function normalizeBaseUrl(baseUrl) { return baseUrl.replace(/\/+$/, ""); } function normalizeContentType(contentType) { return contentType.split(";")[0]?.trim().toLowerCase() ?? ""; } function actionUrl(baseUrl, uploadName, action) { return `${baseUrl}/${encodeURIComponent(uploadName)}/${action}`; } async function resolveHeaders(headers) { return typeof headers === "function" ? headers() : headers; } function mergeHeaders(target, source) { if (!source) return; new Headers(source).forEach((value, key) => { target.set(key, value); }); } async function requestJson(args) { let response; try { response = await args.fetchImpl(args.url, args.init); } catch (error) { throw new UploadClientError({ operation: args.operation, uploadName: args.uploadName, code: "UPLOAD_REQUEST_FAILED", message: `Failed to ${args.operation} "${args.uploadName}".`, cause: error, }); } const body = await parseJsonBody(response, args); if (!response.ok) { const envelope = uploadErrorBody(body); throw new UploadClientError({ operation: args.operation, uploadName: args.uploadName, status: response.status, code: envelope?.code ?? "UPLOAD_REQUEST_FAILED", message: envelope?.message ?? `Failed to ${args.operation} "${args.uploadName}" (${response.status}).`, details: envelope?.details, }); } return body; } async function parseJsonBody(response, args) { const text = await response.text(); if (!text) return undefined; try { return JSON.parse(text); } catch (error) { throw new UploadClientError({ operation: args.operation, uploadName: args.uploadName, status: response.status, code: "INVALID_UPLOAD_RESPONSE", message: `Failed to parse upload response for "${args.uploadName}".`, cause: error, }); } } function uploadErrorBody(body) { if (typeof body !== "object" || body === null) { return undefined; } const errorRecord = body; return { code: typeof errorRecord.code === "string" ? errorRecord.code : undefined, message: typeof errorRecord.message === "string" ? errorRecord.message : undefined, ...("details" in errorRecord ? { details: errorRecord.details } : {}), }; } async function uploadDirectFile(args) { const file = args.file; if (!file) { throw new UploadClientError({ operation: "direct upload", uploadName: args.uploadName, code: "INVALID_UPLOAD_FILE", message: `Missing browser file for prepared upload "${args.preparedFile.key}".`, }); } if (!args.preparedFile.direct) { throw new UploadClientError({ operation: "direct upload", uploadName: args.uploadName, code: "DIRECT_UPLOAD_UNAVAILABLE", message: `Upload "${args.uploadName}" did not include direct instructions for "${args.preparedFile.name}".`, }); } args.onFileBegin?.({ file, fileName: file.name, index: args.index, uploadId: args.preparedFile.uploadId, key: args.preparedFile.key, }); if (args.onProgress && typeof XMLHttpRequest !== "undefined") { await uploadWithXhr({ uploadName: args.uploadName, preparedFile: args.preparedFile, file, index: args.index, signal: args.signal, onProgress: args.onProgress, }); return; } const response = await args.fetchImpl(args.preparedFile.direct.url, { method: args.preparedFile.direct.method, headers: args.preparedFile.direct.headers, signal: args.signal, body: file, }); if (!response.ok) { throw new UploadClientError({ operation: "direct upload", uploadName: args.uploadName, status: response.status, code: "DIRECT_UPLOAD_FAILED", message: `Direct upload failed for "${args.preparedFile.name}" (${response.status}).`, }); } args.onProgress?.({ file, fileName: file.name, index: args.index, uploadId: args.preparedFile.uploadId, key: args.preparedFile.key, loaded: file.size, total: file.size, progress: 1, }); } function uploadWithXhr(args) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const direct = args.preparedFile.direct; if (!direct) { reject(new UploadClientError({ operation: "direct upload", uploadName: args.uploadName, code: "DIRECT_UPLOAD_UNAVAILABLE", message: `Upload "${args.uploadName}" did not include direct instructions for "${args.preparedFile.name}".`, })); return; } const abort = () => xhr.abort(); args.signal?.addEventListener("abort", abort, { once: true }); xhr.upload.onprogress = (event) => { const total = event.lengthComputable ? event.total : args.file.size; args.onProgress?.({ file: args.file, fileName: args.file.name, index: args.index, uploadId: args.preparedFile.uploadId, key: args.preparedFile.key, loaded: event.loaded, total, progress: total > 0 ? event.loaded / total : 0, }); }; xhr.onload = () => { args.signal?.removeEventListener("abort", abort); if (xhr.status >= 200 && xhr.status < 300) { args.onProgress?.({ file: args.file, fileName: args.file.name, index: args.index, uploadId: args.preparedFile.uploadId, key: args.preparedFile.key, loaded: args.file.size, total: args.file.size, progress: 1, }); resolve(); return; } reject(new UploadClientError({ operation: "direct upload", uploadName: args.uploadName, status: xhr.status, code: "DIRECT_UPLOAD_FAILED", message: `Direct upload failed for "${args.preparedFile.name}" (${xhr.status}).`, })); }; xhr.onerror = () => { args.signal?.removeEventListener("abort", abort); reject(new UploadClientError({ operation: "direct upload", uploadName: args.uploadName, code: "DIRECT_UPLOAD_FAILED", message: `Direct upload failed for "${args.preparedFile.name}".`, })); }; xhr.onabort = () => { args.signal?.removeEventListener("abort", abort); reject(new UploadClientError({ operation: "direct upload", uploadName: args.uploadName, code: "DIRECT_UPLOAD_ABORTED", message: `Direct upload was aborted for "${args.preparedFile.name}".`, })); }; xhr.open(direct.method, direct.url); for (const [key, value] of Object.entries(direct.headers ?? {})) { xhr.setRequestHeader(key, value); } xhr.send(args.file); }); } //# sourceMappingURL=client.js.map