Files
TJWaterFrontend_Refine/public/three-dimensional/zjb/v29/resourceCache.mjs
T
jiang d013f34493
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
fix(scene): resume chunk downloads after proxy interruptions
Whole-file loading still failed after adding caching because the public proxy interrupted large GLB response bodies. Download validated 1 MiB ranges, retry transient failures up to three attempts, and persist completed chunks so reloads resume from their last boundary.

Add regression cases observed failing before the change for interrupted downloads, cross-reload resume, and temporary HTTP failures. Verify range integrity, full-cache reuse, permanent errors, and storage fallback.
2026-10-16 18:37:24 +08:00

189 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Only static scene assets enter this database. Live simulation/SCADA data do not.
const DATABASE = 'tjwater-scene-assets-v1';
const STORE = 'resources';
const REQUEST_TIMEOUT_MS = 30_000;
const CHUNK_BYTES = 1024 * 1024;
const MAX_ATTEMPTS = 3;
function openDatabase() {
return new Promise(resolve => {
let settled = false;
const finish = value => { if (!settled) { settled = true; resolve(value); } };
try {
const request = globalThis.indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE, {keyPath:'url'});
request.onerror = request.onblocked = () => finish(null);
request.onsuccess = () => {
const database = request.result;
if (settled) { database.close(); return; }
database.onversionchange = () => database.close();
finish(database);
};
} catch { finish(null); }
});
}
function transact(database, mode, operation) {
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE, mode);
const request = operation(transaction.objectStore(STORE));
transaction.oncomplete = () => resolve(request.result);
transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error);
});
}
export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
// Open lazily, after synchronous WebGL setup, so database callbacks can run promptly.
let database;
const pending = new Map();
async function read(file, revision, decode) {
const url = new URL(file, baseUrl);
const key = url.href;
const report = (stage, loaded = 0, total = 0) => onProgress({file, stage, loaded, total});
const storage = await (database ??= openDatabase());
if (storage && revision) {
try {
const entry = await transact(storage, 'readonly', store => store.get(key));
if (entry?.revision === revision && entry.data instanceof ArrayBuffer) {
report('cache', entry.data.byteLength, entry.data.byteLength);
const result = await decode(entry.data);
report('ready', entry.data.byteLength, entry.data.byteLength);
return result;
}
} catch { /* Unavailable or invalid cache: fetch a fresh copy below. */ }
}
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
if (revision) url.searchParams.set('v', revision);
const partialKey = key + '#partial';
let chunks = [], loaded = 0, total = 0;
if (storage && revision) {
try {
const partial = await transact(storage, 'readonly', store => store.get(partialKey));
if (partial?.revision === revision && Number.isSafeInteger(partial.total) && partial.total > 0 &&
Array.isArray(partial.chunks) && partial.chunks.length &&
partial.chunks.every(chunk => chunk instanceof ArrayBuffer && chunk.byteLength > 0 && chunk.byteLength <= CHUNK_BYTES)) {
const size = partial.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
if (size <= partial.total) { chunks = partial.chunks; loaded = size; total = partial.total; }
}
} catch { /* A denied partial cache does not prevent downloading. */ }
}
async function downloadPart() {
const controller = new AbortController();
let timer;
const resetTimeout = () => {
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
};
resetTimeout();
try {
const end = total ? Math.min(loaded + CHUNK_BYTES, total) - 1 : loaded + CHUNK_BYTES - 1;
const response = await fetch(url, {signal:controller.signal, cache:'no-cache', headers:{Range:`bytes=${loaded}-${end}`}});
if (!response.ok) {
const error = Error('HTTP ' + response.status);
error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status);
throw error;
}
let partTotal = 0, expected = 0;
if (response.status === 206) {
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
const start = Number(range?.[1]), last = Number(range?.[2]);
partTotal = Number(range?.[3]);
if (!range || start !== loaded || last < start || last > end || last >= partTotal ||
!Number.isSafeInteger(partTotal) || (total && total !== partTotal)) {
throw Error('资源分段响应无效');
}
expected = last - start + 1;
} else {
if (loaded) throw Error('服务器未按断点返回资源');
// Servers without Range support can still return an ordinary complete response.
partTotal = Number(response.headers.get('content-length')) || 0;
}
const buffers = [];
let size = 0;
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffers.push(value); size += value.byteLength;
if (expected && size > expected) throw Error('资源分段长度无效');
report('download', loaded + size, partTotal); resetTimeout();
}
} finally { await reader.cancel().catch(() => {}); }
} else {
const buffer = new Uint8Array(await response.arrayBuffer());
buffers.push(buffer); size = buffer.byteLength;
}
if (expected && size !== expected) throw Error('资源分段不完整');
const bytes = new Uint8Array(size);
let offset = 0;
for (const buffer of buffers) { bytes.set(buffer, offset); offset += buffer.byteLength; }
return {data:bytes.buffer, total:response.status === 206 ? partTotal : size, ranged:response.status === 206};
} catch (error) {
controller.abort();
throw error;
} finally { clearTimeout(timer); }
}
report('download', loaded, total);
try {
while (!total || loaded < total) {
let part;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try { part = await downloadPart(); break; }
catch (error) {
// Permanent HTTP/contract errors stop; temporary transport/server errors can recover.
if (!error.retryable && !(error instanceof TypeError) && error.name !== 'AbortError' && error.message !== '资源分段不完整') throw error;
if (attempt + 1 === MAX_ATTEMPTS) throw error;
report('retry', loaded, total);
await new Promise(resolve => setTimeout(resolve, 300 * (attempt + 1)));
}
}
chunks.push(part.data); loaded += part.data.byteLength; total = part.total;
if (storage && revision && part.ranged) {
try { await transact(storage, 'readwrite', store => store.put({url:partialKey, revision, total, chunks})); }
catch { /* Storage denied/full: retain the in-memory download. */ }
}
if (!part.ranged) break;
}
} catch (error) {
throw Error(file + ':' + (error.name === 'AbortError' ? '下载超时,请重试' : error.message));
}
const bytes = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) { bytes.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; }
const data = bytes.buffer;
if (storage) {
try { await transact(storage, 'readwrite', store => store.delete(partialKey)); }
catch { /* Completed bytes can still be parsed when storage is unavailable. */ }
}
report('parse', data.byteLength, data.byteLength);
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
const result = await decode(data);
if (storage && revision) {
try {
// One entry per URL: a successful new revision replaces the old bytes.
await transact(storage, 'readwrite', store => store.put({url:key, revision, data}));
} catch { /* Storage denied/full must not prevent scene rendering. */ }
}
report('ready', data.byteLength, data.byteLength);
return result;
}
return {
load(file, revision, decode = data => data) {
const key = file + ':' + revision;
if (!pending.has(key)) {
const task = read(file, revision, decode).finally(() => pending.delete(key));
pending.set(key, task);
}
return pending.get(key);
},
};
}