Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material. Connect existing skeletons to pending navigation and route boundaries while preserving the map. Previously only dynamic imports showed fallbacks, and rejected model promises prevented retries. Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks.
115 lines
4.2 KiB
JavaScript
115 lines
4.2 KiB
JavaScript
// 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;
|
||
|
||
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 controller = new AbortController();
|
||
let timer;
|
||
const resetTimeout = () => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||
};
|
||
report('download');
|
||
resetTimeout();
|
||
let data;
|
||
try {
|
||
const response = await fetch(url, {signal:controller.signal, cache:'no-cache'});
|
||
if (!response.ok) throw Error('HTTP ' + response.status);
|
||
const total = Number(response.headers.get('content-length')) || 0;
|
||
if (response.body) {
|
||
const reader = response.body.getReader();
|
||
const chunks = [];
|
||
let loaded = 0;
|
||
while (true) {
|
||
const {done, value} = await reader.read();
|
||
if (done) break;
|
||
chunks.push(value); loaded += value.byteLength;
|
||
report('download', loaded, total); resetTimeout();
|
||
}
|
||
const bytes = new Uint8Array(loaded);
|
||
let offset = 0;
|
||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
||
data = bytes.buffer;
|
||
} else {
|
||
data = await response.arrayBuffer();
|
||
}
|
||
} catch (error) {
|
||
throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message));
|
||
} finally { clearTimeout(timer); }
|
||
|
||
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);
|
||
},
|
||
};
|
||
}
|