mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-14 17:42:17 +00:00
perf(offline): stop re-downloading artwork and the offline snapshot
- Sprites are cached as they are viewed instead of bulk-downloaded after every sign-in, in one shared cache that is never pruned and survives sign-out and account changes, since sprites never change. - A "Save all artwork for offline" link saves every remaining sprite on request, shows the remaining size, and is hidden once all are saved. - Page loads reuse an offline snapshot under 15 minutes old; edits, retries and a new sign-in still sync immediately. - An expired session no longer wipes offline data; only the Sign Out button does.
This commit is contained in:
+98
-45
@@ -1,6 +1,16 @@
|
||||
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
|
||||
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
|
||||
const OFFLINE_META_URL = '/__offline/current';
|
||||
// Must match OFFLINE_META_FORMAT in src/lib/stores/offlineSync.ts. Bumped when artwork moved to the
|
||||
// shared sprite cache, so pages re-sync older copies instead of reusing them.
|
||||
const OFFLINE_META_FORMAT = 2;
|
||||
// Sprites never change at a given URL and aren't user data, so one cache serves every account and is
|
||||
// kept forever: it deliberately sits outside OFFLINE_CACHE_PREFIX, which sign-out, account changes
|
||||
// and sync cleanup all delete. Only bump the version if the files at existing URLs are replaced.
|
||||
const SPRITE_CACHE = 'livingdex-sprites-v1';
|
||||
// Every sprite file (all forms, shiny and female) with its size; generated by
|
||||
// scripts/sprite-manifest.mjs. Kept in SPRITE_CACHE, so it changes exactly when the sprites do.
|
||||
const SPRITE_MANIFEST_URL = '/sprites-small/manifest.json';
|
||||
const ARTWORK_FETCH_TIMEOUT_MS = 15_000;
|
||||
let offlineEpoch = 0;
|
||||
let offlineOperation = Promise.resolve();
|
||||
@@ -16,10 +26,50 @@ function dataCacheName(userId, generation) {
|
||||
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
|
||||
}
|
||||
|
||||
// Sprites never change at a given URL, so each user keeps one artwork cache that is topped up
|
||||
// incrementally instead of being rebuilt on every sync.
|
||||
function artworkCacheName(userId) {
|
||||
return `${OFFLINE_CACHE_PREFIX}art-v2-${userId}`;
|
||||
// The old per-user artwork caches hold full-size (or opaque, quota-padded) sprites.
|
||||
function isObsoleteArtworkCache(name) {
|
||||
return name.startsWith(`${OFFLINE_CACHE_PREFIX}art-`);
|
||||
}
|
||||
|
||||
async function deleteObsoleteArtworkCaches() {
|
||||
await Promise.all(
|
||||
(await caches.keys()).filter(isObsoleteArtworkCache).map((name) => caches.delete(name))
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSpriteManifest(cache) {
|
||||
let response = await cache.match(SPRITE_MANIFEST_URL);
|
||||
if (!response) {
|
||||
response = await fetch(SPRITE_MANIFEST_URL);
|
||||
if (!response.ok) throw new Error(`Sprite list unavailable (HTTP ${response.status})`);
|
||||
await cache.put(SPRITE_MANIFEST_URL, response.clone());
|
||||
}
|
||||
const manifest = await response.json();
|
||||
if (manifest?.version !== 1 || !Array.isArray(manifest.files))
|
||||
throw new Error('Unsupported sprite list');
|
||||
return manifest.files;
|
||||
}
|
||||
|
||||
// Maps every sprite in the manifest to its absolute URL under `root` (the page's sprite folder).
|
||||
async function allSpriteSizes(cache, root) {
|
||||
const sizes = new Map();
|
||||
for (const [file, size] of await loadSpriteManifest(cache)) {
|
||||
const url = new URL(`${root}/${file}`, self.location.origin);
|
||||
if (!isSpriteUrl(url)) throw new Error('Invalid sprite location');
|
||||
sizes.set(url.href, size);
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
async function missingSprites(cache, root) {
|
||||
const sizes = await allSpriteSizes(cache, root);
|
||||
const cached = new Set((await cache.keys()).map((request) => request.url));
|
||||
const missing = [...sizes.keys()].filter((url) => !cached.has(url));
|
||||
return {
|
||||
total: sizes.size,
|
||||
missing,
|
||||
missingBytes: missing.reduce((bytes, url) => bytes + sizes.get(url), 0)
|
||||
};
|
||||
}
|
||||
|
||||
// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
|
||||
@@ -46,23 +96,13 @@ async function notifyOfflineDataCleared() {
|
||||
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
|
||||
}
|
||||
|
||||
async function cacheArtwork(cache, urls, isCurrent) {
|
||||
const wanted = new Set(urls.map((url) => new URL(url, self.location.origin).href));
|
||||
const cachedRequests = await cache.keys();
|
||||
const cached = new Set(cachedRequests.map((request) => request.url));
|
||||
await Promise.all(
|
||||
cachedRequests
|
||||
.filter((request) => !wanted.has(request.url))
|
||||
.map((request) => cache.delete(request))
|
||||
);
|
||||
const missing = [...wanted].filter((url) => !cached.has(url));
|
||||
|
||||
async function fetchSprites(cache, missing) {
|
||||
let next = 0;
|
||||
let failed = 0;
|
||||
const workers = Array.from({ length: Math.min(6, missing.length) }, async () => {
|
||||
for (;;) {
|
||||
const index = next++;
|
||||
if (index >= missing.length || !isCurrent()) return;
|
||||
if (index >= missing.length) return;
|
||||
const url = missing[index];
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
|
||||
@@ -120,6 +160,32 @@ self.addEventListener('message', (event) => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Sprites aren't account data, so these work for whoever is signed in and survive sign-out.
|
||||
if (event.data?.type === 'ARTWORK_STATUS' || event.data?.type === 'CACHE_ALL_ARTWORK') {
|
||||
const download = event.data.type === 'CACHE_ALL_ARTWORK';
|
||||
const root = String(event.data.spriteRoot ?? '');
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(SPRITE_CACHE);
|
||||
let status = await missingSprites(cache, root);
|
||||
let failedArtwork = 0;
|
||||
if (download && status.missing.length > 0) {
|
||||
failedArtwork = await fetchSprites(cache, status.missing);
|
||||
status = await missingSprites(cache, root);
|
||||
}
|
||||
reply({
|
||||
ok: true,
|
||||
total: status.total,
|
||||
missing: status.missing.length,
|
||||
missingBytes: status.missingBytes,
|
||||
failedArtwork
|
||||
});
|
||||
})().catch((error) =>
|
||||
reply({ ok: false, error: error instanceof Error ? error.message : String(error) })
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
|
||||
const syncEpoch = offlineEpoch;
|
||||
const syncUserId = claimedUserId;
|
||||
@@ -140,13 +206,7 @@ self.addEventListener('message', (event) => {
|
||||
const previousMeta = await currentOfflineMeta();
|
||||
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
|
||||
await clearOfflineData();
|
||||
// Per-sync artwork caches of opaque responses could fill the whole storage quota, so drop
|
||||
// them before writing anything new.
|
||||
await Promise.all(
|
||||
(await caches.keys())
|
||||
.filter((name) => name.startsWith(`${OFFLINE_CACHE_PREFIX}art-v1-`))
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
await deleteObsoleteArtworkCaches();
|
||||
|
||||
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
|
||||
const generation = `${timestamp}-${crypto.randomUUID()}`;
|
||||
@@ -161,18 +221,15 @@ self.addEventListener('message', (event) => {
|
||||
if (!isCurrent())
|
||||
throw new Error('Offline synchronization was superseded by an account change');
|
||||
|
||||
// Commit the collection before any artwork so a slow or failing sprite download can never
|
||||
// prevent the offline copy from being saved.
|
||||
const artworkCache = artworkCacheName(snapshot.userId);
|
||||
const metaCache = await caches.open(OFFLINE_META_CACHE);
|
||||
await metaCache.put(
|
||||
OFFLINE_META_URL,
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
format: OFFLINE_META_FORMAT,
|
||||
userId: snapshot.userId,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
dataCache: nextData,
|
||||
artworkCache
|
||||
dataCache: nextData
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
@@ -192,19 +249,14 @@ self.addEventListener('message', (event) => {
|
||||
.filter(
|
||||
(name) =>
|
||||
name.startsWith(OFFLINE_CACHE_PREFIX) &&
|
||||
![OFFLINE_META_CACHE, nextData, artworkCache].includes(name)
|
||||
![OFFLINE_META_CACHE, nextData].includes(name)
|
||||
)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
|
||||
const failedArtwork = await cacheArtwork(
|
||||
await caches.open(artworkCache),
|
||||
Array.from(new Set(event.data.artworkUrls ?? [])),
|
||||
isCurrent
|
||||
);
|
||||
if (!isCurrent())
|
||||
throw new Error('Offline synchronization was superseded by an account change');
|
||||
reply({ ok: true, failedArtwork });
|
||||
// Artwork is not downloaded here: the fetch handler caches sprites as they are viewed, and
|
||||
// CACHE_ALL_ARTWORK fetches the rest only when the user asks for it.
|
||||
reply({ ok: true });
|
||||
} catch (error) {
|
||||
if (!committed && nextData) await caches.delete(nextData).catch(() => undefined);
|
||||
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -213,6 +265,10 @@ self.addEventListener('message', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(deleteObsoleteArtworkCaches());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) {
|
||||
@@ -227,17 +283,14 @@ self.addEventListener('fetch', (event) => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.request.destination !== 'image') return;
|
||||
if (event.request.destination !== 'image' || !isSpriteUrl(url)) return;
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const meta = await currentOfflineMeta();
|
||||
if (!meta?.artworkCache) return fetch(event.request);
|
||||
const cache = await caches.open(meta.artworkCache);
|
||||
const cached = await cache.match(event.request, { ignoreSearch: true });
|
||||
// Cache-first with fill-on-miss for everyone, signed in or not: a sprite is downloaded once
|
||||
// and served from the cache from then on.
|
||||
const cache = await caches.open(SPRITE_CACHE);
|
||||
const cached = await cache.match(url.href, { ignoreSearch: true });
|
||||
if (cached) return cached;
|
||||
if (!isSpriteUrl(url)) return fetch(event.request);
|
||||
// Cache-first with fill-on-miss: a sprite shown online is stored once and served from the
|
||||
// cache from then on, so the bulk sync never has to download it again.
|
||||
try {
|
||||
const response = await fetch(url.href, {
|
||||
mode: url.origin === self.location.origin ? 'same-origin' : 'cors'
|
||||
|
||||
Reference in New Issue
Block a user