/* This file must be served from the site root: /service-worker.js (This is done in the PHP code for this plugin) When registered as a service worker it must use the root scope: / (This is done in the PHP code for this plugin) The location of this file should not be changed after it has been installed on a site without a careful multistep process. The service worker for push notifications is separate: OneSignalSDKWorker.js */ // We will load this archive automatically when the service worker is installed. // TODO: get this url from WP config. // const AUTO_ARCHIVE_URL = "/offline-archive-full.msgpack.serve-gz"; // const AUTO_ARCHIVE_URL = "https://wordpress-864237-4974999.cloudwaysapps.com/wp-content/uploads/offline-access-archives/https___wordpress-864237-4974999.cloudwaysapps.com_.archive.msgpack.serve-gz"; // const AUTO_ARCHIVE_URL = "/wp-content/uploads/offline-archive-full.msgpack.serve-gz"; // const AUTO_ARCHIVE_CACHE_NAME = "offline-archive-full"; let ACTIVATED = false; let ACTIVATION_DOWNLOAD_REQUESTED = false; let ACTIVATION_DOWNLOAD_CACHE_NAME = null; let ACTIVATION_DOWNLOAD_ARCHIVE_URL = null; // const LAST_DOWNLOAD_STATUS = {}; // TODO: include this script in our plugin and self host to avoid the security risk of loading // this from a third party into a service worker. importScripts('https://cdnjs.cloudflare.com/ajax/libs/msgpack-lite/0.1.26/msgpack.min.js'); /* LIFE CYCLE EVENTS */ self.addEventListener("install", (event) => { // Runs only once on first-time registration or new update of this service worker. // Runs concurrently with any pre-existing service worker. Allows for any initialization // that needs to be done before any pre-existing service workers can be decomissioned. // The default behavior is to wait for all tabs "controlled" by this service worker to close // before activating the new or updated service worker. That behavior is recommended to avoid // a new service worker handling requests for a page that was loaded with an old service worker. // However, here we are skipping waiting for the user to close tabs and activating the new service // worker immediately. This makes it easier to push updates to the service worker and have them // take effect immediately - but it only works if we keep the logic in the service worker simple // do not tie service worker version to versions of the offline archive. // Proceed immediately to the "activate" event. self.skipWaiting(); }); self.addEventListener("activate", async (event) => { // Runs once any pre-existing service workers have been decomissioned and all future pages // will be handled by this service worker. // We immediately download the "core" archive. This archive should be kept small enough that // we can download it immediately and keep it updated without worrying about the user's data // plan. This archive should also contain a custom offline.html page. // Instead of doing this immediately, we'll wait for a specific message from the client. // await downloadAndCacheArchive("offline-archive-core", "/offline-archive-core.msgpack.serve-gz", false); // We were having some bugs with the archive not always downloading using the client request method, so // so we'll try to trigger it here instead. // await handleDownloadArchiveRequest(AUTO_ARCHIVE_CACHE_NAME, AUTO_ARCHIVE_URL, false); // Unfortunately that doesn't work for the web site where we don't want to download the the archive // immediately and also may want to have more options for different download options. // But since I'm not sure if order of operations can matter, I'm adding this to potentially // improve the reliability of the initial download for mobile app. if (ACTIVATION_DOWNLOAD_REQUESTED) { // await handleDownloadArchiveRequest(AUTO_ARCHIVE_CACHE_NAME, AUTO_ARCHIVE_URL, false); let cache_status = await idb.get(makeDLStatusKey(ACTIVATION_DOWNLOAD_ARCHIVE_URL)); if (!cache_status) { // we only need to do the activation download if we haven't done it yet. await handleDownloadArchiveRequest(ACTIVATION_DOWNLOAD_CACHE_NAME, ACTIVATION_DOWNLOAD_ARCHIVE_URL, false); } } // Note that by default this does not mean that all pages are now controlled by this service // worker - only tabs that are loaded after this service worker is activated will be controlled // by this service worker. // This pre-emptively claims all clients so that all pages are controlled by this service worker, // including the page that registered this service worker. This means that all subsequent requests // can be immdeiately intercepted by this service worker. self.clients.claim(); ACTIVATED = true; }); /* OFFLINE ARCHIVE */ async function downloadAndCacheArchive(cacheName, archiveUrl, keepExisting) { // Fetches the msgpack archive from the server and stores it in the cache. // Requires that the service worker is already registered and active. // This streams the archive data into the cache, processing each request/response // pair as it is decoded from the msgpack stream, so it does not require that the // entire archive be downloaded and buffered in memeory before it can be stored in // the cache. if (!keepExisting) { // Delete any existing cache with the same name await caches.delete(cacheName); } // return; if (DEBUG) { console.log("service-worker.js: downloading archive", archiveUrl); } let response; try { response = await fetch(archiveUrl); } catch (error) { throw new Error(`Network Error while fetching offline archive (${archiveUrl}): ${error.message}`, error); } if (!response.ok) { throw new Error(`HTTP Error while fetching offline archive (${archiveUrl}): ${response.status}`, response); } const reader = response.body.getReader(); const decoder = new msgpack.Decoder(); let errors = []; var complete = false; // Handle each item as it is decoded from the msgpack stream decoder.on("data", async (decodedData) => { try { // Unpack the msgpack item. This structure needs to match the structure we use when // creating the msgpack archive. const { url, request_headers, status_code, response_headers, content } = decodedData; // console.log("data", url, status_code, response_headers); let response = { content: content, params: { headers: response_headers, status: status_code, } } cachePut(cacheName, url, response); } catch (error) { if (DEBUG) { console.error("offline-archive-client.js: error processing msgpack item", error); } errors.push(error); } finally { if (complete) { // This is the last item in the archive, so resolve the promise // to indicate that the archive has been fully processed. console.log("COMPLETE"); } } }); // Return a promise that resolves when the archive has been fully processed. // This allows the caller to await the completion of the archive processing. return new Promise(async (resolve, reject) => { // Pipe streaming archive data into the msgpack decoder. while (true) { try { const { done, value } = await reader.read(); if (done) { complete = true; if (errors.length > 0) { reject(errors); } else { resolve(response); } break; } decoder.decode(value); } catch (error) { // Handle any reading or decoding errors here errors.push(error); reject(errors); break; } } }); } async function cachePut(cacheName, key, response) { // Convert headers object to Headers instance params = JSON.parse(JSON.stringify(response.params)); if (params.headers) { params.headers = new Headers(response.params.headers); } // Create complete Response object. response = new Response(new Blob([response.content]), response.params); const cache = await caches.open(cacheName); await cache.put(key, response); if (DEBUG) { console.log("Cache put: " + key, await cache.match(key)); // message_all({"cache-put": key, "response": await serializeResponse(await cache.match(key))}); } } async function cacheMatch(cacheName, key) { // This wraps the cache.match() and is primarily used for debugging. // Note that it serializes the response before returning it so is not best for // intercepting live requests where streaming the response body is preferred. cache = await caches.open(cacheName); let response = await cache.match(key); response = await serializeResponse(response); if (DEBUG) { console.log("Cache match: " + key, response); message_all({ "cache-match": key, "response": response }); } } self.addEventListener('message', async function (event) { // Listen for messages from client pages. // Client pages can can communiate with the service worker like so: // > navigator.serviceWorker.controller.postMessage({type: "message-type": payload: {key: "value"}}); if (DEBUG) { console.log("service-worker.js received message", event.data) message_all({ "received-message": event.data, "recipient": "service-worker.js:offline-archive" }); } if (event.data && event.data.type === 'offline-archive.initial-download') { // console.log("recieved offline-archive.initial-download", "ACTIVATED", ACTIVATED, "ACTIVATION_DOWNLOAD_REQUESTED", ACTIVATION_DOWNLOAD_REQUESTED); const archiveUrl = event.data.payload.archiveUrl; const cacheName = event.data.payload.cacheName; if (ACTIVATED) { // await handleDownloadArchiveRequest(AUTO_ARCHIVE_CACHE_NAME, AUTO_ARCHIVE_URL, false); let cache_status = await idb.get(makeDLStatusKey(archiveUrl)); if (!cache_status) { // we only need to do the activation download if we haven't done it yet. await handleDownloadArchiveRequest(cacheName, archiveUrl, false, event.source); } } else { ACTIVATION_DOWNLOAD_REQUESTED = true; ACTIVATION_DOWNLOAD_CACHE_NAME = cacheName; ACTIVATION_DOWNLOAD_ARCHIVE_URL = archiveUrl; } } else if (event.data && event.data.type === 'offline-archive.download') { // console.log("recieved offline-archive.download", "ACTIVATED", ACTIVATED, "ACTIVATION_DOWNLOAD_REQUESTED", ACTIVATION_DOWNLOAD_REQUESTED); const archiveUrl = event.data.payload.archiveUrl; const cacheName = event.data.payload.cacheName; const keepExisting = event.data.payload.keepExisting; await handleDownloadArchiveRequest(cacheName, archiveUrl, keepExisting, event.source); } else if (event.data && event.data.type === 'offline-archive.download.status.fetch') { const archiveUrl = event.data.payload.archiveUrl; const cacheName = event.data.payload.cacheName; // if (LAST_DOWNLOAD_STATUS[archiveUrl]) { // event.source.postMessage({ // type: 'offline-archive.download.status', // payload: LAST_DOWNLOAD_STATUS[archiveUrl] // }); // } else { // event.source.postMessage({ // type: 'offline-archive.download.status', // payload: { // status: "missing", // message: "no status available", // cacheName: cacheName, // archiveUrl: archiveUrl, // response: null, // } // }); // } // let cache = await caches.open(cacheName); let cache_status = await idb.get(makeDLStatusKey(archiveUrl)); if (cache_status) { if (cache_status.status == "downloading") { let cache_timestamp = new Date(cache_status.timestamp); let now = new Date(); let age = now - cache_timestamp; // if more than 3 minutes, then we'll assume the download failed. if (age > 1000 * 60 * 3) { cache_status.status = "error"; cache_status.message = "Download timed out."; } } event.source.postMessage({ type: 'offline-archive.download.status', payload: cache_status }); } else { event.source.postMessage({ type: 'offline-archive.download.status', payload: { status: "missing", message: "no status available", cacheName: cacheName, archiveUrl: archiveUrl, response: null, } }); } } else if (event.data && event.data.type === 'offline-archive.match') { const cacheName = event.data.payload.cacheName; const key = event.data.payload.key; // Send a message back with the response. event.source.postMessage({ type: 'offline-archive.match.response', payload: { cacheName: cacheName, key: key, response: await cacheMatch(cacheName, key) } }); } }); async function handleDownloadArchiveRequest(cacheName, archiveUrl, keepExisting, client) { // LAST_DOWNLOAD_STATUS[archiveUrl] = { // status: "downloading", // message: "offline archive download started", // cacheName: cacheName, // archiveUrl: archiveUrl, // response: null, // }; await idb.set(makeDLStatusKey(archiveUrl), { status: "downloading", message: "offline archive download started", cacheName: cacheName, archiveUrl: archiveUrl, response: null, timestamp: new Date().toISOString(), }); let status = "ok"; let message = "offline archive download finished"; let response = null; try { // let manager = downloadManager(cacheName, archiveUrl); // await manager.getStatus(); // if (manager.downloadAvailable()) { // await manager.performDownload(); // } // console.log("downloading archive", cacheName, archiveUrl, keepExisting); response = await downloadAndCacheArchive(cacheName, archiveUrl, keepExisting); // console.log("done downloading archive", cacheName, archiveUrl, keepExisting); const headers = {}; for (let [key, value] of response.headers) { headers[key] = value; } response = { ok: response.ok, redirected: response.redirected, status: response.status, statusText: response.statusText, url: response.url, headers: headers, }; } catch (error) { status = "error"; message = error.message; if (DEBUG) { console.error("service-worker.js: error downloading archive", error); } } // LAST_DOWNLOAD_STATUS[archiveUrl] = { // status: status, // message: message, // cacheName: cacheName, // archiveUrl: archiveUrl, // response: response, // }; let dl_status = { status: status, message: message, cacheName: cacheName, archiveUrl: archiveUrl, response: response, } await idb.set(makeDLStatusKey(archiveUrl), dl_status); console.log("sending post download offline-archive.download.status", client, dl_status); if (client) { client.postMessage({ type: 'offline-archive.download.status', // payload: LAST_DOWNLOAD_STATUS[archiveUrl] payload: dl_status }); } else { message_all({ type: 'offline-archive.download.status', // payload: LAST_DOWNLOAD_STATUS[archiveUrl] payload: dl_status }); } } /* NETWORK INTERCEPT EVENTS */ self.addEventListener('fetch', event => { if (DEBUG) { console.log("service-worker.js received fetch event", event.request); (async function _() { message_all({ "received-fetch-event": await serializeRequest(event.request), "recipient": "service-worker.js:fetch" }); })(); } if (event.request.url.includes('admin-ajax.php')) { // Do nothing, let the browser perform the default fetch return; } let isNavigationRequest = ( event.request.mode === 'navigate' && event.request.method === 'GET' && event.request.destination === 'document' ); event.respondWith( // Try network response first. fetch(event.request) // Try returning a response from the offline archive second. .catch(async error => { if (DEBUG) { console.log("service-worker.js fetch event network failed", error); const error_data = { message: error.message, name: error.name, stack: error.stack, }; message_all({ "fetch-event-network-failed": error_data, "recipient": "service-worker.js:fetch" }); } // let url = event.request.url; // Object.keys(ON2OFF_URL_ROOTS).forEach(function(liveRoot) { // if (url.startsWith(liveRoot)) { // url = url.replace(liveRoot, ON2OFF_URL_ROOTS[liveRoot]); // } // }); offlineRequest = event.request; // if (url != event.request.url) { // offlineRequest = new Request(url, event.request); // } // check if "*wp-login.php*" is in the url if (offlineRequest.url.includes("wp-login.php")) { // if so, attempt to swap with the offline login page let newRequestInit = { method: event.request.method, headers: event.request.headers, mode: "same-origin", // Set the mode to "same-origin" or any other valid mode credentials: event.request.credentials, cache: event.request.cache, redirect: event.request.redirect, referrer: event.request.referrer, integrity: event.request.integrity }; offlineRequest = new Request("/llvp-sign-in-offline/", newRequestInit); } const response = await caches.match(offlineRequest); if (response) { // We found a response in the cache - return it. if (DEBUG) { console.log("service-worker.js fetch event cache hit", response); message_all({ "fetch-event-cache-hit": true, "recipient": "service-worker.js:fetch" }); } return response; } else if (isNavigationRequest) { // We did not find a response in the cache, if this was a navigation request, // return an offline message. let isNavigationRequest = ( event.request.mode === 'navigate' && event.request.method === 'GET' && event.request.destination === 'document' ); if (DEBUG) { console.log("service-worker.js fetch event cache miss", response); message_all({ "fetch-event-cache-miss": true, "recipient": "service-worker.js:fetch" }); } // Return fallback page from cache, if available return caches.match('/llvp-offline/') .then(response_1 => { if (response_1) { if (DEBUG) { console.log("service-worker.js fetch event offline fallback", response_1); message_all({ "fetch-event-offline-fallback": true, "recipient": "service-worker.js:fetch" }); } return response_1; } else { if (DEBUG) { console.log("service-worker.js fetch event offline fallback not found", response_1); message_all({ "fetch-event-offline-fallback-not-found": true, "recipient": "service-worker.js:fetch" }); } // Return hard-coded offline message. // return new Response(OFFLINE_PAGE, { headers: { "Content-Type": "text/html" } }); throw new Error("offline fallback not found"); // if we get to this situation - clicking the link will do nothing which is better than the last-resort fallback page. let bundledResponse = fetch('/offline-app.sc23.conference-program.com/offline/index.html'); if (bundledResponse && bundledResponse.ok) { if (DEBUG) { console.log("service-worker.js fetch event offline fallback", response_1); message_all({ "fetch-event-offline-fallback": true, "recipient": "service-worker.js:fetch" }); } return bundledResponse; } else { if (DEBUG) { console.log("service-worker.js fetch event offline fallback not found", response_1); message_all({ "fetch-event-offline-fallback-not-found": true, "recipient": "service-worker.js:fetch" }); } // Return hard-coded offline message. // return new Response(OFFLINE_PAGE, { headers: { "Content-Type": "text/html" } }); throw new Error("offline fallback not found"); // if we get to this situation - clicking the link will do nothing which is better than the last-resort fallback page. } } }); } }) ); }); const OFFLINE_PAGE = ` Offline

Offline

The requested page is unavailable.

Either the offline program has not been downloaded or this page is not available in the offline program.

`; /* UTILITY FUNCTIONS */ async function serializeResponse(response) { // This is a utility function to serialize a Response object so that it can be sent // over a postMessage() call. This is useful for debugging. if (!response) { return null; } const headers = {}; for (let [key, value] of response.headers) { headers[key] = value; } return { ok: response.ok, redirected: response.redirected, status: response.status, statusText: response.statusText, url: response.url, headers: headers, body: await response.blob() // body: "skipped" }; } async function serializeRequest(request) { // This is a utility function to serialize a Request() object so that it can be sent // over a postMessage() call. This is useful for debugging. if (!request) { return null; } const headers = {}; for (let [key, value] of request.headers) { headers[key] = value; } return { url: request.url, method: request.method, headers: headers, body: await request.blob() }; } /* DEBUGGING */ let DEBUG = false; function message_all(msg) { self.clients.matchAll().then(clients => { clients.forEach(client => { try { client.postMessage(msg); } catch (error) { console.error("Error posting message to client", msg); throw error; } }); }); } if (DEBUG) { setTimeout(function () { message_all("hello from service worker 3"); }, 1000); } self.addEventListener('message', function (event) { if (DEBUG) { console.log("service-worker.js received message", event.data) message_all({ "received-message": event.data, "recipient": "service-worker.js:debug" }); } if (event.data && event.data.type == 'ping') { message_all({ "type": "pong", payload: { "receiver": "service-worker.js:debug" } }); } if (event.data && event.data.type == 'debug.status') { DEBUG = event.data.payload.status; if (DEBUG) { message_all({ "debug": DEBUG }); } } }); /* ERROR HANDLING */ self.addEventListener('error', function (event) { try { if (DEBUG) { console.error('Uncaught error in Service Worker:', event.error); message_all({ "type": "error", payload: event.error }); } } catch (error) { // Avoid infinite loop of event errors. console.error('Uncaught error in Service Worker error handler:', error); } }); self.addEventListener('unhandledrejection', function (event) { if (DEBUG) { console.error('Unhandled promise rejection in Service Worker:', event.reason); message_all({ type: "unhandledrejection", payload: event.reason }); } }); /* IndexDB Util */ function idbKeyValStore({ storeName = 'keyval', dbName = 'DefaultDB', version = 1 } = {}) { const dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(dbName, version); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(storeName)) { db.createObjectStore(storeName); } }; }); return { async set(key, value) { const db = await dbPromise; return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite'); const store = tx.objectStore(storeName); store.put(value, key); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }, async get(key) { const db = await dbPromise; return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readonly'); const store = tx.objectStore(storeName); const request = store.get(key); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } }; } let idb = idbKeyValStore({ storeName: 'llvp' }); function makeDLStatusKey(url) { return `offline-access-dl-status-${url}`; }