diff --git a/lib/bootstrap.js b/lib/bootstrap.js index cc7004ff..6954045b 100644 --- a/lib/bootstrap.js +++ b/lib/bootstrap.js @@ -24,6 +24,7 @@ var rename = require('gulp-rename'); var promptly = require('promisified-promptly'); var gitconfiglocal = require('gitconfiglocal'); var through2 = require('through2'); +var gutil = require('gulp-util'); function gitUrl(dir) { return new Promise(function (resolve, reject) { @@ -124,7 +125,9 @@ module.exports = function(config) { }) .then(function(templateConfig) { return new Promise(function(resolve, reject) { - var stream = gulp.src([__dirname + '/../templates/**']) + var contents = __dirname + '/../templates/**'; + var workerTemplate = __dirname + '/../templates/app/offline-worker.js'; + var stream = gulp.src([contents, '!' + workerTemplate]) .pipe(rename(function (path) { // NPM can't include a .gitignore file so we have to rename it. if (path.basename === 'gitignore') { diff --git a/lib/offline.js b/lib/offline.js index 88732148..bb2187fe 100644 --- a/lib/offline.js +++ b/lib/offline.js @@ -20,11 +20,13 @@ var promisify = require('promisify-node'); var fs = require('fs'); +var conflict = require('gulp-conflict'); var path = require('path'); -var swPrecache = require('sw-precache'); -var gutil = require('gulp-util'); -var ghslug = promisify(require('github-slug')); var glob = require('glob'); +var template = require('gulp-template'); +var crypto = require('crypto'); +var gulp = require('gulp'); +var ghslug = promisify(require('github-slug')); module.exports = promisify(function(config, callback) { var rootDir = config.rootDir || './'; @@ -40,7 +42,7 @@ module.exports = promisify(function(config, callback) { var fileGlobs = config.fileGlobs || ['**/*']; - // Remove the existing service worker, if any, so sw-precache doesn't include + // Remove the existing service worker, if any, so offliner doesn't include // it in the list of files to cache. try { fs.unlinkSync(path.join(rootDir, 'offline-worker.js')); @@ -59,29 +61,49 @@ module.exports = promisify(function(config, callback) { return ''; } }).then(function(cacheId) { - var staticFileGlobs = fileGlobs.map(function(v) { - return path.join(rootDir, v) - }); + var absoluteGlobs = + fileGlobs.map(function (v) { return path.join(rootDir, v); }); + var files = flatGlobs(absoluteGlobs); + var filesAndHashes = getFilesAndHashes(files, rootDir); + var replacements = { resources: filesAndHashes, name: cacheId }; + + var stream = gulp.src([__dirname + '/../templates/app/offline-worker.js']) + .pipe(template(replacements)) + .pipe(conflict(rootDir)) + .pipe(gulp.dest(rootDir)); - staticFileGlobs.forEach(function(globPattern) { - glob.sync(globPattern.replace(path.sep, '/')).forEach(function(file) { - var stat = fs.statSync(file); + stream.on('finish', function () { callback(); }); + stream.on('error', function (e) { callback(e); }); + }); - if (stat.isFile() && stat.size > 2 * 1024 * 1024 && staticFileGlobs.indexOf(file) === -1) { - gutil.log(gutil.colors.red.bold(file + ' is bigger than 2 MiB. Are you sure you want to cache it? To suppress this warning, explicitly include the file in the fileGlobs list.')); - } + function flatGlobs(fileGlobs) { + return Object.keys(fileGlobs.reduce(function (matchings, fileGlob) { + fileGlob = fileGlob.replace(path.sep, '/'); + glob.sync(fileGlob, { nodir: true }).forEach(function (m) { + matchings[m] = m; }); - }); + return matchings; + }, {})); + } + + function getFilesAndHashes(files, stripPrefix) { + return files + .map(function (filepath) { + var data = fs.readFileSync(filepath); + var hash = getHash(data); + return { + filepath: filepath.replace(stripPrefix, function (match, offset) { + return offset === 0 ? '' : match; + }), + hash: hash + }; + }); + } + + function getHash(data) { + var sha1 = crypto.createHash('sha1'); + sha1.update(data); + return sha1.digest('hex'); + } - swPrecache.write(path.join(rootDir, 'offline-worker.js'), { - staticFileGlobs: staticFileGlobs, - stripPrefix: rootDir, - verbose: true, - logger: gutil.log, - importScripts: config.importScripts || [], - cacheId: cacheId, - ignoreUrlParametersMatching: config.ignoreUrlParametersMatching || [/./], - maximumFileSizeToCacheInBytes: Infinity, - }, callback); - }); }); diff --git a/package.json b/package.json index 4269e1bd..7b648f81 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "promisify-node": "^0.2.1", "read-yaml": "^1.0.0", "rimraf": "^2.4.3", - "sw-precache": "^2.0.0", "temp": "^0.8.3", "through2": "^2.0.0", "travis-ci": "^2.0.3", @@ -60,7 +59,6 @@ }, "devDependencies": { "chai": "^3.3.0", - "glob": "^5.0.15", "gulp-istanbul": "^0.10.1", "gulp-mocha": "^2.1.3", "mocha": "^2.3.3", diff --git a/templates/app/index.html b/templates/app/index.html index 179b1190..91874eb7 100644 --- a/templates/app/index.html +++ b/templates/app/index.html @@ -29,8 +29,6 @@ - - diff --git a/templates/app/offline-worker.js b/templates/app/offline-worker.js new file mode 100644 index 00000000..1999528c --- /dev/null +++ b/templates/app/offline-worker.js @@ -0,0 +1,19 @@ +importScripts('./scripts/offliner/offliner.js'); +importScripts('./scripts/offliner/middlewares.js'); + +var offliner = new off.Offliner("<%= name %>"); +offliner.prefetch.use(off.fetchers.urls).resources([ +<% resources.forEach(function (resource) { +%> '<%= resource.filepath %>', /* <%= resource.hash %> */ +<% }); %> +]); + +offliner.fetch + .use(off.sources.cache) + .use(off.sources.network) + .orFail(); + +offliner.update + .use(off.updaters.reinstall); + +offliner.standalone(); diff --git a/templates/app/scripts/offline-manager.js b/templates/app/scripts/offline-manager.js index a0521435..fd65dcdf 100644 --- a/templates/app/scripts/offline-manager.js +++ b/templates/app/scripts/offline-manager.js @@ -1,29 +1,32 @@ -function updateFound() { - var installingWorker = this.installing; +(function (global) { + 'use strict'; - // Wait for the new service worker to be installed before prompting to update. - installingWorker.addEventListener('statechange', function() { - switch (installingWorker.state) { - case 'installed': - // Only show the prompt if there is currently a controller so it is not - // shown on first load. - if (navigator.serviceWorker.controller && - window.confirm('An updated version of this page is available, would you like to update?')) { - window.location.reload(); - return; - } - break; - - case 'redundant': - console.error('The installing service worker became redundant.'); - break; - } - }); -} + if ('serviceWorker' in navigator) { + var script = document.createElement('SCRIPT'); + script.src = 'scripts/offliner/offliner-client.js'; + script.dataset.worker = 'offline-worker.js'; + script.onload = function () { + var off = global.off.restore(); + var isActivationDelayed = false; -if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('offline-worker.js').then(function(registration) { - console.log('offline worker registered'); - registration.addEventListener('updatefound', updateFound); - }); -} + off.on('activationPending', function () { + if (confirm('An updated version of this page is available, would you like to update?')) { + off.activate().then(function () { window.location.reload(); }); + } + else if (!isActivationDelayed) { + global.addEventListener('beforeunload', function () { + off.activate(); + }); + isActivationDelayed = true; + } + }); + off.install().then(function () { + console.log('offline worker registered'); + }); + }; + document.addEventListener('DOMContentLoaded', function onBody() { + document.removeEventListener('DOMContentLoaded', onBody); + document.body.appendChild(script); + }); + } +}(this)); diff --git a/templates/app/scripts/offliner/middlewares.js b/templates/app/scripts/offliner/middlewares.js new file mode 100644 index 00000000..56b2e4ad --- /dev/null +++ b/templates/app/scripts/offliner/middlewares.js @@ -0,0 +1,46 @@ + +self.off.sources.cache = function (request, activeCache) { + return activeCache.match(request).then(function (response) { + return response ? Promise.resolve(response) : Promise.reject(); + }); +}; +self.off.sources.network = function (request) { + return fetch(request); +}; + +self.off.fetchers.urls = { + + type: 'url', + + normalize: function (resource) { + return { type: this.type, url: resource }; + }, + + prefetch: function (resources, cache) { + return Promise.all(resources.map(function (resource) { + var bustedUrl = resource.url + '?__b=' + Date.now(); + var request = new Request(bustedUrl, { mode: 'no-cors' }); + return fetch(request).then(function (response) { + var url = new URL(request.url, location); + if (url.pathname.match(/\/index\.html?$/)) { + cache.put('/', response.clone()); + } + cache.put(resource.url, response); + }); + })); + } +}; + +self.off.updaters.reinstall = { + check: function () { + return Promise.resolve('v' + Date().toString()); + }, + + isNewVersion: function () { + return this.flags.isCalledFromInstall; + }, + + evolve: function (previousCache, newCache, reinstall) { + return reinstall(); + } +}; diff --git a/templates/app/scripts/offliner/offliner-client.js b/templates/app/scripts/offliner/offliner-client.js new file mode 100644 index 00000000..7aebb58e --- /dev/null +++ b/templates/app/scripts/offliner/offliner-client.js @@ -0,0 +1,369 @@ +(function (exports) { + 'use strict'; + + var nextPromiseId = 1; + + var originalOff = exports.off; + + var root = (function () { + var root = new URL( + document.currentScript.dataset.root || '', + window.location.origin + ).href; + return root.endsWith('/') ? root : root + '/'; + }()); + + var workerURL = + root + (document.currentScript.dataset.worker || 'offliner-worker.js'); + + /** + * The exported global `off` object contains methods for communicating with + * the offliner worker in charge. + * + * @class OfflinerClient + */ + exports.off = { + + /** + * Callbacks for the events. + * + * @property _eventListeners + * @type Object + * @private + */ + _eventListeners: {}, + + /** + * Implementation callbacks for cross promises by its unique id. + * + * @property _xpromises + * @type Object + * @private + */ + _xpromises: {}, + + /** + * Call `restore()` when you want the `off` name in the global scope for + * other purposes. The method will restore the previous contents to the + * global variable and return the `OfflinerClient`. + * + * @method restore + * @return {OfflinerClient} The current offliner client. + */ + restore: function () { + exports.off = originalOff; + return this; + }, + + /** + * Register the offliner worker. The worker will be installed with + * root `/` [scope](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Syntax) + * unless you add the `data-root` attribute to the script tag. + * + * In the same way, the client will look for a script in the specified root + * called `offliner-worker.js`. If you want to change this behaviour, use + * the `data-worker` attribute. + * + * For instance, suppose your web application is running under: + * https://delapuente.github.com/offliner + * + * And you have your worker at: + * https://delapuente.github.com/offliner/worker.js + * + * Then the script tag should looks like: + * ```html + * + * ``` + * + * @method install + * @return {Promise} A promise resolving if the installation success. + */ + install: function () { + if (!('serviceWorker' in navigator)) { + return Promise.reject(new Error('serviceworkers-not-supported')); + } + + return navigator.serviceWorker.register(workerURL, { + scope: root + }).then(function (registration) { + return this.connect().then(function () { + return registration; + }); + }.bind(this)); + }, + + /** + * Keeps the promise of connect. + * + * @property _connecting + * @type Promise + * @private + */ + _connecting: null, + + /** + * Connects the client with offliner allowing the client to control offliner + * and receive events. + * + * @method connect + * @return {Promise} A promise resolving once connection has been stablished + * with the worker and communication is possible. + */ + connect: function () { + if (!this._connecting) { this._connecting = this._connect(); } + return this._connecting; + }, + + /** + * The actual implementation for {{#crossLink "connect:method"}}{{/crossLink}} + * + * @method _connect + * @return {Promise} A promise resolving once connection has been stablished + * with the worker and communication is possible. + * @private + */ + _connect: function () { + if (!('serviceWorker' in navigator)) { + return Promise.reject(new Error('serviceworkers-not-supported')); + } + + var installMessageHandlers = this._installMessageHandlers.bind(this); + var checkForActivationPending = this._checkForActivationPending.bind(this); + return new Promise(function (fulfill, reject) { + navigator.serviceWorker.getRegistration(root).then(function (registration) { + if (registration.active) { + installMessageHandlers(); + checkForActivationPending(); + return fulfill(); + } + + var installingWorker = registration.installing; + if (!installingWorker) { + return reject(new Error('impossible-to-connect')); + } + + installingWorker.onstatechange = function () { + if (installingWorker.state === 'installed') { + installMessageHandlers(); + checkForActivationPending(); + fulfill(); + } + }; + }); + }); + }, + + /** + * Attaches a listener for a type of event. + * + * @method on + * @param type {String} The type of the event. + * @param handler {Callback} The callback receiving the event. + * @param willBeThis {Object} The context object `this` for the `handler`. + */ + on: function (type, handler, willBeThis) { + if (!this._has(type, handler, willBeThis)) { + this._eventListeners[type] = this._eventListeners[type] || []; + this._eventListeners[type].push([handler, willBeThis]); + } + }, + + /** + * Request an update to offliner. + * + * @method update + * @return {Promise} If the update process is successful, the promise will + * resolve to a new version and an + * {{#crossLink "OfflinerClient/activationPending:event"}}{{/crossLink}} + * will be triggered. If the update is not needed, the promise will be + * rejected with `no-update-needed` reason. + */ + update: function () { + return this._xpromise('update'); + }, + + /** + * Performs the activation of the pending update. I.e. replaces the current + * cache with that updated in the update process. Normally, you want to + * reload the application when the activation ends successfuly. + * + * @method activate + * @return {Promise} A promise resolving into the activated version or + * rejected with `no-activation-pending` if there was not an activation. + */ + activate: function () { + return this._xpromise('activate'); + }, + + /** + * Run the listeners for some type of event. + * + * @method _runListeners + * @param type {String} The type of the events selecting the listeners to + * be run. + * @param evt {Object} The event contents. + * @private + */ + _runListeners: function (type, evt) { + var listeners = this._eventListeners[type] || []; + listeners.forEach(function (listenerAndThis) { + var listener = listenerAndThis[0]; + var willBeThis = listenerAndThis[1]; + listener.call(willBeThis, evt); + }); + }, + + /** + * Registers the listeners for enabling communication between the worker + * and the client code. + * + * @method _installMessageHandlers + * @private + */ + _installMessageHandlers: function installMessageHandlers() { + var that = this; + if (!installMessageHandlers.done) { + if (typeof BroadcastChannel === 'function') { + var bc = new BroadcastChannel('offliner-channel'); + bc.onmessage = onmessage; + } + else { + navigator.serviceWorker.addEventListener('message', onmessage); + } + installMessageHandlers.done = true; + } + + function onmessage(e) { + var msg = e.data; + var type = msg ? msg.type : ''; + var typeAndSubType = type.split(':'); + if (typeAndSubType[0] === 'offliner') { + that._handleMessage(typeAndSubType[1], msg); + } + } + }, + + /** + * Make offliner to check for pending activations and dispatch + * {{#crossLink "OfflinerClient/activationPending:event"}}{{/crossLink}} + * if so. + * + * @method _checkForActivationPending + * @private + */ + _checkForActivationPending: function () { + // TODO: should we add a prefix for offliner messages? + this._send({ type: 'checkForActivationPending' }); + }, + + /** + * Discriminates between {{#crossLink "OfflinerClient/xpromise:event"}}{{/crossLink}} + * events which are treated in a special way and the rest of the events that + * simply trigger the default dispatching algorithm. + * + * @method _handleMessage + * @param offlinerType {String} The type of the message without the + * `offliner:` prefix. + * @param msg {Any} The event. + * @private + */ + _handleMessage: function (offlinerType, msg) { + var sw = navigator.serviceWorker; + if (offlinerType === 'xpromise') { + this._resolveCrossPromise(msg); + } + else { + this._runListeners(offlinerType, msg); + } + }, + + /** + * @method _has + * @param type {String} The type for the listener registration. + * @param handler {Function} The listener. + * @param willBeThis {Object} The context object `this` which the function + * will be called with. + * @return `true` if the listener registration already exists. + * @private + */ + _has: function (type, handler, willBeThis) { + var listeners = this._eventListeners[type] || []; + for (var i = 0, listenerAndThis; (listenerAndThis = listeners[i]); i++) { + if (listenerAndThis[0] === handler && + listenerAndThis[1] === willBeThis) { + return true; + } + } + return false; + }, + + /** + * Creates a cross promise registration. A _cross promise_ or xpromise + * is a special kind of promise that is generated in the client but whose + * implementation is in a worker. + * + * @method _xpromise + * @param order {String} The string for the implementation part to select + * the implementation to run. + * @return {Promise} A promise delegating its implementation in some code + * running in a worker. + * @private + */ + _xpromise: function (order) { + return new Promise(function (accept, reject) { + var uniqueId = nextPromiseId++; + var msg = { + type: 'xpromise', + id: uniqueId, + order: order + }; + this._xpromises[uniqueId] = [accept, rejectWithError]; + this._send(msg); + + function rejectWithError(errorKey) { + reject(new Error(errorKey)); // TODO: Add a OfflinerError type + } + }.bind(this)); + }, + + /** + * Sends a message to the worker. + * + * @method _send + * @param msg {Any} The message to be sent. + * @private + */ + _send: function (msg) { + navigator.serviceWorker.getRegistration(root) + .then(function (registration) { + if (!registration || !registration.active) { + // TODO: Wait for the service worker to be active and try to + // resend. + console.warn('Not service worker active right now.'); + } + else { + return registration.active.postMessage(msg); + } + }); + }, + + /** + * Resolves a cross promise based on information received by the + * implementation in the worker. + * + * @method _resolveCrossPromise + * @param msg {Object} An object with the proper data to resolve a xpromise. + * @private + */ + _resolveCrossPromise: function (msg) { + var implementation = this._xpromises[msg.id]; + if (implementation) { + implementation[msg.status === 'rejected' ? 1 : 0](msg.value); + } + else { + console.warn('Trying to resolve unexistent promise:', msg.id); + } + } + }; + +}(this.exports || this)); diff --git a/templates/app/scripts/offliner/offliner.js b/templates/app/scripts/offliner/offliner.js new file mode 100644 index 00000000..939a4ebc --- /dev/null +++ b/templates/app/scripts/offliner/offliner.js @@ -0,0 +1,1090 @@ +(function (self) { + 'use strict'; + + ['log', 'warn', 'error'].forEach(function (method) { + self[method] = console[method].bind(console); + }); + + var DEFAULT_VERSION = '-offliner:v0'; + var CONFIG_CACHE = '__offliner-config'; + + /** + * @class UpdateControl + * @private + */ + + /** + * Indicates if updates have been scheduled. + * @property scheduled + * @type boolean + */ + + /** + * Set to `true` when the update has run once. + * @property alreadyRunOnce + * @type boolean + */ + + /** + * Holds the reference to the timer for the next update. + * @property intervalId + * @type Number + */ + + /** + * Holds the reference to the promise representing the currently running + * update process. + * @property inProgressProcess + * @type Object + */ + + /** + * Creates a new Offliner instance. + * @param {String} - a unique name representing the offline handler. This + * allow you to instantiate several offliners for the same or different + * workers without causing collisions between the configuration and cache + * names. + * + * @class Offliner + */ + function Offliner(uniquename) { + Object.defineProperty(this, '_uniquename', { + get: function () { return uniquename ? uniquename + ':' : ''; } + }); + + /** + * Prevent the worker to be installed twice. + * + * @property _isStarted + * @type boolean + * @default false + * @private + */ + this._isStarted = false; + + /** + * Mark the instance to be used as middleware. + * + * @property _isMiddleware + * @type boolean + * @default false + * @private + */ + this._isMiddleware = false; + + /** + * The middleware implementation for serviceworkerware. + * + * @property _middleware; + * @type Object + * @default null + * @private + */ + this._middleware = null; + + /** + * The global update control. + * + * @property _updateControl + * @type UpdateControl + * @readonly + * @private + */ + Object.defineProperty(this, '_updateControl', { value: { + scheduled: false, + alreadyRunOnce: false, + intervalId: null, + inProgressProcess: null + }}); + + /** + * API to configure the fetching pipeline. + * + * @property fetch + * @type FetchConfig + * @readonly + */ + Object.defineProperty(this, 'fetch', { value: new FetchConfig() }); + + /** + * API to configure the prefetch process. + * + * @type PrefetchConfig + * @property prefetch + * @readonly + */ + Object.defineProperty(this, 'prefetch', { value: new PrefetchConfig() }); + + /** + * API to configure the update process. + * + * @type UpdateConfig + * @property update + * @readonly + */ + Object.defineProperty(this, 'update', { value: new UpdateConfig() }); + } + + /** + * Installs the service worker in stand-alone mode. + * @method standalone + * @throws {Error} offliner throws when trying to install it in standalone + * mode if it was already used as middleware by calling + * {{#crossLink "Offliner/asMiddleware:method"}}{{/crossLink}}. + */ + Offliner.prototype.standalone = function () { + if (this._isMiddleware) { + throw new Error('offliner has been already started as a middleware.'); + } + + if (this._isStarted) { return; } + + self.addEventListener('install', function (e) { + e.waitUntil( + this._install() + .then(function () { + log('Offliner installed'); + return typeof self.skipWaiting === 'function' ? + self.skipWaiting() : Promise.resolve(); + }) + ); + }.bind(this)); + + self.addEventListener('activate', function (e) { + var ok = function () { + log('Offliner activated!'); + return typeof self.clients.claim === 'function' ? + self.clients.claim() : Promise.resolve(); + }; + e.waitUntil( + this._activate().then(ok, ok) + ); + }.bind(this)); + + self.addEventListener('fetch', function (e) { + if (e.request.method !== 'GET') { + e.respondWith(fetch(e.request)); + } + else { + e.respondWith(this._fetch(e.request)); + } + }.bind(this)); + + self.addEventListener('message', function (e) { + this._processMessage(e.data); + }.bind(this)); + + this._isStarted = true; + }; + + /** + * Returns an object to be used with [serviceworkerware](https://github.com/arcturus/serviceworkerware). + * Once the method is called once, the method will allways return the same + * object. + * + * @method asMiddleware + * @return {Object} A serviceworkerware middleware. + * @throws {Error} offliner will throw if you try to use it as middleware + * after calling {{#crossLink "Offliner/standalone:method"}}{{/crossLink}}. + */ + Offliner.prototype.asMiddleware = function () { + if (this._isStarted) { + throw new Error('offliner has been already installed in standalone mode'); + } + + if (!this._middleware) { + this._middleware = { + onInstall: this._install.bind(this), + onActivate: this._activate.bind(this), + onFetch: function (request, response) { + if (response || request.method !== 'GET') { + return Promise.resolve(response); + } + this._fetch(request); + }.bind(this), + onMessage: function (e) { this._processMessage(e.data); }.bind(this) + }; + } + + this._isMiddleware = true; + return this._middleware; + }; + + Offliner.prototype._activate = function () { + return this.get('activation-pending') + .then(function (isActivationPending) { + if (isActivationPending) { this._sendActivationPending(); } + }.bind(this)); + }; + + /** + * Process the different messages that can receive the worker. + * + * @method _processMessage + * @private + */ + Offliner.prototype._processMessage = function (msg) { + switch (msg.type) { + case 'xpromise': + this._receiveCrossPromise(msg.id, msg.order); + break; + case 'checkForActivationPending': + this._checkForActivationPending(); + break; + default: + warn('Message not recognized:', msg); + break; + } + }; + + /** + * Executes the promise implementation. + * + * @method _receiveCrossPromise + * @param id {String} The unique id for the cross promise. + * @param order {String} The order to be executed. + * @private + */ + Offliner.prototype._receiveCrossPromise = function (id, order) { + switch (order) { + case 'update': + var fromInstall = false; + this._update().then( + this._resolve.bind(this, id), + this._reject.bind(this, id) + ); + break; + case 'activate': + this._activateNextCache().then( + this._resolve.bind(this, id), + this._reject.bind(this, id) + ); + break; + default: + warn('Cross Promise implementation not recognized:', order); + break; + } + }; + + /** + * Check if there is an activation pending. If so, offliner dispatches an + * activation pending request. + * + * @method _checkForActivationPending + * @private + */ + Offliner.prototype._checkForActivationPending = function () { + this.get('activation-pending').then(function (isActivationPending) { + if (isActivationPending) { + this._sendActivationPending(); + } + }.bind(this)); + }; + + /** + * Resolves a cross promise. + * + * @method _resolve + * @param id {String} The unique id for the cross promise. + * @param value {Any} The value to resolve the promise with. + * @private + */ + Offliner.prototype._resolve = function (id, value) { + this._resolvePromise(id, 'resolved', value); + }; + + /** + * Rejects a cross promise. + * + * @method _reject + * @param id {String} The unique id for the cross promise. + * @param reason {Any} The value to reject the promise with. + * @private + */ + Offliner.prototype._reject = function (id, reason) { + this._resolvePromise(id, 'rejected', reason); + }; + + /** + * Broadcast a message to the clients informing the cross promise to be + * solved in which status and with which value. + * + * @method _resolvePromise + * @param id {String} The unique id for the cross promise. + * @param status {String} The status at which the promise will solve to. + * Can be `'rejected'` or `'solved'`. + * @param value {Any} The value for the cross promise. + * @private + */ + Offliner.prototype._resolvePromise = function (id, status, value) { + this._broadcastMessage({ + type: 'xpromise', + id: id, + status: status, + value: value + }); + }; + + /** + * Gets a setting for the offliner handler. + * + * @method get + * @param {String} key The setting to be retrieved. + * @private + */ + Offliner.prototype.get = function (key) { + var configURL = this._getConfigURL(key); + return caches.open(CONFIG_CACHE).then(function (cache) { + return cache.match(configURL).then(function (response) { + if (!response) { return Promise.resolve(null); } + else { return response.json(); } + }); + }); + }; + + /** + * Sets a setting for the offliner handler. + * + * @method set + * @param {String} key The setting. + * @param {any} value The value to be set. + * @private + */ + Offliner.prototype.set = function (key, value) { + var configURL = this._getConfigURL(key); + var response = new Response(JSON.stringify(value)); + return caches.open(CONFIG_CACHE).then(function (cache) { + return cache.put(configURL, response); + }); + }; + + /** + * Return a fake URL scheme for a setting. + * + * @method _getConfigURL + * @param {String} key The setting. + * @return a fake URL scheme for the setting. + * @private + */ + Offliner.prototype._getConfigURL = function (key) { + return 'http://config/' + this._uniquename + key; + }; + + /** + * Determine if the worker should prefetch or update after (re)installing the + * service worker. + * + * @method _install + * @private + */ + Offliner.prototype._install = function () { + var fromInstall = true; + return this.get('current-version').then(function (currentVersion) { + var isUpdateEnabled = this.update.option('enabled'); + if (currentVersion) { + return isUpdateEnabled ? this._update(fromInstall) : Promise.resolve(); + } + return this._initialize().then(this._prefetch.bind(this)); + }.bind(this), error); + }; + + /** + * Initializes the current version and active cache for the first time. + * + * @method _initialize + * @private + */ + Offliner.prototype._initialize = function () { + return this._getCacheNameForVersion(DEFAULT_VERSION) + .then(this.set.bind(this, 'active-cache')) + .then(this.set.bind(this, 'current-version', DEFAULT_VERSION)) + .then(this.set.bind(this, 'activation-pending', false)); + }; + + /** + * Performs a generic update process. It consists into: + * + * 1. Check for a new version using a middleware. + * 2. Prepare the new version database. + * 3. Evolve the offline cache using the middleware. + * 4. Clean-up. + * + * @method _update + * @param {Boolean} fromInstall Indicates if the call comes from the + * {{#crossLink "Offliner/_install:method"}}{{/crossLink}} method. + * @return {Promise} A Promise resolving in the vertion to update or rejecting + * if there is no update needed (`reason = 'no-update-needed'`). + * @private + */ + Offliner.prototype._update = function (fromInstall) { + // XXX: Only one update process is allowed at a time. + var that = this; + if (!this._updateControl.inProgressProcess) { + this._updateControl.inProgressProcess = this.get('current-version') + .then(function (currentVersion) { + this.update.flags = { + isCalledFromInstall: fromInstall, + isFirstUpdate: (currentVersion === DEFAULT_VERSION) + }; + }.bind(this)) + .then(this._getLatestVersion.bind(this)) + .then(this._checkIfNewVersion.bind(this)) + .then(updateCache); + } + return this._updateControl.inProgressProcess; + + function updateCache(newVersion) { + if (newVersion) { + return that._getCacheNameForVersion(newVersion) + .then(caches.open.bind(caches)) + .then(that._evolveCache.bind(that)) + .then(that.set.bind(that, 'activation-pending', true)) + .then(that._sendActivationPending.bind(that)) + .then(function () { + endUpdateProcess(); // XXX: Notice this call before ending! + return Promise.resolve(newVersion); + }); + } + endUpdateProcess(); // XXX: Notice this call before ending! + return Promise.reject('no-update-needed'); + } + + function endUpdateProcess() { + that._updateControl.alreadyRunOnce = true; + that._updateControl.inProgressProcess = null; + } + }; + + /** + * Broadcast a message to all clients to indicate there is an update + * activation ready. + * + * @method _sendActivationPending + * @private + */ + Offliner.prototype._sendActivationPending = function () { + /** + * Event emitted on worker activation or under request to point out there + * is a new version activation pending. + * + * @event activationPending + * @for OfflinerClient + */ + this._broadcastMessage({ type: 'activationPending' }); + }; + + /** + * Broadcast a message to all clients to indicate the activation of the + * new version ended properly. + * + * @method _sendActivationDone + * @private + * @for Offliner + */ + Offliner.prototype._sendActivationDone = function () { + this._broadcastMessage({ type: 'activationDone' }); + }; + + /** + * Broadcast a message to all clients to indicate there was a failure while + * activating the update. + * + * @method _sendActivationFailed + * @private + */ + Offliner.prototype._sendActivationFailed = function () { + this._broadcastMessage({ type: 'activationFailed' }); + }; + + /** + * Broadcast a message in the clients. The method will add the `offliner:` + * prefix to the type of the events but this is stripped out automatically by + * the {{#crossLink "OfflinerClient/_installMessageHandlers:method"}}{{/crossLink}} + * client side. + * + * @method _broadcastMessage + * @param msg {Any} the message to be broadcasted. + * @private + */ + Offliner.prototype._broadcastMessage = function (msg) { + msg.type = 'offliner:' + msg.type; + if (this._isMiddleware) { + this.asMiddleware().broadcastMessage(msg, 'offliner-channel'); + } + else { + if (typeof BroadcastChannel === 'function') { + var channel = new BroadcastChannel('offliner-channel'); + channel.postMessage(msg); + channel.close(); + } + else { + clients.matchAll().then(function (controlled) { + controlled.forEach(function (client) { + client.postMessage(msg); + }); + }); + } + } + }; + + /** + * Return the CACHE name for a version given. + * + * @method _getCacheNameForVersion + * @param {String} version The version to calculate the name for. + * @return {Promise} A promise resolving with the name for the + * version. + * @private + */ + Offliner.prototype._getCacheNameForVersion = function (version) { + return Promise.resolve(this._uniquename + 'cache-' + version); + }; + + /** + * Opens current active cache and starts prefetch. + * + * @method _prefetch + * @private + */ + Offliner.prototype._prefetch = function () { + return this._openActiveCache().then(this._doPrefetch.bind(this)); + }; + + /** + * Processes prefetch declared resources using the registered middlewares. + * + * @method _doPrefetch + * @param {Cache} cache The cache for the middlewares to populate. + * @private + */ + Offliner.prototype._doPrefetch = function (cache) { + var allResources = this.prefetch.resources(); + var fetchers = this.prefetch.fetchers(); + var resourcesByType = groupResources(fetchers, allResources); + return fetchers.reduce(function (process, fetcher) { + return process.then(function () { + var resources = resourcesByType[fetcher.type]; + return fetcher.prefetch(resources, cache); + }); + }, Promise.resolve()); + + function groupResources(fetchers, resources) { + var resourceGatherers = fetchers.reduce(function (gatherers, fetcher) { + gatherers[fetcher.type] = []; + return gatherers; + }, {}); + resources.forEach(function (resource) { + var resourcesByType = resourceGatherers[resource.type]; + if (resourcesByType) { resourcesByType.push(resource); } + }); + return resourceGatherers; + } + }; + + /** + * Obtains the latest version using the update middleware. + * + * @method _getLatestVersion + * @return {Promise} Tag representing the latest version. The tag will + * be used as suffix for the new cache. + * @private + */ + Offliner.prototype._getLatestVersion = function () { + return this.update.check(); + }; + + /** + * Determine if there is a new version based on the latest version and the + * current one by using the update middleware. + * + * @method _checkIfNewVersion + * @return {Promise} latestVersion The new version tag is returned + * if there is a new version or `null` otherwise. + * @private + */ + Offliner.prototype._checkIfNewVersion = function (latestVersion) { + return this.get('current-version').then(function (currentVersion) { + var isNewVersion = + this.update.isNewVersion(currentVersion, latestVersion); + + if (isNewVersion) { + log('New version ' + latestVersion + ' found!'); + if (currentVersion) { log('Updating from version ' + currentVersion); } + else { log('First update'); } + + return this.set('next-version', latestVersion) + .then(function () { return latestVersion; }); + } + else { + log('No update needed'); + } + return null; + }.bind(this)); + }; + + /** + * Evolves the current cache to the new cache by using the update middleware. + * + * @method _evolveCache + * @param {Cache} newCache The new cache. + * @private + */ + Offliner.prototype._evolveCache = function (newCache) { + return this._openActiveCache().then(function (currentCache) { + var reinstall = this._doPrefetch.bind(this, newCache); + return this.update.evolve(currentCache, newCache, reinstall); + }.bind(this)); + }; + + /** + * Uses dynamic information to open the active CACHE. + * + * @method _openActiveCache + * @return {Promise} A promise resolving to the active cache. + * @private + */ + Offliner.prototype._openActiveCache = function () { + return this.get('active-cache').then(caches.open.bind(caches)); + }; + + /** + * Change the active cache to be the evolved cache if available. Once the + * active cache has been updated, the former one is lost. + * + * @method _activateNextCache + * @return {Promise} A Promise resolving in the new version or rejecting + * if there is no pending activation. + * @private + */ + Offliner.prototype._activateNextCache = function () { + return this.get('activation-pending').then(function (isActivationPending) { + if (isActivationPending) { + return this._swapCaches() + .then(this._updateCurrentVersion.bind(this)); + } + return Promise.reject('no-activation-pending'); + }.bind(this)); + }; + + /** + * Makes active cache to be the next-version cache populated during a past + * update process. After swapping, the previous cache is lost. + * + * @method _swapCaches + * @private + */ + Offliner.prototype._swapCaches = function () { + var that = this; + return Promise.all([ + getCurrentCache(), + getNextCache() + ]).then(swap); + + function getCurrentCache() { + return that.get('active-cache'); + } + + function getNextCache() { + return that.get('next-version') + .then(that._getCacheNameForVersion.bind(that)); + } + + function swap(names) { + var currentCache = names[0], + nextCache = names[1]; + return that.set('active-cache', nextCache) + .then(deleteOtherCaches([nextCache, CONFIG_CACHE])); + } + + function deleteOtherCaches(exclude) { + return function () { + return caches.keys().then(function (cacheNames) { + return Promise.all( + cacheNames.filter(function (cacheName) { + return exclude.indexOf(cacheName) < 0; + }) + .map(function (cacheName) { + return caches.delete(cacheName); + }) + ); + }); + }; + } + }; + + /** + * Updates the current version. + * + * @method _updateCurrentVersion + * @private + */ + Offliner.prototype._updateCurrentVersion = function () { + var nextVersion = this.get('next-version'); + return nextVersion + .then(this.set.bind(this, 'current-version')) + .then(this.set.bind(this, 'activation-pending', false)) + .then(function () { return nextVersion; }); + }; + + /** + * Use configured middlewares to perform the fetch process. + * + * @method _fetch + * @param {Request} request The request to be fetched. + * @private + */ + Offliner.prototype._fetch = function (request) { + return new Promise(function (resolve, reject) { + this._openActiveCache().then(function (cache) { + var sources = this.fetch.pipeline(); + trySources(sources); + + function trySources(sources, from) { + from = from || 0; + var sourcesCount = sources.length; + if (from === sources.length) { reject(); } + else { + sources[from](request, cache).then(resolve, function () { + trySources(sources, from + 1); + }); + } + } + }.bind(this)); + }.bind(this)); + }; + + /** + * A resource is an object with a type and other fields to be retrieved by + * the {{#crossLink "Fetcher"}}{{/crossLink}} with the same type. + * @class Resource + */ + + /** + * The type to associate the resource with an specific + * {{#crossLink "Fetcher"}}{{/crossLink}}. + * + * @property type + * @type String + * @readonly + */ + + /** + * A fetcher is an object for handling resouces during the prefetching + * prefetch process. A fetcher must include a `type` and normalize and + * prefetch implementations. + * + * @class Fetcher + * @private + */ + + /** + * While prefetching resources, each resource has a `type`. The resource + * is handled by the fetcher whose `type` match it. + * + * @property type + * @type String + * @readonly + */ + + /** + * Normalizes a resource not following the {{#crossLink "Resource"}} + * {{/crossLink}} convention. + * + * @method normalize + * @param {any} resource The denormalized resource. + */ + + /** + * Retrieve a set of resources. + * + * @method prefetch + * @param {Resource[]} resource The denormalized resource. + * @param {Cache} cache The cache to populate. + */ + + /** + * Prefetch process consists into recovering from the Web those + * resources configured in offliner. To do so, you call + * {{#crossLink "PrefetchConfig/use:method"}}{{/crossLink}}, then list the + * resources by calling {{#crossLink "PrefetchConfig/resources:method"}} + * {{/crossLink}}. + * + * @class PrefetchConfig + */ + function PrefetchConfig() { + this._resourceFetchers = {}; + this._resources = []; + } + + /** + * Register a {{#crossLink "Fetcher"}}{{/crossLink}}. The fetcher will be used + * to retrieve the resources of the fetcher's type. + * + * @method use + * @param {Fetcher} fetcher The fetcher to be used for resources of fetcher's + * type. + * @chainable + */ + PrefetchConfig.prototype.use = function (fetcher) { + this._resourceFetchers[fetcher.type] = fetcher; + this._activeFetcher = fetcher; + return this; + }; + + /** + * Add resources to the prefetch list of resources. + * + * @method resources + * @param {Resource|Resource[]} resources The list of resources to be added. + * Each resource in the list is normalized by the last registered fetcher so + * some fetchers allows a short syntax for its resources. + * @chainable + */ + PrefetchConfig.prototype.resources = function (resources) { + if (arguments.length === 0) { return this._resources; } + + if (!Array.isArray(resources)) { resources = [resources]; } + for (var i = 0, resource; (resource = resources[i]); i++) { + var normalized; + if (typeof resource !== 'object' || !resource || !resource.type) { + try { + normalized = this._activeFetcher.normalize(resource); + } + catch (e) {} + } + if (!normalized) { + warn(resource, 'can not be normalized by', this._activeFetcher.type); + } + else { + this._resources.push(normalized); + } + } + return this; + }; + + /** + * @method fetchers + * @return {Fetcher[]} the registered fetchers. + */ + PrefetchConfig.prototype.fetchers = function () { + return Object.keys(this._resourceFetchers).map(function (type) { + return this._resourceFetchers[type]; + }.bind(this)); + }; + + /** + * An object implementing methods to check for new version and update the + * activate cache. + * + * @class UpdateImplementation + */ + + /** + * Checks for a new version. + * + * @method check + * @return {Promise} A promise resolving in the new version. + */ + + /** + * Determines if the checked new version is actually a new version. + * + * @method isNewVersion + * @param {String} currentVersion The current version. + * @param {String} latestVersion The version from + * {{#crossLink "UpdateImplementation/check:method"}}{{/crossLink}}. + * @return {Boolean} + */ + + /** + * Populate the updated cache. + * + * @method evolve + * @param {Cache} currentCache The current active cache. **Do not modify this + * cache!** + * @param {Cache} nextCache The cache to be populated. + * @param {Function} reinstall A function to trigger the prefetch process. Some + * update algorithms just want to prefetch again. + * @return {Promise} A promise resolving after finishing the update process. + * If you simply wants to simply reinstall, return the value from `reinstall` + * invocation. + */ + + /** + * Update consists into determine if there is a new version and + * then evolve the current cache to be up to date. To register an update + * algorithm you provide a {{#crossLink "UpdateImplementation"}} + * {{/crossLink}} instance by using {{#crossLink "UpdateConfig/use:method"}} + * {{/crossLink}}. + * + * @class UpdateConfig + */ + function UpdateConfig() { + this._options = {}; + } + + /** + * Gets or set an option. + * + * @method option + * @param {String} optname The name of the option to be set or get. + * @param {any} [value] If provided, the value to be set for the passed option. + * @chainable + * @return {any} The value of the option when getting. + */ + UpdateConfig.prototype.option = function (optname, value) { + if (arguments.length === 2) { + this._options[optname] = value; + return this; + } + if (arguments.length === 1) { + return this._options[optname]; + } + }; + + /** + * Register the update implementation. + * + * @method use + * @param {UpdateImplementation} impl The update implementation to be used. + * @chainable + */ + UpdateConfig.prototype.use = function (impl) { + this.option('enabled', true); + this._impl = impl; + return this; + }; + + /** + * Flags set at the beginning of the update process. They include: + * + * @property flags + * @type UpdateFlags + */ + Object.defineProperty(UpdateConfig.prototype, 'flags', { + set: function (value) { + this._impl.flags = value; + }, + get: function () { + return this._impl.flags; + } + }); + + /** + * Triggers the {{#crossLink "UpdateImplementation/check:method"}} + * {{/crossLink}} algorithm of the registered update implementation. + * + * @method check + */ + UpdateConfig.prototype.check = function () { + return this._impl && this._impl.check(); + }; + + /** + * Calls the {{#crossLink "UpdateImplementation/isNewVersion:method"}} + * {{/crossLink}} check of the registered update implementation. + * + * @method isNewVersion + */ + UpdateConfig.prototype.isNewVersion = + function (currentVersion, latestVersion) { + return this._impl.isNewVersion(currentVersion, latestVersion); + }; + + /** + * Performs the {{#crossLink "UpdateImplementation/evolve:method"}} + * {{/crossLink}} process of the registered update implementation. + * + * @method evolve + */ + UpdateConfig.prototype.evolve = function (currentCache, newCache, prefetch) { + return this._impl.evolve(currentCache, newCache, prefetch); + }; + + /** + * A source handler is a **function** that accepts a request and the + * active cache and return a Promise resolving into the proper Response. It's + * used with {{#crossLink "FetchConfig/use:method"}}{{/crossLink}} of + * {{#crossLink "FetchConfig"}}{{/crossLink}}. + * + * `sourceHandler(request, activeCache)` + * + * @class SourceHandler + */ + + /** + * The fetch process consists into pass the request along a list + * of source handlers. You call {{#crossLink "FetchConfig/use:method"}} + * {{/crossLink}} to add a new source handler to the pipeline. + * + * @class FetchConfig + */ + function FetchConfig() { + this._pipeline = []; + } + + /** + * Adds a new {{#crossLink "SourceHandler"}}{{/crossLink}} to the fetching + * pipeline. + * + * @method use + * @param {SourceHandler} source The handler to be added to the pipeline. + * @chainable + */ + FetchConfig.prototype.use = function (source) { + this._pipeline.push(source); + return this; + }; + + /** + * Gets the current pipeline of sources. + * + * @method pipeline + * @return {SourceHandler[]} The current pipeline of source handlers. + */ + FetchConfig.prototype.pipeline = function () { + return this._pipeline; + }; + + /** + * Adds an always failing source handler to the pipeline. + * + * @method orFail + */ + FetchConfig.prototype.orFail = function () { + this.use(function () { + return Promise.reject(new Error('End of fetch pipeline!')); + }); + }; + + /** + * The exported module for offliner. + * @module off + */ + self.off = {}; + + self.off.Offliner = Offliner; + + /** + * A collection of {{#crossLink "SourceHandler"}}{{/crossLink}} + * constructors to configure offliner. + * @submodule sources + */ + self.off.sources = {}; + + /** + * A collection of {{#crossLink "Fetcher"}}{{/crossLink}} constructors to + * configure offliner. + * @submodule fetchers + */ + self.off.fetchers = {}; + + /** + * A collection of {{#crossLink "UpdateImplementation"}}{{/crossLink}} + * constructors to configure offliner. + * @submodule updaters + */ + self.off.updaters = {}; + +}(typeof self === 'undefined' ? this : self)); + diff --git a/test/testOffline.js b/test/testOffline.js index 98acb602..52bdfd34 100644 --- a/test/testOffline.js +++ b/test/testOffline.js @@ -71,18 +71,6 @@ describe('Offline', function() { }); }); - it('should use importScript in the service worker if the importScripts option is defined', function() { - var dir = temp.mkdirSync('oghliner'); - - return offline({ - rootDir: dir, - importScripts: [ 'a-script.js', ], - }).then(function() { - var content = fs.readFileSync(path.join(dir, 'offline-worker.js'), 'utf8'); - assert.notEqual(content.indexOf('importScripts("a-script.js");'), -1); - }); - }); - it('should use the GitHub slug as the cache ID if it is available', function() { var rootDir = temp.mkdirSync('oghliner'); var dir = path.join(rootDir, 'dist'); @@ -219,13 +207,7 @@ describe('Offline', function() { process.chdir(rootDir); - var checkWarnings = checkWrite([ - 'test_file_1.js is bigger than 2 MiB', - 'test_file_2.js is bigger than 2 MiB', - 'test_file_3.js is bigger than 2 MiB', - ], [], 'Total precache size'); - - var offlinePromise = offline({ + return offline({ rootDir: dir, }).then(function() { var content = fs.readFileSync(path.join(dir, 'offline-worker.js'), 'utf8'); @@ -233,8 +215,6 @@ describe('Offline', function() { assert.notEqual(content.indexOf('test_file_2.js'), -1); assert.notEqual(content.indexOf('test_file_3.js'), -1); }); - - return Promise.all([ checkWarnings, offlinePromise ]); }); it('should not cache excluded files', function() { @@ -250,14 +230,7 @@ describe('Offline', function() { process.chdir(rootDir); - var checkWarnings = checkWrite([ - 'test_file_2.js is bigger than 2 MiB', - 'test_file_3.js is bigger than 2 MiB', - ], [ - 'test_file_1.js is bigger than 2 MiB', - ], 'Total precache size'); - - var offlinePromise = offline({ + return offline({ rootDir: dir, fileGlobs: [ '!(test_file_1.js)', @@ -268,43 +241,5 @@ describe('Offline', function() { assert.notEqual(content.indexOf('test_file_2.js'), -1); assert.notEqual(content.indexOf('test_file_3.js'), -1); }); - - return Promise.all([ checkWarnings, offlinePromise ]); - }); - - it('should not warn about explicitly included files', function() { - var rootDir = temp.mkdirSync('oghliner'); - var dir = path.join(rootDir, 'dist'); - fs.mkdirSync(dir); - - var content = new Buffer(4 * 1024 * 1024); - - fs.writeFileSync(path.join(dir, 'test_file_1.js'), content); - fs.writeFileSync(path.join(dir, 'test_file_2.js'), content); - fs.writeFileSync(path.join(dir, 'test_file_3.js'), content); - - process.chdir(rootDir); - - var checkWarnings = checkWrite([ - 'test_file_2.js is bigger than 2 MiB', - 'test_file_3.js is bigger than 2 MiB', - ], [ - 'test_file_1.js is bigger than 2 MiB', - ], 'Total precache size'); - - var offlinePromise = offline({ - rootDir: dir, - fileGlobs: [ - '*', - 'test_file_1.js', - ], - }).then(function() { - var content = fs.readFileSync(path.join(dir, 'offline-worker.js'), 'utf8'); - assert.notEqual(content.indexOf('test_file_1.js'), -1); - assert.notEqual(content.indexOf('test_file_2.js'), -1); - assert.notEqual(content.indexOf('test_file_3.js'), -1); - }); - - return Promise.all([ checkWarnings, offlinePromise ]); }); });