Improve application responsiveness and background processing
This commit is contained in:
@@ -26,7 +26,9 @@
|
||||
let phase = $state<Phase>('idle'); let currentPath = $state(''); let pauseRequested = $state(false);
|
||||
let acceptedBytes = $state(0); let sendingBytes = $state(0); let completedFiles = $state(0);
|
||||
let folderInput = $state<HTMLInputElement>();
|
||||
let activeRequest: XMLHttpRequest | null = null; let disposed = false;
|
||||
const activeRequests = new Map<XMLHttpRequest, number>(); let disposed = false; let failedTransfer = false;
|
||||
const updateSending = () => { sendingBytes = [...activeRequests.values()].reduce((a, b) => a + b, 0); };
|
||||
const abortUploads = () => { for (const request of activeRequests.keys()) request.abort(); };
|
||||
const canManage = $derived($page.data.user?.role === 'admin');
|
||||
const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled'));
|
||||
const totalBytes = $derived(folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0);
|
||||
@@ -122,21 +124,21 @@
|
||||
finally { uploadBusy = false; }
|
||||
await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
function pauseUpload() { pauseRequested = true; activeRequest?.abort(); }
|
||||
function pauseUpload() { pauseRequested = true; abortUploads(); }
|
||||
async function sendChunk(path: string, file: File, offset: number, length: number): Promise<number> {
|
||||
const chunk = file.slice(offset, offset + length);
|
||||
const hash = await crypto.subtle.digest('SHA-256', await chunk.arrayBuffer());
|
||||
if (pauseRequested || disposed) throw new Error('Upload paused.');
|
||||
if (pauseRequested || disposed || failedTransfer) throw new Error('Upload paused.');
|
||||
const sha256 = [...new Uint8Array(hash)].map(value => value.toString(16).padStart(2, '0')).join('');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest(); activeRequest = request;
|
||||
const request = new XMLHttpRequest(); activeRequests.set(request, 0);
|
||||
request.open('PUT', `/api/game-files/${uploadId}/upload/files/${path.split('/').map(encodeURIComponent).join('/')}`);
|
||||
request.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
request.setRequestHeader('X-Upload-Offset', String(offset)); request.setRequestHeader('X-Upload-Length', String(length));
|
||||
request.setRequestHeader('X-Content-SHA256', sha256); request.timeout = 300_000;
|
||||
request.upload.onprogress = event => { sendingBytes = Math.min(length, event.loaded); };
|
||||
request.upload.onprogress = event => { activeRequests.set(request, Math.min(length, event.loaded)); updateSending(); };
|
||||
request.onload = () => {
|
||||
activeRequest = null;
|
||||
activeRequests.delete(request); updateSending();
|
||||
try {
|
||||
const body = JSON.parse(request.responseText);
|
||||
if (request.status < 200 || request.status >= 300) throw new Error(body.error ?? `Upload failed (${request.status}).`);
|
||||
@@ -144,16 +146,16 @@
|
||||
resolve(body.receivedBytes);
|
||||
} catch (cause) { reject(cause); }
|
||||
};
|
||||
request.onerror = () => { activeRequest = null; reject(new Error('The connection was interrupted. Resume to continue from the last saved chunk.')); };
|
||||
request.ontimeout = () => { activeRequest = null; reject(new Error('This chunk timed out. Resume to check saved progress and retry.')); };
|
||||
request.onabort = () => { activeRequest = null; reject(new Error('Upload paused.')); };
|
||||
request.onerror = () => { activeRequests.delete(request); updateSending(); reject(new Error('The connection was interrupted. Resume to continue from the last saved chunk.')); };
|
||||
request.ontimeout = () => { activeRequests.delete(request); updateSending(); reject(new Error('This chunk timed out. Resume to check saved progress and retry.')); };
|
||||
request.onabort = () => { activeRequests.delete(request); updateSending(); reject(new Error('Upload paused.')); };
|
||||
request.send(chunk);
|
||||
});
|
||||
}
|
||||
async function startUpload(event: SubmitEvent) {
|
||||
event.preventDefault(); if (uploadBusy || !folder) return;
|
||||
if (!globalThis.crypto?.subtle) { uploadError = 'Folder uploads require a secure connection. Open this controller over HTTPS or localhost.'; return; }
|
||||
uploadBusy = true; pauseRequested = false; uploadError = ''; notice = ''; phase = 'preparing';
|
||||
uploadBusy = true; pauseRequested = false; failedTransfer = false; uploadError = ''; notice = ''; phase = 'preparing';
|
||||
try {
|
||||
if (!uploadId) {
|
||||
const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) }));
|
||||
@@ -165,19 +167,28 @@
|
||||
const localFiles = new Map(folder.files.map(item => [item.path, item.file]));
|
||||
const chunkSize = Math.min(8_388_608, Math.max(1, status.chunkSize));
|
||||
phase = 'uploading';
|
||||
for (const remote of status.files) {
|
||||
if (pauseRequested || disposed) break;
|
||||
if (remote.complete) continue;
|
||||
currentPath = remote.path;
|
||||
const file = localFiles.get(remote.path)!;
|
||||
do {
|
||||
if (pauseRequested || disposed) break;
|
||||
const length = Math.min(chunkSize, remote.size - remote.receivedBytes);
|
||||
remote.receivedBytes = await sendChunk(remote.path, file, remote.receivedBytes, length);
|
||||
acceptedBytes += length; sendingBytes = 0;
|
||||
if (remote.receivedBytes === remote.size) { remote.complete = true; completedFiles++; }
|
||||
} while (!remote.complete);
|
||||
}
|
||||
let nextFile = 0;
|
||||
let failure: unknown;
|
||||
const transfer = async () => {
|
||||
while (!pauseRequested && !disposed && !failure) {
|
||||
const remote = status.files[nextFile++];
|
||||
if (!remote) return;
|
||||
if (remote.complete) continue;
|
||||
currentPath = remote.path;
|
||||
const file = localFiles.get(remote.path)!;
|
||||
try {
|
||||
do {
|
||||
if (pauseRequested || disposed || failure) return;
|
||||
const length = Math.min(chunkSize, remote.size - remote.receivedBytes);
|
||||
remote.receivedBytes = await sendChunk(remote.path, file, remote.receivedBytes, length);
|
||||
acceptedBytes += length;
|
||||
if (remote.receivedBytes === remote.size) { remote.complete = true; completedFiles++; }
|
||||
} while (!remote.complete);
|
||||
} catch (cause) { failure ??= cause; failedTransfer = true; abortUploads(); }
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(3, status.files.length) }, transfer));
|
||||
if (failure) throw failure;
|
||||
if (pauseRequested || disposed) { phase = 'paused'; return; }
|
||||
phase = 'verifying'; currentPath = '';
|
||||
const verification = await responseBody(await fetch(`/api/game-files/${uploadId}/upload/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })) as UploadStatus;
|
||||
@@ -192,7 +203,7 @@
|
||||
});
|
||||
onMount(() => {
|
||||
const stop = pollVisible(load, 5000);
|
||||
return () => { disposed = true; pauseRequested = true; activeRequest?.abort(); stop(); };
|
||||
return () => { disposed = true; pauseRequested = true; abortUploads(); stop(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user