CyberSys/fruit-fly-simulation
0
1// The page supplies the public asset root to its bundled worker. This keeps2// runtime downloads correct in development and under any production subpath.3let assetBase;4export function configureAssetBase(url) {5 assetBase = new URL(url).href;6}7export function assetURL(path) {8 if (!assetBase) throw Error('Asset base has not been configured');9 return new URL(path, assetBase).href;10}11 12const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));13const transient = new Set([408, 429, 500, 502, 503, 504]);14 15/** Retry the same file, honoring server cooldowns. Downloaded chunks survive retries/reloads. */16export async function requestBytes(17 url,18 { fetcher = fetch, wait = sleep, notice = () => {}, attempts = 6 } = {},19) {20 url = assetURL(url);21 for (let attempt = 0; attempt < attempts; attempt++) {22 const controller = new AbortController();23 const timer = setTimeout(() => controller.abort(), 45000);24 let delay = 0;25 try {26 const response = await fetcher(url, { signal: controller.signal });27 if (response.ok) return await response.arrayBuffer();28 const retryAfter = response.headers.get('Retry-After');29 const seconds = retryAfter === null ? NaN : Number(retryAfter);30 delay = Number.isFinite(seconds)31 ? seconds * 100032 : Math.max(0, Date.parse(retryAfter) - Date.now()) || 0;33 await response.body?.cancel();34 if (!transient.has(response.status))35 throw Object.assign(36 Error(`Data download failed (HTTP ${response.status}). Please try again later.`),37 { permanent: true },38 );39 if (attempt === attempts - 1)40 throw Object.assign(41 Error(42 'The data server is still busy. Try loading again shortly; saved chunks will be reused.',43 ),44 { permanent: true },45 );46 } catch (error) {47 if (error.permanent) throw error;48 if (attempt === attempts - 1)49 throw Error(50 'The download was interrupted. Check your connection and try again; saved chunks will be reused.',51 );52 } finally {53 clearTimeout(timer);54 }55 delay = Math.max(delay, Math.min(30000, 1000 * 2 ** attempt));56 // Heartbeats keep the initialization watchdog informed during Retry-After waits.57 while (delay > 0) {58 notice(59 `Download paused · retrying in ${Math.ceil(delay / 1000)}s. Completed files are kept.`,60 );61 const step = Math.min(delay, 10000);62 await wait(step);63 delay -= step;64 }65 }66}67 68export async function loadGraph(progress = () => {}, notice = () => {}) {69 const manifestBytes = await requestBytes('./data/manifest.json', { notice });70 const manifest = JSON.parse(new TextDecoder().decode(manifestBytes));71 let cache;72 try {73 cache = await globalThis.caches?.open('malecns-verified-data-v1');74 } catch {75 /* Private browsing or storage restrictions: proceed without persistent cache. */76 }77 const manifestHash = await digest(manifestBytes);78 let lastRequest = 0;79 const pacedFetch = async (...args) => {80 await sleep(Math.max(0, 250 - (Date.now() - lastRequest)));81 lastRequest = Date.now();82 return fetch(...args);83 };84 async function unpack(file, hash) {85 const url = './data/' + file,86 key = new URL(assetURL(url));87 key.searchParams.set('content', hash ?? manifestHash);88 let bytes;89 try {90 const saved = await cache?.match(key.href);91 if (saved) bytes = await saved.arrayBuffer();92 } catch {93 /* Cache is optional. */94 }95 if (bytes && hash && (await digest(bytes)) !== hash) {96 await cache?.delete(key.href).catch(() => {});97 bytes = null;98 }99 if (!bytes) {100 bytes = await requestBytes(url, { notice, fetcher: pacedFetch });101 if (hash && (await digest(bytes)) !== hash)102 throw Error('A downloaded data file failed its checksum. Please retry loading.');103 }104 let unpacked;105 try {106 unpacked = await new Response(107 new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')),108 ).arrayBuffer();109 } catch {110 try {111 await cache?.delete(key.href);112 } catch {}113 throw Error('A data file could not be decompressed. Retry loading to download it again.');114 }115 try {116 if (!(await cache?.match(key.href))) await cache?.put(key.href, new Response(bytes));117 } catch {118 /* Storage is optional. */119 }120 return unpacked;121 }122 notice('Loading saved files and downloading remaining connectivity…');123 const neurons = JSON.parse(new TextDecoder().decode(await unpack(manifest.metadata)));124 const graph = {125 n: manifest.neurons,126 neurons,127 manifest,128 sign: Int32Array.from(neurons, (row) => row[5]),129 };130 let done = 0;131 const total = manifest.arrays.reduce((sum, array) => sum + array.parts.length, 0);132 for (const array of manifest.arrays) {133 const values = new Uint32Array(array.length);134 let offset = 0;135 for (const part of array.parts) {136 const chunk = new Uint32Array(await unpack(part.file, part.sha256));137 values.set(chunk, offset);138 offset += chunk.length;139 progress(++done / total);140 }141 if (offset !== values.length) throw Error('Invalid data length for ' + array.name);142 graph[array.name] = values;143 }144 if (145 graph.offsets.length !== graph.n + 1 ||146 graph.offsets[graph.n] !== graph.sources.length ||147 graph.counts.length !== graph.sources.length148 )149 throw Error('Invalid CSR structure');150 return graph;151}152async function digest(bytes) {153 return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), (x) =>154 x.toString(16).padStart(2, '0'),155 ).join('');156}157 