diff --git a/pwa.js b/pwa.js index 7571bbc..26023b8 100644 --- a/pwa.js +++ b/pwa.js @@ -1,265 +1,325 @@ -const CACHE_VERSION = 1.0; - -const BASE_CACHE_FILES = [ - '/index.html', - '/pwa/404.html', - '/pwa/offline.html', - '/css/style.css', - '/css/animations.css', - '/manifest.json', - '/images/logo.png', - '/js/app.js', -]; - -const OFFLINE_CACHE_FILES = [ - '/pwa/offline.html' -]; - -const NOT_FOUND_CACHE_FILES = [ - '/pwa/404.html' -]; - -const OFFLINE_PAGE = '/pwa/offline.html'; -const NOT_FOUND_PAGE = '/pwa/404.html'; - -const CACHE_VERSIONS = { - assets: 'assets-v' + CACHE_VERSION, - content: 'content-v' + CACHE_VERSION, - offline: 'offline-v' + CACHE_VERSION, - notFound: '404-v' + CACHE_VERSION, -}; - -// Define MAX_TTL's in SECONDS for specific file extensions -const MAX_TTL = { - '/': 3600, - html: 43200, - json: 43200, - js: 86400, - css: 86400, -}; - -const SUPPORTED_METHODS = [ - 'GET', -]; +// @ts-check /** - * getFileExtension - * @param {string} url - * @returns {string} + * @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 */ -function getFileExtension(url) { - let extension = url.split('.').reverse()[0].split('?')[0]; - return (extension.endsWith('/')) ? '/' : extension; -} /** - * getTTL - * @param {string} url + * @typedef {function} AddEventListener + * @param {string} eventName Name of event to add + * @param {EventListener} callback Callback when event is fired + * @returns {void} */ -function getTTL(url) { - if (typeof url === 'string') { - let extension = getFileExtension(url); - if (typeof MAX_TTL[extension] === 'number') { - return MAX_TTL[extension]; + +/** + * @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', + '/pwa/404.html', + '/pwa/offline.html', + '/css/style.css', + '/css/animations.css', + '/manifest.json', + '/images/logo.png', + '/js/app.js', + ]; + /** + * Files needed for offline mode + * @type {string[]} + */ + this.OFFLINE_CACHE_FILES = [ + '/pwa/offline.html' + ]; + /** + * Files needed for 404 page + * @type {string[]} + */ + this.NOT_FOUND_CACHE_FILES = [ + '/pwa/404.html' + ]; + /** + * Page to redirect to when offline + * @type {string} + */ + this.OFFLINE_PAGE = '/pwa/offline.html'; + /** + * Page to show when not found (404) + * @type {string} + */ + this.NOT_FOUND_PAGE = '/pwa/404.html'; + /** + * Versioned caches + * @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, + /** @type {number} Time to live for pages in seconds */ + html: 43200, + /** @type {number} Time to live for JSON in seconds */ + json: 43200, + /** @type {number} Time to live for scripts in seconds */ + js: 86400, + /** @type {number} Time to live for stylesheets in seconds */ + css: 86400, + }; + + /** + * Supported methods for HTTP requests + * @type {string[]} + */ + this.SUPPORTED_METHODS = [ + 'GET', + ]; + } + + /** + * Get the extension of a file from URL + * @param {string} url + * @returns {string} + */ + getFileExtension(url) { + let extension = url.split('.').reverse()[0].split('?')[0]; + return (extension.endsWith('/')) ? '/' : extension; + } + + /** + * Get time to live for cache by extension + * @param {string} url + * @returns {number} Time to live in seconds + */ + getTTL(url) { + if (typeof url === 'string') { + const extension = this.getFileExtension(url); + if (typeof this.MAX_TTL[extension] === 'number') { + return this.MAX_TTL[extension]; + } else { + return null; + } } else { return null; } - } else { - return null; } -} -/** - * installServiceWorker - * @returns {Promise} - */ -async function installServiceWorker() { - try { - await Promise.all([ - caches.open(CACHE_VERSIONS.assets).then((cache) => { - return cache.addAll(BASE_CACHE_FILES); - }, err => console.error(`Error with ${CACHE_VERSIONS.assets}`, err)), - caches.open(CACHE_VERSIONS.offline).then((cache_1) => { - return cache_1.addAll(OFFLINE_CACHE_FILES); - }, err_1 => console.error(`Error with ${CACHE_VERSIONS.offline}`, err_1)), - caches.open(CACHE_VERSIONS.notFound).then((cache_2) => { - return cache_2.addAll(NOT_FOUND_CACHE_FILES); - }, err_2 => console.error(`Error with ${CACHE_VERSIONS.notFound}`, err_2))]); - return self.skipWaiting(); + async installServiceWorker() { + try { + await Promise.all([ + caches.open(this.CACHE_VERSIONS.assets).then((cache) => { + return cache.addAll(this.BASE_CACHE_FILES); + }, err => console.error(`Error with ${this.CACHE_VERSIONS.assets}`, err)), + caches.open(this.CACHE_VERSIONS.offline).then((cache_1) => { + return cache_1.addAll(this.OFFLINE_CACHE_FILES); + }, err_1 => console.error(`Error with ${this.CACHE_VERSIONS.offline}`, err_1)), + caches.open(this.CACHE_VERSIONS.notFound).then((cache_2) => { + return cache_2.addAll(this.NOT_FOUND_CACHE_FILES); + }, err_2 => console.error(`Error with ${this.CACHE_VERSIONS.notFound}`, err_2))]); + return this.scope.skipWaiting(); + } + catch (err_3) { + return console.error("Error with installation: ", err_3); + } } - catch (err_3) { - return console.error("Error with installation: ", err_3); - } -} -/** - * cleanupLegacyCache - * @returns {Promise} - */ -function cleanupLegacyCache() { + /** + * Removes prior cache version + * @returns {Promise} + */ + cleanupLegacyCache() { - const currentCaches = Object.keys(CACHE_VERSIONS).map((key) => { - return CACHE_VERSIONS[key]; - }); + const currentCaches = Object.keys(this.CACHE_VERSIONS).map((key) => { + return this.CACHE_VERSIONS[key]; + }); - return new Promise( - (resolve, reject) => { - - caches.keys().then((keys) => { - return legacyKeys = keys.filter((key) => { - return !~currentCaches.indexOf(key); - }); - }).then((legacy) => { - if (legacy.length) { - Promise.all(legacy.map((legacyKey) => { - return caches.delete(legacyKey) - }) - ).then(() => { - resolve() - }).catch((err) => { - console.error("Error in legacy cleanup: ", err); - reject(err); + return new Promise( + (resolve, reject) => { + caches.keys().then((keys) => { + return keys.filter((key) => { + return !~currentCaches.indexOf(key); }); - } else { - resolve(); - } - }).catch((err) => { - console.error("Error in legacy cleanup: ", err); - reject(err); - }); - - }); -} - -function preCacheUrl(url) { - caches.open(CACHE_VERSIONS.content).then((cache) => { - cache.match(url).then((response) => { - if (!response) { - return fetch(url); - } else { - // already in cache, nothing to do. - return null; - } - }).then((response) => { - if (response) { - return cache.put(url, response.clone()); - } else { - return null; - } - }); - }); - -} - -self.addEventListener('install', event => { - event.waitUntil( - Promise.all([ - installServiceWorker(), - self.skipWaiting(), - ])); -}); - -// The activate handler takes care of cleaning up old caches. -self.addEventListener('activate', event => { - event.waitUntil(Promise.all( - [cleanupLegacyCache(), - self.clients.claim(), - self.skipWaiting()]).catch((err) => { - console.error("Activation error: ", err); - event.skipWaiting(); - })); -}); - -self.addEventListener('fetch', event => { - event.respondWith( - caches.open(CACHE_VERSIONS.content).then((cache) => { - return cache.match(event.request).then((response) => { - if (response) { - let headers = response.headers.entries(); - let date = null; - - for (let pair of headers) { - if (pair[0] === 'date') { - date = new Date(pair[1]); - } - } - - if (date) { - let age = parseInt((new Date().getTime() - date.getTime()) / 1000); - let ttl = getTTL(event.request.url); - - if (ttl && age > ttl) { - return new Promise(async (resolve) => { - try { - const updatedResponse = await fetch(event.request.clone()); - if (updatedResponse) { - cache.put(event.request, updatedResponse.clone()); - resolve(updatedResponse); - } - else { - resolve(response); - } - } - catch (e) { - resolve(response); - } - }).catch(() => { - return response; - }); - } else { - return response; - } + }).then((legacy) => { + if (legacy.length) { + Promise.all(legacy.map((legacyKey) => { + return caches.delete(legacyKey); + }) + ).then(() => { + resolve(); + }).catch((err) => { + console.error("Error in legacy cleanup: ", err); + reject(err); + }); } else { - return response; + resolve(); } + }).catch((err) => { + console.error("Error in legacy cleanup: ", err); + reject(err); + }); + }); + } + + /** + * 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) => { + if (!response) { + return fetch(url); } else { + // already in cache, nothing to do. return null; } }).then((response) => { if (response) { - return response; + return cache.put(url, response.clone()); } 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; - } 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) - }); - }); + return null; } - }).catch((error) => { - console.error('Error in fetch handler:', error); - throw error; }); - }) - ); -}); - -self.addEventListener('message', (event) => { - if (typeof event.data === 'object' && - typeof event.data.action === 'string') { - switch (event.data.action) { - case 'cache': - preCacheUrl(event.data.url); - break; - default: - console.log('Unknown action: ' + event.data.action); - break; - } + }); } -}); \ No newline at end of file + + /** + * Registers the various service worker functions + * @returns {void} + */ + register() { + this.scope.addEventListener('install', event => { + event.waitUntil( + Promise.all([ + this.installServiceWorker(), + this.scope.skipWaiting(), + ])); + }); + + // The activate handler takes care of cleaning up old caches. + this.scope.addEventListener('activate', event => { + event.waitUntil(Promise.all( + [this.cleanupLegacyCache(), + this.scope.clients.claim(), + this.scope.skipWaiting()]).catch((err) => { + console.error("Activation error: ", err); + event.skipWaiting(); + })); + }); + + this.scope.addEventListener('fetch', event => { + event.respondWith( + caches.open(this.CACHE_VERSIONS.content) + .then(async (cache) => { + try { + const response = await cache.match(event.request); + if (response) { + const headers = response.headers.entries(); + let date = null; + for (let pair of headers) { + if (pair[0] === 'date') { + date = new Date(pair[1]); + } + } + if (date) { + let age = parseInt(((new Date().getTime() - date.getTime()) / 1000).toString()); + let ttl = this.getTTL(event.request.url); + if (ttl && age > ttl) { + return new Promise(async (resolve) => { + try { + const updatedResponse = await fetch(event.request.clone()); + if (updatedResponse) { + cache.put(event.request, updatedResponse.clone()); + resolve(updatedResponse); + } + else { + resolve(response); + } + } + catch (e) { + resolve(response); + } + }).catch(() => response); + } + else { + return response; + } + } + else { + return response; + } + } + else { + return null; + } + } + catch (error) { + console.error('Error in fetch handler:', error); + throw error; + } + }) + ); + }); + + this.scope.addEventListener('message', (event) => { + if (typeof event.data === 'object' && + typeof event.data.action === 'string') { + switch (event.data.action) { + case 'cache': + this.preCacheUrl(event.data.url); + break; + default: + console.log(`Unknown action: ${event.data.action}`); + 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(); \ No newline at end of file