@beignet/core
Version:
Core framework primitives for Beignet
918 lines (850 loc) • 25.2 kB
text/typescript
import type {
CompleteUploadResult,
InferUploadMetadata,
InferUploadResult,
PreparedUploadFile,
PreparedUploadResultFile,
PrepareUploadResult,
UploadDef,
UploadFileConstraints,
UploadFileIntent,
UploadFromRegistry,
UploadManifestEntry,
UploadRegistry,
} from "./index.js";
/**
* Upload registry shape accepted by the typed browser client.
*
* Pass the type of a `defineUploads(...)` registry, or an upload definition
* array, to `createUploadClient<...>()`.
*/
export type UploadClientRegistry = UploadRegistry | readonly UploadDef[];
/**
* Union of upload route names available in an upload registry.
*/
export type UploadClientName<Registry> =
UploadFromRegistry<Registry> extends UploadDef<infer Name>
? Name & string
: never;
/**
* Find one upload definition in a registry by its route name.
*/
export type UploadByName<Registry, Name extends string> =
UploadFromRegistry<Registry> extends infer Upload
? Upload extends UploadDef
? Upload extends { readonly name: Name }
? Upload
: never
: never
: never;
/**
* Transport strategy used by `upload(...)`.
*/
export type UploadClientStrategy = "auto" | "direct" | "server";
/**
* Fetch-compatible function used for upload route and direct provider
* requests.
*/
export type UploadClientFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
/**
* Static or lazy headers sent to Beignet upload route requests.
*/
export type UploadClientHeaders =
| HeadersInit
| (() => HeadersInit | Promise<HeadersInit>);
/**
* Request options shared by Beignet upload route requests.
*/
export interface UploadClientRequestOptions {
/**
* Browser credential policy for upload route requests.
*/
credentials?: RequestCredentials;
/**
* Browser request mode for upload route requests.
*/
mode?: RequestMode;
/**
* Browser cache policy for upload route requests.
*/
cache?: RequestCache;
}
/**
* Options for `createUploadClient(...)`.
*/
export interface CreateUploadClientOptions {
/**
* Base URL for the upload route.
*
* @default "/api/uploads"
*/
baseUrl?: string;
/**
* Fetch implementation used for Beignet upload route requests.
*/
fetch?: UploadClientFetch;
/**
* Headers sent to Beignet upload route requests. These are not sent to
* provider-owned direct upload URLs.
*/
headers?: UploadClientHeaders;
/**
* Request options shared by Beignet upload route requests.
*/
request?: UploadClientRequestOptions;
/**
* Optional client-safe upload metadata for UI helpers.
*/
manifest?: readonly UploadManifestEntry[];
}
/**
* File lifecycle event emitted by direct and server upload helpers.
*/
export interface UploadClientFileEvent {
/**
* Browser `File` object being uploaded.
*/
file: File;
/**
* File name supplied by the browser.
*/
fileName: string;
/**
* Zero-based index from the files array passed by the caller.
*/
index: number;
/**
* Prepared upload id when the file has gone through `prepare(...)`.
*/
uploadId?: string;
/**
* Storage key when the file has gone through `prepare(...)`.
*/
key?: string;
}
/**
* Upload progress event emitted while a direct upload is in flight.
*/
export interface UploadClientProgressEvent extends UploadClientFileEvent {
/**
* Uploaded bytes.
*/
loaded: number;
/**
* Total bytes expected for the file.
*/
total: number;
/**
* Fraction from `0` to `1`.
*/
progress: number;
}
/**
* Per-call options shared by upload client route requests.
*/
export interface UploadClientRouteOptions {
/**
* Additional headers sent to Beignet upload route requests.
*/
headers?: UploadClientHeaders;
/**
* Additional request options sent to Beignet upload route requests.
*/
request?: UploadClientRequestOptions;
/**
* Abort signal used for route requests and direct uploads.
*/
signal?: AbortSignal;
}
/**
* Options for preparing an upload.
*/
export interface UploadClientPrepareOptions<Upload extends UploadDef>
extends UploadClientRouteOptions {
/**
* Metadata validated by the upload definition.
*/
metadata: InferUploadMetadata<Upload>;
/**
* Browser files to upload.
*/
files: readonly File[];
}
/**
* Options for direct, server, or automatic upload execution.
*/
export interface UploadClientUploadOptions<Upload extends UploadDef>
extends UploadClientPrepareOptions<Upload> {
/**
* Upload transport strategy. Auto probes for direct-upload instructions and
* falls back to server-handled multipart upload.
*
* @default "auto"
*/
strategy?: UploadClientStrategy;
/**
* Called when a file is about to be uploaded.
*/
onFileBegin?(event: UploadClientFileEvent): void;
/**
* Called with upload progress. Direct uploads use XHR when this callback is
* provided so browsers can report progress events.
*/
onProgress?(event: UploadClientProgressEvent): void;
}
/**
* Browser upload client typed by an upload registry.
*/
export interface UploadClient<Registry extends UploadClientRegistry> {
/**
* Validate metadata and file intent, authorize the upload, and receive
* storage keys plus direct upload instructions when a signer is configured.
*/
prepare<Name extends UploadClientName<Registry>>(
uploadName: Name,
options: UploadClientPrepareOptions<UploadByName<Registry, Name>>,
): Promise<PrepareUploadResult>;
/**
* Complete a prepared direct upload after objects have been written to
* storage.
*/
complete<Name extends UploadClientName<Registry>>(
uploadName: Name,
options: UploadClientRouteOptions & {
metadata: InferUploadMetadata<UploadByName<Registry, Name>>;
files: readonly PreparedUploadFile[];
},
): Promise<
CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
>;
/**
* Upload files through the Beignet application server using multipart form
* data.
*/
server<Name extends UploadClientName<Registry>>(
uploadName: Name,
options: UploadClientUploadOptions<UploadByName<Registry, Name>>,
): Promise<
CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
>;
/**
* Require a direct provider upload flow: prepare, PUT each file to its
* provider URL, then complete.
*/
direct<Name extends UploadClientName<Registry>>(
uploadName: Name,
options: UploadClientUploadOptions<UploadByName<Registry, Name>>,
): Promise<
CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
>;
/**
* Upload using the selected strategy. The default `"auto"` strategy uses a
* direct provider flow when available and falls back to server multipart.
*/
upload<Name extends UploadClientName<Registry>>(
uploadName: Name,
options: UploadClientUploadOptions<UploadByName<Registry, Name>>,
): Promise<
CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
>;
/**
* Return manifest-backed file constraints for UI controls.
*/
constraints<Name extends UploadClientName<Registry>>(
uploadName: Name,
): UploadFileConstraints | undefined;
/**
* Return a comma-delimited file input `accept` value from manifest content
* types.
*/
accept<Name extends UploadClientName<Registry>>(
uploadName: Name,
): string | undefined;
}
/**
* Constructor options for `UploadClientError`.
*/
export interface UploadClientErrorOptions {
/**
* Client operation that failed.
*/
operation: string;
/**
* Upload route name involved in the failure.
*/
uploadName: string;
/**
* Human-readable error message.
*/
message: string;
/**
* HTTP status when a route or provider response was received.
*/
status?: number;
/**
* Machine-readable error code.
*/
code?: string;
/**
* Structured error details from the upload route, when available.
*/
details?: unknown;
/**
* Original error that caused the client failure.
*/
cause?: unknown;
}
/**
* Error thrown by upload client route requests or direct provider uploads.
*/
export class UploadClientError extends Error {
/**
* Client operation that failed.
*/
readonly operation: string;
/**
* Upload route name involved in the failure.
*/
readonly uploadName: string;
/**
* HTTP status when a route or provider response was received.
*/
readonly status?: number;
/**
* Machine-readable error code.
*/
readonly code?: string;
/**
* Structured error details from the upload route, when available.
*/
readonly details?: unknown;
/**
* Create an upload client error.
*/
constructor(options: UploadClientErrorOptions) {
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<
Registry extends UploadClientRegistry = UploadRegistry,
>(options: CreateUploadClientOptions = {}): UploadClient<Registry> {
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?: UploadClientRouteOptions,
contentType?: string,
): Promise<Headers> {
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?: UploadClientRouteOptions,
): UploadClientRequestOptions {
return {
...options.request,
...routeOptions?.request,
};
}
async function prepare<Name extends UploadClientName<Registry>>(
uploadName: Name,
prepareOptions: UploadClientPrepareOptions<UploadByName<Registry, Name>>,
) {
const fileConstraints = constraints(uploadName);
const files = await Promise.all(
prepareOptions.files.map((file) =>
fileIntentFromFile({
file,
constraints: fileConstraints,
uploadName,
}),
),
);
return requestJson<PrepareUploadResult>({
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<Name extends UploadClientName<Registry>>(
uploadName: Name,
completeOptions: UploadClientRouteOptions & {
metadata: InferUploadMetadata<UploadByName<Registry, Name>>;
files: readonly PreparedUploadFile[];
},
) {
return requestJson<
CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
>({
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<Name extends UploadClientName<Registry>>(
uploadName: Name,
uploadOptions: UploadClientUploadOptions<UploadByName<Registry, Name>>,
) {
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<
CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
>({
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<Name extends UploadClientName<Registry>>(
uploadName: Name,
uploadOptions: UploadClientUploadOptions<UploadByName<Registry, Name>>,
) {
const prepared = await prepare(uploadName, uploadOptions);
return directFromPrepared(uploadName, uploadOptions, prepared);
}
async function directFromPrepared<Name extends UploadClientName<Registry>>(
uploadName: Name,
uploadOptions: UploadClientUploadOptions<UploadByName<Registry, Name>>,
prepared: PrepareUploadResult,
) {
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<Name extends UploadClientName<Registry>>(
uploadName: Name,
uploadOptions: UploadClientUploadOptions<UploadByName<Registry, Name>>,
) {
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<Name extends UploadClientName<Registry>>(
uploadName: Name,
) {
return options.manifest?.find((entry) => entry.name === uploadName)?.file;
}
function accept<Name extends UploadClientName<Registry>>(uploadName: Name) {
return constraints(uploadName)?.contentTypes?.join(",");
}
return {
prepare,
complete,
server,
direct,
upload,
constraints,
accept,
};
}
async function fileIntentFromFile(args: {
file: File;
constraints: UploadFileConstraints | undefined;
uploadName: string;
}): Promise<UploadFileIntent> {
const contentType =
normalizeContentType(args.file.type) || "application/octet-stream";
const intent: UploadFileIntent = {
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: PreparedUploadResultFile): PreparedUploadFile {
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: File,
options: { uploadName: string; required: boolean },
): Promise<UploadFileIntent["checksum"]> {
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: string): string {
return baseUrl.replace(/\/+$/, "");
}
function normalizeContentType(contentType: string): string {
return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
}
function actionUrl(
baseUrl: string,
uploadName: string,
action: string,
): string {
return `${baseUrl}/${encodeURIComponent(uploadName)}/${action}`;
}
async function resolveHeaders(
headers: UploadClientHeaders | undefined,
): Promise<HeadersInit | undefined> {
return typeof headers === "function" ? headers() : headers;
}
function mergeHeaders(target: Headers, source: HeadersInit | undefined): void {
if (!source) return;
new Headers(source).forEach((value, key) => {
target.set(key, value);
});
}
async function requestJson<Result>(args: {
fetchImpl: UploadClientFetch;
url: string;
operation: string;
uploadName: string;
init: RequestInit;
}): Promise<Result> {
let response: 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 as Result;
}
async function parseJsonBody(
response: Response,
args: { operation: string; uploadName: string },
): Promise<unknown> {
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: unknown,
): { code?: string; message?: string; details?: unknown } | undefined {
if (typeof body !== "object" || body === null) {
return undefined;
}
const errorRecord = body as Record<string, unknown>;
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: {
fetchImpl: UploadClientFetch;
uploadName: string;
preparedFile: PreparedUploadResultFile;
file: File | undefined;
index: number;
signal?: AbortSignal;
onFileBegin?(event: UploadClientFileEvent): void;
onProgress?(event: UploadClientProgressEvent): void;
}): Promise<void> {
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: {
uploadName: string;
preparedFile: PreparedUploadResultFile;
file: File;
index: number;
signal?: AbortSignal;
onProgress?(event: UploadClientProgressEvent): void;
}): Promise<void> {
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);
});
}