Files
stuffbox-sdk/src/react/upload.ts
T
cyph3rasi 9e1ed2c9cb Publish the unified MIT-licensed Stuffbox SDK source
Hop-State: A_06FPAF0M83E7T0JJ7EP2WQ0
Hop-Proposal: R_06FPAF04S0D4G8TA8TNK3C0
Hop-Task: T_06FPADTV2YD4PD5GMTK3150
Hop-Attempt: AT_06FPADTV2ZYKEY8GH3JTK98
2026-07-15 03:04:24 -07:00

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;
}