"use strict";12const OFFLINE_DATA_FILE = "offline.js";3const CACHE_NAME_PREFIX = "chrome-dino";4const BROADCASTCHANNEL_NAME = "offline";5const CONSOLE_PREFIX = "[SW] ";6const LAZYLOAD_KEYNAME = "";78// Create a BroadcastChannel if supported.9const broadcastChannel = (typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(BROADCASTCHANNEL_NAME));1011//////////////////////////////////////12// Utility methods13function PostBroadcastMessage(o)14{15if (!broadcastChannel)16return; // not supported1718// Impose artificial (and arbitrary!) delay of 3 seconds to make sure client is listening by the time the message is sent.19// Note we could remove the delay on some messages, but then we create a race condition where sometimes messages can arrive20// in the wrong order (e.g. "update ready" arrives before "started downloading update"). So to keep the consistent ordering,21// delay all messages by the same amount.22setTimeout(() => broadcastChannel.postMessage(o), 3000);23};2425function Broadcast(type)26{27PostBroadcastMessage({28"type": type29});30};3132function BroadcastDownloadingUpdate(version)33{34PostBroadcastMessage({35"type": "downloading-update",36"version": version37});38}3940function BroadcastUpdateReady(version)41{42PostBroadcastMessage({43"type": "update-ready",44"version": version45});46}4748function IsUrlInLazyLoadList(url, lazyLoadList)49{50if (!lazyLoadList)51return false; // presumably lazy load list failed to load5253try {54for (const lazyLoadRegex of lazyLoadList)55{56if (new RegExp(lazyLoadRegex).test(url))57return true;58}59}60catch (err)61{62console.error(CONSOLE_PREFIX + "Error matching in lazy-load list: ", err);63}6465return false;66};6768function WriteLazyLoadListToStorage(lazyLoadList)69{70if (typeof localforage === "undefined")71return Promise.resolve(); // bypass if localforage not imported72else73return localforage.setItem(LAZYLOAD_KEYNAME, lazyLoadList)74};7576function ReadLazyLoadListFromStorage()77{78if (typeof localforage === "undefined")79return Promise.resolve([]); // bypass if localforage not imported80else81return localforage.getItem(LAZYLOAD_KEYNAME);82};8384function GetCacheBaseName()85{86// Include the scope to avoid name collisions with any other SWs on the same origin.87// e.g. "c3offline-https://example.com/foo/" (won't collide with anything under bar/)88return CACHE_NAME_PREFIX + "-" + self.registration.scope;89};9091function GetCacheVersionName(version)92{93// Append the version number to the cache name.94// e.g. "c3offline-https://example.com/foo/-v2"95return GetCacheBaseName() + "-v" + version;96};9798// Return caches.keys() filtered down to just caches we're interested in (with the right base name).99// This filters out caches from unrelated scopes.100async function GetAvailableCacheNames()101{102const cacheNames = await caches.keys();103const cacheBaseName = GetCacheBaseName();104return cacheNames.filter(n => n.startsWith(cacheBaseName));105};106107// Identify if an update is pending, which is the case when we have 2 or more available caches.108// One must be an update that is waiting, since the next navigate that does an upgrade will109// delete all the old caches leaving just one currently-in-use cache.110async function IsUpdatePending()111{112const availableCacheNames = await GetAvailableCacheNames();113return (availableCacheNames.length >= 2);114};115116// Automatically deduce the main page URL (e.g. index.html or main.aspx) from the available browser windows.117// This prevents having to hard-code an index page in the file list, implicitly caching it like AppCache did.118async function GetMainPageUrl()119{120const allClients = await clients.matchAll({121includeUncontrolled: true,122type: "window"123});124125for (const c of allClients)126{127// Parse off the scope from the full client URL, e.g. https://example.com/index.html -> index.html128let url = c.url;129if (url.startsWith(self.registration.scope))130url = url.substring(self.registration.scope.length);131132if (url && url !== "/") // ./ is also implicitly cached so don't bother returning that133{134// If the URL is solely a search string, prefix it with / to ensure it caches correctly.135// e.g. https://example.com/?foo=bar needs to cache as /?foo=bar, not just ?foo=bar.136if (url.startsWith("?"))137url = "/" + url;138139return url;140}141}142143return ""; // no main page URL could be identified144};145146// Fetch optionally bypassing HTTP cache using fetch cache options147function fetchWithBypass(request, bypassCache)148{149if (typeof request === "string")150request = new Request(request);151152if (bypassCache)153{154return fetch(request.url, {155headers: request.headers,156mode: request.mode,157credentials: request.credentials,158redirect: request.redirect,159cache: "no-store"160});161}162else163{164// bypass disabled: perform normal fetch which is allowed to return from HTTP cache165return fetch(request);166}167};168169// Effectively a cache.addAll() that only creates the cache on all requests being successful (as a weak attempt at making it atomic)170// and can optionally cache-bypass with fetchWithBypass in every request171async function CreateCacheFromFileList(cacheName, fileList, bypassCache)172{173// Kick off all requests and wait for them all to complete174const responses = await Promise.all(fileList.map(url => fetchWithBypass(url, bypassCache)));175176// Check if any request failed. If so don't move on to opening the cache.177// This makes sure we only open a cache if all requests succeeded.178let allOk = true;179180for (const response of responses)181{182if (!response.ok)183{184allOk = false;185console.error(CONSOLE_PREFIX + "Error fetching '" + response.url + "' (" + response.status + " " + response.statusText + ")");186}187}188189if (!allOk)190throw new Error("not all resources were fetched successfully");191192// Can now assume all responses are OK. Open a cache and write all responses there.193// TODO: ideally we can do this transactionally to ensure a complete cache is written as one atomic operation.194// This needs either new transactional features in the spec, or at the very least a way to rename a cache195// (so we can write to a temporary name that won't be returned by GetAvailableCacheNames() and then rename it when ready).196const cache = await caches.open(cacheName);197198try {199return await Promise.all(responses.map(200(response, i) => cache.put(fileList[i], response)201));202}203catch (err)204{205// Not sure why cache.put() would fail (maybe if storage quota exceeded?) but in case it does,206// clean up the cache to try to avoid leaving behind an incomplete cache.207console.error(CONSOLE_PREFIX + "Error writing cache entries: ", err);208caches.delete(cacheName);209throw err;210}211};212213async function UpdateCheck(isFirst)214{215try {216// Always bypass cache when requesting offline.js to make sure we find out about new versions.217const response = await fetchWithBypass(OFFLINE_DATA_FILE, true);218219if (!response.ok)220throw new Error(OFFLINE_DATA_FILE + " responded with " + response.status + " " + response.statusText);221222const data = await response.json();223224const version = data.version;225const fileList = data.fileList;226const lazyLoadList = data.lazyLoad;227const currentCacheName = GetCacheVersionName(version);228229const cacheExists = await caches.has(currentCacheName);230231// Don't recache if there is already a cache that exists for this version. Assume it is complete.232if (cacheExists)233{234// Log whether we are up-to-date or pending an update.235const isUpdatePending = await IsUpdatePending();236if (isUpdatePending)237{238console.log(CONSOLE_PREFIX + "Update pending");239Broadcast("update-pending");240}241else242{243console.log(CONSOLE_PREFIX + "Up to date");244Broadcast("up-to-date");245}246return;247}248249// Implicitly add the main page URL to the file list, e.g. "index.html", so we don't have to assume a specific name.250const mainPageUrl = await GetMainPageUrl();251252// Prepend the main page URL to the file list if we found one and it is not already in the list.253// Also make sure we request the base / which should serve the main page.254fileList.unshift("./");255256if (mainPageUrl && fileList.indexOf(mainPageUrl) === -1)257fileList.unshift(mainPageUrl);258259console.log(CONSOLE_PREFIX + "Caching " + fileList.length + " files for offline use");260261if (isFirst)262Broadcast("downloading");263else264BroadcastDownloadingUpdate(version);265266// Note we don't bypass the cache on the first update check. This is because SW installation and the following267// update check caching will race with the normal page load requests. For any normal loading fetches that have already268// completed or are in-flight, it is pointless and wasteful to cache-bust the request for offline caching, since that269// forces a second network request to be issued when a response from the browser HTTP cache would be fine.270if (lazyLoadList)271await WriteLazyLoadListToStorage(lazyLoadList); // dump lazy load list to local storage#272273await CreateCacheFromFileList(currentCacheName, fileList, !isFirst);274const isUpdatePending = await IsUpdatePending();275276if (isUpdatePending)277{278console.log(CONSOLE_PREFIX + "All resources saved, update ready");279BroadcastUpdateReady(version);280}281else282{283console.log(CONSOLE_PREFIX + "All resources saved, offline support ready");284Broadcast("offline-ready");285}286}287catch (err)288{289// Update check fetches fail when we're offline, but in case there's any other kind of problem with it, log a warning.290console.warn(CONSOLE_PREFIX + "Update check failed: ", err);291}292};293294self.addEventListener("install", event =>295{296// On install kick off an update check to cache files on first use.297// If it fails we can still complete the install event and leave the SW running, we'll just298// retry on the next navigate.299event.waitUntil(300UpdateCheck(true) // first update301.catch(() => null)302);303});304305async function GetCacheNameToUse(availableCacheNames, doUpdateCheck)306{307// Prefer the oldest cache available. This avoids mixed-version responses by ensuring that if a new cache308// is created and filled due to an update check while the page is running, we keep returning resources309// from the original (oldest) cache only.310if (availableCacheNames.length === 1 || !doUpdateCheck)311return availableCacheNames[0];312313// We are making a navigate request with more than one cache available. Check if we can expire any old ones.314const allClients = await clients.matchAll();315316// If there are other clients open, don't expire anything yet. We don't want to delete any caches they317// might be using, which could cause mixed-version responses.318if (allClients.length > 1)319return availableCacheNames[0];320321// Identify newest cache to use. Delete all the others.322const latestCacheName = availableCacheNames[availableCacheNames.length - 1];323console.log(CONSOLE_PREFIX + "Updating to new version");324325await Promise.all(326availableCacheNames.slice(0, -1)327.map(c => caches.delete(c))328);329330return latestCacheName;331};332333async function HandleFetch(event, doUpdateCheck)334{335const availableCacheNames = await GetAvailableCacheNames();336337// No caches available: go to network338if (!availableCacheNames.length)339return fetch(event.request);340341const useCacheName = await GetCacheNameToUse(availableCacheNames, doUpdateCheck);342const cache = await caches.open(useCacheName);343const cachedResponse = await cache.match(event.request);344345if (cachedResponse)346return cachedResponse; // use cached response347348// We need to check if this request is to be lazy-cached. Send the request and load the lazy-load list349// from storage simultaneously.350const result = await Promise.all([fetch(event.request), ReadLazyLoadListFromStorage()]);351const fetchResponse = result[0];352const lazyLoadList = result[1];353354if (IsUrlInLazyLoadList(event.request.url, lazyLoadList))355{356// Handle failure writing to the cache. This can happen if the storage quota is exceeded, which is particularly357// likely in Safari 11.1, which appears to have very tight storage limits. Make sure even in the event of an error358// we continue to return the response from the fetch.359try {360// Note clone response since we also respond with it361await cache.put(event.request, fetchResponse.clone());362}363catch (err)364{365console.warn(CONSOLE_PREFIX + "Error caching '" + event.request.url + "': ", err);366}367}368369return fetchResponse;370};371372self.addEventListener("fetch", event =>373{374/** NOTE (iain)375* This check is to prevent a bug with XMLHttpRequest where if its376* proxied with "FetchEvent.prototype.respondWith" no upload progress377* events are triggered. By returning we allow the default action to378* occur instead. Currently all cross-origin requests fall back to default.379*/380if (new URL(event.request.url).origin !== location.origin)381return;382383// Check for an update on navigate requests384const doUpdateCheck = (event.request.mode === "navigate");385386const responsePromise = HandleFetch(event, doUpdateCheck);387388if (doUpdateCheck)389{390// allow the main request to complete, then check for updates391event.waitUntil(392responsePromise393.then(() => UpdateCheck(false)) // not first check394);395}396397event.respondWith(responsePromise);398});399400