@beignet/core
Version:
Core framework primitives for Beignet
463 lines • 16.2 kB
JavaScript
/**
* 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