UNPKG

@lcdp/offline-plugin

Version:
738 lines (585 loc) 20.2 kB
'use strict'; if (typeof DEBUG === 'undefined') { var DEBUG = false; } function WebpackServiceWorker(params, helpers) { var cacheMaps = helpers.cacheMaps; // navigationPreload: true, { map: (URL) => URL, test: (URL) => boolean } var navigationPreload = helpers.navigationPreload; // (update)strategy: changed, all var strategy = params.strategy; // responseStrategy: cache-first, network-first var responseStrategy = params.responseStrategy; var assets = params.assets; var hashesMap = params.hashesMap; var externals = params.externals; var prefetchRequest = params.prefetchRequest || { credentials: 'same-origin', mode: 'cors' }; var CACHE_PREFIX = params.name; var CACHE_TAG = params.version; var CACHE_NAME = CACHE_PREFIX + ':' + CACHE_TAG; var PRELOAD_CACHE_NAME = CACHE_PREFIX + '$preload'; var STORED_DATA_KEY = '__offline_webpack__data'; mapAssets(); var allAssets = [].concat(assets.main, assets.additional, assets.optional); self.addEventListener('install', function (event) { console.log('[SW]:', 'Install event'); var installing = undefined; if (strategy === 'changed') { installing = cacheChanged('main'); } else { installing = cacheAssets('main'); } event.waitUntil(installing); }); self.addEventListener('activate', function (event) { console.log('[SW]:', 'Activate event'); var activation = cacheAdditional(); // Delete all assets which name starts with CACHE_PREFIX and // is not current cache (CACHE_NAME) activation = activation.then(storeCacheData); activation = activation.then(deleteObsolete); activation = activation.then(function () { if (self.clients && self.clients.claim) { return self.clients.claim(); } }); if (navigationPreload && self.registration.navigationPreload) { activation = Promise.all([activation, self.registration.navigationPreload.enable()]); } event.waitUntil(activation); }); function cacheAdditional() { if (!assets.additional.length) { return Promise.resolve(); } if (DEBUG) { console.log('[SW]:', 'Caching additional'); } var operation = undefined; if (strategy === 'changed') { operation = cacheChanged('additional'); } else { operation = cacheAssets('additional'); } // Ignore fail of `additional` cache section return operation['catch'](function (e) { console.error('[SW]:', 'Cache section `additional` failed to load'); }); } function cacheAssets(section) { var batch = assets[section]; return caches.open(CACHE_NAME).then(function (cache) { return addAllNormalized(cache, batch, { bust: params.version, request: prefetchRequest, failAll: section === 'main' }); }).then(function () { logGroup('Cached assets: ' + section, batch); })['catch'](function (e) { console.error(e); throw e; }); } function cacheChanged(section) { return getLastCache().then(function (args) { if (!args) { return cacheAssets(section); } var lastCache = args[0]; var lastKeys = args[1]; var lastData = args[2]; var lastMap = lastData.hashmap; var lastVersion = lastData.version; if (!lastData.hashmap || lastVersion === params.version) { return cacheAssets(section); } var lastHashedAssets = Object.keys(lastMap).map(function (hash) { return lastMap[hash]; }); var lastUrls = lastKeys.map(function (req) { var url = new URL(req.url); url.search = ''; url.hash = ''; return url.toString(); }); var sectionAssets = assets[section]; var moved = []; var changed = sectionAssets.filter(function (url) { if (lastUrls.indexOf(url) === -1 || lastHashedAssets.indexOf(url) === -1) { return true; } return false; }); Object.keys(hashesMap).forEach(function (hash) { var asset = hashesMap[hash]; // Return if not in sectionAssets or in changed or moved array if (sectionAssets.indexOf(asset) === -1 || changed.indexOf(asset) !== -1 || moved.indexOf(asset) !== -1) return; var lastAsset = lastMap[hash]; if (lastAsset && lastUrls.indexOf(lastAsset) !== -1) { moved.push([lastAsset, asset]); } else { changed.push(asset); } }); logGroup('Changed assets: ' + section, changed); logGroup('Moved assets: ' + section, moved); var movedResponses = Promise.all(moved.map(function (pair) { return lastCache.match(pair[0]).then(function (response) { return [pair[1], response]; }); })); return caches.open(CACHE_NAME).then(function (cache) { var move = movedResponses.then(function (responses) { return Promise.all(responses.map(function (pair) { return cache.put(pair[0], pair[1]); })); }); return Promise.all([move, addAllNormalized(cache, changed, { bust: params.version, request: prefetchRequest, failAll: section === 'main', deleteFirst: section !== 'main' })]); }); }); } function deleteObsolete() { return caches.keys().then(function (keys) { var all = keys.map(function (key) { if (key.indexOf(CACHE_PREFIX) !== 0 || key.indexOf(CACHE_NAME) === 0) return; console.log('[SW]:', 'Delete cache:', key); return caches['delete'](key); }); return Promise.all(all); }); } function getLastCache() { return caches.keys().then(function (keys) { var index = keys.length; var key = undefined; while (index--) { key = keys[index]; if (key.indexOf(CACHE_PREFIX) === 0) { break; } } if (!key) return; var cache = undefined; return caches.open(key).then(function (_cache) { cache = _cache; return _cache.match(new URL(STORED_DATA_KEY, location).toString()); }).then(function (response) { if (!response) return; return Promise.all([cache, cache.keys(), response.json()]); }); }); } function storeCacheData() { return caches.open(CACHE_NAME).then(function (cache) { var data = new Response(JSON.stringify({ version: params.version, hashmap: hashesMap })); return cache.put(new URL(STORED_DATA_KEY, location).toString(), data); }); } self.addEventListener('fetch', function (event) { // Handle only GET requests if (event.request.method !== 'GET') { return; } // This prevents some weird issue with Chrome DevTools and 'only-if-cached' // Fixes issue #385, also ref to: // - https://github.com/paulirish/caltrainschedule.io/issues/49 // - https://bugs.chromium.org/p/chromium/issues/detail?id=823392 if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { return; } var url = new URL(event.request.url); url.hash = ''; var urlString = url.toString(); // Not external, so search part of the URL should be stripped, // if it's external URL, the search part should be kept if (externals.indexOf(urlString) === -1) { url.search = ''; urlString = url.toString(); } var assetMatches = allAssets.indexOf(urlString) !== -1; var cacheUrl = urlString; if (!assetMatches) { var cacheRewrite = matchCacheMap(event.request); if (cacheRewrite) { cacheUrl = cacheRewrite; assetMatches = true; } } if (!assetMatches) { // Use request.mode === 'navigate' instead of isNavigateRequest // because everything what supports navigationPreload supports // 'navigate' request.mode if (event.request.mode === 'navigate') { // Requesting with fetchWithPreload(). // Preload is used only if navigationPreload is enabled and // navigationPreload mapping is not used. if (navigationPreload === true) { event.respondWith(fetchWithPreload(event)); return; } } // Something else, positive, but not `true` if (navigationPreload) { var preloadedResponse = retrivePreloadedResponse(event); if (preloadedResponse) { event.respondWith(preloadedResponse); return; } } // Logic exists here if no cache match return; } // Cache handling/storing/fetching starts here var resource = undefined; if (responseStrategy === 'network-first') { resource = networkFirstResponse(event, urlString, cacheUrl); } // 'cache-first' otherwise // (responseStrategy has been validated before) else { resource = cacheFirstResponse(event, urlString, cacheUrl); } event.respondWith(resource); }); self.addEventListener('message', function (e) { var data = e.data; if (!data) return; switch (data.action) { case 'skipWaiting': { if (self.skipWaiting) self.skipWaiting(); }break; } }); function cacheFirstResponse(event, urlString, cacheUrl) { handleNavigationPreload(event); return cachesMatch(cacheUrl, CACHE_NAME).then(function (response) { if (response) { if (DEBUG) { console.log('[SW]:', 'URL [' + cacheUrl + '](' + urlString + ') from cache'); } return response; } // Load and cache known assets var fetching = fetch(event.request).then(function (response) { if (!response.ok) { if (DEBUG) { console.log('[SW]:', 'URL [' + urlString + '] wrong response: [' + response.status + '] ' + response.type); } return response; } if (DEBUG) { console.log('[SW]:', 'URL [' + urlString + '] from network'); } if (cacheUrl === urlString) { (function () { var responseClone = response.clone(); var storing = caches.open(CACHE_NAME).then(function (cache) { return cache.put(urlString, responseClone); }).then(function () { console.log('[SW]:', 'Cache asset: ' + urlString); }); event.waitUntil(storing); })(); } return response; }); return fetching; }); } function networkFirstResponse(event, urlString, cacheUrl) { return fetchWithPreload(event).then(function (response) { if (response.ok) { if (DEBUG) { console.log('[SW]:', 'URL [' + urlString + '] from network'); } return response; } // Throw to reach the code in the catch below throw response; }) // This needs to be in a catch() and not just in the then() above // cause if your network is down, the fetch() will throw ['catch'](function (erroredResponse) { if (DEBUG) { console.log('[SW]:', 'URL [' + urlString + '] from cache if possible'); } return cachesMatch(cacheUrl, CACHE_NAME).then(function (response) { if (response) { return response; } if (erroredResponse instanceof Response) { return erroredResponse; } // Not a response at this point, some other error throw erroredResponse; // return Response.error(); }); }); } function handleNavigationPreload(event) { if (navigationPreload && typeof navigationPreload.map === 'function' && // Use request.mode === 'navigate' instead of isNavigateRequest // because everything what supports navigationPreload supports // 'navigate' request.mode event.preloadResponse && event.request.mode === 'navigate') { var mapped = navigationPreload.map(new URL(event.request.url), event.request); if (mapped) { storePreloadedResponse(mapped, event); } } } // Temporary in-memory store for faster access var navigationPreloadStore = new Map(); function storePreloadedResponse(_url, event) { var url = new URL(_url, location); var preloadResponsePromise = event.preloadResponse; navigationPreloadStore.set(preloadResponsePromise, { url: url, response: preloadResponsePromise }); var isSamePreload = function isSamePreload() { return navigationPreloadStore.has(preloadResponsePromise); }; var storing = preloadResponsePromise.then(function (res) { // Return if preload isn't enabled or hasn't happened if (!res) return; // If navigationPreloadStore already consumed // or navigationPreloadStore already contains another preload, // then do not store anything and return if (!isSamePreload()) { return; } var clone = res.clone(); // Storing the preload response for later consume (hasn't yet been consumed) return caches.open(PRELOAD_CACHE_NAME).then(function (cache) { if (!isSamePreload()) return; return cache.put(url, clone).then(function () { if (!isSamePreload()) { return caches.open(PRELOAD_CACHE_NAME).then(function (cache) { return cache['delete'](url); }); } }); }); }); event.waitUntil(storing); } function retriveInMemoryPreloadedResponse(url) { if (!navigationPreloadStore) { return; } var foundResponse = undefined; var foundKey = undefined; navigationPreloadStore.forEach(function (store, key) { if (store.url.href === url.href) { foundResponse = store.response; foundKey = key; } }); if (foundResponse) { navigationPreloadStore['delete'](foundKey); return foundResponse; } } function retrivePreloadedResponse(event) { var url = new URL(event.request.url); if (self.registration.navigationPreload && navigationPreload && navigationPreload.test && navigationPreload.test(url, event.request)) {} else { return; } var fromMemory = retriveInMemoryPreloadedResponse(url); var request = event.request; if (fromMemory) { event.waitUntil(caches.open(PRELOAD_CACHE_NAME).then(function (cache) { return cache['delete'](request); })); return fromMemory; } return cachesMatch(request, PRELOAD_CACHE_NAME).then(function (response) { if (response) { event.waitUntil(caches.open(PRELOAD_CACHE_NAME).then(function (cache) { return cache['delete'](request); })); } return response || fetch(event.request); }); } function mapAssets() { Object.keys(assets).forEach(function (key) { assets[key] = assets[key].map(function (path) { var url = new URL(path, location); url.hash = ''; if (externals.indexOf(path) === -1) { url.search = ''; } return url.toString(); }); }); hashesMap = Object.keys(hashesMap).reduce(function (result, hash) { var url = new URL(hashesMap[hash], location); url.search = ''; url.hash = ''; result[hash] = url.toString(); return result; }, {}); externals = externals.map(function (path) { var url = new URL(path, location); url.hash = ''; return url.toString(); }); } function addAllNormalized(cache, requests, options) { requests = requests.slice(); var bustValue = options.bust; var failAll = options.failAll !== false; var deleteFirst = options.deleteFirst === true; var requestInit = options.request || { credentials: 'omit', mode: 'cors' }; var deleting = Promise.resolve(); if (deleteFirst) { deleting = Promise.all(requests.map(function (request) { return cache['delete'](request)['catch'](function () {}); })); } return Promise.all(requests.map(function (request) { if (bustValue) { request = applyCacheBust(request, bustValue); } return fetch(request, requestInit).then(fixRedirectedResponse).then(function (response) { if (!response.ok) { return { error: true }; } return { response: response }; }, function () { return { error: true }; }); })).then(function (responses) { if (failAll && responses.some(function (data) { return data.error; })) { return Promise.reject(new Error('Wrong response status')); } if (!failAll) { responses = responses.filter(function (data, i) { if (!data.error) { return true; } requests.splice(i, 1); return false; }); } return deleting.then(function () { var addAll = responses.map(function (_ref, i) { var response = _ref.response; return cache.put(requests[i], response); }); return Promise.all(addAll); }); }); } function matchCacheMap(request) { var urlString = request.url; var url = new URL(urlString); var requestType = undefined; if (isNavigateRequest(request)) { requestType = 'navigate'; } else if (url.origin === location.origin) { requestType = 'same-origin'; } else { requestType = 'cross-origin'; } for (var i = 0; i < cacheMaps.length; i++) { var map = cacheMaps[i]; if (!map) continue; if (map.requestTypes && map.requestTypes.indexOf(requestType) === -1) { continue; } var newString = undefined; if (typeof map.match === 'function') { newString = map.match(url, request); } else { newString = urlString.replace(map.match, map.to); } if (newString && newString !== urlString) { return newString; } } } function fetchWithPreload(event) { if (!event.preloadResponse || navigationPreload !== true) { return fetch(event.request); } return event.preloadResponse.then(function (response) { return response || fetch(event.request); }); } } function cachesMatch(request, cacheName) { return caches.match(request, { cacheName: cacheName }).then(function (response) { if (isNotRedirectedResponse(response)) { return response; } // Fix already cached redirected responses return fixRedirectedResponse(response).then(function (fixedResponse) { return caches.open(cacheName).then(function (cache) { return cache.put(request, fixedResponse); }).then(function () { return fixedResponse; }); }); }) // Return void if error happened (cache not found) ['catch'](function () {}); } function applyCacheBust(asset, key) { var hasQuery = asset.indexOf('?') !== -1; return asset + (hasQuery ? '&' : '?') + '__uncache=' + encodeURIComponent(key); } function isNavigateRequest(request) { return request.mode === 'navigate' || request.headers.get('Upgrade-Insecure-Requests') || (request.headers.get('Accept') || '').indexOf('text/html') !== -1; } function isNotRedirectedResponse(response) { return !response || !response.redirected || !response.ok || response.type === 'opaqueredirect'; } // Based on https://github.com/GoogleChrome/sw-precache/pull/241/files#diff-3ee9060dc7a312c6a822cac63a8c630bR85 function fixRedirectedResponse(response) { if (isNotRedirectedResponse(response)) { return Promise.resolve(response); } var body = 'body' in response ? Promise.resolve(response.body) : response.blob(); return body.then(function (data) { return new Response(data, { headers: response.headers, status: response.status }); }); } function copyObject(original) { return Object.keys(original).reduce(function (result, key) { result[key] = original[key]; return result; }, {}); } function logGroup(title, assets) { console.groupCollapsed('[SW]:', title); assets.forEach(function (asset) { console.log('Asset:', asset); }); console.groupEnd(); }