Large plugin packages previously uploaded through a single multipart POST, which can exceed proxy/body-size limits and offered no progress feedback. Add a chunked upload path alongside the existing one. Server: - New generic internal/uploads session Manager assembles chunks into a pre-sized temp file via positioned writes, with TTL expiry, idempotent chunk retries, and size validation. - Four endpoints under /admin/plugins/uploads/chunked (create/put-chunk/complete/cancel); completion reuses the shared install path and sniffs the zip magic from disk instead of reading the whole archive into memory. Web: - Reusable uploadFileInChunks helper with adaptive chunk-size backoff on 413, plus a usePluginUpload hook and shared Progress component driving the upload progress bars on both admin plugin pages. - Files at or below the chunk threshold keep using the multipart path. - api() now respects a caller-supplied Content-Type so chunks can be sent as application/octet-stream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { ApiClientError, api } from "@/api/client";
|
|
import { uploadFileInChunks, type ChunkedUploadSession } from "./chunkedUpload";
|
|
|
|
vi.mock("@/api/client", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/api/client")>()),
|
|
api: vi.fn(),
|
|
}));
|
|
|
|
const apiMock = vi.mocked(api);
|
|
|
|
describe("uploadFileInChunks", () => {
|
|
beforeEach(() => {
|
|
apiMock.mockReset();
|
|
});
|
|
|
|
it("creates an upload session, sends file slices, then completes the upload", async () => {
|
|
const uploadedChunks: string[] = [];
|
|
|
|
apiMock.mockImplementation(async (path: string, options?: RequestInit) => {
|
|
if (path === "/uploads") {
|
|
return session({ received_chunks: 0, received_bytes: 0 });
|
|
}
|
|
if (path.startsWith("/uploads/session-1/chunks/")) {
|
|
const body = options?.body;
|
|
if (!(body instanceof Blob)) {
|
|
throw new Error("chunk body must be a Blob");
|
|
}
|
|
uploadedChunks.push(await body.text());
|
|
return session({
|
|
received_chunks: uploadedChunks.length,
|
|
received_bytes: uploadedChunks.join("").length,
|
|
});
|
|
}
|
|
if (path === "/uploads/session-1/complete") {
|
|
return { installed: true };
|
|
}
|
|
throw new Error(`unexpected path ${path}`);
|
|
});
|
|
|
|
const progress: number[] = [];
|
|
const result = await uploadFileInChunks<{ installed: boolean }>({
|
|
file: new File(["abcdefghij"], "plugin.bin"),
|
|
createPath: "/uploads",
|
|
chunkPath: (uploadId, index) => `/uploads/${uploadId}/chunks/${index}`,
|
|
completePath: (uploadId) => `/uploads/${uploadId}/complete`,
|
|
chunkSize: 4,
|
|
onProgress: (next) => progress.push(next.percent),
|
|
});
|
|
|
|
expect(result).toEqual({ installed: true });
|
|
expect(uploadedChunks).toEqual(["abcd", "efgh", "ij"]);
|
|
expect(progress).toEqual([0, 40, 80, 100]);
|
|
expect(apiMock).toHaveBeenCalledWith("/uploads", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
filename: "plugin.bin",
|
|
size_bytes: 10,
|
|
chunk_size: 4,
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("retries with a smaller chunk size when the proxy rejects the first chunk", async () => {
|
|
const file = new File([new Uint8Array(300 * 1024)], "plugin.bin");
|
|
const createChunkSizes: number[] = [];
|
|
|
|
apiMock.mockImplementation(async (path: string, options?: RequestInit) => {
|
|
if (path === "/uploads") {
|
|
const body = JSON.parse(String(options?.body)) as { chunk_size: number };
|
|
createChunkSizes.push(body.chunk_size);
|
|
const uploadID = `session-${createChunkSizes.length}`;
|
|
return sessionForFile(file, uploadID, body.chunk_size, 0);
|
|
}
|
|
|
|
if (path === "/uploads/session-1/chunks/0") {
|
|
throw new ApiClientError(413, "unknown", "Request Entity Too Large");
|
|
}
|
|
|
|
if (path === "/uploads/session-1") {
|
|
return undefined;
|
|
}
|
|
|
|
if (path.startsWith("/uploads/session-2/chunks/")) {
|
|
const parts = path.split("/");
|
|
const chunkIndex = Number(parts[parts.length - 1]);
|
|
const chunkSize = createChunkSizes[createChunkSizes.length - 1] ?? 128 * 1024;
|
|
return sessionForFile(file, "session-2", chunkSize, chunkIndex + 1);
|
|
}
|
|
|
|
if (path === "/uploads/session-2/complete") {
|
|
return { installed: true };
|
|
}
|
|
|
|
throw new Error(`unexpected path ${path}`);
|
|
});
|
|
|
|
const result = await uploadFileInChunks<{ installed: boolean }>({
|
|
file,
|
|
createPath: "/uploads",
|
|
chunkPath: (uploadId, index) => `/uploads/${uploadId}/chunks/${index}`,
|
|
completePath: (uploadId) => `/uploads/${uploadId}/complete`,
|
|
cancelPath: (uploadId) => `/uploads/${uploadId}`,
|
|
chunkSize: 256 * 1024,
|
|
});
|
|
|
|
expect(result).toEqual({ installed: true });
|
|
expect(createChunkSizes).toEqual([256 * 1024, 128 * 1024]);
|
|
expect(apiMock).toHaveBeenCalledWith("/uploads/session-1", { method: "DELETE" });
|
|
});
|
|
});
|
|
|
|
function session(overrides: Partial<ChunkedUploadSession>): ChunkedUploadSession {
|
|
return {
|
|
upload_id: "session-1",
|
|
filename: "plugin.bin",
|
|
size_bytes: 10,
|
|
chunk_size: 4,
|
|
total_chunks: 3,
|
|
received_chunks: 0,
|
|
received_bytes: 0,
|
|
complete: false,
|
|
expires_at: "2026-06-05T12:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function sessionForFile(
|
|
file: File,
|
|
uploadId: string,
|
|
chunkSize: number,
|
|
receivedChunks: number,
|
|
): ChunkedUploadSession {
|
|
return session({
|
|
upload_id: uploadId,
|
|
size_bytes: file.size,
|
|
chunk_size: chunkSize,
|
|
total_chunks: Math.ceil(file.size / chunkSize),
|
|
received_chunks: receivedChunks,
|
|
received_bytes: Math.min(file.size, receivedChunks * chunkSize),
|
|
});
|
|
}
|