UNPKG

@enplug/scripts

Version:
379 lines (336 loc) 15.8 kB
/** * Service worker responsible for providing offline support to Enplug Apps. * @author Michal Drewniak (michal@enplug.com) */ // CRITICAL ISSUE: // It is extremely important to not perform cache.match() and cache.put() on the same URL/request in the the same // promise chain or even a short period of time. Doing so, results in the browser creating additional file descriptors // which are marked as deleted by the system but continue to exist. Please keep in mind that caches.match suffers from // the same issue. Said file descriptors are found in the com.enplug.player process folder /proc/PID/fd/ and are only // removed when the device is rebooted. Since the devices allow for relatively small amount of descriptors, this quickly // leads to devices crashing. // // Sample code which MUST NOT be added at any place within a service worker: // // // const cacheRequest = new Request('https://apps.enplug.com/myapp.js'); // const fetchRequest = new Request('https://apps.enplug.com/myapp.js'); // caches.open('my-cache').then((cache) => { // return cache.match(cacheRequest).then((cacheResposne) => { // Can't have this .match(), and... // if (!cacheResposne) { // return fetch((fetchRequest)).then((fetchResponse) => { // cache.put(fetchResponse.clone()); // ... this .put() // return fetchResponse; // }); // } // }); // }); 'use strict'; /** * Offline support configuration. * @type {Object} * @property {string} cacheName - Name of the cache. This is usually the same as the application * name or ID. * @property {string} cacheVersion - Version of the cache. This should be updated any time we want * the previous version of the cache removed. * @property {number} cacheTime - Time in minutes. After the given time, any URL in cache which hasn't been used for * that long or longer, will be removed from cache. * @property {Array.<string>} staticResources - A list of static URLs which should be cached. * @property {Object.<string, number>} refreshUrls- A mapping of URLs (or their parts) to time in minutes. * The URL denotes for which URL responses should be re-fetched and its response re-cached. Time denotes * after what amount of time (in minutes), given response should be re-fetched and its response re-cached. * Time set to 0 means that a given response should never be cached. */ var config = <% config %>; const TAG = '[<% app_name %>|offline]'; const LAST_USED_TIME_CACHE = 'last-used-time-cache'; const REFRESH_TIME_CACHE = 'refresh-time'; const CACHE_PRUNE_CHECK_INTERVAL = 60; // in minutes const META_DATA_STORE_INTERVAL = 20; // in minutes const MAX_CACHE_TIME = config.cacheTime || 60 * 24; // in minutes (default to 1 day) // An object used to keep track of when was the last time a given URL has been re-fetched and its response re-cached. let refreshTime = {}; // An object used to keep track of when whas a given URL last fetched. This helps us determine which URLs should // be removed from cache (when they're not used for more than the time specified in config.cacheTime) let lastUsedTime = {}; // Last time cache was pruned from stale entries (see config.cacheTime) let cachePruneTime = Date.now(); // Last time we've written lastUsedTime object to cache. We want to avoid writing to cache every time 'fetch' listener // fires. To accomplish this, we keep track of when we did the last write and write to cache again only wn // META_DATA_STORE_INTERVAL minutes have passed. let lastUsedTimeWriteTimestamp = Date.now(); /** * Installs service worker and adds static resources to cache. */ self.addEventListener('install', function (event) { event.waitUntil(caches.open(config.cacheVersion).then((cache) => { return cache.addAll(config.staticResources) .then(() => { console.log(`${TAG} Offline support service worker v. ${config.cacheVersion} installed.`); self.skipWaiting(); }) .catch((error) => { console.warn(`${TAG} Error adding static resources to cache ${config.cacheVersion}.`, error); console.warn(`[<% app_name %>] Static resources: ${JSON.stringify(config.staticResources)}`, config.staticResources); }) // Read Refresh Time object from cache .then(() => cache.match(REFRESH_TIME_CACHE)) .then((cachedData) => cachedData && cachedData.text()) .then((text) => { try { refreshTime = text ? JSON.parse(text) : {}; } catch (e) { refreshTime = {}; } }) // Read lastUsedTime object from cache .catch(() => {}) .then(() => cache.match(LAST_USED_TIME_CACHE)) .then((cachedData) => cachedData && cachedData.text()) .then((text) => { try { lastUsedTime = text ? JSON.parse(text) : {}; return removeLeastUsedCacheEntries(); } catch (e) { lastUsedTime = {}; } }) })); }); /** * Activate phase. Clears all non-whitelisted cache. */ self.addEventListener('activate', (event) => { var cacheWhitelist = [config.cacheVersion]; // Check all available caches. If a cache for the specific app is found but its cache name differs (different app // version), remove that cache. event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map(function (cacheName) { if (cacheName.indexOf(config.appName) === 0 && cacheWhitelist.indexOf(cacheName) === -1) { console.log(`${TAG} Deleting cache ${cacheName}`); return caches.delete(cacheName); } }) ).then(() => self.clients.claim()); }) ); }); /** * Captures requests and either exectures request to the server or returns a cached version. This is captures all URL * requests comping from an App. * IMPORTANT: See critical issue description at the top of this file. */ self.addEventListener('fetch', (event) => { // There following requests should not be cached: POST, .mp4 files, URLs specified in config.noCacheUrls array. // For any of those requests, a fetch is executed without any cache operations. if (event.request.method === 'POST') { return event.respondWith(fetch(event.request)); } if (event.request.url.startsWith('blob:') || event.request.url.endsWith('.mp4')) { return event.respondWith(fetch(event.request)); } for (const noCacheUrl of config.noCacheUrls) { if (event.request.url.indexOf(noCacheUrl) >= 0) { return event.respondWith(fetch(event.request)); } } // Get rid of the app token to generalize URL for caching. Having unique URLs (unique appToken) for each request // will only make the cache get bigger over time. We use 2 types of URLs. // fetchUrl - original request URL. Used to fetch the data which will then be cached. // cacheUrl - URL without the unique appToken. Used to cache the data in service worker cache. const fetchUrl = event.request.url; const cacheUrl = fetchUrl.replace(/\?apptoken.*/g, ''); // Remove least used cache entries and update used time for the current URL. if (shouldPruneCache()) { removeLeastUsedCacheEntries(); } updateLastUsedTime(cacheUrl); // URL does not have to be refreshed. Return cached version or fetch and store new version if possible. if (!shouldUrlBeRefreshed(cacheUrl)) { return event.respondWith(caches.open(config.cacheVersion).then((cache) => { const cacheRequest = new Request(cacheUrl); return cache.match(cacheRequest) .then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } else { // Cached data not found. Throw an error so that it's caught below and a fetch is attempted. cachedResponse = null; throw new Error('Cached data not found'); } }) .catch(() => { return fetchFileAndCache(event.request, cache, fetchUrl, cacheUrl); }); })); } // URL has to be refreshed. Fetch new file and store it in cache. It's possible that this will get called while // the device is offline. In order to ensure the apps work, fetchFileAndCache implements a catch which will attempt // to return a cached version if the normal fetch fails. return event.respondWith(caches.open(config.cacheVersion).then((cache) => { return fetchFileAndCache(event.request, cache, fetchUrl, cacheUrl); })); }) // End of fetch listener. /** * Fetches specified file (fetchUrl) and caches it. * @param {*} cache - Opened Cache. * @param {*} fetchUrl - Original request URL. Used to fetch new response. * @param {*} cacheUrl - Modified URL (removed appToken). Used to cache fetched response. */ function fetchFileAndCache(originalRequest, cache, fetchUrl, cacheUrl) { // TripAdvisor API requires the headers and the rest of the options to be set in this specific way. If this needs // to be changed at any point in time, make sure the TripAdvisor pulls all the posts and works offline. const fetchRequest = new Request(fetchUrl, { headers: originalRequest.headers, mode: 'cors', credentials: 'omit', referrer: 'no-referrer', cache: config.cache ? config.cache : 'default' }); return fetch(fetchRequest) // Initial error handling for requests which resolved but have incorrect status or null response. Returns // either cached response if available, or failed response if cached response is not available. Returning // invalid response will ensure we do not cache it. .then((fetchResponse) => { if (!fetchResponse || fetchResponse.status !== 200) { throw Error('CORS request failed.'); } // Propagate correct response down the Promise chain. return fetchResponse; }) // CORS request has failed. We need attempt to refetch the request as no-cors (which is still preferable) // then a failed response. We want to propagate down the Promise chain the response to our no-CORS request. .catch((err) => { console.log(`${TAG} CORS request failed. Attempting fetch with no-cors set.`); if (originalRequest.mode === 'no-cors') { return fetch(originalRequest); } else { const noCorsFetchRequest = new Request(fetchUrl, { credentials: 'omit', mode: 'no-cors', referrer: 'no-referrer', cache: config.cache ? config.cache : 'default' }); return fetch(noCorsFetchRequest); } }) // The response from either successful CORS or successful or successful no-CORS request. If there is no response, // throw an error so that service worker attempts to return a cached version of the response. .then((response) => { if (!response) { // We don't check whether response.ok is set to true because if the final request was a no-cors request, // we would get a false-positive (no-cors doesn't set the ok flag). return fetch(originalRequest); } const cacheRequest = new Request(cacheUrl); // A request is a stream and can only be consumed once. Since we are consuming it once by the cache and once // by the browser for fetch, we need to clone it. return cache.put(cacheRequest, response.clone()) .then(() => storeCachedTime(cacheUrl)) .then(() => { return response; }); }) // This usually happens when a player starts while being offline. In such a case, the fetch will fail. In order // to be able to show the app, we must return a cached response here. .catch(() => { console.log(`${TAG} No-CORS request has failed. Attempting to return cached response.`); const cacheRequest = new Request(cacheUrl); return cache.match(cacheRequest); }); } /** * Determines whether enough time has passed since last cache prune check to allow for another round of pruning. * @returns {boolean} true if cache should be pruned again. */ function shouldPruneCache() { const timeDiff = (Date.now() - cachePruneTime) / 1000 / 60; // in minutes if (timeDiff >= CACHE_PRUNE_CHECK_INTERVAL) { cachePruneTime = Date.now(); return true; } return false; } /** * Checks whether given URL should be refeched and cached value associated with it replaced. * @param {string} url * @return {boolean} - If true, the cached value associated with the URL should be refreshed. */ function shouldUrlBeRefreshed(url) { // Number of seconds that need to elapse since last refresh before the URL can be refreshed let refreshInterval = null; let isWhitelisted = false; let hasRequiredTimePassed = false; // Check whether the url is whitelisted if (config.refreshUrls) { for (var keyUrl in config.refreshUrls) { if (url.indexOf(keyUrl) >= 0) { isWhitelisted = true; refreshInterval = config.refreshUrls[keyUrl] * 60 * 1000; // convert to milliseconds break; } } // If refresh time is set to 0, requests should always return most recent version. if (refreshInterval === 0) { return true; } } // Check whether appropriate amount of time ellapsed since last refresh var currentTime = Date.now(); var lastRefreshTime = refreshTime[url] || 0; hasRequiredTimePassed = currentTime - lastRefreshTime >= refreshInterval; return isWhitelisted && hasRequiredTimePassed; } /** * Sets the time a request with the given URL was last cached. Service workers have no access to localStorage. * However, we need some way of persisting some data across reloads of the worker. To do so, we utilize CacheStorage. * The function constructs and returns name of the cache used for storage purposes. * @param {string} url - URL for which the time is set. */ function storeCachedTime(url) { refreshTime[url] = Date.now(); const response = new Response(JSON.stringify(refreshTime)); return caches.open(config.cacheVersion).then((cache) => cache.put(REFRESH_TIME_CACHE, response)); } /** * Stores lastUsedTime object in cache. */ function storeLastUsedTimeInCache(cache) { const response = new Response(JSON.stringify(lastUsedTime)); return cache.put(LAST_USED_TIME_CACHE, response); } /** * Based on the lastUsedTime object, it removes from Cache entries which haven't been accessed for longer than * MAX_CACHE_TIME. */ function removeLeastUsedCacheEntries() { return caches.open(config.cacheVersion).then((cache) => { for (const url in lastUsedTime) { if (lastUsedTime.hasOwnProperty(url)) { let timeDiff = (Date.now() - lastUsedTime[url]) / 1000 / 60; // in minutes if (timeDiff >= MAX_CACHE_TIME) { console.log(`${TAG} Deleting cache entry for URL: ${url}`); delete lastUsedTime[url]; cache.delete(url); } } } return storeLastUsedTimeInCache(cache); }); } /** * Stores the object containing times where each URL has last been used in Cache. We use this data to determine * whether the stored URL and associated response are still used or whether they can be removed from cache. * While the time stored in memory is constantly updated, the full object is written to Cache every * META_DATA_STORE_INTERVAL minutes. This is done to prevent extremely frequent writes to Cache. * @param {string} url - URL of a reasourse which is currently being accessed. */ function updateLastUsedTime(url) { const writeTimeDiff = (Date.now() - lastUsedTimeWriteTimestamp) / 1000 / 60; lastUsedTime[url] = Date.now(); if (writeTimeDiff >= META_DATA_STORE_INTERVAL) { caches.open(config.cacheVersion).then((cache) => storeLastUsedTimeInCache(cache)); } }