Improve application responsiveness and background processing

This commit is contained in:
2026-10-04 19:56:06 -04:00
parent 1392a082da
commit ed004ddbcf
70 changed files with 1324 additions and 345 deletions
+35 -24
View File
@@ -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>