@enplug/scripts
Version:
Enplug scripts
379 lines (336 loc) • 15.8 kB
JavaScript
/**
* 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;
// });
// }
// });
// });
;
/**
* 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));
}
}