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:
+128
-38
@@ -1,24 +1,46 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||
import { resolveSpriteUrl, spriteRoot } from '$lib/utils/spriteUrl';
|
||||
|
||||
export type OfflineSyncStatus = {
|
||||
state: 'idle' | 'syncing' | 'ready' | 'partial' | 'error';
|
||||
state: 'idle' | 'syncing' | 'ready' | 'error';
|
||||
generatedAt: string | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export type ArtworkDownloadStatus = {
|
||||
// unknown: not checked yet; missing: some sprites aren't saved; done: every sprite is saved.
|
||||
state: 'unknown' | 'missing' | 'downloading' | 'done' | 'error';
|
||||
missingBytes: number | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export const offlineSyncStatus = writable<OfflineSyncStatus>({
|
||||
state: 'idle',
|
||||
generatedAt: null,
|
||||
message: null
|
||||
});
|
||||
|
||||
export const artworkDownloadStatus = writable<ArtworkDownloadStatus>({
|
||||
state: 'unknown',
|
||||
missingBytes: null,
|
||||
message: null
|
||||
});
|
||||
|
||||
const SYNC_EVENT = 'livingdex:offline-sync';
|
||||
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 static/offline-worker.js. Older copies are always re-synced so
|
||||
// the worker can migrate them (e.g. drop the full-size artwork cache).
|
||||
const OFFLINE_META_FORMAT = 2;
|
||||
// A page load reuses an offline copy this recent instead of downloading the whole collection again.
|
||||
// Changes made in the app request a sync explicitly, so this only delays picking up edits made on
|
||||
// another device.
|
||||
const SNAPSHOT_FRESH_MS = 15 * 60 * 1000;
|
||||
|
||||
type OfflineMeta = { userId: string; generatedAt?: string; format?: number };
|
||||
|
||||
async function workerMessage(
|
||||
message: unknown,
|
||||
@@ -50,6 +72,14 @@ export function requestOfflineSync(): void {
|
||||
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT));
|
||||
}
|
||||
|
||||
async function readOfflineMeta(): Promise<OfflineMeta | null> {
|
||||
if (!('caches' in window)) return null;
|
||||
if (!(await caches.keys()).includes(OFFLINE_META_CACHE)) return null;
|
||||
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||
const meta = await response?.json().catch(() => null);
|
||||
return typeof meta?.userId === 'string' ? meta : null;
|
||||
}
|
||||
|
||||
async function deleteOfflineCaches(): Promise<void> {
|
||||
if (typeof window === 'undefined' || !('caches' in window)) return;
|
||||
const names = await caches.keys();
|
||||
@@ -60,13 +90,9 @@ async function deleteOfflineCaches(): Promise<void> {
|
||||
|
||||
export async function claimOfflineData(userId: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys();
|
||||
if (names.includes(OFFLINE_META_CACHE)) {
|
||||
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||
const meta = await response?.json().catch(() => null);
|
||||
if (typeof meta?.userId !== 'string' || meta.userId !== userId) await deleteOfflineCaches();
|
||||
}
|
||||
if ('caches' in window && (await caches.keys()).includes(OFFLINE_META_CACHE)) {
|
||||
const meta = await readOfflineMeta();
|
||||
if (meta?.userId !== userId) await deleteOfflineCaches();
|
||||
}
|
||||
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
|
||||
@@ -79,9 +105,69 @@ export async function clearOfflineData(): Promise<void> {
|
||||
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||
await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false);
|
||||
}
|
||||
// Sprites are kept across sign-out (they aren't account data), so the artwork status stays valid.
|
||||
offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null });
|
||||
}
|
||||
|
||||
function currentSpriteRoot(): string {
|
||||
return spriteRoot(PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true');
|
||||
}
|
||||
|
||||
function applyArtworkResult(result: Record<string, unknown>): void {
|
||||
const missing = Number(result.missing ?? 0);
|
||||
const failed = Number(result.failedArtwork ?? 0);
|
||||
if (failed > 0) {
|
||||
artworkDownloadStatus.set({
|
||||
state: 'error',
|
||||
missingBytes: Number(result.missingBytes ?? 0),
|
||||
message: `${failed} artwork files could not be saved`
|
||||
});
|
||||
} else {
|
||||
artworkDownloadStatus.set({
|
||||
state: missing > 0 ? 'missing' : 'done',
|
||||
missingBytes: Number(result.missingBytes ?? 0),
|
||||
message: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks whether every sprite (all forms, shiny and female) is already saved on this device. */
|
||||
export async function checkArtworkStatus(): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
applyArtworkResult(
|
||||
await workerMessage({ type: 'ARTWORK_STATUS', spriteRoot: currentSpriteRoot() }, 30_000)
|
||||
);
|
||||
} catch (error) {
|
||||
// Leave the link hidden rather than offering a download that can't be checked.
|
||||
console.error('Unable to check saved artwork', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Artwork is normally cached as it is viewed. This saves every sprite that exists - all forms,
|
||||
* shiny and female variants, not just the saved dexes - skipping any that are already cached.
|
||||
*/
|
||||
export async function downloadAllArtwork(): Promise<void> {
|
||||
if (typeof window === 'undefined' || !navigator.onLine) return;
|
||||
artworkDownloadStatus.update((status) => ({ ...status, state: 'downloading', message: null }));
|
||||
try {
|
||||
// Generous timeout: the worker fetches each missing sprite with its own 15s limit.
|
||||
applyArtworkResult(
|
||||
await workerMessage(
|
||||
{ type: 'CACHE_ALL_ARTWORK', spriteRoot: currentSpriteRoot() },
|
||||
30 * 60 * 1000
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
artworkDownloadStatus.update((status) => ({
|
||||
...status,
|
||||
state: 'error',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||
let timer: number | null = null;
|
||||
let stopped = false;
|
||||
@@ -89,7 +175,7 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||
let rerun = false;
|
||||
let lastGeneratedAt: string | null = null;
|
||||
|
||||
const synchronize = async () => {
|
||||
const synchronize = async (reuseFreshCopy: boolean) => {
|
||||
if (stopped || !navigator.onLine) return;
|
||||
if (running) {
|
||||
rerun = true;
|
||||
@@ -98,9 +184,24 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||
const userId = getUserId();
|
||||
if (!userId || !('serviceWorker' in navigator)) return;
|
||||
running = true;
|
||||
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
|
||||
try {
|
||||
await claimOfflineData(userId);
|
||||
if (reuseFreshCopy) {
|
||||
const meta = await readOfflineMeta();
|
||||
const age = meta?.generatedAt ? Date.now() - Date.parse(meta.generatedAt) : Infinity;
|
||||
if (
|
||||
meta?.userId === userId &&
|
||||
meta.format === OFFLINE_META_FORMAT &&
|
||||
age >= 0 &&
|
||||
age < SNAPSHOT_FRESH_MS
|
||||
) {
|
||||
lastGeneratedAt = meta.generatedAt ?? null;
|
||||
offlineSyncStatus.set({ state: 'ready', generatedAt: lastGeneratedAt, message: null });
|
||||
void checkArtworkStatus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
|
||||
const response = await fetch('/api/offline-snapshot', {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' }
|
||||
@@ -109,32 +210,19 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||
const snapshot = (await response.json()) as OfflineSnapshot;
|
||||
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
|
||||
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
|
||||
const artworkUrls = Array.from(
|
||||
new Set(
|
||||
snapshot.pokedexes.flatMap(({ pokedex, entries }) =>
|
||||
entries.map(({ pokedexEntry }) => {
|
||||
const url = resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
|
||||
(
|
||||
pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }
|
||||
).offlineSpriteUrl = url;
|
||||
return url;
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
// The worker derives which artwork belongs to the collection from these URLs, and the offline
|
||||
// viewer renders them.
|
||||
for (const { pokedex, entries } of snapshot.pokedexes) {
|
||||
for (const { pokedexEntry } of entries) {
|
||||
(pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }).offlineSpriteUrl =
|
||||
resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
|
||||
}
|
||||
}
|
||||
if (getUserId() !== userId) throw new Error('Session changed during offline synchronization');
|
||||
const result = await workerMessage({
|
||||
type: 'SYNC_OFFLINE_SNAPSHOT',
|
||||
snapshot,
|
||||
artworkUrls
|
||||
});
|
||||
const failed = Number(result.failedArtwork ?? 0);
|
||||
offlineSyncStatus.set({
|
||||
state: failed > 0 ? 'partial' : 'ready',
|
||||
generatedAt: snapshot.generatedAt,
|
||||
message: failed > 0 ? `${failed} artwork files could not be cached` : null
|
||||
});
|
||||
await workerMessage({ type: 'SYNC_OFFLINE_SNAPSHOT', snapshot });
|
||||
offlineSyncStatus.set({ state: 'ready', generatedAt: snapshot.generatedAt, message: null });
|
||||
lastGeneratedAt = snapshot.generatedAt;
|
||||
void checkArtworkStatus();
|
||||
} catch (error) {
|
||||
offlineSyncStatus.set({
|
||||
state: 'error',
|
||||
@@ -150,19 +238,21 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
const scheduleSync = (reuseFreshCopy: boolean) => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
void synchronize();
|
||||
void synchronize(reuseFreshCopy);
|
||||
}, 1_000);
|
||||
};
|
||||
// Explicit requests (edits, Retry, a new sign-in) and reconnecting always fetch a new copy.
|
||||
const schedule = () => scheduleSync(false);
|
||||
|
||||
// Best effort: ask the browser not to evict the offline artwork cache under storage pressure.
|
||||
void navigator.storage?.persist?.().catch(() => undefined);
|
||||
window.addEventListener(SYNC_EVENT, schedule);
|
||||
window.addEventListener('online', schedule);
|
||||
schedule();
|
||||
scheduleSync(true);
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
import SignOut from '$lib/components/SignOut.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import {
|
||||
artworkDownloadStatus,
|
||||
claimOfflineData,
|
||||
clearOfflineData,
|
||||
downloadAllArtwork,
|
||||
offlineSyncStatus,
|
||||
requestOfflineSync,
|
||||
startOfflineSync
|
||||
@@ -65,14 +66,21 @@
|
||||
const {
|
||||
data: { subscription }
|
||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||
const previousUserId = localUser?.id ?? null;
|
||||
if (session) {
|
||||
localUser = session.user;
|
||||
void claimOfflineData(session.user.id)
|
||||
.then(requestOfflineSync)
|
||||
.catch((error) => console.error('Unable to claim offline data', error));
|
||||
// SIGNED_IN also fires when a tab regains focus, so only a change of account fetches a new
|
||||
// offline copy. Page loads and token refreshes reuse the saved one while it is fresh.
|
||||
if (event === 'SIGNED_IN' && session.user.id !== previousUserId) {
|
||||
void claimOfflineData(session.user.id)
|
||||
.then(requestOfflineSync)
|
||||
.catch((error) => console.error('Unable to claim offline data', error));
|
||||
}
|
||||
} else {
|
||||
// Offline data is only cleared by the Sign Out button (or another account claiming it).
|
||||
// An expired or rejected session must not throw away artwork that would then have to be
|
||||
// downloaded again after signing back in.
|
||||
localUser = null;
|
||||
if (event === 'SIGNED_OUT') void clearOfflineData();
|
||||
}
|
||||
user.set(localUser);
|
||||
});
|
||||
@@ -90,6 +98,11 @@
|
||||
};
|
||||
});
|
||||
|
||||
function formatMegabytes(bytes: number) {
|
||||
const megabytes = bytes / 1048576;
|
||||
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
|
||||
}
|
||||
|
||||
async function getUser() {
|
||||
const {
|
||||
data: { session }
|
||||
@@ -214,10 +227,12 @@
|
||||
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||
</div>
|
||||
{:else if localUser && $offlineSyncStatus.state === 'partial'}
|
||||
{:else if localUser && $artworkDownloadStatus.state === 'error'}
|
||||
<div class="alert alert-warning rounded-none" role="status">
|
||||
<span>Offline data is saved, but {$offlineSyncStatus.message}.</span>
|
||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||
<span
|
||||
>Offline data is saved, but artwork could not be saved: {$artworkDownloadStatus.message}.</span
|
||||
>
|
||||
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
||||
@@ -225,6 +240,15 @@
|
||||
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
||||
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
||||
{#if $artworkDownloadStatus.state === 'downloading'}
|
||||
Saving all artwork for offline…
|
||||
{:else if $artworkDownloadStatus.state === 'missing'}
|
||||
<button class="link" on:click={downloadAllArtwork}>
|
||||
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
|
||||
$artworkDownloadStatus.missingBytes
|
||||
)}){/if}
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
+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