2024-08-18 15:25:31 -05:00
|
|
|
|
import { Chunk } from './Chunk';
|
2024-10-11 01:09:12 -05:00
|
|
|
|
import { UploadInitRequest } from './UploadInitRequest';
|
2023-08-28 01:25:03 -05:00
|
|
|
|
import axios, { AxiosProgressEvent } from 'axios';
|
2023-04-07 13:46:12 -05:00
|
|
|
|
|
2026-01-07 18:24:24 -06:00
|
|
|
|
export class ChunkUploader {
|
2023-08-27 18:17:52 -05:00
|
|
|
|
FileInput: HTMLInputElement | undefined;
|
|
|
|
|
|
UploadButton: HTMLButtonElement | undefined;
|
|
|
|
|
|
ObjectKeyInput: HTMLInputElement | undefined;
|
2023-08-28 01:25:03 -05:00
|
|
|
|
ProgressBar: HTMLElement | undefined;
|
|
|
|
|
|
ProgressText: HTMLElement | undefined;
|
|
|
|
|
|
ProgressRate: HTMLElement | undefined;
|
2023-04-07 13:46:12 -05:00
|
|
|
|
|
2023-08-27 18:17:52 -05:00
|
|
|
|
File: File | undefined;
|
2023-04-07 13:46:12 -05:00
|
|
|
|
|
2025-01-08 17:10:03 -06:00
|
|
|
|
InitRoute: string = "/api/Upload/Init";
|
|
|
|
|
|
ChunkRoute: string = "/api/Upload/Chunk";
|
2023-04-07 13:46:12 -05:00
|
|
|
|
|
2023-04-09 19:20:41 -05:00
|
|
|
|
MaxChunkSize: number = 1024 * 1024 * 50;
|
2023-08-27 18:17:52 -05:00
|
|
|
|
TotalChunks: number = 0;
|
|
|
|
|
|
CurrentChunk: number = 0;
|
|
|
|
|
|
Chunks: Chunk[] = [];
|
2023-04-07 13:46:12 -05:00
|
|
|
|
|
2023-08-27 18:17:52 -05:00
|
|
|
|
Key: string = "";
|
2024-10-11 01:09:12 -05:00
|
|
|
|
StorageLocationId: string = "";
|
2023-08-27 18:17:52 -05:00
|
|
|
|
Id: string = "";
|
2026-01-07 18:24:24 -06:00
|
|
|
|
|
|
|
|
|
|
public static Create(): ChunkUploader {
|
|
|
|
|
|
return new ChunkUploader();
|
|
|
|
|
|
}
|
2023-04-07 13:46:12 -05:00
|
|
|
|
|
2025-01-08 17:10:03 -06:00
|
|
|
|
async Init(fileInputId: string, storageLocationId: string, objectKey: string)
|
|
|
|
|
|
{
|
2023-04-07 15:26:21 -05:00
|
|
|
|
this.FileInput = document.getElementById(fileInputId) as HTMLInputElement;
|
2024-03-30 12:23:56 -05:00
|
|
|
|
this.ProgressBar = document.querySelector('.uploader-progress .ant-progress-circle-path');
|
2023-08-28 01:25:03 -05:00
|
|
|
|
this.ProgressText = document.querySelector('.uploader-progress .ant-progress-text');
|
|
|
|
|
|
this.ProgressRate = document.querySelector('.uploader-progress-rate');
|
2023-08-28 18:00:22 -05:00
|
|
|
|
|
|
|
|
|
|
if (objectKey == undefined || objectKey == "") {
|
2026-04-01 17:25:24 +02:00
|
|
|
|
const request = new UploadInitRequest();
|
2024-10-11 01:09:12 -05:00
|
|
|
|
|
Propagate init net failure instead of swallowing
The original catch block was simply swalloing errors instead of
propagating them upwards.
When the /api/Upload/Init request failed it set this.Key = null, logged
to the console, and returned normally. From the caller's perspective
Init() had succeeded.
Start() in the Blazor component then called Upload() immediately after,
which proceeded with this.Key = null. Every chunk was sent to the server
with a null key, the server returned 400 for each one, each UploadChunk
call threw, and the whole upload failed - but only after sending every
chunk, and only with the opaque "Error uploading chunk N/M" message
rather than anything pointing at the actual cause.
By removing the catch, an axios error in Init() propagates up through
the JS interop call InvokeVoidAsync("Init", ...) in Start(). That throws
a JSException in C#, Start() exits immediately before Upload() is ever
called, and the finally block resets Uploading so the UI recovers. The
user gets a failure where it actually happened.
2026-04-01 14:44:27 +02:00
|
|
|
|
request.storageLocationId = storageLocationId;
|
|
|
|
|
|
request.key = objectKey;
|
2024-10-11 01:09:12 -05:00
|
|
|
|
|
2026-06-04 22:03:18 -05:00
|
|
|
|
const response = await axios.post<{ key: string }>(this.InitRoute, request);
|
2023-08-28 18:00:22 -05:00
|
|
|
|
|
2026-06-04 22:03:18 -05:00
|
|
|
|
this.Key = response.data.key;
|
2023-08-28 18:00:22 -05:00
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
this.Key = objectKey;
|
|
|
|
|
|
|
2023-04-07 15:26:21 -05:00
|
|
|
|
this.Chunks = [];
|
2023-08-28 18:00:22 -05:00
|
|
|
|
}
|
2023-04-07 15:26:21 -05:00
|
|
|
|
|
2023-08-28 18:00:22 -05:00
|
|
|
|
async Upload(dotNetObject: any) {
|
2023-04-07 15:26:21 -05:00
|
|
|
|
this.File = this.FileInput.files.item(0);
|
|
|
|
|
|
this.TotalChunks = Math.ceil(this.File.size / this.MaxChunkSize);
|
|
|
|
|
|
|
2023-08-27 18:17:52 -05:00
|
|
|
|
try {
|
2023-04-07 15:26:21 -05:00
|
|
|
|
this.GetChunks();
|
|
|
|
|
|
|
2023-12-22 02:14:54 -06:00
|
|
|
|
for (let chunk of this.Chunks) {
|
|
|
|
|
|
await this.UploadChunk(chunk);
|
2023-04-07 13:46:12 -05:00
|
|
|
|
}
|
2023-12-22 02:14:54 -06:00
|
|
|
|
|
|
|
|
|
|
dotNetObject.invokeMethodAsync('JSOnUploadComplete', this.Key);
|
2023-08-27 18:17:52 -05:00
|
|
|
|
} catch (ex) {
|
2026-04-01 14:44:27 +02:00
|
|
|
|
const message = ex instanceof Error ? ex.message : String(ex);
|
|
|
|
|
|
dotNetObject.invokeMethodAsync('JSOnUploadError', message);
|
2023-08-28 18:00:22 -05:00
|
|
|
|
console.error(`Could not chunk upload: ${ex}`);
|
2023-04-07 13:46:12 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async UploadChunk(chunk: Chunk) {
|
2026-04-01 17:25:24 +02:00
|
|
|
|
const formData = new FormData();
|
2023-04-07 13:46:12 -05:00
|
|
|
|
|
|
|
|
|
|
formData.append('file', this.File.slice(chunk.Start, chunk.End + 1));
|
|
|
|
|
|
formData.append('start', chunk.Start.toString());
|
|
|
|
|
|
formData.append('end', chunk.End.toString());
|
|
|
|
|
|
formData.append('key', this.Key);
|
|
|
|
|
|
formData.append('total', this.File.size.toString());
|
|
|
|
|
|
|
|
|
|
|
|
console.info(`Uploading chunk ${chunk.Index}/${this.TotalChunks}...`);
|
|
|
|
|
|
|
2023-08-27 18:17:52 -05:00
|
|
|
|
try {
|
2026-04-01 17:25:24 +02:00
|
|
|
|
await axios({
|
2023-08-27 18:17:52 -05:00
|
|
|
|
method: "post",
|
|
|
|
|
|
url: this.ChunkRoute,
|
|
|
|
|
|
data: formData,
|
2025-02-16 00:05:11 -06:00
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "multipart/form-data"
|
|
|
|
|
|
},
|
2023-08-28 01:25:03 -05:00
|
|
|
|
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
|
|
|
|
|
|
console.log(progressEvent);
|
|
|
|
|
|
|
|
|
|
|
|
this.UpdateProgressBar(chunk.Index, progressEvent);
|
|
|
|
|
|
}
|
2023-08-27 18:17:52 -05:00
|
|
|
|
});
|
|
|
|
|
|
} catch (ex) {
|
2023-08-28 01:25:03 -05:00
|
|
|
|
console.error(ex);
|
2023-04-07 13:46:12 -05:00
|
|
|
|
throw `Error uploading chunk ${chunk.Index}/${this.TotalChunks}`;
|
2023-08-27 18:17:52 -05:00
|
|
|
|
}
|
2023-04-07 13:46:12 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
GetChunks() {
|
|
|
|
|
|
for (let currentChunk = 1; currentChunk <= this.TotalChunks; currentChunk++) {
|
2026-04-01 17:25:24 +02:00
|
|
|
|
const start = (currentChunk - 1) * this.MaxChunkSize;
|
2023-04-07 13:46:12 -05:00
|
|
|
|
let end = (currentChunk * this.MaxChunkSize) - 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (currentChunk == this.TotalChunks)
|
|
|
|
|
|
end = this.File.size;
|
|
|
|
|
|
|
2024-10-11 01:09:12 -05:00
|
|
|
|
this.Chunks.push(new Chunk(this.Key, start, end, currentChunk));
|
2023-04-07 13:46:12 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-08-28 01:25:03 -05:00
|
|
|
|
UpdateProgressBar(chunkIndex: number, progressEvent: AxiosProgressEvent) {
|
2026-04-01 17:25:24 +02:00
|
|
|
|
const percent = ((1 / this.TotalChunks) * progressEvent.progress) + ((chunkIndex - 1) / this.TotalChunks);
|
2023-08-28 01:25:03 -05:00
|
|
|
|
|
2026-04-01 14:44:27 +02:00
|
|
|
|
if (this.ProgressBar)
|
|
|
|
|
|
this.ProgressBar.style.strokeDasharray = percent * 295.31 + 'px, 295.31px';
|
2023-08-28 01:25:03 -05:00
|
|
|
|
|
2026-04-01 14:44:27 +02:00
|
|
|
|
if (this.ProgressText)
|
|
|
|
|
|
this.ProgressText.innerText = Math.ceil(percent * 100) + '%';
|
|
|
|
|
|
|
|
|
|
|
|
if (this.ProgressRate && progressEvent.rate > 0)
|
2023-08-28 01:25:03 -05:00
|
|
|
|
this.ProgressRate.innerText = this.GetHumanFileSize(progressEvent.rate, false, 1) + '/s';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
GetHumanFileSize(bytes: number, si: boolean, dp: number) {
|
|
|
|
|
|
const thresh = si ? 1000 : 1024;
|
|
|
|
|
|
|
|
|
|
|
|
if (Math.abs(bytes) < thresh) {
|
|
|
|
|
|
return bytes + ' B';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const units = si
|
|
|
|
|
|
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
|
|
|
|
|
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
|
|
|
|
|
let u = -1;
|
|
|
|
|
|
const r = 10 ** dp;
|
|
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
bytes /= thresh;
|
|
|
|
|
|
++u;
|
|
|
|
|
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
|
|
|
|
|
|
|
|
|
|
|
return bytes.toFixed(dp) + ' ' + units[u];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-03-30 12:23:56 -05:00
|
|
|
|
Clear() {
|
|
|
|
|
|
this.File = undefined;
|
2025-01-08 17:10:03 -06:00
|
|
|
|
|
|
|
|
|
|
if (this.FileInput != null)
|
|
|
|
|
|
this.FileInput.value = "";
|
2024-03-30 12:23:56 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2023-04-07 13:46:12 -05:00
|
|
|
|
OnStart: () => void;
|
|
|
|
|
|
OnComplete: (id: string, key: string) => void;
|
|
|
|
|
|
OnProgress: (percent: number) => void;
|
|
|
|
|
|
OnError: () => void;
|
|
|
|
|
|
}
|