123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- "use strict";
- const OFFLINE_DATA_FILE = "offline.js";
- const CACHE_NAME_PREFIX = "c2offline";
- const BROADCASTCHANNEL_NAME = "offline";
- const CONSOLE_PREFIX = "[SW] ";
- const LAZYLOAD_KEYNAME = "";
- // Create a BroadcastChannel if supported.
- const broadcastChannel = (typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(BROADCASTCHANNEL_NAME));
- //////////////////////////////////////
- // Utility methods
- function PostBroadcastMessage(o)
- {
- if (!broadcastChannel)
- return; // not supported
-
- // Impose artificial (and arbitrary!) delay of 3 seconds to make sure client is listening by the time the message is sent.
- // Note we could remove the delay on some messages, but then we create a race condition where sometimes messages can arrive
- // in the wrong order (e.g. "update ready" arrives before "started downloading update"). So to keep the consistent ordering,
- // delay all messages by the same amount.
- setTimeout(() => broadcastChannel.postMessage(o), 3000);
- };
- function Broadcast(type)
- {
- PostBroadcastMessage({
- "type": type
- });
- };
- function BroadcastDownloadingUpdate(version)
- {
- PostBroadcastMessage({
- "type": "downloading-update",
- "version": version
- });
- }
- function BroadcastUpdateReady(version)
- {
- PostBroadcastMessage({
- "type": "update-ready",
- "version": version
- });
- }
- function IsUrlInLazyLoadList(url, lazyLoadList)
- {
- if (!lazyLoadList)
- return false; // presumably lazy load list failed to load
-
- try {
- for (const lazyLoadRegex of lazyLoadList)
- {
- if (new RegExp(lazyLoadRegex).test(url))
- return true;
- }
- }
- catch (err)
- {
- console.error(CONSOLE_PREFIX + "Error matching in lazy-load list: ", err);
- }
-
- return false;
- };
- function WriteLazyLoadListToStorage(lazyLoadList)
- {
- if (typeof localforage === "undefined")
- return Promise.resolve(); // bypass if localforage not imported
- else
- return localforage.setItem(LAZYLOAD_KEYNAME, lazyLoadList)
- };
- function ReadLazyLoadListFromStorage()
- {
- if (typeof localforage === "undefined")
- return Promise.resolve([]); // bypass if localforage not imported
- else
- return localforage.getItem(LAZYLOAD_KEYNAME);
- };
- function GetCacheBaseName()
- {
- // Include the scope to avoid name collisions with any other SWs on the same origin.
- // e.g. "c2offline-https://example.com/foo/" (won't collide with anything under bar/)
- return CACHE_NAME_PREFIX + "-" + self.registration.scope;
- };
- function GetCacheVersionName(version)
- {
- // Append the version number to the cache name.
- // e.g. "c2offline-https://example.com/foo/-v2"
- return GetCacheBaseName() + "-v" + version;
- };
- // Return caches.keys() filtered down to just caches we're interested in (with the right base name).
- // This filters out caches from unrelated scopes.
- async function GetAvailableCacheNames()
- {
- const cacheNames = await caches.keys();
- const cacheBaseName = GetCacheBaseName();
- return cacheNames.filter(n => n.startsWith(cacheBaseName));
- };
- // Identify if an update is pending, which is the case when we have 2 or more available caches.
- // One must be an update that is waiting, since the next navigate that does an upgrade will
- // delete all the old caches leaving just one currently-in-use cache.
- async function IsUpdatePending()
- {
- const availableCacheNames = await GetAvailableCacheNames();
- return (availableCacheNames.length >= 2);
- };
- // Automatically deduce the main page URL (e.g. index.html or main.aspx) from the available browser windows.
- // This prevents having to hard-code an index page in the file list, implicitly caching it like AppCache did.
- async function GetMainPageUrl()
- {
- const allClients = await clients.matchAll({
- includeUncontrolled: true,
- type: "window"
- });
-
- for (const c of allClients)
- {
- // Parse off the scope from the full client URL, e.g. https://example.com/index.html -> index.html
- let url = c.url;
- if (url.startsWith(self.registration.scope))
- url = url.substring(self.registration.scope.length);
-
- if (url && url !== "/") // ./ is also implicitly cached so don't bother returning that
- {
- // If the URL is solely a search string, prefix it with / to ensure it caches correctly.
- // e.g. https://example.com/?foo=bar needs to cache as /?foo=bar, not just ?foo=bar.
- if (url.startsWith("?"))
- url = "/" + url;
-
- return url;
- }
- }
-
- return ""; // no main page URL could be identified
- };
- // Hack to fetch optionally bypassing HTTP cache until fetch cache options are supported in Chrome (crbug.com/453190)
- function fetchWithBypass(request, bypassCache)
- {
- if (typeof request === "string")
- request = new Request(request);
-
- if (bypassCache)
- {
- // bypass enabled: add a random search parameter to avoid getting a stale HTTP cache result
- const url = new URL(request.url);
- url.search += Math.floor(Math.random() * 1000000);
- return fetch(url, {
- headers: request.headers,
- mode: request.mode,
- credentials: request.credentials,
- redirect: request.redirect,
- cache: "no-store"
- });
- }
- else
- {
- // bypass disabled: perform normal fetch which is allowed to return from HTTP cache
- return fetch(request);
- }
- };
- // Effectively a cache.addAll() that only creates the cache on all requests being successful (as a weak attempt at making it atomic)
- // and can optionally cache-bypass with fetchWithBypass in every request
- async function CreateCacheFromFileList(cacheName, fileList, bypassCache)
- {
- // Kick off all requests and wait for them all to complete
- const responses = await Promise.all(fileList.map(url => fetchWithBypass(url, bypassCache)));
-
- // Check if any request failed. If so don't move on to opening the cache.
- // This makes sure we only open a cache if all requests succeeded.
- let allOk = true;
-
- for (const response of responses)
- {
- if (!response.ok)
- {
- allOk = false;
- console.error(CONSOLE_PREFIX + "Error fetching '" + response.url + "' (" + response.status + " " + response.statusText + ")");
- }
- }
-
- if (!allOk)
- throw new Error("not all resources were fetched successfully");
-
- // Can now assume all responses are OK. Open a cache and write all responses there.
- // TODO: ideally we can do this transactionally to ensure a complete cache is written as one atomic operation.
- // This needs either new transactional features in the spec, or at the very least a way to rename a cache
- // (so we can write to a temporary name that won't be returned by GetAvailableCacheNames() and then rename it when ready).
- const cache = await caches.open(cacheName);
-
- try {
- return await Promise.all(responses.map(
- (response, i) => cache.put(fileList[i], response)
- ));
- }
- catch (err)
- {
- // Not sure why cache.put() would fail (maybe if storage quota exceeded?) but in case it does,
- // clean up the cache to try to avoid leaving behind an incomplete cache.
- console.error(CONSOLE_PREFIX + "Error writing cache entries: ", err);
- caches.delete(cacheName);
- throw err;
- }
- };
- async function UpdateCheck(isFirst)
- {
- try {
- // Always bypass cache when requesting offline.js to make sure we find out about new versions.
- const response = await fetchWithBypass(OFFLINE_DATA_FILE, true);
-
- if (!response.ok)
- throw new Error(OFFLINE_DATA_FILE + " responded with " + response.status + " " + response.statusText);
-
- const data = await response.json();
-
- const version = data.version;
- const fileList = data.fileList;
- const lazyLoadList = data.lazyLoad;
- const currentCacheName = GetCacheVersionName(version);
-
- const cacheExists = await caches.has(currentCacheName);
- // Don't recache if there is already a cache that exists for this version. Assume it is complete.
- if (cacheExists)
- {
- // Log whether we are up-to-date or pending an update.
- const isUpdatePending = await IsUpdatePending();
- if (isUpdatePending)
- {
- console.log(CONSOLE_PREFIX + "Update pending");
- Broadcast("update-pending");
- }
- else
- {
- console.log(CONSOLE_PREFIX + "Up to date");
- Broadcast("up-to-date");
- }
- return;
- }
-
- // Implicitly add the main page URL to the file list, e.g. "index.html", so we don't have to assume a specific name.
- const mainPageUrl = await GetMainPageUrl();
-
- // Prepend the main page URL to the file list if we found one and it is not already in the list.
- // Also make sure we request the base / which should serve the main page.
- fileList.unshift("./");
-
- if (mainPageUrl && fileList.indexOf(mainPageUrl) === -1)
- fileList.unshift(mainPageUrl);
-
- console.log(CONSOLE_PREFIX + "Caching " + fileList.length + " files for offline use");
-
- if (isFirst)
- Broadcast("downloading");
- else
- BroadcastDownloadingUpdate(version);
-
- // Note we don't bypass the cache on the first update check. This is because SW installation and the following
- // update check caching will race with the normal page load requests. For any normal loading fetches that have already
- // completed or are in-flight, it is pointless and wasteful to cache-bust the request for offline caching, since that
- // forces a second network request to be issued when a response from the browser HTTP cache would be fine.
- if (lazyLoadList)
- await WriteLazyLoadListToStorage(lazyLoadList); // dump lazy load list to local storage#
-
- await CreateCacheFromFileList(currentCacheName, fileList, !isFirst);
- const isUpdatePending = await IsUpdatePending();
-
- if (isUpdatePending)
- {
- console.log(CONSOLE_PREFIX + "All resources saved, update ready");
- BroadcastUpdateReady(version);
- }
- else
- {
- console.log(CONSOLE_PREFIX + "All resources saved, offline support ready");
- Broadcast("offline-ready");
- }
- }
- catch (err)
- {
- // Update check fetches fail when we're offline, but in case there's any other kind of problem with it, log a warning.
- console.warn(CONSOLE_PREFIX + "Update check failed: ", err);
- }
- };
- self.addEventListener("install", event =>
- {
- // On install kick off an update check to cache files on first use.
- // If it fails we can still complete the install event and leave the SW running, we'll just
- // retry on the next navigate.
- event.waitUntil(
- UpdateCheck(true) // first update
- .catch(() => null)
- );
- });
- async function GetCacheNameToUse(availableCacheNames, doUpdateCheck)
- {
- // Prefer the oldest cache available. This avoids mixed-version responses by ensuring that if a new cache
- // is created and filled due to an update check while the page is running, we keep returning resources
- // from the original (oldest) cache only.
- if (availableCacheNames.length === 1 || !doUpdateCheck)
- return availableCacheNames[0];
-
- // We are making a navigate request with more than one cache available. Check if we can expire any old ones.
- const allClients = await clients.matchAll();
-
- // If there are other clients open, don't expire anything yet. We don't want to delete any caches they
- // might be using, which could cause mixed-version responses.
- if (allClients.length > 1)
- return availableCacheNames[0];
-
- // Identify newest cache to use. Delete all the others.
- const latestCacheName = availableCacheNames[availableCacheNames.length - 1];
- console.log(CONSOLE_PREFIX + "Updating to new version");
-
- await Promise.all(
- availableCacheNames.slice(0, -1)
- .map(c => caches.delete(c))
- );
-
- return latestCacheName;
- };
- async function HandleFetch(event, doUpdateCheck)
- {
- const availableCacheNames = await GetAvailableCacheNames();
-
- // No caches available: go to network
- if (!availableCacheNames.length)
- return fetch(event.request);
-
- const useCacheName = await GetCacheNameToUse(availableCacheNames, doUpdateCheck);
- const cache = await caches.open(useCacheName);
- const cachedResponse = await cache.match(event.request);
-
- if (cachedResponse)
- return cachedResponse; // use cached response
-
- // We need to check if this request is to be lazy-cached. Send the request and load the lazy-load list
- // from storage simultaneously.
- const result = await Promise.all([fetch(event.request), ReadLazyLoadListFromStorage()]);
- const fetchResponse = result[0];
- const lazyLoadList = result[1];
-
- if (IsUrlInLazyLoadList(event.request.url, lazyLoadList))
- {
- // Handle failure writing to the cache. This can happen if the storage quota is exceeded, which is particularly
- // likely in Safari 11.1, which appears to have very tight storage limits. Make sure even in the event of an error
- // we continue to return the response from the fetch.
- try {
- // Note clone response since we also respond with it
- await cache.put(event.request, fetchResponse.clone());
- }
- catch (err)
- {
- console.warn(CONSOLE_PREFIX + "Error caching '" + event.request.url + "': ", err);
- }
- }
-
- return fetchResponse;
- };
- self.addEventListener("fetch", event =>
- {
- /** NOTE (iain)
- * This check is to prevent a bug with XMLHttpRequest where if its
- * proxied with "FetchEvent.prototype.respondWith" no upload progress
- * events are triggered. By returning we allow the default action to
- * occur instead. Currently all cross-origin requests fall back to default.
- */
- if (new URL(event.request.url).origin !== location.origin)
- return;
-
- // Check for an update on navigate requests
- const doUpdateCheck = (event.request.mode === "navigate");
-
- const responsePromise = HandleFetch(event, doUpdateCheck);
- if (doUpdateCheck)
- {
- // allow the main request to complete, then check for updates
- event.waitUntil(
- responsePromise
- .then(() => UpdateCheck(false)) // not first check
- );
- }
- event.respondWith(responsePromise);
- });
|