sw.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. "use strict";
  2. const OFFLINE_DATA_FILE = "offline.js";
  3. const CACHE_NAME_PREFIX = "c2offline";
  4. const BROADCASTCHANNEL_NAME = "offline";
  5. const CONSOLE_PREFIX = "[SW] ";
  6. const LAZYLOAD_KEYNAME = "";
  7. // Create a BroadcastChannel if supported.
  8. const broadcastChannel = (typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(BROADCASTCHANNEL_NAME));
  9. //////////////////////////////////////
  10. // Utility methods
  11. function PostBroadcastMessage(o)
  12. {
  13. if (!broadcastChannel)
  14. return; // not supported
  15. // 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 arrive
  17. // 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.
  19. setTimeout(() => broadcastChannel.postMessage(o), 3000);
  20. };
  21. function Broadcast(type)
  22. {
  23. PostBroadcastMessage({
  24. "type": type
  25. });
  26. };
  27. function BroadcastDownloadingUpdate(version)
  28. {
  29. PostBroadcastMessage({
  30. "type": "downloading-update",
  31. "version": version
  32. });
  33. }
  34. function BroadcastUpdateReady(version)
  35. {
  36. PostBroadcastMessage({
  37. "type": "update-ready",
  38. "version": version
  39. });
  40. }
  41. function IsUrlInLazyLoadList(url, lazyLoadList)
  42. {
  43. if (!lazyLoadList)
  44. return false; // presumably lazy load list failed to load
  45. try {
  46. for (const lazyLoadRegex of lazyLoadList)
  47. {
  48. if (new RegExp(lazyLoadRegex).test(url))
  49. return true;
  50. }
  51. }
  52. catch (err)
  53. {
  54. console.error(CONSOLE_PREFIX + "Error matching in lazy-load list: ", err);
  55. }
  56. return false;
  57. };
  58. function WriteLazyLoadListToStorage(lazyLoadList)
  59. {
  60. if (typeof localforage === "undefined")
  61. return Promise.resolve(); // bypass if localforage not imported
  62. else
  63. return localforage.setItem(LAZYLOAD_KEYNAME, lazyLoadList)
  64. };
  65. function ReadLazyLoadListFromStorage()
  66. {
  67. if (typeof localforage === "undefined")
  68. return Promise.resolve([]); // bypass if localforage not imported
  69. else
  70. return localforage.getItem(LAZYLOAD_KEYNAME);
  71. };
  72. function GetCacheBaseName()
  73. {
  74. // Include the scope to avoid name collisions with any other SWs on the same origin.
  75. // e.g. "c2offline-https://example.com/foo/" (won't collide with anything under bar/)
  76. return CACHE_NAME_PREFIX + "-" + self.registration.scope;
  77. };
  78. function GetCacheVersionName(version)
  79. {
  80. // Append the version number to the cache name.
  81. // e.g. "c2offline-https://example.com/foo/-v2"
  82. return GetCacheBaseName() + "-v" + version;
  83. };
  84. // Return caches.keys() filtered down to just caches we're interested in (with the right base name).
  85. // This filters out caches from unrelated scopes.
  86. async function GetAvailableCacheNames()
  87. {
  88. const cacheNames = await caches.keys();
  89. const cacheBaseName = GetCacheBaseName();
  90. return cacheNames.filter(n => n.startsWith(cacheBaseName));
  91. };
  92. // Identify if an update is pending, which is the case when we have 2 or more available caches.
  93. // One must be an update that is waiting, since the next navigate that does an upgrade will
  94. // delete all the old caches leaving just one currently-in-use cache.
  95. async function IsUpdatePending()
  96. {
  97. const availableCacheNames = await GetAvailableCacheNames();
  98. return (availableCacheNames.length >= 2);
  99. };
  100. // Automatically deduce the main page URL (e.g. index.html or main.aspx) from the available browser windows.
  101. // This prevents having to hard-code an index page in the file list, implicitly caching it like AppCache did.
  102. async function GetMainPageUrl()
  103. {
  104. const allClients = await clients.matchAll({
  105. includeUncontrolled: true,
  106. type: "window"
  107. });
  108. for (const c of allClients)
  109. {
  110. // Parse off the scope from the full client URL, e.g. https://example.com/index.html -> index.html
  111. let url = c.url;
  112. if (url.startsWith(self.registration.scope))
  113. url = url.substring(self.registration.scope.length);
  114. if (url && url !== "/") // ./ is also implicitly cached so don't bother returning that
  115. {
  116. // If the URL is solely a search string, prefix it with / to ensure it caches correctly.
  117. // e.g. https://example.com/?foo=bar needs to cache as /?foo=bar, not just ?foo=bar.
  118. if (url.startsWith("?"))
  119. url = "/" + url;
  120. return url;
  121. }
  122. }
  123. return ""; // no main page URL could be identified
  124. };
  125. // Hack to fetch optionally bypassing HTTP cache until fetch cache options are supported in Chrome (crbug.com/453190)
  126. function fetchWithBypass(request, bypassCache)
  127. {
  128. if (typeof request === "string")
  129. request = new Request(request);
  130. if (bypassCache)
  131. {
  132. // bypass enabled: add a random search parameter to avoid getting a stale HTTP cache result
  133. const url = new URL(request.url);
  134. url.search += Math.floor(Math.random() * 1000000);
  135. return fetch(url, {
  136. headers: request.headers,
  137. mode: request.mode,
  138. credentials: request.credentials,
  139. redirect: request.redirect,
  140. cache: "no-store"
  141. });
  142. }
  143. else
  144. {
  145. // bypass disabled: perform normal fetch which is allowed to return from HTTP cache
  146. return fetch(request);
  147. }
  148. };
  149. // Effectively a cache.addAll() that only creates the cache on all requests being successful (as a weak attempt at making it atomic)
  150. // and can optionally cache-bypass with fetchWithBypass in every request
  151. async function CreateCacheFromFileList(cacheName, fileList, bypassCache)
  152. {
  153. // Kick off all requests and wait for them all to complete
  154. const responses = await Promise.all(fileList.map(url => fetchWithBypass(url, bypassCache)));
  155. // Check if any request failed. If so don't move on to opening the cache.
  156. // This makes sure we only open a cache if all requests succeeded.
  157. let allOk = true;
  158. for (const response of responses)
  159. {
  160. if (!response.ok)
  161. {
  162. allOk = false;
  163. console.error(CONSOLE_PREFIX + "Error fetching '" + response.url + "' (" + response.status + " " + response.statusText + ")");
  164. }
  165. }
  166. if (!allOk)
  167. throw new Error("not all resources were fetched successfully");
  168. // Can now assume all responses are OK. Open a cache and write all responses there.
  169. // TODO: ideally we can do this transactionally to ensure a complete cache is written as one atomic operation.
  170. // This needs either new transactional features in the spec, or at the very least a way to rename a cache
  171. // (so we can write to a temporary name that won't be returned by GetAvailableCacheNames() and then rename it when ready).
  172. const cache = await caches.open(cacheName);
  173. try {
  174. return await Promise.all(responses.map(
  175. (response, i) => cache.put(fileList[i], response)
  176. ));
  177. }
  178. catch (err)
  179. {
  180. // Not sure why cache.put() would fail (maybe if storage quota exceeded?) but in case it does,
  181. // clean up the cache to try to avoid leaving behind an incomplete cache.
  182. console.error(CONSOLE_PREFIX + "Error writing cache entries: ", err);
  183. caches.delete(cacheName);
  184. throw err;
  185. }
  186. };
  187. async function UpdateCheck(isFirst)
  188. {
  189. try {
  190. // Always bypass cache when requesting offline.js to make sure we find out about new versions.
  191. const response = await fetchWithBypass(OFFLINE_DATA_FILE, true);
  192. if (!response.ok)
  193. throw new Error(OFFLINE_DATA_FILE + " responded with " + response.status + " " + response.statusText);
  194. const data = await response.json();
  195. const version = data.version;
  196. const fileList = data.fileList;
  197. const lazyLoadList = data.lazyLoad;
  198. const currentCacheName = GetCacheVersionName(version);
  199. const cacheExists = await caches.has(currentCacheName);
  200. // Don't recache if there is already a cache that exists for this version. Assume it is complete.
  201. if (cacheExists)
  202. {
  203. // Log whether we are up-to-date or pending an update.
  204. const isUpdatePending = await IsUpdatePending();
  205. if (isUpdatePending)
  206. {
  207. console.log(CONSOLE_PREFIX + "Update pending");
  208. Broadcast("update-pending");
  209. }
  210. else
  211. {
  212. console.log(CONSOLE_PREFIX + "Up to date");
  213. Broadcast("up-to-date");
  214. }
  215. return;
  216. }
  217. // Implicitly add the main page URL to the file list, e.g. "index.html", so we don't have to assume a specific name.
  218. const mainPageUrl = await GetMainPageUrl();
  219. // Prepend the main page URL to the file list if we found one and it is not already in the list.
  220. // Also make sure we request the base / which should serve the main page.
  221. fileList.unshift("./");
  222. if (mainPageUrl && fileList.indexOf(mainPageUrl) === -1)
  223. fileList.unshift(mainPageUrl);
  224. console.log(CONSOLE_PREFIX + "Caching " + fileList.length + " files for offline use");
  225. if (isFirst)
  226. Broadcast("downloading");
  227. else
  228. BroadcastDownloadingUpdate(version);
  229. // Note we don't bypass the cache on the first update check. This is because SW installation and the following
  230. // update check caching will race with the normal page load requests. For any normal loading fetches that have already
  231. // completed or are in-flight, it is pointless and wasteful to cache-bust the request for offline caching, since that
  232. // forces a second network request to be issued when a response from the browser HTTP cache would be fine.
  233. if (lazyLoadList)
  234. await WriteLazyLoadListToStorage(lazyLoadList); // dump lazy load list to local storage#
  235. await CreateCacheFromFileList(currentCacheName, fileList, !isFirst);
  236. const isUpdatePending = await IsUpdatePending();
  237. if (isUpdatePending)
  238. {
  239. console.log(CONSOLE_PREFIX + "All resources saved, update ready");
  240. BroadcastUpdateReady(version);
  241. }
  242. else
  243. {
  244. console.log(CONSOLE_PREFIX + "All resources saved, offline support ready");
  245. Broadcast("offline-ready");
  246. }
  247. }
  248. catch (err)
  249. {
  250. // Update check fetches fail when we're offline, but in case there's any other kind of problem with it, log a warning.
  251. console.warn(CONSOLE_PREFIX + "Update check failed: ", err);
  252. }
  253. };
  254. self.addEventListener("install", event =>
  255. {
  256. // On install kick off an update check to cache files on first use.
  257. // If it fails we can still complete the install event and leave the SW running, we'll just
  258. // retry on the next navigate.
  259. event.waitUntil(
  260. UpdateCheck(true) // first update
  261. .catch(() => null)
  262. );
  263. });
  264. async function GetCacheNameToUse(availableCacheNames, doUpdateCheck)
  265. {
  266. // Prefer the oldest cache available. This avoids mixed-version responses by ensuring that if a new cache
  267. // is created and filled due to an update check while the page is running, we keep returning resources
  268. // from the original (oldest) cache only.
  269. if (availableCacheNames.length === 1 || !doUpdateCheck)
  270. return availableCacheNames[0];
  271. // We are making a navigate request with more than one cache available. Check if we can expire any old ones.
  272. const allClients = await clients.matchAll();
  273. // If there are other clients open, don't expire anything yet. We don't want to delete any caches they
  274. // might be using, which could cause mixed-version responses.
  275. if (allClients.length > 1)
  276. return availableCacheNames[0];
  277. // Identify newest cache to use. Delete all the others.
  278. const latestCacheName = availableCacheNames[availableCacheNames.length - 1];
  279. console.log(CONSOLE_PREFIX + "Updating to new version");
  280. await Promise.all(
  281. availableCacheNames.slice(0, -1)
  282. .map(c => caches.delete(c))
  283. );
  284. return latestCacheName;
  285. };
  286. async function HandleFetch(event, doUpdateCheck)
  287. {
  288. const availableCacheNames = await GetAvailableCacheNames();
  289. // No caches available: go to network
  290. if (!availableCacheNames.length)
  291. return fetch(event.request);
  292. const useCacheName = await GetCacheNameToUse(availableCacheNames, doUpdateCheck);
  293. const cache = await caches.open(useCacheName);
  294. const cachedResponse = await cache.match(event.request);
  295. if (cachedResponse)
  296. return cachedResponse; // use cached response
  297. // We need to check if this request is to be lazy-cached. Send the request and load the lazy-load list
  298. // from storage simultaneously.
  299. const result = await Promise.all([fetch(event.request), ReadLazyLoadListFromStorage()]);
  300. const fetchResponse = result[0];
  301. const lazyLoadList = result[1];
  302. if (IsUrlInLazyLoadList(event.request.url, lazyLoadList))
  303. {
  304. // Handle failure writing to the cache. This can happen if the storage quota is exceeded, which is particularly
  305. // likely in Safari 11.1, which appears to have very tight storage limits. Make sure even in the event of an error
  306. // we continue to return the response from the fetch.
  307. try {
  308. // Note clone response since we also respond with it
  309. await cache.put(event.request, fetchResponse.clone());
  310. }
  311. catch (err)
  312. {
  313. console.warn(CONSOLE_PREFIX + "Error caching '" + event.request.url + "': ", err);
  314. }
  315. }
  316. return fetchResponse;
  317. };
  318. self.addEventListener("fetch", event =>
  319. {
  320. /** NOTE (iain)
  321. * This check is to prevent a bug with XMLHttpRequest where if its
  322. * proxied with "FetchEvent.prototype.respondWith" no upload progress
  323. * events are triggered. By returning we allow the default action to
  324. * occur instead. Currently all cross-origin requests fall back to default.
  325. */
  326. if (new URL(event.request.url).origin !== location.origin)
  327. return;
  328. // Check for an update on navigate requests
  329. const doUpdateCheck = (event.request.mode === "navigate");
  330. const responsePromise = HandleFetch(event, doUpdateCheck);
  331. if (doUpdateCheck)
  332. {
  333. // allow the main request to complete, then check for updates
  334. event.waitUntil(
  335. responsePromise
  336. .then(() => UpdateCheck(false)) // not first check
  337. );
  338. }
  339. event.respondWith(responsePromise);
  340. });