Refactor and document service worker

This commit is contained in:
Jeremy Likness 2019-12-03 11:17:35 -08:00
parent 9287b2b802
commit 41ed91a49d

310
pwa.js
View File

@ -1,6 +1,52 @@
const CACHE_VERSION = 1.0; // @ts-check
const BASE_CACHE_FILES = [ /**
* @typedef {object} CacheVersions
* @property {string} assets Cache name for base assets
* @property {string} content Cache for pages
* @property {string} offline Cache for offline content
* @property {string} notFound Cache for 404 content
*/
/**
* @typedef {function} AddEventListener
* @param {string} eventName Name of event to add
* @param {EventListener} callback Callback when event is fired
* @returns {void}
*/
/**
* @typedef {object} ServiceWorkerGlobalScope
* @property {function} skipWaiting skip the waiting
* @property {AddEventListener} addEventListener Add event listener
* @property {any} clients Service worker clients
*/
/**
* Service worker for Progressive Web App
* */
class Pwa {
/**
* Create a new instance
* @param {ServiceWorkerGlobalScope} self
*/
constructor(self) {
/**
* Global scope
* @type {ServiceWorkerGlobalScope}
*/
this.scope = self;
/**
* Cache version
* @type {number}
*/
this.CACHE_VERSION = 1.0;
/**
* Pre-emptive files to cache
* @type {string[]}
*/
this.BASE_CACHE_FILES = [
'/index.html', '/index.html',
'/pwa/404.html', '/pwa/404.html',
'/pwa/offline.html', '/pwa/offline.html',
@ -9,113 +55,137 @@ const BASE_CACHE_FILES = [
'/manifest.json', '/manifest.json',
'/images/logo.png', '/images/logo.png',
'/js/app.js', '/js/app.js',
]; ];
/**
const OFFLINE_CACHE_FILES = [ * Files needed for offline mode
* @type {string[]}
*/
this.OFFLINE_CACHE_FILES = [
'/pwa/offline.html' '/pwa/offline.html'
]; ];
/**
const NOT_FOUND_CACHE_FILES = [ * Files needed for 404 page
* @type {string[]}
*/
this.NOT_FOUND_CACHE_FILES = [
'/pwa/404.html' '/pwa/404.html'
]; ];
/**
const OFFLINE_PAGE = '/pwa/offline.html'; * Page to redirect to when offline
const NOT_FOUND_PAGE = '/pwa/404.html'; * @type {string}
*/
const CACHE_VERSIONS = { this.OFFLINE_PAGE = '/pwa/offline.html';
assets: 'assets-v' + CACHE_VERSION, /**
content: 'content-v' + CACHE_VERSION, * Page to show when not found (404)
offline: 'offline-v' + CACHE_VERSION, * @type {string}
notFound: '404-v' + CACHE_VERSION, */
}; this.NOT_FOUND_PAGE = '/pwa/404.html';
/**
// Define MAX_TTL's in SECONDS for specific file extensions * Versioned caches
const MAX_TTL = { * @type {CacheVersions}
*/
this.CACHE_VERSIONS = {
assets: `assets-v${this.CACHE_VERSION}`,
content: `content-v${this.CACHE_VERSION}`,
offline: `offline-v${this.CACHE_VERSION}`,
notFound: `404-v${this.CACHE_VERSION}`,
};
/**
* The time to live in cache
* @type {object}
*/
this.MAX_TTL = {
/** @type {number} Default time to live in seconds */
'/': 3600, '/': 3600,
/** @type {number} Time to live for pages in seconds */
html: 43200, html: 43200,
/** @type {number} Time to live for JSON in seconds */
json: 43200, json: 43200,
/** @type {number} Time to live for scripts in seconds */
js: 86400, js: 86400,
/** @type {number} Time to live for stylesheets in seconds */
css: 86400, css: 86400,
}; };
const SUPPORTED_METHODS = [ /**
* Supported methods for HTTP requests
* @type {string[]}
*/
this.SUPPORTED_METHODS = [
'GET', 'GET',
]; ];
}
/** /**
* getFileExtension * Get the extension of a file from URL
* @param {string} url * @param {string} url
* @returns {string} * @returns {string}
*/ */
function getFileExtension(url) { getFileExtension(url) {
let extension = url.split('.').reverse()[0].split('?')[0]; let extension = url.split('.').reverse()[0].split('?')[0];
return (extension.endsWith('/')) ? '/' : extension; return (extension.endsWith('/')) ? '/' : extension;
} }
/** /**
* getTTL * Get time to live for cache by extension
* @param {string} url * @param {string} url
* @returns {number} Time to live in seconds
*/ */
function getTTL(url) { getTTL(url) {
if (typeof url === 'string') { if (typeof url === 'string') {
let extension = getFileExtension(url); const extension = this.getFileExtension(url);
if (typeof MAX_TTL[extension] === 'number') { if (typeof this.MAX_TTL[extension] === 'number') {
return MAX_TTL[extension]; return this.MAX_TTL[extension];
} else { } else {
return null; return null;
} }
} else { } else {
return null; return null;
} }
} }
/** async installServiceWorker() {
* installServiceWorker
* @returns {Promise}
*/
async function installServiceWorker() {
try { try {
await Promise.all([ await Promise.all([
caches.open(CACHE_VERSIONS.assets).then((cache) => { caches.open(this.CACHE_VERSIONS.assets).then((cache) => {
return cache.addAll(BASE_CACHE_FILES); return cache.addAll(this.BASE_CACHE_FILES);
}, err => console.error(`Error with ${CACHE_VERSIONS.assets}`, err)), }, err => console.error(`Error with ${this.CACHE_VERSIONS.assets}`, err)),
caches.open(CACHE_VERSIONS.offline).then((cache_1) => { caches.open(this.CACHE_VERSIONS.offline).then((cache_1) => {
return cache_1.addAll(OFFLINE_CACHE_FILES); return cache_1.addAll(this.OFFLINE_CACHE_FILES);
}, err_1 => console.error(`Error with ${CACHE_VERSIONS.offline}`, err_1)), }, err_1 => console.error(`Error with ${this.CACHE_VERSIONS.offline}`, err_1)),
caches.open(CACHE_VERSIONS.notFound).then((cache_2) => { caches.open(this.CACHE_VERSIONS.notFound).then((cache_2) => {
return cache_2.addAll(NOT_FOUND_CACHE_FILES); return cache_2.addAll(this.NOT_FOUND_CACHE_FILES);
}, err_2 => console.error(`Error with ${CACHE_VERSIONS.notFound}`, err_2))]); }, err_2 => console.error(`Error with ${this.CACHE_VERSIONS.notFound}`, err_2))]);
return self.skipWaiting(); return this.scope.skipWaiting();
} }
catch (err_3) { catch (err_3) {
return console.error("Error with installation: ", err_3); return console.error("Error with installation: ", err_3);
} }
} }
/** /**
* cleanupLegacyCache * Removes prior cache version
* @returns {Promise} * @returns {Promise}
*/ */
function cleanupLegacyCache() { cleanupLegacyCache() {
const currentCaches = Object.keys(CACHE_VERSIONS).map((key) => { const currentCaches = Object.keys(this.CACHE_VERSIONS).map((key) => {
return CACHE_VERSIONS[key]; return this.CACHE_VERSIONS[key];
}); });
return new Promise( return new Promise(
(resolve, reject) => { (resolve, reject) => {
caches.keys().then((keys) => { caches.keys().then((keys) => {
return legacyKeys = keys.filter((key) => { return keys.filter((key) => {
return !~currentCaches.indexOf(key); return !~currentCaches.indexOf(key);
}); });
}).then((legacy) => { }).then((legacy) => {
if (legacy.length) { if (legacy.length) {
Promise.all(legacy.map((legacyKey) => { Promise.all(legacy.map((legacyKey) => {
return caches.delete(legacyKey) return caches.delete(legacyKey);
}) })
).then(() => { ).then(() => {
resolve() resolve();
}).catch((err) => { }).catch((err) => {
console.error("Error in legacy cleanup: ", err); console.error("Error in legacy cleanup: ", err);
reject(err); reject(err);
@ -127,12 +197,15 @@ function cleanupLegacyCache() {
console.error("Error in legacy cleanup: ", err); console.error("Error in legacy cleanup: ", err);
reject(err); reject(err);
}); });
}); });
} }
function preCacheUrl(url) { /**
caches.open(CACHE_VERSIONS.content).then((cache) => { * Pre-fetches URL to store to cache
* @param {string} url
*/
preCacheUrl(url) {
caches.open(this.CACHE_VERSIONS.content).then((cache) => {
cache.match(url).then((response) => { cache.match(url).then((response) => {
if (!response) { if (!response) {
return fetch(url); return fetch(url);
@ -148,46 +221,49 @@ function preCacheUrl(url) {
} }
}); });
}); });
}
} /**
* Registers the various service worker functions
self.addEventListener('install', event => { * @returns {void}
*/
register() {
this.scope.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
Promise.all([ Promise.all([
installServiceWorker(), this.installServiceWorker(),
self.skipWaiting(), this.scope.skipWaiting(),
])); ]));
}); });
// The activate handler takes care of cleaning up old caches. // The activate handler takes care of cleaning up old caches.
self.addEventListener('activate', event => { this.scope.addEventListener('activate', event => {
event.waitUntil(Promise.all( event.waitUntil(Promise.all(
[cleanupLegacyCache(), [this.cleanupLegacyCache(),
self.clients.claim(), this.scope.clients.claim(),
self.skipWaiting()]).catch((err) => { this.scope.skipWaiting()]).catch((err) => {
console.error("Activation error: ", err); console.error("Activation error: ", err);
event.skipWaiting(); event.skipWaiting();
})); }));
}); });
self.addEventListener('fetch', event => { this.scope.addEventListener('fetch', event => {
event.respondWith( event.respondWith(
caches.open(CACHE_VERSIONS.content).then((cache) => { caches.open(this.CACHE_VERSIONS.content)
return cache.match(event.request).then((response) => { .then(async (cache) => {
try {
const response = await cache.match(event.request);
if (response) { if (response) {
let headers = response.headers.entries(); const headers = response.headers.entries();
let date = null; let date = null;
for (let pair of headers) { for (let pair of headers) {
if (pair[0] === 'date') { if (pair[0] === 'date') {
date = new Date(pair[1]); date = new Date(pair[1]);
} }
} }
if (date) { if (date) {
let age = parseInt((new Date().getTime() - date.getTime()) / 1000); let age = parseInt(((new Date().getTime() - date.getTime()) / 1000).toString());
let ttl = getTTL(event.request.url); let ttl = this.getTTL(event.request.url);
if (ttl && age > ttl) { if (ttl && age > ttl) {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
try { try {
@ -203,63 +279,47 @@ self.addEventListener('fetch', event => {
catch (e) { catch (e) {
resolve(response); resolve(response);
} }
}).catch(() => { }).catch(() => response);
return response; }
}); else {
} else {
return response; return response;
} }
} else { }
else {
return response; return response;
} }
} else { }
else {
return null; return null;
} }
}).then((response) => {
if (response) {
return response;
} else {
return fetch(event.request.clone()).then(
(response) => {
if (response.status < 400) {
if (~SUPPORTED_METHODS.indexOf(event.request.method)) {
cache.put(event.request, response.clone());
} }
return response; catch (error) {
} else {
return caches.open(CACHE_VERSIONS.notFound).then((cache) => {
return cache.match(NOT_FOUND_PAGE);
});
}
}).then((response) => {
if (response) {
return response;
}
}).catch(() => {
return caches.open(CACHE_VERSIONS.offline).then(
(offlineCache) => {
return offlineCache.match(OFFLINE_PAGE)
});
});
}
}).catch((error) => {
console.error('Error in fetch handler:', error); console.error('Error in fetch handler:', error);
throw error; throw error;
}); }
}) })
); );
}); });
self.addEventListener('message', (event) => { this.scope.addEventListener('message', (event) => {
if (typeof event.data === 'object' && if (typeof event.data === 'object' &&
typeof event.data.action === 'string') { typeof event.data.action === 'string') {
switch (event.data.action) { switch (event.data.action) {
case 'cache': case 'cache':
preCacheUrl(event.data.url); this.preCacheUrl(event.data.url);
break; break;
default: default:
console.log('Unknown action: ' + event.data.action); console.log(`Unknown action: ${event.data.action}`);
break; break;
} }
} }
}); });
}
}
/**
* Sadly this is a pathetic hack to workaround JsDoc limitations
* @type {any} */
const _self = self;
var pwa = new Pwa(/**@type {ServiceWorkerGlobalScope}*/(_self));
pwa.register();