UNPKG

dsw

Version:

Dynamic Service Worker, offline Progressive Web Apps much easier

630 lines (599 loc) 29.4 kB
import indexedDBManager from './indexeddb-manager.js'; import utils from './utils.js'; import logger from './logger.js'; const DEFAULT_CACHE_NAME = 'defaultDSWCached'; const CACHE_CREATED_DBNAME = 'cacheCreatedTime'; let DEFAULT_CACHE_VERSION = null; let DSWManager, PWASettings, goFetch; // finds the real size of an utf-8 string function lengthInUtf8Bytes(str) { // Matches only the 10.. bytes that are non-initial characters in a multi-byte sequence. var m = encodeURIComponent(str).match(/%[89ABab]/g); return str.length + (m ? m.length : 0); } const parseExpiration= (rule, expires)=>{ let duration = expires || -1; if (typeof duration == 'string') { // let's use a formated string to know the expiration time const sizes = { s: 1, m: 60, h: 3600, d: 86400, w: 604800, M: 2592000, Y: 31449600 }; let size = duration.slice(-1), val = duration.slice(0, -1); if (sizes[size]) { duration = val * sizes[size]; } else { logger.warn('Invalid duration ' + duration, rule); duration = -1; } } if (duration >= 0) { return parseInt(duration, 10) * 1000; } else { return 0; } }; const cacheManager = { setup: (DSWMan, PWASet, ftch)=>{ PWASettings = PWASet; DSWManager = DSWMan; goFetch = ftch; DEFAULT_CACHE_VERSION = PWASettings.dswVersion || '1'; indexedDBManager.setup(cacheManager); // we will also create an IndexedDB to store the cache creationDates // for rules that have cash expiration indexedDBManager.create({ version: 1, name: CACHE_CREATED_DBNAME, key: 'url' }); }, registeredCaches: [], createDB: db=>{ return indexedDBManager.create(db); }, // Delete all the unused caches for the new version of the Service Worker deleteUnusedCaches: keepUnused=>{ if (!keepUnused) { return caches.keys().then(keys=>{ cacheManager.registeredCaches; return Promise.all(keys.map(function(key) { if (cacheManager.registeredCaches.indexOf(key) < 0) { return caches.delete(key); } })); }); } }, // this method will delete all the caches clear: _=>{ if ('window' in self) { // if we are not in the ServiceWorkerScope, we message it // to clear all the cache return window.DSW.sendMessage({ clearEverythingUp: true }, true); } else { // we are in the ServiceWorkerScope, and should delete everything return caches.keys().then(keys=>{ let cleanItUp = keys.map(function(key) { return caches.delete(key); }); // we will also drop the databases from IndexedDB cleanItUp.push(indexedDBManager.clear()); return Promise.all(cleanItUp); }); } }, // return a name for a default rule or the name for cache using the version // and a separator mountCacheId: rule => { if(typeof rule == 'string') { return rule; } let cacheConf = rule? rule.action.cache : false; if (cacheConf) { return (cacheConf.name || DEFAULT_CACHE_NAME) + '::' + (cacheConf.version || DEFAULT_CACHE_VERSION); } return DEFAULT_CACHE_NAME + '::' + DEFAULT_CACHE_VERSION; }, register: rule=>{ cacheManager.registeredCaches.push(cacheManager.mountCacheId(rule)); }, addAll: bundle=>{ return new Promise((resolve, reject)=>{ // for adding a group of files or rules // we use it as a list if (Array.isArray(bundle)) { bundle = { files: bundle }; } let promises = []; // then, we use the cacheManager.add with a new rule // this way it will be able to expire. bundle.files.map(file=>{ promises.push(cacheManager.add( file, null, null, { action: { fetch: file, cache: { name: bundle.name, version: bundle.version || 1, expires: bundle.expires || false } } })); }); // once all of them have been cached, we resolve it // or in case one or more failed, we reject it Promise.all(promises) .then(resolve) .catch(reject); }); }, // just a different method signature, for .add put: (rule, request, response) => { return cacheManager.add( request, typeof rule == 'string'? rule: cacheManager.mountCacheId(rule), response, rule ); }, add: (request, cacheId, response, rule) => { cacheId = cacheId || cacheManager.mountCacheId(rule); return new Promise((resolve, reject)=>{ function addIt (response) { if (response.status == 200 || response.type == 'opaque') { caches.open(cacheId).then(cache => { // adding to cache let opts = response.type == 'opaque'? { mode: 'no-cors' } : {}; if ((request.url || request).indexOf('http') !== 0) { request = utils.fixURL((request.url || request)); } request = utils.createRequest(request, opts); if (request.method != 'POST') { let cacheData = {}; if (rule && rule.action && rule.action.cache) { cacheData = rule.action.cache; } else { cacheData = { name: cacheId, version: cacheId.split('::')[1] }; } let clonedResponse; if (response.bodyUsed) { // sometimes, due to different flows, the // request body may have been already used // In this case, we use cache.add instead // of cache.put cache.add(request).then(cached=>{ DSWManager.traceStep( request, 'Added to cache', { cacheData } ); }).catch(err=>{ logger.error('Could not save into cache', err); }); } else { clonedResponse = response.clone(); DSWManager.traceStep( request, 'Added to cache', { cacheData } ); clonedResponse & request & cache.put(request, clonedResponse); } } resolve(response); // in case it is supposed to expire if (rule && rule.action && rule.action.cache && rule.action.cache.expires) { // saves the current time for further validation cacheManager.setExpiringTime(request, rule||cacheId, rule.action.cache.expires); } }).catch(err=>{ logger.error('Could not save into cache', err); resolve(response); }); } else { reject(response); } } if (!response) { fetch(goFetch(null, request)) .then(addIt) .catch(err=>{ DSWManager.traceStep(request, 'Fetch failed'); logger.error('[ DSW ] :: Failed fetching ' + (request.url || request), err); reject(response); }); } else { addIt(response); } }); }, setExpiringTime: (request, rule, expiresAt=0)=>{ if (typeof expiresAt == 'string') { expiresAt = parseExpiration(rule, expiresAt); } indexedDBManager.addOrUpdate( { url: request.url||request, dateAdded: (new Date).getTime(), expiresAt }, CACHE_CREATED_DBNAME ); }, hasExpired: (request)=>{ return new Promise((resolve, reject)=>{ indexedDBManager.find(CACHE_CREATED_DBNAME, 'url', request.url || request) .then(r=>{ if (r && ((new Date).getTime() > r.dateAdded + r.expiresAt)) { resolve(true); } else { resolve(false); } }) .catch(_=>{ resolve(false); }); }); }, get: (rule, request, event, matching, forceFromCache, treatFailure=true)=>{ let actionType = Object.keys(rule.action)[0], url = request.url || request, pathName = (new URL(url)).pathname; // requests to / should be cached by default if (rule.action.cache !== false && (pathName == '/' || pathName.match(/^\/index\.([a-z0-9]+)/i))) { rule.action.cache = rule.action.cache || {}; } let opts = rule.options || {}; opts.headers = opts.headers || new Headers(); actionType = actionType.toLowerCase(); // let's allow an idb alias for indexeddb...maybe we could move it to a // separated structure actionType = actionType == 'idb'? 'indexeddb': actionType; // cache may expire...if so, we will use this verification afterwards let verifyCache, urlToMatch = null; if (rule.action.cache && rule.action.cache.expires) { verifyCache = cacheManager.hasExpired(request); } else { // if it will not expire, we just use it as a resolved promise verifyCache = Promise.resolve(); } switch (actionType) { case 'bypass': { // if it is a bypass action (no rule shall be applied, at all) if (rule.action[actionType] == 'request') { // it may be of type request // and we will simple allow it to go ahead // this also means we will NOT treat any result from it //logger.info('Bypassing request, going for the network for', request.url); let treatResponse = function (response) { if (response.status >= 200 && response.status < 300) { DSWManager.traceStep(event.request, 'Request bypassed'); return response; } else { DSWManager.traceStep(event.request, 'Bypassed request failed and was ignored'); let resp = new Response(''); // ignored return resp; } }; // here we will use a "raw" fetch, instead of goFetch, which would // create a new Request and define propreties to it return fetch(goFetch(null, event.request)) .then(treatResponse) .catch(treatResponse); } else { // or of type 'ignore' (or anything else, actually) // and we will simply output nothing, as if ignoring both the // request and response DSWManager.traceStep(event.request, 'Bypassed request'); actionType = 'output'; rule.action[actionType] = ''; } } case 'output': { DSWManager.traceStep(event.request, 'Responding with string output', { output: (rule.action[actionType]+'').substring(0, 180) }); return new Response( utils.applyMatch(matching, rule.action[actionType]) ); } case 'indexeddb': { return new Promise((resolve, reject)=>{ // function to be used after fetching function treatFetch (response) { if (response && response.status == 200) { // with success or not(saving it), we resolve it let done = err=>{ if (err) { DSWManager.traceStep(event.request, 'Could not save response into IndexedDB', { err }); } else { DSWManager.traceStep(event.request, 'Response object saved into IndexedDB'); } resolve(response); }; // store it in the indexedDB indexedDBManager.save(rule.name, response.clone(), request, rule) .then(done) .catch(done); // if failed saving, we still have the reponse to deliver }else{ // if it failed, we can look for a fallback url = request.url; pathName = new URL(url).pathname; DSWManager.traceStep(event.request, 'Fetch failed', { url: request.url, status: response.status, statusText: response.statusText }); return DSWManager.treatBadPage(response, pathName, event); } } // let's look for it in our cache, and then in the database // (we use the cache, just so we can user) indexedDBManager.get(rule.name, request) .then(result=>{ // if we did have it in the indexedDB if (result) { // we use it DSWManager.traceStep(event.request, 'Found stored in IndexedDB'); return treatFetch(result); }else{ // if it was not stored, let's fetch it DSWManager.traceStep(event.request, 'Will fetch', { url: request.url, method: request.method }); return goFetch(rule, request, event, matching) .then(treatFetch) .catch(treatFetch); } }); }); } case 'redirect': case 'fetch': { request = DSWManager.createRedirect(rule.action.fetch || rule.action.redirect, event, matching); url = request.url; pathName = new URL(url).pathname; // keep going to be treated with the cache case } case 'cache': { let cacheId; if (event.request.cachedFrom) { // rule.action.cache && rule.action.cache.from) { urlToMatch = event.request.cachedFrom; } else { urlToMatch = null; } if(rule.action.cache){ cacheId = cacheManager.mountCacheId(rule); } // lets verify if the cache is expired or not return verifyCache.then(expired=>{ let lookForCache; if (expired && !forceFromCache) { // in case it has expired, it resolves automatically // with no results from cache DSWManager.traceStep(event.request, 'Cache was expired'); lookForCache = Promise.resolve(); } else{ // if not expired, let's look for it! lookForCache = caches.match(urlToMatch || request); } // look for the request in the cache return lookForCache .then(result=>{ // if it does not exist (cache could not be verified) if (result && result.status != 200) { DSWManager.traceStep( event.request, 'Not found in cache', { url: request.url, status: result.status, statusText: result.statusText }); // if it has expired in cache, failed requests for // updates should return the previously cached data // even if it has expired if (expired) { DSWManager.traceStep( event.request, 'Forcing '+ (expired? 'expired ': '') +'from cache' ); // the true argument flag means it should come from cache, anyways return cacheManager.get(rule, request, event, matching, true); } if (treatFailure) { // look for rules that match for the request and its status (DSWManager.rules[result.status]||[]).some((cur, idx)=>{ if (pathName.match(cur.rx)) { // if a rule matched for the status and request // and it tries to fetch a different source if (cur.action.fetch || cur.action.redirect) { DSWManager.traceStep( event.request, 'Found fallback for failure', { rule: cur, url: request.url } ); // problematic requests should result = goFetch(rule, request, event, matching); return true; // stopping the loop } } }); } // we, then, return the promise of the failed result(for it // could not be loaded and was not in cache) return result; } else { // We will return the result, if successful, or // fetch an anternative resource(or redirect) // and treat both success and failure with the // same "callback" // In case it is a redirect, we also set the header to 302 // and really change the url of the response. if (result) { // when it comes from a redirect, we let the browser know about it // or else...we simply return the result itself if (request.url == event.request.url) { DSWManager.traceStep( event.request, 'Result found in cache', { url: event.request.url, cacheSource: event.request.cachedFrom || event.request.url }); // it was successful return result; } else { // it is a redirect (different urls) DSWManager.traceStep( event.request, 'Redirecting', { from: event.request.url, to: request.url }, false, { // telling the tracker that it has moved url: request.url, id: request.requestId, steps: request.traceSteps, rule }); // let's move the browser's url and return // the appropriate header return Response.redirect(request.url, 302); } } else if (actionType == 'redirect') { // if this is supposed to redirect DSWManager.traceStep( event.request, 'Must redirect', { from: event.request.url, to: request.url }, false, { // telling the tracker that it has moved url: request.url, id: request.requestId, steps: request.traceSteps, rule }); return Response.redirect(request.url, 302); } else { // this is a "normal" request, let's deliver it // but we will be using a new Request with some info // to allow browsers to understand redirects in case // it must be redirected later on let treatFetch = function (response) { if (response.type == 'opaque') { // if it is a opaque response, let it go! if (rule.action.cache !== false) { DSWManager.traceStep(event.request, 'Added to cache (opaque)', { url: request.url }); return cacheManager.add(utils.createRequest(request, { mode: request.mode || 'no-cors' }), cacheManager.mountCacheId(rule), response, rule); } return response; } if(!response.status){ response.status = 404; } // after retrieving it, we cache it // if it was ok if (response.status == 200) { DSWManager.traceStep(event.request, 'Received result OK (200)', { url: request.url }); // if cache is not false, it will be added to cache if (rule.action.cache !== false) { // let's save it into cache DSWManager.traceStep(event.request, 'Saving into cache', { url: request.url }); return cacheManager.add(request, cacheManager.mountCacheId(rule), response, rule); }else{ return response; } } else { // if it had expired, but could not be retrieved // from network, let's give its cache a chance! DSWManager.traceStep(request, 'Failed fetching', { url: request.url }); if (expired) { logger.warn('Cache for ', request.url || request, 'had expired, but the updated version could not be retrieved from the network!\n', 'Delivering the outdated cached data'); DSWManager.traceStep(event.request, 'Using expired cache', { note: 'Failed fetching, loading from cache even though it was expired' }); return cacheManager.get(rule, request, event, matching, true); } // otherwise...let's see if there is a fallback // for the 404 requisition return DSWManager.treatBadPage(response, pathName, event); } }; // if not in cache, let's see if we should look // for it in the network if (treatFailure) { DSWManager.traceStep(event.request, 'Will fetch', { url: request.url, method: request.method }); return goFetch(rule, request, event, matching) .then(treatFetch) .catch(treatFetch); } } } }); // end lookForCache }); // end verifyCache } default: { // also used in fetch actions return event; } } } }; export default cacheManager;