"use strict";12const OFFLINE_DATA_FILE = "offline.js";3const CACHE_NAME_PREFIX = "c2offline";4const BROADCASTCHANNEL_NAME = "offline";5const CONSOLE_PREFIX = "[SW] ";67// Create a BroadcastChannel if supported.8const broadcastChannel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(BROADCASTCHANNEL_NAME);910//////////////////////////////////////11// Utility methods12function PostBroadcastMessage(o) {13if (!broadcastChannel) return; // not supported1415// Impose artificial (and arbitrary!) delay of 3 seconds to make sure client is listening by the time the message is sent.16// Note we could remove the delay on some messages, but then we create a race condition where sometimes messages can arrive17// in the wrong order (e.g. "update ready" arrives before "started downloading update"). So to keep the consistent ordering,18// delay all messages by the same amount.19setTimeout(() => broadcastChannel.postMessage(o), 3000);20}2122function Broadcast(type) {23PostBroadcastMessage({24type: type,25});26}2728function BroadcastDownloadingUpdate(version) {29PostBroadcastMessage({30type: "downloading-update",31version: version,32});33}3435function BroadcastUpdateReady(version) {36PostBroadcastMessage({37type: "update-ready",38version: version,39});40}4142function GetCacheBaseName() {43// Include the scope to avoid name collisions with any other SWs on the same origin.44// e.g. "c2offline-https://example.com/foo/" (won't collide with anything under bar/)45return CACHE_NAME_PREFIX + "-" + self.registration.scope;46}4748function GetCacheVersionName(version) {49// Append the version number to the cache name.50// e.g. "c2offline-https://example.com/foo/-v2"51return GetCacheBaseName() + "-v" + version;52}5354// Return caches.keys() filtered down to just caches we're interested in (with the right base name).55// This filters out caches from unrelated scopes.56function GetAvailableCacheNames() {57return caches.keys().then((cacheNames) => {58const cacheBaseName = GetCacheBaseName();59return cacheNames.filter((n) => n.startsWith(cacheBaseName));60});61}6263// Identify if an update is pending, which is the case when we have 2 or more available caches.64// One must be an update that is waiting, since the next navigate that does an upgrade will65// delete all the old caches leaving just one currently-in-use cache.66function IsUpdatePending() {67return GetAvailableCacheNames().then((availableCacheNames) => availableCacheNames.length >= 2);68}6970// Automatically deduce the main page URL (e.g. index.html or main.aspx) from the available browser windows.71// This prevents having to hard-code an index page in the file list, implicitly caching it like AppCache did.72function GetMainPageUrl() {73return clients74.matchAll({75includeUncontrolled: true,76type: "window",77})78.then((clients) => {79for (let c of clients) {80// Parse off the scope from the full client URL, e.g. https://example.com/index.html -> index.html81let url = c.url;82if (url.startsWith(self.registration.scope)) url = url.substring(self.registration.scope.length);8384if (url && url !== "/") {85// ./ is also implicitly cached so don't bother returning that86// If the URL is solely a search string, prefix it with / to ensure it caches correctly.87// e.g. https://example.com/?foo=bar needs to cache as /?foo=bar, not just ?foo=bar.88if (url.startsWith("?")) url = "/" + url;8990return url;91}92}9394return ""; // no main page URL could be identified95});96}9798// Hack to fetch optionally bypassing HTTP cache until fetch cache options are supported in Chrome (crbug.com/453190)99function fetchWithBypass(request, bypassCache) {100if (typeof request === "string") request = new Request(request);101102if (bypassCache) {103// bypass enabled: add a random search parameter to avoid getting a stale HTTP cache result104const url = new URL(request.url);105url.search += Math.floor(Math.random() * 1000000);106107return fetch(url, {108headers: request.headers,109mode: request.mode,110credentials: request.credentials,111redirect: request.redirect,112cache: "no-store",113});114} else {115// bypass disabled: perform normal fetch which is allowed to return from HTTP cache116return fetch(request);117}118}119120// Effectively a cache.addAll() that only creates the cache on all requests being successful (as a weak attempt at making it atomic)121// and can optionally cache-bypass with fetchWithBypass in every request122function CreateCacheFromFileList(cacheName, fileList, bypassCache) {123// Kick off all requests and wait for them all to complete124return Promise.all(fileList.map((url) => fetchWithBypass(url, bypassCache))).then((responses) => {125// Check if any request failed. If so don't move on to opening the cache.126// This makes sure we only open a cache if all requests succeeded.127let allOk = true;128129for (let response of responses) {130if (!response.ok) {131allOk = false;132console.error(CONSOLE_PREFIX + "Error fetching '" + originalUrl + "' (" + response.status + " " + response.statusText + ")");133}134}135136if (!allOk) throw new Error("not all resources were fetched successfully");137138// Can now assume all responses are OK. Open a cache and write all responses there.139// TODO: ideally we can do this transactionally to ensure a complete cache is written as one atomic operation.140// This needs either new transactional features in the spec, or at the very least a way to rename a cache141// (so we can write to a temporary name that won't be returned by GetAvailableCacheNames() and then rename it when ready).142return caches143.open(cacheName)144.then((cache) => {145return Promise.all(responses.map((response, i) => cache.put(fileList[i], response)));146})147.catch((err) => {148// Not sure why cache.put() would fail (maybe if storage quota exceeded?) but in case it does,149// clean up the cache to try to avoid leaving behind an incomplete cache.150console.error(CONSOLE_PREFIX + "Error writing cache entries: ", err);151caches.delete(cacheName);152throw err;153});154});155}156157function UpdateCheck(isFirst) {158// Always bypass cache when requesting offline.js to make sure we find out about new versions.159return fetchWithBypass(OFFLINE_DATA_FILE, true)160.then((r) => r.json())161.then((data) => {162const version = data.version;163let fileList = data.fileList;164const currentCacheName = GetCacheVersionName(version);165166return caches.has(currentCacheName).then((cacheExists) => {167// Don't recache if there is already a cache that exists for this version. Assume it is complete.168if (cacheExists) {169// Log whether we are up-to-date or pending an update.170return IsUpdatePending().then((isUpdatePending) => {171if (isUpdatePending) {172console.log(CONSOLE_PREFIX + "Update pending");173Broadcast("update-pending");174} else {175console.log(CONSOLE_PREFIX + "Up to date");176Broadcast("up-to-date");177}178});179}180181// Implicitly add the main page URL to the file list, e.g. "index.html", so we don't have to assume a specific name.182return GetMainPageUrl().then((mainPageUrl) => {183// Prepend the main page URL to the file list if we found one and it is not already in the list.184// Also make sure we request the base / which should serve the main page.185fileList.unshift("./");186187if (mainPageUrl && fileList.indexOf(mainPageUrl) === -1) fileList.unshift(mainPageUrl);188189console.log(CONSOLE_PREFIX + "Caching " + fileList.length + " files for offline use");190191if (isFirst) Broadcast("downloading");192else BroadcastDownloadingUpdate(version);193194// Note we don't bypass the cache on the first update check. This is because SW installation and the following195// update check caching will race with the normal page load requests. For any normal loading fetches that have already196// completed or are in-flight, it is pointless and wasteful to cache-bust the request for offline caching, since that197// forces a second network request to be issued when a response from the browser HTTP cache would be fine.198return CreateCacheFromFileList(currentCacheName, fileList, !isFirst)199.then(IsUpdatePending)200.then((isUpdatePending) => {201if (isUpdatePending) {202console.log(CONSOLE_PREFIX + "All resources saved, update ready");203BroadcastUpdateReady(version);204} else {205console.log(CONSOLE_PREFIX + "All resources saved, offline support ready");206Broadcast("offline-ready");207}208});209});210});211})212.catch((err) => {213// Update check fetches fail when we're offline, but in case there's any other kind of problem with it, log a warning.214console.warn(CONSOLE_PREFIX + "Update check failed: ", err);215});216}217218self.addEventListener("install", (event) => {219// On install kick off an update check to cache files on first use.220// If it fails we can still complete the install event and leave the SW running, we'll just221// retry on the next navigate.222event.waitUntil(223UpdateCheck(true) // first update224.catch(() => null)225);226});227228self.addEventListener("fetch", (event) => {229const isNavigateRequest = event.request.mode === "navigate";230231let responsePromise = GetAvailableCacheNames().then((availableCacheNames) => {232// No caches available: go to network233if (!availableCacheNames.length) return fetch(event.request);234235// Resolve with the cache name to use.236return Promise.resolve()237.then(() => {238// Prefer the oldest cache available. This avoids mixed-version responses by ensuring that if a new cache239// is created and filled due to an update check while the page is running, we keep returning resources240// from the original (oldest) cache only.241if (availableCacheNames.length === 1 || !isNavigateRequest) return availableCacheNames[0];242243// We are making a navigate request with more than one cache available. Check if we can expire any old ones.244return clients.matchAll().then((clients) => {245// If there are other clients open, don't expire anything yet. We don't want to delete any caches they246// might be using, which could cause mixed-version responses.247// TODO: verify client count is as expected in navigate requests.248// TODO: find a way to upgrade on reloading the only client. Chrome seems to think there are 2 clients in that case.249if (clients.length > 1) return availableCacheNames[0];250251// Identify newest cache to use. Delete all the others.252let latestCacheName = availableCacheNames[availableCacheNames.length - 1];253console.log(CONSOLE_PREFIX + "Updating to new version");254255return Promise.all(availableCacheNames.slice(0, -1).map((c) => caches.delete(c))).then(() => latestCacheName);256});257})258.then((useCacheName) => {259return caches260.open(useCacheName)261.then((c) => c.match(event.request))262.then((response) => response || fetch(event.request));263});264});265266if (isNavigateRequest) {267// allow the main request to complete, then check for updates268event.waitUntil(responsePromise.then(() => UpdateCheck(false))); // not first check269}270271event.respondWith(responsePromise);272});273274275