// Misc Consts const SW_VERSION = 1; const SW_CACHE_NAME = "OFFLINE_V1"; // Message Types const PURGE_SW_CACHE = "PURGE_SW_CACHE"; const DEL_SW_CACHE = "DEL_SW_CACHE"; const GET_SW_CACHE = "GET_SW_CACHE"; const GET_SW_VERSION = "GET_SW_VERSION"; // Cache Types const CACHE_ONLY = "CACHE_ONLY"; const CACHE_NEVER = "CACHE_NEVER"; const CACHE_UPDATE_SYNC = "CACHE_UPDATE_SYNC"; const CACHE_UPDATE_ASYNC = "CACHE_UPDATE_ASYNC"; /** * Define routes and their directives. Takes `routes`, `type`, and `fallback`. * * Routes (Required): * Either a string of the exact request, or a RegExp. Order precedence. * * Fallback (Optional): * A fallback function. If the request fails, this function is executed and * its return value is returned as the result. * * Types (Required): * - CACHE_ONLY * Cache once & never refresh. * - CACHE_NEVER * Never cache & always perform a request. * - CACHE_UPDATE_SYNC * Update cache & return result. * - CACHE_UPDATE_ASYNC * Return cache if exists & update cache in background. **/ const ROUTES = [ { route: "/local", type: CACHE_UPDATE_ASYNC }, { route: "/reader", type: CACHE_UPDATE_ASYNC }, { route: "/manifest.json", type: CACHE_UPDATE_ASYNC }, { route: /^\/assets\/reader\/fonts\//, type: CACHE_ONLY }, { route: /^\/assets\//, type: CACHE_UPDATE_ASYNC }, { route: /^\/documents\/[a-zA-Z0-9]{32}\/(cover|file)$/, type: CACHE_UPDATE_ASYNC, }, { route: /^\/reader\/progress\/[a-zA-Z0-9]{32}$/, type: CACHE_UPDATE_SYNC, }, { route: /.*/, type: CACHE_NEVER, fallback: (event) => caches.match("/local"), }, ]; /** * These are assets that are cached on initial service worker installation. **/ const PRECACHE_ASSETS = [ // Offline & Reader Assets "/local", "/reader", "/assets/local/index.js", "/assets/reader/index.js", "/assets/reader/fonts.css", "/assets/reader/themes.css", "/assets/icons/icon512.png", "/assets/images/no-cover.jpg", // Main App Assets "/manifest.json", "/assets/index.js", "/assets/style.css", "/assets/common.js", // Library Assets "/assets/lib/jszip.min.js", "/assets/lib/epub.min.js", "/assets/lib/no-sleep.min.js", "/assets/lib/idb-keyval.min.js", // Fonts "/assets/reader/fonts/arbutus-slab-v16-latin_latin-ext-regular.woff2", "/assets/reader/fonts/lato-v24-latin_latin-ext-100.woff2", "/assets/reader/fonts/lato-v24-latin_latin-ext-100italic.woff2", "/assets/reader/fonts/lato-v24-latin_latin-ext-700.woff2", "/assets/reader/fonts/lato-v24-latin_latin-ext-700italic.woff2", "/assets/reader/fonts/lato-v24-latin_latin-ext-italic.woff2", "/assets/reader/fonts/lato-v24-latin_latin-ext-regular.woff2", "/assets/reader/fonts/open-sans-v36-latin_latin-ext-700.woff2", "/assets/reader/fonts/open-sans-v36-latin_latin-ext-700italic.woff2", "/assets/reader/fonts/open-sans-v36-latin_latin-ext-italic.woff2", "/assets/reader/fonts/open-sans-v36-latin_latin-ext-regular.woff2", ]; // ------------------------------------------------------- // // ----------------------- Helpers ----------------------- // // ------------------------------------------------------- // async function purgeCache() { console.log("[purgeCache] Purging Cache"); return caches.keys().then(function (names) { for (let name of names) caches.delete(name); }); } async function updateCache(request) { let url = request.url ? new URL(request.url).pathname : request; console.log("[updateCache] Updating Cache:", url); let cache = await caches.open(SW_CACHE_NAME); return fetch(request) .then((response) => { const resClone = response.clone(); if (response.status < 400) cache.put(request, resClone); return response; }) .catch((e) => { console.log("[updateCache] Updating Cache Failed:", url); throw e; }); } // ------------------------------------------------------- // // ------------------- Event Listeners ------------------- // // ------------------------------------------------------- // async function handleFetch(event) { // Get Path let url = new URL(event.request.url).pathname; // Find Directive const directive = ROUTES.find( (item) => (item.route instanceof RegExp && url.match(item.route)) || url == item.route, ) || { type: CACHE_NEVER }; // Get Fallback const fallbackFunc = (event) => { console.log("[handleFetch] Fallback:", { url, directive }); if (directive.fallback) return directive.fallback(event); }; console.log("[handleFetch] Processing:", { url, directive }); // Get Current Cache let currentCache = await caches.match(event.request); // Perform Caching Method switch (directive.type) { case CACHE_NEVER: return fetch(event.request).catch((e) => fallbackFunc(event)); case CACHE_ONLY: return ( currentCache || updateCache(event.request).catch((e) => fallbackFunc(event)) ); case CACHE_UPDATE_SYNC: return updateCache(event.request).catch( (e) => currentCache || fallbackFunc(event), ); case CACHE_UPDATE_ASYNC: let newResponse = updateCache(event.request).catch((e) => fallbackFunc(event), ); return currentCache || newResponse; } } function handleMessage(event) { console.log("[handleMessage] Received Message:", event.data); let { id, data } = event.data; if (data.type === GET_SW_VERSION) { event.source.postMessage({ id, data: SW_VERSION }); } else if (data.type === PURGE_SW_CACHE) { purgeCache() .then(() => event.source.postMessage({ id, data: "SUCCESS" })) .catch(() => event.source.postMessage({ id, data: "FAILURE" })); } else if (data.type === GET_SW_CACHE) { caches.open(SW_CACHE_NAME).then(async (cache) => { let allKeys = await cache.keys(); // Get Cached Resources let docResources = allKeys .map((item) => new URL(item.url).pathname) .filter( (item) => item.startsWith("/documents/") || item.startsWith("/reader/progress/"), ); // Derive Unique IDs let documentIDs = Array.from( new Set( docResources .filter((item) => item.startsWith("/documents/")) .map((item) => item.split("/")[2]), ), ); /** * Filter for cached items only. Attempt to fetch updated result. If * failure, return cached version. This ensures we return the most up to * date version possible. **/ let cachedDocuments = await Promise.all( documentIDs .filter( (id) => docResources.includes("/documents/" + id + "/file") && docResources.includes("/reader/progress/" + id), ) .map(async (id) => { let url = "/reader/progress/" + id; let currentCache = await caches.match(url); let resp = await updateCache(url).catch((e) => currentCache); return resp.json(); }), ); event.source.postMessage({ id, data: cachedDocuments }); }); } else if (data.type === DEL_SW_CACHE) { caches .open(SW_CACHE_NAME) .then((cache) => Promise.all([ cache.delete("/documents/" + data.id + "/file"), cache.delete("/reader/progress/" + data.id), ]), ) .then(() => event.source.postMessage({ id, data: "SUCCESS" })) .catch(() => event.source.postMessage({ id, data: "FAILURE" })); } else { event.source.postMessage({ id, data: { pong: 1 } }); } } async function handleInstall(event) { let cache = await caches.open(SW_CACHE_NAME); return cache.addAll(PRECACHE_ASSETS); } self.addEventListener("message", handleMessage); self.addEventListener("install", function (event) { event.waitUntil(handleInstall(event)); }); self.addEventListener("fetch", (event) => { /** * Weird things happen when a service worker attempts to handle a request * when the server responds with chunked transfer encoding. Right now we only * use chunked encoding on POSTs. So this is to avoid processing those. **/ if (event.request.method != "GET") return; return event.respondWith(handleFetch(event)); });