diff --git a/package-lock.json b/package-lock.json index 1f762b94971f..ba86d1c4a83e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,15 @@ "tslib": "1.7.1" } }, + "@angular/service-worker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-5.0.0.tgz", + "integrity": "sha1-maL103A1BivRBWEgzP9D1jI8OYc=", + "dev": true, + "requires": { + "tslib": "1.7.1" + } + }, "@ngtools/json-schema": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@ngtools/json-schema/-/json-schema-1.1.0.tgz", diff --git a/package.json b/package.json index 764b79298cde..557a4da3ac05 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/@angular/cli/commands/build.ts b/packages/@angular/cli/commands/build.ts index f6f5ecbcf207..09180561f791 100644 --- a/packages/@angular/cli/commands/build.ts +++ b/packages/@angular/cli/commands/build.ts @@ -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.' } ]; diff --git a/packages/@angular/cli/models/build-options.ts b/packages/@angular/cli/models/build-options.ts index ff605bb56dc0..13f7c23324ed 100644 --- a/packages/@angular/cli/models/build-options.ts +++ b/packages/@angular/cli/models/build-options.ts @@ -31,4 +31,5 @@ export interface BuildOptions { namedChunks?: boolean; subresourceIntegrity?: boolean; forceTsCommonjs?: boolean; + serviceWorker?: boolean; } diff --git a/packages/@angular/cli/models/webpack-configs/production.ts b/packages/@angular/cli/models/webpack-configs/production.ts index feab13846220..fddc7f373861 100644 --- a/packages/@angular/cli/models/webpack-configs/production.ts +++ b/packages/@angular/cli/models/webpack-configs/production.ts @@ -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. @@ -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) { diff --git a/packages/@angular/cli/tasks/build.ts b/packages/@angular/cli/tasks/build.ts index b934e2ef0d58..580de6845d3b 100644 --- a/packages/@angular/cli/tasks/build.ts +++ b/packages/@angular/cli/tasks/build.ts @@ -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'); @@ -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(); + } } }; diff --git a/packages/@angular/cli/utilities/service-worker/index.ts b/packages/@angular/cli/utilities/service-worker/index.ts new file mode 100644 index 000000000000..8d8b93ac7f55 --- /dev/null +++ b/packages/@angular/cli/utilities/service-worker/index.ts @@ -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 { + 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 { + const file = this.canonical(_path); + return Promise.resolve(fs.readFileSync(file).toString()); + } + + hash(_path: string): Promise { + 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 { + 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 { + 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); + }); +} diff --git a/scripts/publish/validate_dependencies.js b/scripts/publish/validate_dependencies.js index ef52c1ec5420..28060d94caea 100644 --- a/scripts/publish/validate_dependencies.js +++ b/scripts/publish/validate_dependencies.js @@ -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', diff --git a/tests/e2e/tests/build/legacy-service-worker.ts b/tests/e2e/tests/build/legacy-service-worker.ts new file mode 100644 index 000000000000..071b99fb9a03 --- /dev/null +++ b/tests/e2e/tests/build/legacy-service-worker.ts @@ -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/service-worker@1.0.0-beta.16')) + .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')); +} diff --git a/tests/e2e/tests/build/service-worker.ts b/tests/e2e/tests/build/service-worker.ts index bf56a025d7ad..8a2c7aebfbae 100644 --- a/tests/e2e/tests/build/service-worker.ts +++ b/tests/e2e/tests/build/service-worker.ts @@ -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/service-worker@1.0.0-beta.16') + 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')); }