mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +00:00
b2b750115f
Every grid cell downloaded the full detail sprite. Generate a smaller /sprites-grid/v1/ set at build time and let the grid ask for those first, falling back through the existing detail URLs when a thumbnail is missing so detail resolution is unchanged. The new prefix is versioned, so it can be cached forever, and the service worker recognises it alongside the other sprite roots. The placeholder is now an empty box rather than a spinner: a thousand spinners cost layout work and announced nothing useful.
306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
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();
|
|
let claimedUserId = null;
|
|
|
|
function queueOfflineOperation(operation) {
|
|
const result = offlineOperation.then(operation, operation);
|
|
offlineOperation = result.catch(() => undefined);
|
|
return result;
|
|
}
|
|
|
|
function dataCacheName(userId, generation) {
|
|
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
|
|
}
|
|
|
|
// 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.
|
|
function isSpriteUrl(url) {
|
|
return /\/sprites(?:-small|-grid\/v1)?\//.test(url.pathname) && url.pathname.endsWith('.webp');
|
|
}
|
|
|
|
async function currentOfflineMeta() {
|
|
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
|
if (!response) return null;
|
|
const data = await response.json().catch(() => null);
|
|
return typeof data?.userId === 'string' ? data : null;
|
|
}
|
|
|
|
async function clearOfflineData() {
|
|
const names = await caches.keys();
|
|
await Promise.all(
|
|
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
|
|
);
|
|
}
|
|
|
|
async function notifyOfflineDataCleared() {
|
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
|
|
}
|
|
|
|
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) return;
|
|
const url = missing[index];
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
|
|
try {
|
|
// CORS rather than no-cors: opaque responses are padded to several MB each for storage
|
|
// quota, which a full Living Dex of artwork would exhaust.
|
|
const response = await fetch(url, {
|
|
mode: url.startsWith(self.location.origin) ? 'same-origin' : 'cors',
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
await cache.put(url, response);
|
|
} catch {
|
|
failed++;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
});
|
|
await Promise.all(workers);
|
|
return failed;
|
|
}
|
|
|
|
self.addEventListener('message', (event) => {
|
|
const reply = (value) => event.ports[0]?.postMessage(value);
|
|
if (event.data?.type === 'CLEAR_OFFLINE_DATA') {
|
|
claimedUserId = null;
|
|
offlineEpoch++;
|
|
event.waitUntil(
|
|
queueOfflineOperation(async () => {
|
|
await clearOfflineData();
|
|
await notifyOfflineDataCleared();
|
|
})
|
|
.then(() => reply({ ok: true }))
|
|
.catch((error) => reply({ ok: false, error: String(error) }))
|
|
);
|
|
return;
|
|
}
|
|
if (event.data?.type === 'CLAIM_OFFLINE_USER') {
|
|
if (typeof event.data.userId !== 'string') {
|
|
reply({ ok: false, error: 'Invalid offline cache owner' });
|
|
return;
|
|
}
|
|
claimedUserId = event.data.userId;
|
|
offlineEpoch++;
|
|
event.waitUntil(
|
|
queueOfflineOperation(async () => {
|
|
const meta = await currentOfflineMeta();
|
|
if (meta?.userId && meta.userId !== event.data.userId) {
|
|
await clearOfflineData();
|
|
await notifyOfflineDataCleared();
|
|
}
|
|
reply({ ok: true });
|
|
}).catch((error) => reply({ ok: false, error: String(error) }))
|
|
);
|
|
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;
|
|
const isCurrent = () => syncEpoch === offlineEpoch;
|
|
|
|
event.waitUntil(
|
|
queueOfflineOperation(async () => {
|
|
let nextData;
|
|
let committed = false;
|
|
try {
|
|
const snapshot = event.data.snapshot;
|
|
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
|
|
throw new Error('Unsupported offline snapshot');
|
|
}
|
|
if (!syncUserId || snapshot.userId !== syncUserId) {
|
|
throw new Error('Offline snapshot owner did not match the claimed account');
|
|
}
|
|
const previousMeta = await currentOfflineMeta();
|
|
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
|
|
await clearOfflineData();
|
|
await deleteObsoleteArtworkCaches();
|
|
|
|
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
|
|
const generation = `${timestamp}-${crypto.randomUUID()}`;
|
|
nextData = dataCacheName(snapshot.userId, generation);
|
|
const dataCache = await caches.open(nextData);
|
|
await dataCache.put(
|
|
`/__offline/snapshot/${encodeURIComponent(snapshot.userId)}`,
|
|
new Response(JSON.stringify(snapshot), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
);
|
|
if (!isCurrent())
|
|
throw new Error('Offline synchronization was superseded by an account change');
|
|
|
|
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
|
|
}),
|
|
{
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
);
|
|
if (!isCurrent()) {
|
|
const current = await currentOfflineMeta();
|
|
if (current?.dataCache === nextData) await metaCache.delete(OFFLINE_META_URL);
|
|
throw new Error('Offline synchronization was superseded by an account change');
|
|
}
|
|
committed = true;
|
|
|
|
const currentCaches = await caches.keys();
|
|
await Promise.all(
|
|
currentCaches
|
|
.filter(
|
|
(name) =>
|
|
name.startsWith(OFFLINE_CACHE_PREFIX) &&
|
|
![OFFLINE_META_CACHE, nextData].includes(name)
|
|
)
|
|
.map((name) => caches.delete(name))
|
|
);
|
|
|
|
// 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) });
|
|
}
|
|
})
|
|
);
|
|
});
|
|
|
|
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)) {
|
|
const fallback = async () =>
|
|
(await caches.match('/offline', { ignoreSearch: true })) ??
|
|
(await caches.match('/offline.html', { ignoreSearch: true })) ??
|
|
Response.error();
|
|
event.respondWith(
|
|
self.navigator.onLine
|
|
? fetch(new Request(event.request, { cache: 'no-store' })).catch(fallback)
|
|
: fallback()
|
|
);
|
|
return;
|
|
}
|
|
if (event.request.destination !== 'image' || !isSpriteUrl(url)) return;
|
|
event.respondWith(
|
|
(async () => {
|
|
// 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;
|
|
try {
|
|
const response = await fetch(url.href, {
|
|
mode: url.origin === self.location.origin ? 'same-origin' : 'cors'
|
|
});
|
|
if (response.ok) event.waitUntil(cache.put(url.href, response.clone()));
|
|
return response;
|
|
} catch {
|
|
return fetch(event.request);
|
|
}
|
|
})()
|
|
);
|
|
});
|