diff --git a/src/App.vue b/src/App.vue index 96c3910..bc61392 100644 --- a/src/App.vue +++ b/src/App.vue @@ -172,8 +172,15 @@ export default { }, }, mounted() { - if ('serviceWorker' in navigator) { - runtime.register().catch((error) => { + // Service worker is for caching only, so it needs both SW support and + // caching API support. + if ('serviceWorker' in navigator && 'caches' in window) { + runtime.register().then( + // Clean expired tiles from the cache at startup + () => navigator.serviceWorker.controller.postMessage(JSON.stringify({ + action: 'PURGE_EXPIRED_TILES', + })), + ).catch((error) => { console.log(`Registration failed with ${error}.`); }); } diff --git a/src/sw.js b/src/sw.js index 83439cf..6203190 100644 --- a/src/sw.js +++ b/src/sw.js @@ -1,4 +1,10 @@ -import { VERSION as CACHE_NAME } from '@/constants'; +import localforage from 'localforage'; + +import { + TILE_SERVERS, + VERSION as CACHE_NAME, +} from '@/constants'; +import { loadDataFromStorage } from '@/storage'; const DEBUG = (process.env.NODE_ENV !== 'production'); @@ -17,11 +23,52 @@ assetsToCache = assetsToCache.filter( 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; +} global.self.addEventListener('install', (event) => { DEBUG && console.log('SW: installing…'); event.waitUntil( - !DEBUG && global.caches.open(CACHE_NAME) // Don't cache during dev + global.caches.open(CACHE_NAME) // Don't cache during dev .then((cache) => { DEBUG && console.log('SW: cache opened.'); cache.addAll(assetsToCache).then( @@ -61,14 +108,17 @@ global.self.addEventListener('fetch', (event) => { // Do not touch requests which are not GET if (request.method !== 'GET') { - DEBUG && console.log(`SW: ignore non-GET request: ${request.method}`); + DEBUG && console.log(`SW: ignore non-GET request: ${request.method}.`); return; } - // Do not touch requests from a different origin + // 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) { - DEBUG && console.log(`SW: ignore different origin ${requestURL.origin}`); + 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; } @@ -76,28 +126,140 @@ global.self.addEventListener('fetch', (event) => { 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}`); + 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) => { + // If there is a match from the cache + if (response) { + DEBUG && console.log(`SW: serving ${request.url} from cache.`); + const expirationDate = Date.parse(response.headers.get('sw-cache-expires')); + const now = new Date(); + // Check it is not already expired and return from the + // cache + if (expirationDate > now) { + return response; + } + } + + // Otherwise, let's fetch it from the network + DEBUG && console.log(`SW: no match in cache for ${request.url}, using network.`); + return fetch(request.url).then((liveResponse) => { + // 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.then((tileCachingDuration) => { + // This is caching duration from settings + const cachingDuration = ( + tileCachingDuration || 0 + ); + // This is caching duration specified in HTTP headers + const cachingDurationFromHeaders = getExpiresFromHeaders( + liveResponse.headers, + ) || 0; + + // If any form of caching is possible, do it + if (tileCachingDuration > 0 || cachingDurationFromHeaders > 0) { + // Compute expires date from caching duration + const expires = new Date(); + expires.setSeconds( + expires.getSeconds() + // Caching duration cannot be less than + // specified by HTTP headers. + + Math.max(cachingDuration, cachingDurationFromHeaders), + ); + // 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-Cache-Expires': expires.toUTCString() }, + }; + 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} until ${expires.toUTCString()}.`, + ); + // 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; + }); + }); + }, + ), + )); return; } // For the other requests, try to match it in the cache, otherwise do a // network call if (DEBUG) { - // Never match from cache in production + // 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`); + DEBUG && console.log(`SW: serving ${request.url} from cache.`); return response; } - DEBUG && console.log(`SW: no match in cache for ${request.url}, using network`); + 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) => { + const expirationDate = Date.parse(cachedResponse.headers.get('sw-cache-expires')); + const now = new Date(); + // Check it is not already expired + if (expirationDate < now) { + DEBUG && console.log(`SW: purging (expired) tile ${key.url} from cache.`); + cache.delete(key); + } + }), + ), + ), + ); + } +});