9e1ed2c9cb
Hop-State: A_06FPAF0M83E7T0JJ7EP2WQ0 Hop-Proposal: R_06FPAF04S0D4G8TA8TNK3C0 Hop-Task: T_06FPADTV2YD4PD5GMTK3150 Hop-Attempt: AT_06FPADTV2ZYKEY8GH3JTK98
223 lines
5.5 KiB
TypeScript
223 lines
5.5 KiB
TypeScript
import type { StuffboxClient } from "../client.js";
|
|
import { StuffboxError } from "../errors.js";
|
|
import type {
|
|
Asset,
|
|
CompleteUploadInput,
|
|
CreateUploadInput,
|
|
RequestOptions,
|
|
UploadSession,
|
|
} from "../types.js";
|
|
|
|
export type UploadPhase =
|
|
| "creating"
|
|
| "uploading"
|
|
| "completing"
|
|
| "complete";
|
|
|
|
export interface UploadProgress {
|
|
phase: UploadPhase;
|
|
loaded: number;
|
|
total: number;
|
|
percent: number;
|
|
}
|
|
|
|
export interface UploadTransportOptions {
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
/**
|
|
* The deliberately narrow boundary used by browser upload code. Implement it
|
|
* with same-origin endpoints so app access and refresh tokens remain on the
|
|
* integrating application's server.
|
|
*/
|
|
export interface UploadTransport {
|
|
createUpload(
|
|
input: CreateUploadInput,
|
|
options?: UploadTransportOptions,
|
|
): Promise<UploadSession>;
|
|
completeUpload(
|
|
uploadId: string,
|
|
input?: CompleteUploadInput,
|
|
options?: UploadTransportOptions,
|
|
): Promise<Asset>;
|
|
}
|
|
|
|
export interface UploadFileOptions {
|
|
sha256?: string;
|
|
signal?: AbortSignal;
|
|
onProgress?: (progress: UploadProgress) => void;
|
|
}
|
|
|
|
/**
|
|
* Adapts an SDK client to the upload-only browser transport. Only use this when
|
|
* the client's credential is explicitly browser-safe and user-scoped. An app
|
|
* bearer or refresh token belongs behind a same-origin proxy instead.
|
|
*/
|
|
export function createSdkUploadTransport(client: StuffboxClient): UploadTransport {
|
|
return {
|
|
createUpload: (input, options) =>
|
|
client.createUpload(input, options as RequestOptions | undefined),
|
|
completeUpload: (uploadId, input, options) =>
|
|
client.completeUpload(
|
|
uploadId,
|
|
input,
|
|
options as RequestOptions | undefined,
|
|
),
|
|
};
|
|
}
|
|
|
|
function progress(
|
|
phase: UploadPhase,
|
|
loaded: number,
|
|
total: number,
|
|
): UploadProgress {
|
|
return {
|
|
phase,
|
|
loaded,
|
|
total,
|
|
percent: total === 0 ? 0 : Math.min(100, (loaded / total) * 100),
|
|
};
|
|
}
|
|
|
|
function putFile(
|
|
session: UploadSession,
|
|
file: File,
|
|
signal: AbortSignal | undefined,
|
|
onProgress: ((value: UploadProgress) => void) | undefined,
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof XMLHttpRequest === "undefined") {
|
|
reject(
|
|
new StuffboxError({
|
|
code: "browser_api_unavailable",
|
|
message: "Direct upload requires XMLHttpRequest in a browser",
|
|
status: 0,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
let settled = false;
|
|
|
|
const cleanup = () => {
|
|
signal?.removeEventListener("abort", abort);
|
|
xhr.upload.onprogress = null;
|
|
xhr.onload = null;
|
|
xhr.onerror = null;
|
|
xhr.onabort = null;
|
|
};
|
|
|
|
const succeed = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
|
|
const fail = (error: StuffboxError) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const abort = () => {
|
|
xhr.abort();
|
|
fail(
|
|
new StuffboxError({
|
|
code: "request_aborted",
|
|
message: "Upload was aborted",
|
|
status: 0,
|
|
}),
|
|
);
|
|
};
|
|
|
|
if (signal?.aborted) {
|
|
abort();
|
|
return;
|
|
}
|
|
|
|
xhr.open(session.method, session.uploadUrl, true);
|
|
for (const [name, value] of Object.entries(session.requiredHeaders)) {
|
|
xhr.setRequestHeader(name, value);
|
|
}
|
|
|
|
xhr.upload.onprogress = (event) => {
|
|
const loaded = Math.min(event.loaded, file.size);
|
|
onProgress?.(progress("uploading", loaded, file.size));
|
|
};
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
onProgress?.(progress("uploading", file.size, file.size));
|
|
succeed();
|
|
return;
|
|
}
|
|
fail(
|
|
new StuffboxError({
|
|
code: "object_upload_failed",
|
|
message: `Object storage rejected the upload with status ${xhr.status}`,
|
|
status: xhr.status,
|
|
}),
|
|
);
|
|
};
|
|
xhr.onerror = () => {
|
|
fail(
|
|
new StuffboxError({
|
|
code: "network_error",
|
|
message: "The direct object-storage upload failed",
|
|
status: 0,
|
|
}),
|
|
);
|
|
};
|
|
xhr.onabort = () => {
|
|
fail(
|
|
new StuffboxError({
|
|
code: "request_aborted",
|
|
message: "Upload was aborted",
|
|
status: 0,
|
|
}),
|
|
);
|
|
};
|
|
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
onProgress?.(progress("uploading", 0, file.size));
|
|
xhr.send(file);
|
|
});
|
|
}
|
|
|
|
/** Creates a session, PUTs bytes directly, then asks Stuffbox to verify it. */
|
|
export async function uploadFile(
|
|
transport: UploadTransport,
|
|
file: File,
|
|
options: UploadFileOptions = {},
|
|
): Promise<Asset> {
|
|
const transportOptions: UploadTransportOptions = options.signal
|
|
? { signal: options.signal }
|
|
: {};
|
|
options.onProgress?.(progress("creating", 0, file.size));
|
|
const session = await transport.createUpload(
|
|
{
|
|
filename: file.name,
|
|
mimeType: file.type || "application/octet-stream",
|
|
size: file.size,
|
|
...(options.sha256 ? { sha256: options.sha256 } : {}),
|
|
},
|
|
transportOptions,
|
|
);
|
|
|
|
await putFile(session, file, options.signal, options.onProgress);
|
|
options.onProgress?.(progress("completing", file.size, file.size));
|
|
|
|
const asset = await transport.completeUpload(
|
|
session.id,
|
|
session.completionToken
|
|
? { completionToken: session.completionToken }
|
|
: undefined,
|
|
transportOptions,
|
|
);
|
|
options.onProgress?.(progress("complete", file.size, file.size));
|
|
return asset;
|
|
}
|
|
|