Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Generate an AppCache/ServiceWorker manifest during the build step. #215

Merged
merged 1 commit into from
Feb 19, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
.idea
jsconfig.json
npm-debug.log
typings/
tmp/
tmp/
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The generated project has dependencies that require **Node 4 or greater**.
* [Running Unit Tests](#running-unit-tests)
* [Running End-to-End Tests](#running-end-to-end-tests)
* [Deploying the App via GitHub Pages](#deploying-the-app-via-github-pages)
* [Support for offline applications](#support-for-offline-applications)
* [Known Issues](#known-issues)

## Installation
Expand Down Expand Up @@ -192,6 +193,23 @@ This will use the `format` npm script that in generated projects uses `clang-for
You can modify the `format` script in `package.json` to run whatever formatting tool
you prefer and `ng format` will still run it.

### Support for offline applications

By default a file `manifest.appcache` will be generated which lists all files included in
a project's output, along with SHA1 hashes of all file contents. This file can be used
directly as an AppCache manifest (for now, `index.html` must be manually edited to set this up).

The manifest is also annotated for use with `angular2-service-worker`. Some manual operations
are currently required to enable this usage. The package must be installed, and `worker.js`
manually copied into the project `src` directory:

```bash
npm install angular2-service-worker
cp node_modules/angular2-service-worker/dist/worker.js src/
```

Then, the commented snippet in `index.html` must be uncommented to register the worker script
as a service worker.

## Known issues

Expand Down
12 changes: 12 additions & 0 deletions addon/ng2/blueprints/ng2/files/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
<base href="/">
{{content-for 'head'}}
<link rel="icon" type="image/x-icon" href="favicon.ico">

<!-- Service worker support is disabled by default.
Install the worker script and uncomment to enable.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add some more detail about how to install the worker script?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (in the README).

Only enable service workers in production.
<script type="text/javascript">
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/worker.js').catch(function(err) {
console.log('Error installing service worker: ', err);
});
}
</script>
-->
</head>
<body>
<<%= htmlComponentName %>-app>Loading...</<%= htmlComponentName %>-app>
Expand Down
5 changes: 4 additions & 1 deletion lib/broccoli/angular2-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ var path = require('path');
var Concat = require('broccoli-concat');
var configReplace = require('./broccoli-config-replace');
var compileWithTypescript = require('./broccoli-typescript').default;
var SwManifest = require('./service-worker-manifest').default;
var fs = require('fs');
var Funnel = require('broccoli-funnel');
var mergeTrees = require('broccoli-merge-trees');
Expand Down Expand Up @@ -82,7 +83,7 @@ Angular2App.prototype.toTree = function() {
allowNone: true
});

return mergeTrees([
var merged = mergeTrees([
assetTree,
tsSrcTree,
tsTree,
Expand All @@ -91,6 +92,8 @@ Angular2App.prototype.toTree = function() {
vendorNpmJs,
thirdPartyJs
], { overwrite: true });

return mergeTrees([merged, new SwManifest(merged)]);
};

/**
Expand Down
98 changes: 98 additions & 0 deletions lib/broccoli/service-worker-manifest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"use strict";

var diffingPlugin = require('./diffing-broccoli-plugin');
var path = require('path');
var fs = require('fs');
var crypto = require('crypto');

var FILE_ENCODING = { encoding: 'utf-8' };
var MANIFEST_FILE = 'manifest.appcache';
var FILE_HASH_PREFIX = '# sw.file.hash:';

class DiffingSWManifest {
constructor(inputPath, cachePath, options) {
this.inputPath = inputPath;
this.cachePath = cachePath;
this.options = options;
this.firstBuild = true;
}

rebuild(diff) {
var manifest = {};
if (this.firstBuild) {
this.firstBuild = false;
} else {
// Read manifest from disk.
manifest = this.readManifestFromCache();
}

// Remove manifest entries for files that are no longer present.
diff.removedPaths.forEach((file) => delete manifest[file]);

// Merge the lists of added and changed paths and update their hashes in the manifest.
[]
.concat(diff.addedPaths)
.concat(diff.changedPaths)
.filter((file) => file !== MANIFEST_FILE)
.forEach((file) => manifest[file] = this.computeFileHash(file));
var manifestPath = path.join(this.cachePath, MANIFEST_FILE);
fs.writeFileSync(manifestPath, this.generateManifest(manifest));
}

// Compute the hash of the given relative file.
computeFileHash(file) {
var contents = fs.readFileSync(path.join(this.inputPath, file));
return crypto
.createHash('sha1')
.update(contents)
.digest('hex');
}

// Compute the hash of the bundle from the names and hashes of all included files.
computeBundleHash(files, manifest) {
var hash = crypto.createHash('sha1');
files.forEach((file) => hash.update(manifest[file] + ':' + file));
return hash.digest('hex');
}

// Generate the string contents of the manifest.
generateManifest(manifest) {
var files = Object.keys(manifest).sort();
var bundleHash = this.computeBundleHash(files, manifest);
var contents = files
.map((file) => `# sw.file.hash: ${this.computeFileHash(file)}\n/${file}`)
.join('\n');
return `CACHE MANIFEST
# sw.bundle: ng-cli
# sw.version: ${bundleHash}
${contents}
`;
}

// Read the manifest from the cache and split it out into a dict of files to hashes.
readManifestFromCache() {
var contents = fs.readFileSync(path.join(this.cachePath, MANIFEST_FILE), FILE_ENCODING);
var manifest = {};
var hash = null;
contents
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== 'CACHE MANIFEST')
.filter((line) => line !== '')
.filter((line) => !line.startsWith('#') || line.startsWith('# sw.'))
.forEach((line) => {
if (line.startsWith(FILE_HASH_PREFIX)) {
// This is a hash prefix for the next file in the list.
hash = line.substring(FILE_HASH_PREFIX.length).trim();
} else if (line.startsWith('/')) {
// This is a file belonging to the application.
manifest[line.substring(1)] = hash;
hash = null;
}
});
return manifest;
}
}

Object.defineProperty(exports, "__esModule", { value: true });
exports.default = diffingPlugin.wrapDiffingPlugin(DiffingSWManifest);
14 changes: 14 additions & 0 deletions tests/e2e/e2e_workflow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ describe('Basic end-to-end Workflow', function () {
});
});

it('Produces a service worker manifest after initial build', function() {
var manifestPath = path.join(process.cwd(), 'dist', 'manifest.appcache');
expect(fs.existsSync(manifestPath)).to.equal(true);
// Read the worker.
var lines = fs
.readFileSync(manifestPath, {encoding: 'utf8'})
.trim()
.split('\n');

// Check that a few critical files have been detected.
expect(lines).to.include('/index.html');
expect(lines).to.include('/thirdparty/vendor.js');
});

it('Perform `ng test` after initial build', function() {
this.timeout(420000);

Expand Down