Publish the unified MIT-licensed Stuffbox SDK source
Hop-State: A_06FPAF0M83E7T0JJ7EP2WQ0 Hop-Proposal: R_06FPAF04S0D4G8TA8TNK3C0 Hop-Task: T_06FPADTV2YD4PD5GMTK3150 Hop-Attempt: AT_06FPADTV2ZYKEY8GH3JTK98
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user