From 3163082aafb7a1f58025cb793a856b57e71bd51a Mon Sep 17 00:00:00 2001 From: dzucconi Date: Wed, 29 Apr 2026 17:37:44 -0400 Subject: [PATCH] Supports upload progress on the client-side --- packages/sdk/src/uploads.ts | 131 +++++++++++++++++++++++++++--- packages/sdk/test/uploads.test.ts | 87 ++++++++++++++++++++ 2 files changed, 208 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/uploads.ts b/packages/sdk/src/uploads.ts index 483f797..8a20cb5 100644 --- a/packages/sdk/src/uploads.ts +++ b/packages/sdk/src/uploads.ts @@ -138,6 +138,33 @@ const putPresignedFile = async ( presignedFile: PresignedFile, options?: UploadOptions, ): Promise => { + await uploadPresignedFile(file, presignedFile, options); + + return { + contentType: presignedFile.content_type, + key: presignedFile.key, + uploadUrl: presignedFile.upload_url, + url: `${TEMP_BUCKET_BASE_URL}/${encodeS3KeyForUrl(presignedFile.key)}`, + }; +}; + +const uploadPresignedFile = ( + file: NormalizedUploadInput, + presignedFile: PresignedFile, + options?: UploadOptions, +) => { + if (options?.onProgress && typeof XMLHttpRequest !== "undefined") { + return uploadWithXhr(file, presignedFile, options); + } + + return uploadWithFetch(file, presignedFile, options); +}; + +const uploadWithFetch = async ( + file: NormalizedUploadInput, + presignedFile: PresignedFile, + options?: UploadOptions, +) => { options?.onProgress?.(0, file.size); const requestInit: RequestInit = { @@ -159,17 +186,98 @@ const putPresignedFile = async ( } options?.onProgress?.(file.size ?? 1, file.size); +}; - return { - contentType: presignedFile.content_type, - key: presignedFile.key, - uploadUrl: presignedFile.upload_url, - url: `${TEMP_BUCKET_BASE_URL}/${encodeS3KeyForUrl(presignedFile.key)}`, - }; +const uploadWithXhr = ( + file: NormalizedUploadInput, + presignedFile: PresignedFile, + options: UploadOptions, +) => + new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()); + return; + } + + const xhr = new XMLHttpRequest(); + let lastProgress: { sent: number; total?: number } | undefined; + let settled = false; + + const emitProgress = (sent: number, total?: number) => { + if (lastProgress?.sent === sent && lastProgress.total === total) { + return; + } + + lastProgress = { sent, total }; + options.onProgress?.(sent, total); + }; + + const cleanup = () => { + options.signal?.removeEventListener("abort", abortUpload); + }; + + const settle = (callback: () => void) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + callback(); + }; + + const abortUpload = () => { + xhr.abort(); + }; + + options.signal?.addEventListener("abort", abortUpload, { once: true }); + + xhr.upload.onprogress = (event) => { + emitProgress(event.loaded, event.lengthComputable ? event.total : file.size); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + settle(() => { + emitProgress(file.size ?? 1, file.size); + resolve(); + }); + return; + } + + settle(() => { + reject(new Error(`S3 upload failed with status ${xhr.status}.`)); + }); + }; + + xhr.onerror = () => { + settle(() => { + reject(new Error("Network error during S3 upload.")); + }); + }; + + xhr.onabort = () => { + settle(() => { + reject(createAbortError()); + }); + }; + + xhr.open("PUT", presignedFile.upload_url); + xhr.setRequestHeader("Content-Type", presignedFile.content_type); + emitProgress(0, file.size); + xhr.send(file.body); + }); + +const createAbortError = () => { + if (typeof DOMException !== "undefined") { + return new DOMException("The operation was aborted.", "AbortError"); + } + + return new Error("The operation was aborted."); }; interface NormalizedUploadInput { - body: BodyInit; + body: Blob | ArrayBuffer; contentType: string; filename: string; size?: number; @@ -201,12 +309,15 @@ const normalizeUploadInput = async ( throw new Error("Unsupported upload input."); } - const body = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data); + const bytes = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data); + const body = new ArrayBuffer(bytes.byteLength); + new Uint8Array(body).set(bytes); + return { - body: body as unknown as BodyInit, + body, contentType: options?.contentType ?? input.contentType, filename: options?.filename ?? input.filename, - size: body.byteLength, + size: bytes.byteLength, }; }; diff --git a/packages/sdk/test/uploads.test.ts b/packages/sdk/test/uploads.test.ts index e1eb74b..deddada 100644 --- a/packages/sdk/test/uploads.test.ts +++ b/packages/sdk/test/uploads.test.ts @@ -59,6 +59,86 @@ describe("uploads", () => { } }); + it("uses browser XHR upload progress when available", async () => { + const apiFetch = vi.fn(async () => + json( + { + expires_in: 3600, + files: [ + { + content_type: "text/plain", + key: "uploads/file.txt", + upload_url: "https://s3.example/upload", + }, + ], + }, + { status: 201 }, + ), + ); + const originalXhr = globalThis.XMLHttpRequest; + const xhrInstances: MockXMLHttpRequest[] = []; + + class MockXMLHttpRequest { + headers: Record = {}; + method?: string; + onabort: ((event: ProgressEvent) => void) | null = null; + onerror: ((event: ProgressEvent) => void) | null = null; + onload: ((event: ProgressEvent) => void) | null = null; + status = 0; + upload = { + onprogress: null as ((event: ProgressEvent) => void) | null, + }; + url?: string; + + constructor() { + xhrInstances.push(this); + } + + abort = vi.fn(); + open = vi.fn((method: string, url: string) => { + this.method = method; + this.url = url; + }); + send = vi.fn((body: Blob | BufferSource | null) => { + expect(body).toBeInstanceOf(ArrayBuffer); + + this.upload.onprogress?.(progressEvent(2, 5)); + this.upload.onprogress?.(progressEvent(5, 5)); + this.status = 200; + this.onload?.(progressEvent(5, 5)); + }); + setRequestHeader = vi.fn((name: string, value: string) => { + this.headers[name] = value; + }); + } + + globalThis.XMLHttpRequest = MockXMLHttpRequest as unknown as typeof XMLHttpRequest; + + try { + const arena = createArena({ fetch: apiFetch }); + const progress = vi.fn(); + + await arena.uploads.upload( + { + contentType: "text/plain", + data: new TextEncoder().encode("hello"), + filename: "file.txt", + }, + { onProgress: progress }, + ); + + expect(xhrInstances).toHaveLength(1); + expect(xhrInstances[0]?.method).toBe("PUT"); + expect(xhrInstances[0]?.url).toBe("https://s3.example/upload"); + expect(xhrInstances[0]?.headers).toEqual({ "Content-Type": "text/plain" }); + expect(progress).toHaveBeenNthCalledWith(1, 0, 5); + expect(progress).toHaveBeenNthCalledWith(2, 2, 5); + expect(progress).toHaveBeenNthCalledWith(3, 5, 5); + } finally { + globalThis.XMLHttpRequest = originalXhr; + } + }); + it("sends raw filenames when presigning directly", async () => { const apiFetch = vi.fn(async (input) => { const request = asRequest(input); @@ -92,6 +172,13 @@ describe("uploads", () => { }); }); +const progressEvent = (loaded: number, total: number) => + ({ + lengthComputable: true, + loaded, + total, + }) as ProgressEvent; + const asRequest = (input: RequestInfo | URL) => { if (input instanceof Request) { return input;