Files
Oxicloud/static/js/features/files/deltaUpload.js
T

161 lines
6.0 KiB
JavaScript
Raw Normal View History

/**
* OxiCloud - Delta upload ("upload only what changed").
*
* Main-thread orchestrator for `workers/deltaWorker.js`, which runs the
* whole client side of the delta protocol off the UI thread: FastCDC
* chunking + BLAKE3 (the same WASM crate and parameters as the server,
* so boundaries match bit for bit), per-batch negotiation, upload of
* only the missing chunks, and the commit.
*
* This SUBSUMES the previous whole-file instant upload: a fully known
* file negotiates to "nothing missing" and the commit short-circuits on
* possession of the file hash — same zero-byte outcome, one pipeline.
*
* Performance posture:
* - Stages overlap inside the worker (hash ‖ negotiate ‖ upload), so
* wall-clock approaches max(hash, upload) instead of their sum.
* - RAM stays flat: 8 MiB read slices; chunk bytes are re-sliced from
* the File at upload time, never hoarded.
* - Files below {@link DELTA_UPLOAD_MIN_SIZE} skip the pipeline: the
* round-trips cost more than the bytes.
* - Any failure falls back silently to the normal byte upload — delta
* is an optimization, never a gate.
*/
import { getCsrfToken } from '../../core/csrf.js';
/**
* Files smaller than this upload normally: hashing + negotiation
* round-trips outweigh the transfer.
*/
export const DELTA_UPLOAD_MIN_SIZE = 8 * 1024 * 1024;
// Absolute URL on purpose — works in dev and in the release IIFE bundle
// (same pattern as the pdf.js loader in thumbnail.js).
const DELTA_WORKER_URL = '/js/workers/deltaWorker.js';
/** Budget: 120 s base + 90 s per GB (hashing + uploading the delta). */
const DELTA_TIMEOUT_BASE_MS = 120000;
const DELTA_TIMEOUT_PER_GB_MS = 90000;
/**
* `false` once the environment proved unable to run the worker/WASM —
* later files skip straight to the byte upload. `null` = not yet known.
* @type {boolean | null}
*/
let _deltaUploadUsable = null;
/**
* Result contract shared with the uploaders' `UploadAnswer`, plus the
* bandwidth accounting the UI surfaces.
* @typedef {Object} DeltaUploadAnswer
* @property {boolean} ok
* @property {any} [data] FileDto on success
* @property {string} [errorMsg]
* @property {boolean} [isQuotaError]
* @property {number} [savedBytes] bytes NOT transferred thanks to dedup
*/
/**
* Try to upload `file` through the delta protocol.
*
* Resolves `null` whenever the plain byte upload should proceed (file too
* small, environment unusable, any transport/protocol failure). Resolves
* a {@link DeltaUploadAnswer} when the outcome is conclusive — success,
* quota exceeded, or a name conflict a byte upload would also hit.
*
* @param {File} file
* @param {string | null | undefined} folderId
* @param {(pct: number) => void} [onProgress] 0-99 while transferring
* @returns {Promise<DeltaUploadAnswer | null>}
*/
export function tryDeltaUpload(file, folderId, onProgress) {
if (!folderId || file.size < DELTA_UPLOAD_MIN_SIZE || _deltaUploadUsable === false || typeof Worker === 'undefined') {
return Promise.resolve(null);
}
return new Promise((resolve) => {
/** @type {Worker} */
let worker;
try {
worker = new Worker(DELTA_WORKER_URL, { type: 'module' });
} catch (_) {
_deltaUploadUsable = false;
resolve(null);
return;
}
const sizeGB = file.size / (1024 * 1024 * 1024);
const timeoutMs = DELTA_TIMEOUT_BASE_MS + Math.ceil(sizeGB) * DELTA_TIMEOUT_PER_GB_MS;
let savedBytes = 0;
/** @param {DeltaUploadAnswer | null} answer */
const settle = (answer) => {
clearTimeout(timer);
worker.terminate();
resolve(answer);
};
const timer = setTimeout(() => settle(null), timeoutMs);
worker.onmessage = (event) => {
const msg = /** @type {any} */ (event.data);
if (msg.type === 'progress') {
savedBytes = msg.reusedBytes;
if (onProgress && msg.totalBytes > 0) {
const pct = Math.min(99, Math.round((100 * (msg.reusedBytes + msg.uploadedBytes)) / msg.totalBytes));
onProgress(pct);
}
return;
}
if (msg.type === 'fallback') {
settle(null);
return;
}
if (msg.type === 'done') {
if (msg.status === 201 || msg.status === 200) {
settle({ ok: true, data: msg.body, savedBytes });
return;
}
/** @type {string} */
const errorMsg = msg.body?.message || msg.body?.error || `Delta upload failed (HTTP ${msg.status})`;
if (msg.status === 507) {
settle({ ok: false, isQuotaError: true, errorMsg });
return;
}
if (msg.status === 409 && !msg.body?.still_missing) {
// Duplicate name — a byte upload would hit the same wall.
settle({ ok: false, errorMsg });
return;
}
// still_missing exhausted, 4xx/5xx oddities: byte upload is
// the safe road (the server dedups it on write anyway).
settle(null);
}
};
worker.onerror = () => {
// Worker script failed to load/parse — permanent environment trait.
_deltaUploadUsable = false;
settle(null);
};
worker.postMessage({
file,
folderId,
name: file.name,
csrfToken: getCsrfToken() || ''
});
});
}
/**
* Bilingual one-line summary for the bandwidth saved by a batch.
* @param {number} savedBytes
* @param {string} locale
* @returns {string}
*/
export function formatSavedSummary(savedBytes, locale) {
const mb = (savedBytes / (1024 * 1024)).toFixed(1);
return locale.startsWith('es') ? `Deduplicación: ${mb} MB no necesitaron subirse` : `Deduplication: ${mb} MB didn't need uploading`;
}