cygnal/src/sw.js
Phyks (Lucas Verney) cecf1fd8a5 Rework caching expiration handling
Check expiration of cached items when serving them instead of computing
an expiration date when first caching them. If the user changes the
caching duration after a request was cached, the new setting will be
used now.
2019-01-05 15:52:12 +01:00

313 lines
13 KiB
JavaScript

import localforage from 'localforage';
import {
TILE_SERVERS,
VERSION as CACHE_NAME,
} from '@/constants';
import { loadDataFromStorage } from '@/storage';
const DEBUG = (process.env.NODE_ENV !== 'production');
// Define the assets to cache
const { assets } = global.serviceWorkerOption;
let assetsToCache = [...assets, './'];
assetsToCache = assetsToCache.map(
path => new URL(path, global.location).toString(),
);
assetsToCache = assetsToCache.filter(
// Remove some assets from cache, such as Webpack hot-reload stuff
url => !url.endsWith('hot-update.json'),
);
// Define the locations from which we allow caching
const ALLOW_CACHING_FROM = [
global.location.origin,
];
const TILE_SERVERS_ORIGINS = [];
// Allow alterations of tile servers requests
Object.keys(TILE_SERVERS).forEach((tileServer) => {
const tileServerURL = TILE_SERVERS[tileServer].url;
if (tileServerURL.indexOf('{a-c}') !== -1) {
TILE_SERVERS_ORIGINS.push(
(new URL(tileServerURL.replace('{a-c}', 'a'))).origin,
);
TILE_SERVERS_ORIGINS.push(
(new URL(tileServerURL.replace('{a-c}', 'b'))).origin,
);
TILE_SERVERS_ORIGINS.push(
(new URL(tileServerURL.replace('{a-c}', 'c'))).origin,
);
} else {
TILE_SERVERS_ORIGINS.push((new URL(tileServerURL)).origin);
}
});
// Get duration (in s) before (cache) expiration from headers of a fetch
// request.
function _getExpiresFromHeaders(headers) {
// Try to use the Cache-Control header (and max-age)
if (headers.get('cache-control')) {
const maxAge = headers.get('cache-control').match(/max-age=(\d+)/);
return parseInt(maxAge ? maxAge[1] : 0, 10);
}
// Otherwise try to get expiration duration from the Expires header
if (headers.get('expires')) {
return (
parseInt(
(new Date(headers.get('expires'))).getTime() / 1000,
10,
)
- (new Date()).getTime()
);
}
return null;
}
// Get tile caching duration from config
function _getTileCachingDurationPromise() {
// Get tile caching duration from settings
let tileCachingDurationPromise = (
Promise.resolve(0) // no caching by default
);
if (
localforage.driver() === localforage.INDEXEDDB
) {
tileCachingDurationPromise = loadDataFromStorage(
'settings', 'tileCachingDuration',
);
}
return tileCachingDurationPromise;
}
// Check expiration of an item in cache
// Return the response is not expired. Deletes it from cache and returns null
// otherwise.
function _checkExpiration(cache, request, response) {
return _getTileCachingDurationPromise().then((cachingDurationFromConfig) => {
const cachedDate = Date.parse(response.headers.get('sw-cached-date'));
const now = new Date();
// Check wether it is expired according to config
if (cachingDurationFromConfig > 0) {
const expirationDateFromConfig = new Date(cachedDate.valueOf());
expirationDateFromConfig.setSeconds(
expirationDateFromConfig.getSeconds() + cachingDurationFromConfig,
);
if (expirationDateFromConfig > now) {
DEBUG && console.log(`SW: Found ${request.url} in cache.`);
return response;
}
DEBUG && console.log(`SW: Deleting expired ${request.url} from cache.`);
cache.delete(request);
}
// If no special caching from config or not expired from config, check
// whether it is expired from HTTP headers.
const cachingDurationFromHeaders = response.headers.get('sw-http-cache-duration');
const expirationDateFromHeaders = new Date(cachedDate.valueOf());
expirationDateFromHeaders.setSeconds(
expirationDateFromHeaders.getSeconds() + cachingDurationFromHeaders,
);
if (expirationDateFromHeaders > now) {
return response;
}
DEBUG && console.log(`SW: Deleting expired ${request.url} from cache.`);
cache.delete(request);
return null;
});
}
global.self.addEventListener('install', (event) => {
DEBUG && console.log('SW: installing…');
event.waitUntil(
global.caches.open(CACHE_NAME) // Don't cache during dev
.then((cache) => {
DEBUG && console.log('SW: cache opened.');
cache.addAll(assetsToCache).then(
() => {
if (DEBUG) {
console.log(`SW: cached assets ${assetsToCache}.`);
console.log('SW: successfully installed!');
}
return global.self.skipWaiting(); // Immediately update the SW
},
);
}),
);
});
global.self.addEventListener('activate', (event) => {
DEBUG && console.log('SW: activating…');
event.waitUntil(
// Delete all caches but the current one
global.caches.keys().then(
cacheNames => Promise.all(
cacheNames.map((cacheName) => {
if (cacheName.indexOf(CACHE_NAME) !== 0) {
DEBUG && console.log(`SW: Deleting unused cache ${cacheName}.`);
return global.caches.delete(cacheName);
}
return null;
}),
).then(() => DEBUG && console.log('SW: activated!')),
),
);
});
global.self.addEventListener('fetch', (event) => {
const { request } = event;
// Do not touch requests which are not GET
if (request.method !== 'GET') {
DEBUG && console.log(`SW: ignore non-GET request: ${request.method}.`);
return;
}
// Only touch requests which we can cache or from tiles servers.
const requestURL = new URL(request.url);
if (
ALLOW_CACHING_FROM.indexOf(requestURL.origin) === -1
&& TILE_SERVERS_ORIGINS.indexOf(requestURL.origin) === -1
) {
DEBUG && console.log(`SW: ignore different origin ${requestURL.origin}.`);
return;
}
// Never touch requests going from / to the API
if (requestURL.pathname.startsWith('/api')) {
// Note that if API is on a different location, it will be ignored by
// the previous rule.
DEBUG && console.log(`SW: ignore API call ${requestURL.pathname}.`);
return;
}
// Serve tiles from cache or download and cache them
if (TILE_SERVERS_ORIGINS.indexOf(requestURL.origin) !== -1) {
event.respondWith(global.caches.open(`${CACHE_NAME}-tiles`).then(
cache => cache.match(request).then(
(response) => {
// Helper function to fetch data from the network
const _fetchFromNetwork = (
// Note: We HAVE to use fetch(request.url) here to ensure we
// have a CORS-compliant request. Otherwise, we could get back
// an opaque response which we cannot inspect
// (https://developer.mozilla.org/en-US/docs/Web/API/Response/type).
() => fetch(request.url).then(
liveResponse => _getTileCachingDurationPromise().then(
(cachingDurationFromConfig) => {
// This is caching duration specified in HTTP headers
const cachingDurationFromHeaders = _getExpiresFromHeaders(
liveResponse.headers,
) || 0;
// If any form of caching is possible, do it
if (
cachingDurationFromConfig > 0
|| cachingDurationFromHeaders > 0
) {
// Recreate a Response object from scratch to put
// it in the cache, with the extra header for
// managing cache expiration.
const cachedResponseFields = {
status: liveResponse.status,
statusText: liveResponse.statusText,
headers: {
'SW-Cached-Date': (new Date()).toUTCString(),
'SW-HTTP-Cache-Duration': cachingDurationFromHeaders,
},
};
liveResponse.headers.forEach((v, k) => {
cachedResponseFields.headers[k] = v;
});
// We will consume body of the live response, so
// clone it before to be able to return it
// afterwards.
const returnedResponse = liveResponse.clone();
return liveResponse.blob().then((body) => {
DEBUG && console.log(
`SW: caching tiles ${request.url}.`,
);
// Put the duplicated Response in the cache
cache.put(
request, new Response(body, cachedResponseFields),
);
// Return the live response from the network
return returnedResponse;
});
}
// Otherwise, just return the live result from the network
return liveResponse;
},
),
)
);
// If there is a match from the cache
if (response) {
DEBUG && console.log(`SW: Found ${request.url} in cache.`);
return _checkExpiration(cache, request, response).then(
(notExpiredResponse) => {
if (notExpiredResponse) {
DEBUG && console.log(`SW: Serving ${request.url} from cache.`);
return notExpiredResponse;
}
DEBUG && console.log(`SW: Serving ${request.url} from network, expired in cache.`);
return _fetchFromNetwork();
},
);
}
// Otherwise, let's fetch it from the network
DEBUG && console.log(`SW: no match in cache for ${request.url}, using network.`);
return _fetchFromNetwork();
},
),
));
return;
}
// For the other requests, try to match it in the cache, otherwise do a
// network call
if (DEBUG) {
// Never match from cache in development
return;
}
const resource = global.caches.open(CACHE_NAME).then(
cache => cache.match(request).then(
(response) => {
if (response) {
DEBUG && console.log(`SW: serving ${request.url} from cache.`);
return response;
}
DEBUG && console.log(`SW: no match in cache for ${request.url}, using network.`);
return fetch(request);
},
),
);
event.respondWith(resource);
});
global.self.addEventListener('message', (event) => {
console.log(`SW: received message ${event.data}.`);
const eventData = JSON.parse(event.data);
// Clean tiles cache
if (eventData.action === 'PURGE_EXPIRED_TILES') {
DEBUG && console.log('SW: purging expired tiles from cache.');
global.caches.open(`${CACHE_NAME}-tiles`).then(
cache => cache.keys().then(
keys => keys.forEach(
key => cache.match(key).then((cachedResponse) => {
_checkExpiration(cache, key, cachedResponse);
}),
),
),
);
}
});