Skip to content

feat(@angular/cli): support 5.0.0+ builds of @angular/service-worker #8080

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

Merged
merged 1 commit into from
Nov 6, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"@angular/compiler": "^5.0.0",
"@angular/compiler-cli": "^5.0.0",
"@angular/core": "^5.0.0",
"@angular/service-worker": "^5.0.0",
"@types/common-tags": "^1.2.4",
"@types/copy-webpack-plugin": "^4.0.0",
"@types/denodeify": "^1.2.30",
Expand Down
8 changes: 8 additions & 0 deletions packages/@angular/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ export const baseBuildCommandOptions: any = [
default: 'none',
description: 'Available on server platform only. Which external dependencies to bundle into '
+ 'the module. By default, all of node_modules will be kept as requires.'
},
{
name: 'service-worker',
type: Boolean,
default: true,
aliases: ['sw'],
description: 'Generates a service worker config for production builds, if the app has '
+ 'service worker enabled.'
}
];

Expand Down
1 change: 1 addition & 0 deletions packages/@angular/cli/models/build-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export interface BuildOptions {
namedChunks?: boolean;
subresourceIntegrity?: boolean;
forceTsCommonjs?: boolean;
serviceWorker?: boolean;
}
105 changes: 57 additions & 48 deletions packages/@angular/cli/models/webpack-configs/production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import { PurifyPlugin } from '@angular-devkit/build-optimizer';
import { StaticAssetPlugin } from '../../plugins/static-asset';
import { GlobCopyWebpackPlugin } from '../../plugins/glob-copy-webpack-plugin';
import { WebpackConfigOptions } from '../webpack-config';
import { NEW_SW_VERSION } from '../../utilities/service-worker';

const UglifyJSPlugin = require('uglifyjs-webpack-plugin');

const OLD_SW_VERSION = '>= 1.0.0-beta.5 < 2.0.0';

/**
* license-webpack-plugin has a peer dependency on webpack-sources, list it in a comment to
* let the dependency validator know it is used.
Expand Down Expand Up @@ -40,63 +43,69 @@ export function getProdConfig(wco: WebpackConfigOptions) {

// Read the version of @angular/service-worker and throw if it doesn't match the
// expected version.
const allowedVersion = '>= 1.0.0-beta.5 < 2.0.0';
const swPackageJson = fs.readFileSync(`${swModule}/package.json`).toString();
const swVersion = JSON.parse(swPackageJson)['version'];
if (!semver.satisfies(swVersion, allowedVersion)) {

const isLegacySw = semver.satisfies(swVersion, OLD_SW_VERSION);
const isModernSw = semver.satisfies(swVersion, NEW_SW_VERSION);

if (!isLegacySw && !isModernSw) {
throw new Error(stripIndent`
The installed version of @angular/service-worker is ${swVersion}. This version of the CLI
requires the @angular/service-worker version to satisfy ${allowedVersion}. Please upgrade
requires the @angular/service-worker version to satisfy ${OLD_SW_VERSION}. Please upgrade
your service worker version.
`);
}

// Path to the worker script itself.
const workerPath = path.resolve(swModule, 'bundles/worker-basic.min.js');

// Path to a small script to register a service worker.
const registerPath = path.resolve(swModule, 'build/assets/register-basic.min.js');

// Sanity check - both of these files should be present in @angular/service-worker.
if (!fs.existsSync(workerPath) || !fs.existsSync(registerPath)) {
throw new Error(stripIndent`
The installed version of @angular/service-worker isn't supported by the CLI.
Please install a supported version. The following files should exist:
- ${registerPath}
- ${workerPath}
`);
if (isLegacySw) {
// Path to the worker script itself.
const workerPath = path.resolve(swModule, 'bundles/worker-basic.min.js');

// Path to a small script to register a service worker.
const registerPath = path.resolve(swModule, 'build/assets/register-basic.min.js');

// Sanity check - both of these files should be present in @angular/service-worker.
if (!fs.existsSync(workerPath) || !fs.existsSync(registerPath)) {
throw new Error(stripIndent`
The installed version of @angular/service-worker isn't supported by the CLI.
Please install a supported version. The following files should exist:
- ${registerPath}
- ${workerPath}
`);
}

// CopyWebpackPlugin replaces GlobCopyWebpackPlugin, but AngularServiceWorkerPlugin depends
// on specific behaviour from latter.
// AngularServiceWorkerPlugin expects the ngsw-manifest.json to be present in the 'emit' phase
// but with CopyWebpackPlugin it's only there on 'after-emit'.
// So for now we keep it here, but if AngularServiceWorkerPlugin changes we remove it.
extraPlugins.push(new GlobCopyWebpackPlugin({
patterns: [
'ngsw-manifest.json',
{ glob: 'ngsw-manifest.json',
input: path.resolve(projectRoot, appConfig.root), output: '' }
],
globOptions: {
cwd: projectRoot,
optional: true,
},
}));

// Load the Webpack plugin for manifest generation and install it.
const AngularServiceWorkerPlugin = require('@angular/service-worker/build/webpack')
.AngularServiceWorkerPlugin;
extraPlugins.push(new AngularServiceWorkerPlugin({
baseHref: buildOptions.baseHref || '/',
}));

// Copy the worker script into assets.
const workerContents = fs.readFileSync(workerPath).toString();
extraPlugins.push(new StaticAssetPlugin('worker-basic.min.js', workerContents));

// Add a script to index.html that registers the service worker.
// TODO(alxhub): inline this script somehow.
entryPoints['sw-register'] = [registerPath];
}

// CopyWebpackPlugin replaces GlobCopyWebpackPlugin, but AngularServiceWorkerPlugin depends
// on specific behaviour from latter.
// AngularServiceWorkerPlugin expects the ngsw-manifest.json to be present in the 'emit' phase
// but with CopyWebpackPlugin it's only there on 'after-emit'.
// So for now we keep it here, but if AngularServiceWorkerPlugin changes we remove it.
extraPlugins.push(new GlobCopyWebpackPlugin({
patterns: [
'ngsw-manifest.json',
{ glob: 'ngsw-manifest.json', input: path.resolve(projectRoot, appConfig.root), output: '' }
],
globOptions: {
cwd: projectRoot,
optional: true,
},
}));

// Load the Webpack plugin for manifest generation and install it.
const AngularServiceWorkerPlugin = require('@angular/service-worker/build/webpack')
.AngularServiceWorkerPlugin;
extraPlugins.push(new AngularServiceWorkerPlugin({
baseHref: buildOptions.baseHref || '/',
}));

// Copy the worker script into assets.
const workerContents = fs.readFileSync(workerPath).toString();
extraPlugins.push(new StaticAssetPlugin('worker-basic.min.js', workerContents));

// Add a script to index.html that registers the service worker.
// TODO(alxhub): inline this script somehow.
entryPoints['sw-register'] = [registerPath];
}

if (buildOptions.extractLicenses) {
Expand Down
11 changes: 10 additions & 1 deletion packages/@angular/cli/tasks/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NgCliWebpackConfig } from '../models/webpack-config';
import { getWebpackStatsConfig } from '../models/webpack-configs/utils';
import { CliConfig } from '../models/config';
import { statsToString, statsWarningsToString, statsErrorsToString } from '../utilities/stats';
import { augmentAppWithServiceWorker, usesServiceWorker } from '../utilities/service-worker';

const Task = require('../ember-cli/lib/models/task');
const SilentError = require('silent-error');
Expand Down Expand Up @@ -69,7 +70,15 @@ export default Task.extend({
if (stats.hasErrors()) {
reject();
} else {
resolve();
if (!!app.serviceWorker && runTaskOptions.target === 'production' &&
usesServiceWorker(this.project.root) && runTaskOptions.serviceWorker !== false) {
const appRoot = path.resolve(this.project.root, app.root);
augmentAppWithServiceWorker(this.project.root, appRoot, path.resolve(outputPath),
runTaskOptions.baseHref || '/')
.then(() => resolve(), (err: any) => reject(err));
} else {
resolve();
}
}
};

Expand Down
91 changes: 91 additions & 0 deletions packages/@angular/cli/utilities/service-worker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Filesystem } from '@angular/service-worker/config';
import { stripIndent } from 'common-tags';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as semver from 'semver';

export const NEW_SW_VERSION = '>= 5.0.0-rc.0';

class CliFilesystem implements Filesystem {
constructor(private base: string) {}

list(_path: string): Promise<string[]> {
return Promise.resolve(this.syncList(_path));
}

private syncList(_path: string): string[] {
const dir = this.canonical(_path);
const entries = fs.readdirSync(dir).map(
(entry: string) => ({entry, stats: fs.statSync(path.posix.join(dir, entry))}));
const files = entries.filter((entry: any) => !entry.stats.isDirectory())
.map((entry: any) => path.posix.join(_path, entry.entry));

return entries.filter((entry: any) => entry.stats.isDirectory())
.map((entry: any) => path.posix.join(_path, entry.entry))
.reduce((list: string[], subdir: string) => list.concat(this.syncList(subdir)), files);
}

read(_path: string): Promise<string> {
const file = this.canonical(_path);
return Promise.resolve(fs.readFileSync(file).toString());
}

hash(_path: string): Promise<string> {
const sha1 = crypto.createHash('sha1');
const file = this.canonical(_path);
const contents: Buffer = fs.readFileSync(file);
sha1.update(contents);
return Promise.resolve(sha1.digest('hex'));
}

write(_path: string, contents: string): Promise<void> {
const file = this.canonical(_path);
fs.writeFileSync(file, contents);
return Promise.resolve();
}

private canonical(_path: string): string { return path.posix.join(this.base, _path); }
}

export function usesServiceWorker(projectRoot: string): boolean {
const nodeModules = path.resolve(projectRoot, 'node_modules');
const swModule = path.resolve(nodeModules, '@angular/service-worker');
if (!fs.existsSync(swModule)) {
return false;
}

const swPackageJson = fs.readFileSync(`${swModule}/package.json`).toString();
const swVersion = JSON.parse(swPackageJson)['version'];

return semver.satisfies(swVersion, NEW_SW_VERSION);
}

export function augmentAppWithServiceWorker(projectRoot: string, appRoot: string,
outputPath: string, baseHref: string): Promise<void> {
const nodeModules = path.resolve(projectRoot, 'node_modules');
const swModule = path.resolve(nodeModules, '@angular/service-worker');

// Path to the worker script itself.
const workerPath = path.resolve(swModule, 'ngsw-worker.js');
const configPath = path.resolve(appRoot, 'ngsw-config.json');

if (!fs.existsSync(configPath)) {
throw new Error(stripIndent`Expected to find an ngsw-config.json configuration file in the
application root. Either provide one or disable Service Worker
build support in angular-cli.json.`);
}
const config = fs.readFileSync(configPath, 'utf8');

const Generator = require('@angular/service-worker/config').Generator;
const gen = new Generator(new CliFilesystem(outputPath), baseHref);
return gen
.process(JSON.parse(config))
.then((output: Object) => {
const manifest = JSON.stringify(output, null, 2);
fs.writeFileSync(path.resolve(outputPath, 'ngsw.json'), manifest);
// Copy worker script to dist directory.
const workerCode = fs.readFileSync(workerPath);
fs.writeFileSync(path.resolve(outputPath, 'ngsw-worker.js'), workerCode);
});
}
1 change: 1 addition & 0 deletions scripts/publish/validate_dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const REQUIRE_RE = /\brequire\('[^)]+?'\)/g;
const IGNORE_RE = /\s+@ignoreDep\s+\S+/g;
const NODE_PACKAGES = [
'child_process',
'crypto',
'fs',
'https',
'os',
Expand Down
32 changes: 32 additions & 0 deletions tests/e2e/tests/build/legacy-service-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {join} from 'path';
import {getGlobalVariable} from '../../utils/env';
import {expectFileToExist, expectFileToMatch, writeFile, moveFile} from '../../utils/fs';
import {ng, silentNpm} from '../../utils/process';

export default function() {
// Skip this in ejected tests.
if (getGlobalVariable('argv').eject) {
return Promise.resolve();
}

const rootManifest = join(process.cwd(), 'ngsw-manifest.json');

// Can't use the `ng` helper because somewhere the environment gets
// stuck to the first build done
return silentNpm('remove', '@angular/service-worker')
.then(() => silentNpm('install', '@angular/[email protected]'))
.then(() => ng('set', 'apps.0.serviceWorker=true'))
.then(() => ng('build', '--prod'))
.then(() => expectFileToExist(join(process.cwd(), 'dist')))
.then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw-manifest.json')))
.then(() => ng('build', '--prod', '--base-href=/foo/bar'))
.then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw-manifest.json')))
.then(() => expectFileToMatch('dist/ngsw-manifest.json', /"\/foo\/bar\/index.html"/))
.then(() => writeFile(rootManifest, '{"local": true}'))
.then(() => ng('build', '--prod'))
.then(() => expectFileToMatch('dist/ngsw-manifest.json', /\"local\"/))
.then(() => moveFile(rootManifest, join(process.cwd(), 'src/ngsw-manifest.json')))
.then(() => ng('build', '--prod'))
.then(() => expectFileToMatch('dist/ngsw-manifest.json', /\"local\"/))
.then(() => ng('set', 'apps.0.serviceWorker=false'));
}
42 changes: 29 additions & 13 deletions tests/e2e/tests/build/service-worker.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
import {join} from 'path';
import {getGlobalVariable} from '../../utils/env';
import {expectFileToExist, expectFileToMatch, writeFile, moveFile} from '../../utils/fs';
import {expectFileNotToExist, expectFileToExist, expectFileToMatch, writeFile} from '../../utils/fs';
import {ng, silentNpm} from '../../utils/process';

const MANIFEST = {
index: '/index.html',
assetGroups: [{
name: 'cli',
resources: {
files: [
'/**/*.html',
'/**/*.js',
'/**/*.css',
'/assets/**/*',
'!/ngsw-worker.js',
],
urls: [
'http://test.com/foo/bar',
],
},
}],
};

export default function() {
// Skip this in ejected tests.
if (getGlobalVariable('argv').eject) {
return Promise.resolve();
}

const rootManifest = join(process.cwd(), 'ngsw-manifest.json');

// Can't use the `ng` helper because somewhere the environment gets
// stuck to the first build done
return silentNpm('install', '@angular/[email protected]')
return silentNpm('remove', '@angular/service-worker')
.then(() => silentNpm('install', '@angular/service-worker'))
.then(() => ng('set', 'apps.0.serviceWorker=true'))
.then(() => writeFile('src/ngsw-config.json', JSON.stringify(MANIFEST, null, 2)))
.then(() => ng('build', '--prod'))
.then(() => expectFileToExist(join(process.cwd(), 'dist')))
.then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw-manifest.json')))
.then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw.json')))
.then(() => ng('build', '--prod', '--base-href=/foo/bar'))
.then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw-manifest.json')))
.then(() => expectFileToMatch('dist/ngsw-manifest.json', /"\/foo\/bar\/index.html"/))
.then(() => writeFile(rootManifest, '{"local": true}'))
.then(() => ng('build', '--prod'))
.then(() => expectFileToMatch('dist/ngsw-manifest.json', /\"local\"/))
.then(() => moveFile(rootManifest, join(process.cwd(), 'src/ngsw-manifest.json')))
.then(() => ng('build', '--prod'))
.then(() => expectFileToMatch('dist/ngsw-manifest.json', /\"local\"/));
.then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw.json')))
.then(() => expectFileToMatch('dist/ngsw.json', /"\/foo\/bar\/index.html"/))
.then(() => ng('build', '--prod', '--service-worker=false'))
.then(() => expectFileNotToExist('dist/ngsw.json'))
.then(() => ng('set', 'apps.0.serviceWorker=false'));
}