Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 121 additions & 10 deletions packages/sdk/src/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,33 @@ const putPresignedFile = async (
presignedFile: PresignedFile,
options?: UploadOptions,
): Promise<UploadedFile> => {
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 = {
Expand All @@ -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<void>((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;
Expand Down Expand Up @@ -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,
};
};

Expand Down
87 changes: 87 additions & 0 deletions packages/sdk/test/uploads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,86 @@ describe("uploads", () => {
}
});

it("uses browser XHR upload progress when available", async () => {
const apiFetch = vi.fn<typeof fetch>(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<string, string> = {};
method?: string;
onabort: ((event: ProgressEvent<EventTarget>) => void) | null = null;
onerror: ((event: ProgressEvent<EventTarget>) => void) | null = null;
onload: ((event: ProgressEvent<EventTarget>) => void) | null = null;
status = 0;
upload = {
onprogress: null as ((event: ProgressEvent<EventTarget>) => 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<typeof fetch>(async (input) => {
const request = asRequest(input);
Expand Down Expand Up @@ -92,6 +172,13 @@ describe("uploads", () => {
});
});

const progressEvent = (loaded: number, total: number) =>
({
lengthComputable: true,
loaded,
total,
}) as ProgressEvent<EventTarget>;

const asRequest = (input: RequestInfo | URL) => {
if (input instanceof Request) {
return input;
Expand Down
Loading