UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

918 lines (850 loc) 25.2 kB
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); }); }