diff --git a/lib/bootstrap.js b/lib/bootstrap.js index 7fdce116..287011b0 100644 --- a/lib/bootstrap.js +++ b/lib/bootstrap.js @@ -119,7 +119,9 @@ module.exports = function(config) { console.log('\nCreating files…'); 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 f6d98c9f..779dbfc2 100644 --- a/lib/offline.js +++ b/lib/offline.js @@ -21,13 +21,18 @@ var promisify = require('promisify-node'); var fs = require('fs'); var path = require('path'); -var swPrecache = promisify(require('sw-precache')); var chalk = require('chalk'); -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')); +var prettyBytes = require('pretty-bytes'); module.exports = function(config) { return new Promise(function(resolve, reject) { + 'use strict'; + var rootDir = config.rootDir || './'; console.log('Offlining ' + chalk.bold(rootDir) + ' to ' + chalk.bold(path.join(rootDir, 'offline-worker.js')) + '…\n'); @@ -42,9 +47,10 @@ module.exports = function(config) { } var fileGlobs = config.fileGlobs || ['**/*']; + var importScripts = config.importScripts || []; - // Remove the existing service worker, if any, so sw-precache doesn't include - // it in the list of files to cache. + // Remove the existing service worker, if any, so the worker doesn't include + // itself in the list of files to cache. try { fs.unlinkSync(path.join(rootDir, 'offline-worker.js')); } catch (ex) { @@ -55,60 +61,116 @@ module.exports = function(config) { } } - resolve(ghslug('./').catch(function() { + ghslug('./') + .catch(function() { // Use the name from package.json if there's an error while fetching the GitHub slug. try { return JSON.parse(fs.readFileSync('package.json')).name; } catch (ex) { return ''; } - }).then(function(cacheId) { - var staticFileGlobs = fileGlobs.map(function(v) { + }) + .then(function(cacheId) { + checkImports(importScripts, rootDir); + + var absoluteGlobs = fileGlobs.map(function (v) { return path.join(rootDir, v); }); + var files = flatGlobs(absoluteGlobs); + var filesAndHashes = getFilesAndHashes(files, rootDir, absoluteGlobs); - staticFileGlobs.forEach(function(globPattern) { - glob.sync(globPattern.replace(path.sep, '/')).forEach(function(file) { - var stat = fs.statSync(file); + var replacements = { + cacheId: cacheId, + cacheVersion: getHashOfHashes(pluckHashes(filesAndHashes)), + resources: filesAndHashes, + importScripts: importScripts, + }; - if (stat.isFile() && stat.size > 2 * 1024 * 1024 && staticFileGlobs.indexOf(file) === -1) { - console.log(chalk.yellow.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.')); - } - }); + var stream = gulp.src([__dirname + '/../templates/app/offline-worker.js']) + .pipe(template(replacements)) + .pipe(gulp.dest(rootDir)); + + return new Promise(function (resolve, reject) { + stream.on('finish', resolve); + stream.on('error', reject); }); + }) + .then(resolve, reject); - var importScripts = config.importScripts || []; - importScripts.forEach(function(script) { - var stat; - try { - stat = fs.statSync(path.join(rootDir, script)); - } catch (ex) { - console.log(chalk.red.bold(script + ' doesn\'t exist.')); - throw ex; - } - - if (!stat.isFile()) { - console.log(chalk.red.bold(script + ' is not a file.')); - throw new Error(script + ' is not a file.'); - } + function flatGlobs(fileGlobs) { + return Object.keys(fileGlobs.reduce(function (matches, fileGlob) { + fileGlob = fileGlob.replace(path.sep, '/'); + glob.sync(fileGlob, { nodir: true }).forEach(function (filepath) { + matches[filepath] = filepath; + }); + return matches; + }, {})); + } + + function getFilesAndHashes(files, rootDir, sizeWhiteList) { + var totalSize = 0; + var filesAndHashes = files.map(function (filepath) { + var size = countFileSize(filepath, sizeWhiteList); + totalSize += size; + var data = fs.readFileSync(filepath); + var hash = getHash(data); + console.log(chalk.bold.green('✓ ') + 'Caching ' + filepath + ' (' + prettyBytes(size) + ')'); + return { + path: filepath.replace(rootDir, function (match, offset) { + // The root must be the worker's directory + return offset === 0 ? './' : match; + }), + hash: hash, + }; }); - return swPrecache.write(path.join(rootDir, 'offline-worker.js'), { - staticFileGlobs: staticFileGlobs, - stripPrefix: rootDir, - verbose: true, - logger: function(message) { - if (message.indexOf('Caching') === 0) { - message = chalk.bold.green('✓ ') + message.replace('static resource ', ''); - } - - console.log(message); - }, - importScripts: importScripts, - cacheId: cacheId, - ignoreUrlParametersMatching: config.ignoreUrlParametersMatching || [/./], - maximumFileSizeToCacheInBytes: Infinity, + console.log('Total cache size is ' + prettyBytes(totalSize) + + ' for ' + files.length + ' files.\n'); + + return filesAndHashes; + } + + function checkImports(imports, rootDir) { + imports.forEach(function (filepath) { + assertExists(path.join(rootDir, filepath)); }); - })); + } + + function pluckHashes(entries) { + return entries.map(function (entry) { return entry.hash; }); + } + + function assertExists(filepath) { + var stat; + try { + stat = fs.statSync(filepath); + } catch (ex) { + console.log(chalk.red.bold(filepath + ' doesn\'t exist.')); + throw ex; + } + + if (!stat.isFile()) { + console.log(chalk.red.bold(filepath + ' is not a file.')); + throw new Error(filepath + ' is not a file.'); + } + } + + function countFileSize(filepath, whiteList) { + var stat = fs.statSync(filepath); + if (stat.isFile() && stat.size > 2 * 1024 * 1024 && whiteList.indexOf(filepath) === -1) { + console.log(chalk.yellow.bold(filepath + ' 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.')); + } + return stat.size; + } + + function getHashOfHashes(hashArray) { + return getHash(new Buffer(hashArray.join(''), 'hex')); + } + + function getHash(data) { + var sha1 = crypto.createHash('sha1'); + sha1.update(data); + return sha1.digest('hex'); + } }); }; diff --git a/package.json b/package.json index 9512b7c5..8af4b6d0 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "gulp-rename": "^1.2.2", "gulp-template": "^3.0.0", "mozilla-tabzilla": "^0.5.1", + "pretty-bytes": "^2.0.1", "promisified-promptly": "^1.0.0", "promisify-node": "^0.2.1", "read-yaml": "^1.0.0", diff --git a/templates/app/offline-worker.js b/templates/app/offline-worker.js new file mode 100644 index 00000000..81157f9b --- /dev/null +++ b/templates/app/offline-worker.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +<% importScripts.forEach(function (filepath) { +%>importScripts('<%- filepath %>'); +<% }); %> +(function (self) { + 'use strict'; + + // On install, cache resources and skip waiting so the worker won't + // wait for clients to be closed before becoming active. + self.addEventListener('install', function (event) { + event.waitUntil(oghliner.cacheResources().then(function () { + return self.skipWaiting(); + })); + }); + + // On activation, delete old caches and start controlling the clients + // without waiting for them to reload. + self.addEventListener('activate', function (event) { + event.waitUntil(oghliner.clearOtherCaches().then(function () { + return self.clients.claim(); + })); + }); + + // Retrieves the request following oghliner strategy. + self.addEventListener('fetch', function (event) { + if (event.request.method === 'GET') { + event.respondWith(oghliner.get(event.request)); + } else { + event.respondWith(self.fetch(event.request)); + } + }); + + var oghliner = self.oghliner = { + + // This is the unique prefix for all the caches controlled by this worker. + CACHE_PREFIX: 'offline-cache:<%= cacheId %>:' + (self.registration ? self.registration.scope : '') + ':', + + // This is the unique name for the cache controlled by this version of the worker. + get CACHE_NAME() { + return this.CACHE_PREFIX + '<%= cacheVersion %>'; + }, + + // This is a list of resources that will be cached. + RESOURCES: [ + './', // cache always the current root to make the default page available +<% resources.forEach(function (pathAndHash) { +%> '<%- pathAndHash.path %>', // <%- pathAndHash.hash %> +<% }); %> + ], + + // Adds the resources to the cache controlled by this worker. + cacheResources: function () { + var now = Date.now(); + var baseUrl = self.location; + return this.prepareCache() + .then(function (cache) { + return Promise.all(this.RESOURCES.map(function (resource) { + // Bust the request to get a fresh response + var url = new URL(resource, baseUrl); + var bustParameter = (url.search ? '&' : '') + '__bust=' + now; + var bustedUrl = new URL(url.toString()); + bustedUrl.search += bustParameter; + + // But cache the response for the original request + var requestConfig = { credentials: 'same-origin' }; + var originalRequest = new Request(url.toString(), requestConfig); + var bustedRequest = new Request(bustedUrl.toString(), requestConfig); + return fetch(bustedRequest).then(function (response) { + if (response.ok) { + return cache.put(originalRequest, response); + } + console.error('Error fetching ' + url + ', status was ' + response.status); + }); + })); + }.bind(this)); + }, + + // Remove the offline caches not controlled by this worker. + clearOtherCaches: function () { + var deleteIfNotCurrent = function (cacheName) { + if (cacheName.indexOf(this.CACHE_PREFIX) !== 0 || cacheName === this.CACHE_NAME) { + return Promise.resolve(); + } + return self.caches.delete(cacheName); + }.bind(self); + + return self.caches.keys() + .then(function (cacheNames) { + return Promise.all(cacheNames.map(deleteIfNotCurrent)); + }); + + }, + + // Get a response from the current offline cache or from the network. + get: function (request) { + return this.openCache() + .then(function (cache) { + return cache.match(request); + }) + .then(function (response) { + if (response) { + return response; + } + return self.fetch(request); + }); + }, + + // Prepare the cache for installation, deleting it before if it already exists. + prepareCache: function () { + return self.caches.delete(this.CACHE_NAME).then(this.openCache.bind(this)); + }, + + // Open and cache the offline cache promise to improve the performance when + // serving from the offline-cache. + openCache: function () { + if (!this._cache) { + this._cache = self.caches.open(this.CACHE_NAME); + } + return this._cache; + } + + }; +}(self)); diff --git a/test/testOffline.js b/test/testOffline.js index 00b9e91e..07fb52a7 100644 --- a/test/testOffline.js +++ b/test/testOffline.js @@ -71,7 +71,7 @@ describe('Offline', function() { }); }); - it('should use importScript in the service worker if the importScripts option is defined', function() { + it('should use importScripts in the service worker if the importScripts option is defined', function() { var dir = temp.mkdirSync('oghliner'); fs.writeFileSync(path.join(dir, 'a-script.js'), 'data'); @@ -80,7 +80,7 @@ describe('Offline', function() { 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); + assert.notEqual(content.indexOf('importScripts(\'a-script.js\');'), -1); }); }); @@ -251,7 +251,7 @@ describe('Offline', function() { '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'); + ], [], 'Total cache size'); var offlinePromise = offline({ rootDir: dir, @@ -283,7 +283,7 @@ describe('Offline', function() { 'test_file_3.js is bigger than 2 MiB', ], [ 'test_file_1.js is bigger than 2 MiB', - ], 'Total precache size'); + ], 'Total cache size'); var offlinePromise = offline({ rootDir: dir, @@ -318,7 +318,7 @@ describe('Offline', function() { 'test_file_3.js is bigger than 2 MiB', ], [ 'test_file_1.js is bigger than 2 MiB', - ], 'Total precache size'); + ], 'Total cache size'); var offlinePromise = offline({ rootDir: dir,