updraft / lib /fetch-progress.js
Nicholas Celestin
Build update — 2026-05-22T18:34:00.912Z
3f22414
Raw
History Blame Contribute Delete
4.18 kB
/**
* Fetch a URL with streaming download progress and Cache API persistence.
*
* Built-in models use relative URLs (e.g. `models/foo.onnx`). In the web
* app those resolve against the deploy origin and just work. In the
* desktop app they resolve against `http://127.0.0.1:<random>/`, where
* the file is only present if the user bundled it at download time. When
* the local fetch 404s and `branding.json` defines a `remoteOrigin`, we
* re-fetch from there — turning the local bundle into a preload
* optimization rather than a hard gate.
*
* Cache keys are normalised against `remoteOrigin` (when configured) so
* the Cache API entry survives across desktop launches even though the
* localhost port is ephemeral.
*
* @param {string} url
* @param {(frac: number, message: string) => void} [onProgress]
* @returns {Promise<ArrayBuffer>}
*/
const MODEL_CACHE_NAME = 'aitools-models-v1';
export const CUSTOM_MODEL_URL_PREFIX = 'https://cache.aitools.local/custom-model/';
const LEGACY_CUSTOM_MODEL_URL_PREFIX = 'aitools-custom-model://';
export async function getModelCache() {
try { return await caches.open(MODEL_CACHE_NAME); }
catch { return null; }
}
export function isCustomModelUrl(url) {
return typeof url === 'string' && (
url.startsWith(CUSTOM_MODEL_URL_PREFIX) ||
url.startsWith(LEGACY_CUSTOM_MODEL_URL_PREFIX)
);
}
function isRelativeHttpUrl(url) {
return typeof url === 'string' && !/^https?:\/\//i.test(url) && !isCustomModelUrl(url);
}
let _remoteOriginPromise = null;
function getRemoteOrigin() {
if (_remoteOriginPromise) return _remoteOriginPromise;
_remoteOriginPromise = fetch('branding.json', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((b) => b?.remoteOrigin || null)
.catch(() => null);
return _remoteOriginPromise;
}
// A cache key that doesn't depend on the (possibly ephemeral) origin we
// fetched from. In desktop mode the localhost port changes each launch,
// so caching under that URL would mean a fresh download every run.
async function stableCacheKey(url) {
if (!isRelativeHttpUrl(url)) return url;
const remoteOrigin = await getRemoteOrigin();
return remoteOrigin ? new URL(url, remoteOrigin).toString() : url;
}
export async function fetchWithProgress(url, onProgress) {
if (onProgress != null && typeof onProgress !== 'function') {
console.warn('[fetchWithProgress] Ignoring non-function onProgress callback.', {
type: typeof onProgress,
value: onProgress,
url,
});
}
const report = typeof onProgress === 'function' ? onProgress : null;
const cache = await getModelCache();
const cacheKey = await stableCacheKey(url);
if (cache) {
const cached = await cache.match(cacheKey);
if (cached) {
report?.(1, 'Loading model from cache…');
return cached.arrayBuffer();
}
if (isCustomModelUrl(url)) {
throw new Error('Custom model not found in cache. Please re-upload the model file.');
}
}
report?.(0, 'Downloading model…');
let resp = await fetch(url);
if (!resp.ok && resp.status === 404 && isRelativeHttpUrl(url)) {
const remoteOrigin = await getRemoteOrigin();
if (remoteOrigin) {
const remoteUrl = new URL(url, remoteOrigin).toString();
report?.(0, 'Model not in local bundle, fetching from server…');
resp = await fetch(remoteUrl);
}
}
if (!resp.ok) throw new Error(`Model download failed: HTTP ${resp.status}`);
const respForCache = resp.clone();
const total = parseInt(resp.headers.get('content-length') || '0', 10);
const reader = resp.body.getReader();
const chunks = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
if (total) {
const frac = loaded / total;
report?.(frac, `Downloading model… ${(loaded / 1e6).toFixed(1)} / ${(total / 1e6).toFixed(1)} MB`);
}
}
if (cache) {
cache.put(cacheKey, respForCache).catch(() => {});
}
const buf = new Uint8Array(loaded);
let off = 0;
for (const c of chunks) { buf.set(c, off); off += c.length; }
return buf.buffer;
}