524 lines
22 KiB
JavaScript
524 lines
22 KiB
JavaScript
/* ============================================================================
|
|
* sw.js — YT Player Service Worker
|
|
*
|
|
* Strategy:
|
|
* App shell (HTML/CSS/JS) → cache-first, versioned cache
|
|
* /api/* requests → network-only (never cache yt-dlp results)
|
|
* /api/download/* → network-only (streamed binary, never cache)
|
|
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
|
* Everything else → network, fallback to cache
|
|
*
|
|
* Auto-update flow:
|
|
* 1. New SW installs alongside the old one and waits (skipWaiting() is NOT
|
|
* called automatically — see the install handler below).
|
|
* 2. The client (app.js) shows the "Update available" banner only when the
|
|
* build it is running differs from the server's /api/version buildTag —
|
|
* a waiting worker or a broadcast is merely a prompt to re-check.
|
|
* 3. "Refresh UI" (sw-update.js) re-downloads every shell file into every
|
|
* versioned shell cache (all-or-nothing), activates a waiting worker if
|
|
* there is one (SKIP_WAITING), and reloads once.
|
|
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
|
* activation is genuinely replacing a previous deploy, not just the
|
|
* first-ever install of a freshly (re)registered worker), broadcast
|
|
* SW_UPDATE_AVAILABLE to all clients as a secondary/fallback signal.
|
|
* ========================================================================== */
|
|
|
|
// BUILD_TAG is injected by the server at request time (GET /sw.js).
|
|
// It changes on every deploy/restart so the cache is busted automatically
|
|
// without any manual version bump.
|
|
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
|
const ASSET_SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;
|
|
if (ASSET_SYNC) importScripts('/asset-sync-core.js');
|
|
const CACHE = 'ytplayer-' + VERSION;
|
|
|
|
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
|
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
|
// match this — they aren't versioned and must survive every activate.
|
|
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
|
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts', 'ytplayer-assets']);
|
|
const THUMB_CACHE = 'ytplayer-thumbs';
|
|
|
|
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
|
// i.ytimg.com today, but the numbered mirrors and the ggpht/googleusercontent
|
|
// avatar hosts show up too — an unlisted host silently bypasses the cache and
|
|
// leaves a broken image offline, so cover them all.
|
|
const THUMB_HOSTS = new Set([
|
|
'i.ytimg.com', 'i1.ytimg.com', 'i2.ytimg.com', 'i3.ytimg.com', 'i4.ytimg.com', 'i9.ytimg.com',
|
|
'img.youtube.com', 'yt3.ggpht.com', 'yt3.googleusercontent.com', 'lh3.googleusercontent.com',
|
|
]);
|
|
|
|
// Thumbnails accumulate with every card ever scrolled past — keep it bounded.
|
|
const THUMB_CACHE_MAX = 25000;
|
|
function isVersionedShellCache(key) {
|
|
return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key);
|
|
}
|
|
|
|
// Files that form the installable app shell.
|
|
const SHELL = [
|
|
'/',
|
|
'/index.html',
|
|
'/styles.css',
|
|
'/service-ui.css',
|
|
'/refresh.css',
|
|
'/theme-glass.css',
|
|
'/theme-bento-hub.css',
|
|
'/theme-f7.css',
|
|
'/fonts/PlusJakartaSans-latin.woff2',
|
|
'/fonts/PlusJakartaSans-latin-ext.woff2',
|
|
'/theme-glass-panel.css',
|
|
'/glass-panel-layout.js',
|
|
'/theme-classic.css',
|
|
'/recommendations.css',
|
|
'/flag-ui.js',
|
|
'/export.js',
|
|
'/async-guard.js',
|
|
'/visible-timer.js',
|
|
'/asset-sync-core.js',
|
|
'/sw-update.js',
|
|
'/fingerprint.js',
|
|
'/opfs.js',
|
|
'/opfs-worker.js',
|
|
'/video-edit.js',
|
|
'/lyrics-core.js',
|
|
'/lyrics-display.js',
|
|
'/stats-core.js',
|
|
'/sha256.js',
|
|
'/resume-core.js',
|
|
'/loudness-wasm.js',
|
|
'/loudness.js',
|
|
'/sha256-wasm.js',
|
|
'/device-db.js',
|
|
'/hash-worker.js',
|
|
'/p2p-client.js',
|
|
'/p2p-core.js',
|
|
'/direct-media.css',
|
|
'/direct-protocol.js',
|
|
'/direct-media.js',
|
|
'/direct-stream.js',
|
|
'/direct-recv-worker.js',
|
|
'/p2p-transfer.js',
|
|
'/p2p-recv-worker.js',
|
|
'/settings-sections.js',
|
|
'/related-videos.js',
|
|
'/related-videos.css',
|
|
'/audio-continuity.js',
|
|
'/fullscreen-orientation.js',
|
|
'/fullscreen-orientation.css',
|
|
'/download-actions.js',
|
|
'/download-actions.css',
|
|
'/download-errors.js',
|
|
'/downloads.js',
|
|
'/downloads-page.js',
|
|
'/offline-pages.css',
|
|
'/saved-page.js',
|
|
'/server-backfill.js',
|
|
'/glass-controls.js',
|
|
'/bento-hub.js',
|
|
'/f7-layout.js',
|
|
'/vendor/framework7-swipe.min.js',
|
|
'/theme-glass-controls.css',
|
|
'/saved-page.css',
|
|
'/party-dj.js',
|
|
'/lyrics-window.js',
|
|
'/midi.js',
|
|
'/lower-third.js',
|
|
'/vocal-reducer.js',
|
|
'/eq-core.js',
|
|
'/piano-core.js',
|
|
'/piano.js',
|
|
'/setlist-import.js',
|
|
'/car-mode.js',
|
|
'/prewarm-next.js',
|
|
'/car-mode.css',
|
|
'/lower-third.css',
|
|
'/settings-sections.css',
|
|
'/app.js',
|
|
'/manifest.webmanifest',
|
|
'/icons/icon-192.png',
|
|
'/icons/icon-512.png',
|
|
'/fonts/fonts.css',
|
|
'/fonts/BricolageGrotesque-latin-ext.woff2',
|
|
'/fonts/BricolageGrotesque-latin.woff2',
|
|
'/fonts/HankenGrotesk-latin-ext.woff2',
|
|
'/fonts/HankenGrotesk-latin.woff2',
|
|
'/fonts/JetBrainsMono-latin-ext.woff2',
|
|
'/fonts/JetBrainsMono-latin.woff2',
|
|
];
|
|
|
|
// ---- Install: pre-cache the app shell ----
|
|
// skipWaiting() is NOT called here — the page activates a waiting worker
|
|
// from "Refresh UI" (see sw-update.js).
|
|
self.addEventListener('install', (e) => {
|
|
e.waitUntil(ASSET_SYNC ? installAssets() : precacheShell());
|
|
});
|
|
|
|
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
|
|
// otherwise hand the new worker yesterday's app.js), a few attempts per file
|
|
// because the homelab link drops requests, and a failed install deletes its
|
|
// partial cache — a leftover ytplayer-<tag> cache is exactly what activate
|
|
// uses to decide "this is a genuine update", and it used to re-open the
|
|
// update banner on pages that were already current.
|
|
const PRECACHE_ATTEMPTS = 3;
|
|
async function precacheShell() {
|
|
const cache = await caches.open(CACHE);
|
|
try {
|
|
await Promise.all(SHELL.map(async (url) => {
|
|
let lastErr = null;
|
|
for (let attempt = 0; attempt < PRECACHE_ATTEMPTS; attempt++) {
|
|
try {
|
|
const res = await fetch(new Request(url, { cache: 'reload' }));
|
|
if (!res.ok) throw new Error(url + ' → ' + res.status);
|
|
await cache.put(url, res);
|
|
return;
|
|
} catch (err) { lastErr = err; }
|
|
}
|
|
throw lastErr;
|
|
}));
|
|
} catch (err) {
|
|
await caches.delete(CACHE);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ---- Activate: evict old caches, claim clients, notify about update ----
|
|
self.addEventListener('activate', (e) => {
|
|
e.waitUntil((async () => {
|
|
if (ASSET_SYNC) { await activateAssets(); return; }
|
|
const keys = await caches.keys();
|
|
|
|
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
|
// this activation is a genuine version bump — worth telling the client
|
|
// about. If the only versioned shell cache present is our own CACHE (or
|
|
// none at all), this is the first-ever activation of a freshly
|
|
// (re)registered worker — e.g. right after hardReloadUI() unregisters
|
|
// the old SW and hard-navigates — and there is nothing new to report.
|
|
// Without this check, that harmless re-install would re-broadcast
|
|
// SW_UPDATE_AVAILABLE and immediately reopen the "Update ready" banner
|
|
// the user just dismissed by clicking "Reload now".
|
|
const staleShellCaches = keys.filter((k) => isVersionedShellCache(k) && k !== CACHE);
|
|
const isGenuineUpdate = staleShellCaches.length > 0;
|
|
|
|
// Delete every stale *versioned shell* cache — never the utility caches
|
|
// (thumbs/fonts), which aren't tied to a deploy version and should
|
|
// survive every activate.
|
|
await Promise.all(staleShellCaches.map((k) => caches.delete(k)));
|
|
|
|
// Claim all open clients immediately (new installs)
|
|
await self.clients.claim();
|
|
|
|
if (isGenuineUpdate) {
|
|
// Broadcast to every open window so the app can show an update banner
|
|
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
all.forEach((c) => c.postMessage({ type: 'SW_UPDATE_AVAILABLE', version: VERSION }));
|
|
}
|
|
})());
|
|
});
|
|
|
|
// ---- Fetch: routing logic ----
|
|
// ---- Share target (manifest share_target) ----
|
|
// The OS share sheet POSTs the shared link/text/files here. Files are parked in
|
|
// a cache the app reads on open (name does not start with "ytplayer-", so no
|
|
// deploy ever evicts it), then the page is opened to process them.
|
|
const SHARE_INBOX = 'ytp-share-inbox';
|
|
async function receiveShare(request) {
|
|
let form;
|
|
try { form = await request.formData(); } catch { return Response.redirect('/?shared=', 303); }
|
|
const files = form.getAll('media').filter((f) => f && typeof f !== 'string' && f.size);
|
|
if (files.length) {
|
|
const inbox = await caches.open(SHARE_INBOX);
|
|
let n = 0;
|
|
for (const f of files) {
|
|
const key = `/__share/${Date.now()}-${n++}`;
|
|
await inbox.put(key, new Response(f, { headers: {
|
|
'Content-Type': f.type || 'application/octet-stream',
|
|
'X-Name': encodeURIComponent(f.name || 'shared'),
|
|
'Content-Length': String(f.size),
|
|
} }));
|
|
}
|
|
return Response.redirect('/?share=files', 303);
|
|
}
|
|
const pick = (k) => String(form.get(k) || '').trim();
|
|
const text = [pick('url'), pick('text'), pick('title')].filter(Boolean).join(' ');
|
|
return Response.redirect('/?shared=' + encodeURIComponent(text.slice(0, 2000)), 303);
|
|
}
|
|
|
|
self.addEventListener('fetch', (e) => {
|
|
const { request } = e;
|
|
const url = new URL(request.url);
|
|
|
|
if (request.method === 'POST' && url.pathname === '/share-target') {
|
|
e.respondWith(receiveShare(request));
|
|
return;
|
|
}
|
|
|
|
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
|
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
|
|
|
// The admin page is online-only and must never be served from a cache.
|
|
if (url.pathname === '/admin' || url.pathname.startsWith('/admin.')) return;
|
|
|
|
// Catalog artwork uses the same offline image cache as YouTube artwork.
|
|
if (url.origin === self.location.origin && /^\/api\/catalog\/[\w-]{11}\/thumbnail$/.test(url.pathname)) {
|
|
e.respondWith(thumbnail(request));
|
|
return;
|
|
}
|
|
|
|
// API calls and binary downloads → network only, no caching
|
|
if (url.pathname.startsWith('/api/')) {
|
|
e.respondWith(fetch(request));
|
|
return;
|
|
}
|
|
|
|
// YouTube thumbnails → cache-first, kept across deploys
|
|
if (THUMB_HOSTS.has(url.hostname)) {
|
|
e.respondWith(thumbnail(request));
|
|
return;
|
|
}
|
|
|
|
// Google Fonts CSS — stale-while-revalidate so offline doesn't break type
|
|
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') {
|
|
e.respondWith(staleWhileRevalidate(request, 'ytplayer-fonts'));
|
|
return;
|
|
}
|
|
|
|
// App shell → cache-first, then network, then generic offline fallback
|
|
e.respondWith(cacheFirst(request, e.clientId));
|
|
});
|
|
|
|
// ---- Message: handle SKIP_WAITING from the client ----
|
|
const playingClients = new Map(), deferredClients = new Set();
|
|
self.addEventListener('message', (e) => {
|
|
if (e.data && e.data.type === 'PLAYING' && e.source) {
|
|
playingClients.set(e.source.id, !!e.data.value);
|
|
if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting();
|
|
}
|
|
if (e.data && e.data.type === 'SKIP_WAITING') {
|
|
const id = e.source && e.source.id;
|
|
if (playingClients.get(id)) deferredClients.add(id);
|
|
else self.skipWaiting();
|
|
}
|
|
// "Is your shell already downloaded?" — a waiting worker has precached the
|
|
// whole new build during install, so "Refresh UI" can swap to it instantly
|
|
// instead of downloading everything a second time. Answer honestly: install
|
|
// deletes its cache on failure, but a cache can also be evicted under
|
|
// storage pressure, so the files are actually checked.
|
|
if (e.data && e.data.type === 'CACHE_STATUS') {
|
|
e.waitUntil((async () => {
|
|
if (ASSET_SYNC) {
|
|
let reply = { ready: false, missing: -1, version: VERSION };
|
|
try { const cache = await caches.open(AssetSyncCore.CACHE); const m = await candidate(cache); if(m) reply = await AssetSyncCore.status(m,cache); } catch {}
|
|
reply.type = 'CACHE_STATUS'; reply.assetSync = true;
|
|
if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply);
|
|
return;
|
|
}
|
|
let missing = SHELL.length;
|
|
try {
|
|
const cache = await caches.open(CACHE);
|
|
const found = await Promise.all(SHELL.map((url) => cache.match(url)));
|
|
missing = found.filter((r) => !r).length;
|
|
} catch { /* storage blocked — report not ready */ }
|
|
const reply = { type: 'CACHE_STATUS', version: VERSION, ready: missing === 0, missing };
|
|
if (e.ports && e.ports[0]) e.ports[0].postMessage(reply);
|
|
else if (e.source) e.source.postMessage(reply);
|
|
})());
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// Fetch helpers
|
|
// ============================================================================
|
|
|
|
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
|
async function cacheFirst(request, clientId) {
|
|
if (ASSET_SYNC) return assetFetch(request, clientId);
|
|
const cache = await caches.open(CACHE);
|
|
// Shell files are precached under their plain URL; index.html asks for them as
|
|
// `file?v=<build>` (so the HTTP cache can keep them forever) — match ignoring it.
|
|
const versioned = /[?&]v=/.test(request.url);
|
|
const cached = await cache.match(request, versioned ? { ignoreSearch: true } : undefined);
|
|
if (cached) return cached;
|
|
try {
|
|
const response = await fetch(request);
|
|
// Only cache successful, non-opaque responses
|
|
if (response && response.status === 200 && response.type !== 'opaque') {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
// Network failed and nothing in cache — return a minimal offline page
|
|
// for navigation requests; let sub-resources fail naturally.
|
|
if (request.mode === 'navigate') {
|
|
const nav = await cache.match('/index.html');
|
|
if (nav) return nav;
|
|
}
|
|
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
|
}
|
|
}
|
|
|
|
// Thumbnails: cache-first. A given thumbnail URL is immutable, so there is
|
|
// nothing to revalidate — cache-first also means an offline launch renders
|
|
// every card it has ever shown.
|
|
//
|
|
// The catch that made offline thumbnails disappear entirely: an <img> to
|
|
// another origin is a **no-cors** request, so `fetch(request)` resolves to an
|
|
// OPAQUE response with `status === 0`. The old `if (r.status === 200)` guard
|
|
// rejected every single one, so this cache was always empty. ytimg/ggpht all
|
|
// send `Access-Control-Allow-Origin: *`, so re-issue the request in cors mode
|
|
// and store a real, readable response; an opaque one is the last resort (it
|
|
// still renders, it just costs padded quota).
|
|
async function thumbnail(request) {
|
|
const cache = await caches.open(THUMB_CACHE);
|
|
const cached = await cache.match(request, { ignoreVary: true });
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const cors = await fetch(request.url, { mode: 'cors', credentials: 'omit' });
|
|
if (cors && cors.ok) {
|
|
await cache.put(request, cors.clone()).catch(() => {});
|
|
if (++_thumbPuts % 100 === 0) trimThumbCache();
|
|
return cors;
|
|
}
|
|
} catch { /* CORS refused or offline — fall through to the raw request */ }
|
|
|
|
try {
|
|
const raw = await fetch(request);
|
|
if (raw && (raw.status === 200 || raw.status === 0)) {
|
|
await cache.put(request, raw.clone()).catch(() => {});
|
|
// keys() on a 25k-entry cache is not free — trim every 100th put, not each one.
|
|
if (++_thumbPuts % 100 === 0) trimThumbCache();
|
|
}
|
|
return raw;
|
|
} catch {
|
|
// Offline and never cached — a neutral tile beats a broken-image glyph.
|
|
return placeholderThumb();
|
|
}
|
|
}
|
|
|
|
// Cache.keys() is insertion-ordered, so the front of the list is the oldest.
|
|
let _trimmingThumbs = false;
|
|
let _thumbPuts = 0;
|
|
async function trimThumbCache() {
|
|
if (_trimmingThumbs) return;
|
|
_trimmingThumbs = true;
|
|
try {
|
|
const cache = await caches.open(THUMB_CACHE);
|
|
const keys = await cache.keys();
|
|
if (keys.length > THUMB_CACHE_MAX) {
|
|
const excess = keys.slice(0, keys.length - THUMB_CACHE_MAX);
|
|
await Promise.all(excess.map((k) => cache.delete(k)));
|
|
}
|
|
} catch { /* trimming is best-effort */ }
|
|
finally { _trimmingThumbs = false; }
|
|
}
|
|
|
|
const PLACEHOLDER_THUMB =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">' +
|
|
'<rect width="320" height="180" fill="#1d1d24"/>' +
|
|
'<path d="M134 66l56 24-56 24z" fill="#3c3c48"/></svg>';
|
|
function placeholderThumb() {
|
|
return new Response(PLACEHOLDER_THUMB, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' },
|
|
});
|
|
}
|
|
|
|
// Stale-while-revalidate: return cached immediately, update in background.
|
|
async function staleWhileRevalidate(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
const cached = await cache.match(request);
|
|
// Start a background revalidation — don't await it before responding
|
|
const networkFetch = fetch(request).then((r) => {
|
|
if (r && r.status === 200) cache.put(request, r.clone());
|
|
return r;
|
|
}).catch(() => null);
|
|
|
|
// networkFetch resolves to null when offline; respondWith(null) throws, so
|
|
// surface a real network error instead.
|
|
return cached || (await networkFetch) || Response.error();
|
|
}
|
|
|
|
// Installation stores a candidate only. Activation publishes one atomic state record.
|
|
const candidateKey = '/__ytp_candidate/' + VERSION;
|
|
async function candidate(cache) { const r = await cache.match(candidateKey); return r ? r.json() : null; }
|
|
async function installAssets() {
|
|
const r = await fetch('/api/manifest', { cache: 'no-store' });
|
|
if (!r.ok) throw Error('Manifest unavailable');
|
|
const m = await r.json();
|
|
if (m.buildTag !== VERSION) throw Error('Deployment changed during install');
|
|
const cache = await caches.open(AssetSyncCore.CACHE);
|
|
const freshInstall = !(await AssetSyncCore.state(cache));
|
|
const fetchAsset = async (url,init) => {
|
|
// Fonts/icons still use plain URLs in CSS/manifest. On first install reuse
|
|
// their HTTP-cache responses, but only if the hash proves current bytes.
|
|
if(freshInstall && /^(?:\/fonts\/|\/icons\/|\/manifest.webmanifest)/.test(url)) {
|
|
const r = await fetch(url.split('?')[0],{...init,cache:'force-cache'});
|
|
if(r.ok && r.headers.get('X-Asset-Hash') === url.split('v=')[1]) return r;
|
|
}
|
|
return fetch(url,init);
|
|
};
|
|
await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset });
|
|
await cache.put(candidateKey, new Response(JSON.stringify(m)));
|
|
}
|
|
async function migrateLegacy(cache, m) {
|
|
// Legacy responses lack hash headers. Verify their actual bytes locally before
|
|
// retaining them; this also preserves old whole-build URLs in already-open tabs.
|
|
const names = (await caches.keys()).filter(isVersionedShellCache);
|
|
let previous = null;
|
|
for (const name of names) {
|
|
const old = await caches.open(name);
|
|
const files = {}, legacyURLs = {};
|
|
for (const req of await old.keys()) {
|
|
const u = new URL(req.url), p = u.pathname === '/' ? '/index.html' : u.pathname;
|
|
if (!m.files[p]) continue;
|
|
const r = await old.match(req);
|
|
if (!r || !r.ok) continue;
|
|
const bytes = await r.clone().arrayBuffer();
|
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
const h = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join('').slice(0,10);
|
|
const headers = new Headers(r.headers); headers.set('X-Asset-Hash', h);
|
|
files[p] = { h }; legacyURLs[p] = p + '?v=' + h;
|
|
await cache.put(legacyURLs[p], new Response(bytes, { headers }));
|
|
}
|
|
if (Object.keys(files).length) previous = { buildTag:name.slice(SHELL_CACHE_PREFIX.length),files,groups:{core:{files:Object.keys(files)}},legacyURLs };
|
|
}
|
|
if (previous && !(await AssetSyncCore.state(cache))) await cache.put(AssetSyncCore.STATE, new Response(JSON.stringify({current:previous})));
|
|
return names;
|
|
}
|
|
async function activateAssets() {
|
|
const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache);
|
|
if (!m) throw Error('Missing candidate manifest');
|
|
const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache);
|
|
const state = await AssetSyncCore.commit(m,cache);
|
|
state.previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
|
await cache.put(AssetSyncCore.STATE,new Response(JSON.stringify(state)));
|
|
// Legacy deletion occurs strictly after verified commit.
|
|
await Promise.all(names.map(n => caches.delete(n)));
|
|
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
|
|
await self.clients.claim();
|
|
if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION});
|
|
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
|
|
}
|
|
async function assetFetch(request, clientId) {
|
|
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
|
|
if(!state) return fetch(request);
|
|
const u = new URL(request.url);
|
|
let m = state.current;
|
|
if(u.origin !== self.location.origin) return fetch(request);
|
|
if(u.searchParams.has('__ytpfresh')) return fetch(request);
|
|
const nav = request.mode === 'navigate' || u.pathname === '/' || u.pathname === '/index.html';
|
|
if(!nav && state.previousClients?.includes(clientId) && state.previous?.files[u.pathname]) m = state.previous;
|
|
let path = nav ? '/index.html' : u.pathname;
|
|
let key;
|
|
if(u.searchParams.has('v') && !nav) {
|
|
key = u.pathname + u.search;
|
|
if(state.previous?.legacyURLs && u.searchParams.get('v') === state.previous.buildTag) key = state.previous.legacyURLs[path] || key;
|
|
} else if(m.files[path]) key = AssetSyncCore.url(path,m.files[path]);
|
|
if(key) {
|
|
const cached = await cache.match(key);
|
|
const h = new URL(key,self.location.origin).searchParams.get('v');
|
|
if(cached && cached.headers.get('X-Asset-Hash') === h) return cached;
|
|
try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') === h) await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); }
|
|
}
|
|
try { return await fetch(request); } catch { return new Response('Offline',{status:503}); }
|
|
}
|