From d9249c9bcf3303f36a09e78c8816cc1cae1df416 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Fri, 12 Oct 2018 16:29:30 -0400 Subject: [PATCH 1/6] feat(@angular-devkit/build-angular): support module web workers. Supports new Worker(...,{type:module}) using github.com/googlechromelabs/worker-plugin --- packages/angular/cli/lib/config/schema.json | 5 +++++ packages/angular_devkit/build_angular/package.json | 3 ++- .../src/angular-cli-files/models/build-options.ts | 1 + .../src/angular-cli-files/models/webpack-configs/common.ts | 6 ++++++ yarn.lock | 7 +++++++ 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/angular/cli/lib/config/schema.json b/packages/angular/cli/lib/config/schema.json index 0e0383fbe6d6..44748d4e9e0f 100644 --- a/packages/angular/cli/lib/config/schema.json +++ b/packages/angular/cli/lib/config/schema.json @@ -836,6 +836,11 @@ "ngswConfigPath": { "type": "string", "description": "Path to ngsw-config.json." + }, + "autoBundleWorkerModules": { + "type": "boolean", + "description": "Automatically bundle new Worker('..', { type:'module' })", + "default": true }, "skipAppShell": { "type": "boolean", diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index 96ebc8101aee..64f787b299eb 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -51,7 +51,8 @@ "webpack-dev-server": "3.2.1", "webpack-merge": "4.2.1", "webpack-sources": "1.3.0", - "webpack-subresource-integrity": "1.1.0-rc.6" + "webpack-subresource-integrity": "1.1.0-rc.6", + "worker-plugin": "3.1.0" }, "devDependencies": { "@angular/animations": "^8.0.0-beta.10", diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts index 993ae9593239..8cc3c4e6635d 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts @@ -54,6 +54,7 @@ export interface BuildOptions { namedChunks?: boolean; subresourceIntegrity?: boolean; serviceWorker?: boolean; + autoBundleWorkerModules?: boolean; skipAppShell?: boolean; statsJson: boolean; forkTypeChecker: boolean; diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts index a9c03738a641..fc8f334ba67d 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts @@ -28,6 +28,7 @@ const ProgressPlugin = require('webpack/lib/ProgressPlugin'); const CircularDependencyPlugin = require('circular-dependency-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const StatsPlugin = require('stats-webpack-plugin'); +const WorkerPlugin = require('worker-plugin'); // tslint:disable-next-line:no-any @@ -133,6 +134,11 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { }); } + if (buildOptions.autoBundleWorkerModules) { + const workerPluginInstance = new WorkerPlugin({ globalObject: false }); + extraPlugins.push(workerPluginInstance); + } + // process asset entries if (buildOptions.assets) { const copyWebpackPluginPatterns = buildOptions.assets.map((asset: AssetPatternClass) => { diff --git a/yarn.lock b/yarn.lock index a188e29a6b5b..0bf1108371b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9890,6 +9890,13 @@ worker-farm@^1.5.2: dependencies: errno "~0.1.7" +worker-plugin@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/worker-plugin/-/worker-plugin-3.1.0.tgz#6311778f3514a87c273510ee3f809cc3fe161e6f" + integrity sha512-iQ9KTTmmN5fhfc2KMR7CcDblvcrg1QQ4pXymqZ3cRZF8L0890YLBcEqlIsGPdxoFwghyN8RA1pCEhCKuTF4Lkw== + dependencies: + loader-utils "^1.1.0" + wrap-ansi@^2.0.0: version "2.1.0" resolved "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" From cf3171fc3ed6646aa5f739a72ff417033faa21b9 Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Mon, 18 Feb 2019 17:10:12 +0000 Subject: [PATCH 2/6] test(@angular-devkit/build-angular): test worker bundling --- .../build_angular/src/browser/schema.json | 5 + .../test/browser/bundle-worker_spec_large.ts | 91 +++++++++++++++++++ .../e2e/tests/misc/bundle-worker.ts | 47 ++++++++++ 3 files changed, 143 insertions(+) create mode 100644 packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts create mode 100644 tests/legacy-cli/e2e/tests/misc/bundle-worker.ts diff --git a/packages/angular_devkit/build_angular/src/browser/schema.json b/packages/angular_devkit/build_angular/src/browser/schema.json index d8aeb05a119e..9746dd496297 100644 --- a/packages/angular_devkit/build_angular/src/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/browser/schema.json @@ -259,6 +259,11 @@ "description": "Generates a service worker config for production builds.", "default": false }, + "autoBundleWorkerModules": { + "type": "boolean", + "description": "Automatically bundle new Worker('..', { type:'module' })", + "default": true + }, "ngswConfigPath": { "type": "string", "description": "Path to ngsw-config.json." diff --git a/packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts new file mode 100644 index 000000000000..ada51ef9df4a --- /dev/null +++ b/packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { runTargetSpec } from '@angular-devkit/architect/testing'; +import { join, virtualFs } from '@angular-devkit/core'; +import { tap } from 'rxjs/operators'; +import { browserTargetSpec, host, outputPath } from '../utils'; + + +describe('Browser Builder bundle worker', () => { + beforeEach(done => host.initialize().toPromise().then(done, done.fail)); + // afterEach(done => host.restore().toPromise().then(done, done.fail)); + + const workerFiles = { + 'src/dep.js': `export const foo = 'bar';`, + 'src/worker.js': ` + import { foo } from './dep'; + + console.log('hello from worker'); + + addEventListener('message', ({ data }) => { + console.log('worker got message:', data); + if (data === 'hello') { + postMessage(foo); + } + }); + `, + 'src/main.ts': ` + const worker = new Worker('./worker', { type: 'module' }); + worker.onmessage = ({ data }) => { + console.log('page got message:', data); + }; + worker.postMessage('hello'); + `, + }; + + describe('js workers', () => { + it('bundles worker', (done) => { + host.writeMultipleFiles(workerFiles); + const overrides = { autoBundleWorkerModules: true }; + runTargetSpec(host, browserTargetSpec, overrides).pipe( + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => { + const workerContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, '0.worker.js')), + ); + // worker bundle contains worker code. + expect(workerContent).toContain('hello from worker'); + expect(workerContent).toContain('bar'); + + const mainContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, 'main.js')), + ); + // main bundle references worker. + expect(mainContent).toContain('0.worker.js'); + }), + ).toPromise().then(done, done.fail); + }); + + it('minimizes and hashes worker', (done) => { + host.writeMultipleFiles(workerFiles); + const overrides = { autoBundleWorkerModules: true, outputHashing: 'all', optimization: true }; + runTargetSpec(host, browserTargetSpec, overrides).pipe( + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => { + const workerBundle = host.fileMatchExists(outputPath, + /0\.[0-9a-f]{20}\.worker\.js/) as string; + expect(workerBundle).toBeTruthy('workerBundle should exist'); + const workerContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, workerBundle)), + ); + expect(workerContent).toContain('hello from worker'); + expect(workerContent).toContain('bar'); + expect(workerContent).toContain('"hello"===e&&postMessage("bar")'); + + const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{20}\.js/) as string; + expect(mainBundle).toBeTruthy('mainBundle should exist'); + const mainContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, mainBundle)), + ); + expect(mainContent).toContain(workerBundle); + }), + ).toPromise().then(done, done.fail); + }); + }); +}); diff --git a/tests/legacy-cli/e2e/tests/misc/bundle-worker.ts b/tests/legacy-cli/e2e/tests/misc/bundle-worker.ts new file mode 100644 index 000000000000..22f600e6aaf8 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/misc/bundle-worker.ts @@ -0,0 +1,47 @@ +import { writeMultipleFiles, appendToFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; + + +export default async function () { + // console.warn has to be used because chrome only captures warnings and errors by default + // https://github.com/angular/protractor/issues/2207 + await writeMultipleFiles({ + './src/dep.js': `export const foo = 'bar';`, + './src/worker.js': ` + import { foo } from './dep'; + + console.warn('hello from worker'); + + addEventListener('message', ({ data }) => { + console.warn(\`worker got message: \${ data }\`); + if (data === 'hello') { + postMessage(foo); + } + }); + `, + './e2e/app.e2e-spec.ts': ` + import { browser } from 'protractor'; + + describe('worker bundle', function() { + it('should log worker messages', () => { + page.navigateTo(); + const logs = await browser.manage().logs().get(logging.Type.BROWSER); + expect(logs.length).toEqual(3); + expect(logs[0].message).toContain('hello from worker'); + expect(logs[1].message).toContain('worker got message: hello'); + expect(logs[2].message).toContain('page got message: bar'); + }); + }); + `, + }); + + await appendToFile('./src/main.ts', ` + const worker = new Worker('./worker', { type: 'module' }); + worker.onmessage = ({ data }) => { + console.warn(\`page got message: \${ data }\`); + }; + worker.postMessage('hello'); + `); + + await ng('e2e'); +} From 224cbe1b4e970af003efa34017d2f20c2ff79ac9 Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Thu, 21 Feb 2019 12:32:29 +0000 Subject: [PATCH 3/6] feat(@ngtools/webpack): reuse compiler host in webpack child compilations --- .../webpack/src/angular_compiler_plugin.ts | 26 +++++++++++++++++-- .../src/virtual_file_system_decorator.ts | 4 +++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/ngtools/webpack/src/angular_compiler_plugin.ts b/packages/ngtools/webpack/src/angular_compiler_plugin.ts index 6dda7de68b0f..914123c00332 100644 --- a/packages/ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/ngtools/webpack/src/angular_compiler_plugin.ts @@ -596,7 +596,7 @@ export class AngularCompilerPlugin { // Registration hook for webpack plugin. // tslint:disable-next-line:no-big-function - apply(compiler: Compiler & { watchMode?: boolean }) { + apply(compiler: Compiler & { watchMode?: boolean, parentCompilation?: compilation.Compilation }) { // The below is require by NGCC processor // since we need to know which fields we need to process compiler.hooks.environment.tap('angular-compiler', () => { @@ -610,8 +610,14 @@ export class AngularCompilerPlugin { // cleanup if not watching compiler.hooks.thisCompilation.tap('angular-compiler', compilation => { compilation.hooks.finishModules.tap('angular-compiler', () => { + let rootCompiler = compiler; + while (rootCompiler.parentCompilation) { + // tslint:disable-next-line:no-any + rootCompiler = compiler.parentCompilation as any; + } + // only present for webpack 4.23.0+, assume true otherwise - const watchMode = compiler.watchMode === undefined ? true : compiler.watchMode; + const watchMode = rootCompiler.watchMode === undefined ? true : rootCompiler.watchMode; if (!watchMode) { this._program = null; this._transformers = []; @@ -859,6 +865,22 @@ export class AngularCompilerPlugin { throw new Error('An @ngtools/webpack plugin already exist for this compilation.'); } + // If there is no compiler host at this point, it means that the environment hook did not run. + // This happens in child compilations that inherit the parent compilation file system. + if (this._compilerHost === undefined) { + const inputFs = compilation.compiler.inputFileSystem as VirtualFileSystemDecorator; + if (!inputFs.getWebpackCompilerHost) { + throw new Error('AngularCompilerPlugin is running in a child compilation, but could' + + 'not find a WebpackCompilerHost in the parent compilation.'); + } + + // Use the existing WebpackCompilerHost to ensure builds and rebuilds work. + this._compilerHost = createCompilerHost({ + options: this._compilerOptions, + tsHost: inputFs.getWebpackCompilerHost(), + }) as CompilerHost & WebpackCompilerHost; + } + // Set a private variable for this plugin instance. // tslint:disable-next-line:no-any (compilation as any)._ngToolsWebpackPluginInstance = this; diff --git a/packages/ngtools/webpack/src/virtual_file_system_decorator.ts b/packages/ngtools/webpack/src/virtual_file_system_decorator.ts index 853d13378072..3f4aaf3b6d99 100644 --- a/packages/ngtools/webpack/src/virtual_file_system_decorator.ts +++ b/packages/ngtools/webpack/src/virtual_file_system_decorator.ts @@ -21,6 +21,10 @@ export class VirtualFileSystemDecorator implements InputFileSystem { private _webpackCompilerHost: WebpackCompilerHost, ) { } + getWebpackCompilerHost() { + return this._webpackCompilerHost; + } + getVirtualFilesPaths() { return this._webpackCompilerHost.getNgFactoryPaths(); } From ced9890e9fe8c2108ddebb9e9b38da944dc4da1a Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Thu, 21 Feb 2019 12:37:58 +0000 Subject: [PATCH 4/6] feat(@angular-devkit/build-angular): support TS web workers --- packages/angular/cli/lib/config/schema.json | 9 +- .../angular-cli-files/models/build-options.ts | 2 +- .../models/webpack-configs/common.ts | 6 - .../models/webpack-configs/index.ts | 1 + .../models/webpack-configs/typescript.ts | 21 ++ .../models/webpack-configs/worker.ts | 30 +++ .../build_angular/src/browser/index.ts | 5 + .../build_angular/src/browser/schema.json | 9 +- .../test/browser/bundle-worker_spec_large.ts | 91 --------- .../test/browser/web-worker_spec_large.ts | 186 ++++++++++++++++++ .../webpack/src/angular_compiler_plugin.ts | 2 + .../e2e/tests/experimental/worker.ts | 87 ++++++++ .../e2e/tests/misc/bundle-worker.ts | 47 ----- 13 files changed, 341 insertions(+), 155 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/worker.ts delete mode 100644 packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts create mode 100644 packages/angular_devkit/build_angular/test/browser/web-worker_spec_large.ts create mode 100644 tests/legacy-cli/e2e/tests/experimental/worker.ts delete mode 100644 tests/legacy-cli/e2e/tests/misc/bundle-worker.ts diff --git a/packages/angular/cli/lib/config/schema.json b/packages/angular/cli/lib/config/schema.json index 44748d4e9e0f..774eb7daeb44 100644 --- a/packages/angular/cli/lib/config/schema.json +++ b/packages/angular/cli/lib/config/schema.json @@ -836,11 +836,6 @@ "ngswConfigPath": { "type": "string", "description": "Path to ngsw-config.json." - }, - "autoBundleWorkerModules": { - "type": "boolean", - "description": "Automatically bundle new Worker('..', { type:'module' })", - "default": true }, "skipAppShell": { "type": "boolean", @@ -887,6 +882,10 @@ "type": "boolean", "default": false, "x-deprecated": true + }, + "webWorkerTsConfig": { + "type": "string", + "description": "TypeScript configuration for Web Worker modules." } }, "additionalProperties": false, diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts index 8cc3c4e6635d..d32668885405 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts @@ -54,7 +54,7 @@ export interface BuildOptions { namedChunks?: boolean; subresourceIntegrity?: boolean; serviceWorker?: boolean; - autoBundleWorkerModules?: boolean; + webWorkerTsConfig?: string; skipAppShell?: boolean; statsJson: boolean; forkTypeChecker: boolean; diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts index fc8f334ba67d..a9c03738a641 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts @@ -28,7 +28,6 @@ const ProgressPlugin = require('webpack/lib/ProgressPlugin'); const CircularDependencyPlugin = require('circular-dependency-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const StatsPlugin = require('stats-webpack-plugin'); -const WorkerPlugin = require('worker-plugin'); // tslint:disable-next-line:no-any @@ -134,11 +133,6 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { }); } - if (buildOptions.autoBundleWorkerModules) { - const workerPluginInstance = new WorkerPlugin({ globalObject: false }); - extraPlugins.push(workerPluginInstance); - } - // process asset entries if (buildOptions.assets) { const copyWebpackPluginPatterns = buildOptions.assets.map((asset: AssetPatternClass) => { diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/index.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/index.ts index 1ec5c1a271bf..11bbaeaa3118 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/index.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/index.ts @@ -13,3 +13,4 @@ export * from './test'; export * from './typescript'; export * from './utils'; export * from './stats'; +export * from './worker'; diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts index d9a0b8a06748..89bfe5ed7571 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts @@ -108,3 +108,24 @@ export function getAotConfig(wco: WebpackConfigOptions, extract = false) { plugins: [_createAotPlugin(wco, { tsConfigPath }, true, extract)] }; } + +export function getTypescriptWorkerPlugin(wco: WebpackConfigOptions, workerTsConfigPath: string) { + const { buildOptions } = wco; + + const pluginOptions: AngularCompilerPluginOptions = { + skipCodeGeneration: true, + tsConfigPath: workerTsConfigPath, + mainPath: undefined, + platform: PLATFORM.Browser, + sourceMap: buildOptions.sourceMap.scripts, + forkTypeChecker: buildOptions.forkTypeChecker, + contextElementDependencyConstructor: require('webpack/lib/dependencies/ContextElementDependency'), + logger: wco.logger, + // Run no transformers. + platformTransformers: [], + // Don't attempt lazy route discovery. + discoverLazyRoutes: false, + }; + + return new AngularCompilerPlugin(pluginOptions); +} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/worker.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/worker.ts new file mode 100644 index 000000000000..1ec0a148ed0d --- /dev/null +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/worker.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { resolve } from 'path'; +import { Configuration } from 'webpack'; +import { WebpackConfigOptions } from '../build-options'; +import { getTypescriptWorkerPlugin } from './typescript'; + +const WorkerPlugin = require('worker-plugin'); + + +export function getWorkerConfig(wco: WebpackConfigOptions): Configuration { + const { buildOptions } = wco; + if (!buildOptions.webWorkerTsConfig) { + throw new Error('The `webWorkerTsConfig` must be a string.'); + } + + const workerTsConfigPath = resolve(wco.root, buildOptions.webWorkerTsConfig); + + return { + plugins: [new WorkerPlugin({ + globalObject: false, + plugins: [getTypescriptWorkerPlugin(wco, workerTsConfigPath)], + })], + }; +} diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index 182e83e1110d..4dd67931ce82 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -42,6 +42,7 @@ import { getNonAotConfig, getStatsConfig, getStylesConfig, + getWorkerConfig, } from '../angular-cli-files/models/webpack-configs'; import { readTsconfig } from '../angular-cli-files/utilities/read-tsconfig'; import { requireProjectModule } from '../angular-cli-files/utilities/require-project-module'; @@ -161,6 +162,10 @@ export function buildWebpackConfig( webpackConfigs.push(typescriptConfigPartial); } + if (wco.buildOptions.webWorkerTsConfig) { + webpackConfigs.push(getWorkerConfig(wco)); + } + const webpackConfig = webpackMerge(webpackConfigs); if (options.profile || process.env['NG_BUILD_PROFILING']) { diff --git a/packages/angular_devkit/build_angular/src/browser/schema.json b/packages/angular_devkit/build_angular/src/browser/schema.json index 9746dd496297..1dbf30b90b01 100644 --- a/packages/angular_devkit/build_angular/src/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/browser/schema.json @@ -259,11 +259,6 @@ "description": "Generates a service worker config for production builds.", "default": false }, - "autoBundleWorkerModules": { - "type": "boolean", - "description": "Automatically bundle new Worker('..', { type:'module' })", - "default": true - }, "ngswConfigPath": { "type": "string", "description": "Path to ngsw-config.json." @@ -325,6 +320,10 @@ "description": "**EXPERIMENTAL** Transform import statements for lazy routes to import factories when using View Engine. Should only be used when switching back and forth between View Engine and Ivy. See https://angular.io/guide/ivy for usage information.", "type": "boolean", "default": false + }, + "webWorkerTsConfig": { + "type": "string", + "description": "TypeScript configuration for Web Worker modules." } }, "additionalProperties": false, diff --git a/packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts deleted file mode 100644 index ada51ef9df4a..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host, outputPath } from '../utils'; - - -describe('Browser Builder bundle worker', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - // afterEach(done => host.restore().toPromise().then(done, done.fail)); - - const workerFiles = { - 'src/dep.js': `export const foo = 'bar';`, - 'src/worker.js': ` - import { foo } from './dep'; - - console.log('hello from worker'); - - addEventListener('message', ({ data }) => { - console.log('worker got message:', data); - if (data === 'hello') { - postMessage(foo); - } - }); - `, - 'src/main.ts': ` - const worker = new Worker('./worker', { type: 'module' }); - worker.onmessage = ({ data }) => { - console.log('page got message:', data); - }; - worker.postMessage('hello'); - `, - }; - - describe('js workers', () => { - it('bundles worker', (done) => { - host.writeMultipleFiles(workerFiles); - const overrides = { autoBundleWorkerModules: true }; - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const workerContent = virtualFs.fileBufferToString( - host.scopedSync().read(join(outputPath, '0.worker.js')), - ); - // worker bundle contains worker code. - expect(workerContent).toContain('hello from worker'); - expect(workerContent).toContain('bar'); - - const mainContent = virtualFs.fileBufferToString( - host.scopedSync().read(join(outputPath, 'main.js')), - ); - // main bundle references worker. - expect(mainContent).toContain('0.worker.js'); - }), - ).toPromise().then(done, done.fail); - }); - - it('minimizes and hashes worker', (done) => { - host.writeMultipleFiles(workerFiles); - const overrides = { autoBundleWorkerModules: true, outputHashing: 'all', optimization: true }; - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const workerBundle = host.fileMatchExists(outputPath, - /0\.[0-9a-f]{20}\.worker\.js/) as string; - expect(workerBundle).toBeTruthy('workerBundle should exist'); - const workerContent = virtualFs.fileBufferToString( - host.scopedSync().read(join(outputPath, workerBundle)), - ); - expect(workerContent).toContain('hello from worker'); - expect(workerContent).toContain('bar'); - expect(workerContent).toContain('"hello"===e&&postMessage("bar")'); - - const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{20}\.js/) as string; - expect(mainBundle).toBeTruthy('mainBundle should exist'); - const mainContent = virtualFs.fileBufferToString( - host.scopedSync().read(join(outputPath, mainBundle)), - ); - expect(mainContent).toContain(workerBundle); - }), - ).toPromise().then(done, done.fail); - }); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/web-worker_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/web-worker_spec_large.ts new file mode 100644 index 000000000000..73526ee403d6 --- /dev/null +++ b/packages/angular_devkit/build_angular/test/browser/web-worker_spec_large.ts @@ -0,0 +1,186 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { DefaultTimeout, TestLogger, runTargetSpec } from '@angular-devkit/architect/testing'; +import { join, virtualFs } from '@angular-devkit/core'; +import { debounceTime, takeWhile, tap } from 'rxjs/operators'; +import { browserTargetSpec, host, outputPath } from '../utils'; + + +describe('Browser Builder Web Worker support', () => { + beforeEach(done => host.initialize().toPromise().then(done, done.fail)); + afterEach(done => host.restore().toPromise().then(done, done.fail)); + + const workerFiles: { [k: string]: string } = { + 'src/app/dep.ts': `export const foo = 'bar';`, + 'src/app/app.worker.ts': ` + import { foo } from './dep'; + console.log('hello from worker'); + addEventListener('message', ({ data }) => { + console.log('worker got message:', data); + if (data === 'hello') { + postMessage(foo); + } + }); + `, + 'src/main.ts': ` + import { enableProdMode } from '@angular/core'; + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + import { environment } from './environments/environment'; + if (environment.production) { enableProdMode(); } + platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.error(err)); + + const worker = new Worker('./app/app.worker', { type: 'module' }); + worker.onmessage = ({ data }) => { + console.log('page got message:', data); + }; + worker.postMessage('hello'); + `, + // Make a new tsconfig for the *.worker.ts files. + // The final place for this tsconfig must take into consideration editor tooling, unit + // tests, and integration with other build targets. + './src/tsconfig.worker.json': ` + { + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/worker", + "lib": [ + "es2018", + "webworker" + ], + "types": [] + }, + "include": [ + "**/*.worker.ts", + ] + }`, + // Alter the app tsconfig to not include *.worker.ts files. + './src/tsconfig.app.json': ` + { + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/worker", + "types": [] + }, + "exclude": [ + "test.ts", + "**/*.spec.ts", + "**/*.worker.ts", + "app/dep.ts", + ] + }`, + }; + + it('bundles TS worker', (done) => { + const logger = new TestLogger('worker-warnings'); + host.writeMultipleFiles(workerFiles); + const overrides = { webWorkerTsConfig: 'src/tsconfig.worker.json' }; + runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout, logger).pipe( + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => { + const workerContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, '0.worker.js')), + ); + // worker bundle contains worker code. + expect(workerContent).toContain('hello from worker'); + expect(workerContent).toContain('bar'); + + const mainContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, 'main.js')), + ); + // main bundle references worker. + expect(mainContent).toContain('0.worker.js'); + }), + // Doesn't show any warnings. + tap(() => expect(logger.includes('WARNING')).toBe(false, 'Should show no warnings.')), + ).toPromise().then(done, done.fail); + }); + + it('minimizes and hashes worker', (done) => { + host.writeMultipleFiles(workerFiles); + const overrides = { + webWorkerTsConfig: 'src/tsconfig.worker.json', + outputHashing: 'all', + optimization: true, + }; + runTargetSpec(host, browserTargetSpec, overrides).pipe( + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => { + const workerBundle = host.fileMatchExists(outputPath, + /0\.[0-9a-f]{20}\.worker\.js/) as string; + expect(workerBundle).toBeTruthy('workerBundle should exist'); + const workerContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, workerBundle)), + ); + expect(workerContent).toContain('hello from worker'); + expect(workerContent).toContain('bar'); + expect(workerContent).toContain('"hello"===t&&postMessage'); + + const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{20}\.js/) as string; + expect(mainBundle).toBeTruthy('mainBundle should exist'); + const mainContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, mainBundle)), + ); + expect(mainContent).toContain(workerBundle); + }), + ).toPromise().then(done, done.fail); + }); + + it('rebuilds TS worker', (done) => { + host.writeMultipleFiles(workerFiles); + const overrides = { + webWorkerTsConfig: 'src/tsconfig.worker.json', + watch: true, + }; + + let buildCount = 0; + let phase = 1; + const workerPath = join(outputPath, '0.worker.js'); + let workerContent = ''; + + runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe( + // Wait for files to be written to disk. + debounceTime(1000), + tap((buildEvent) => expect(buildEvent.success).toBe(true, 'build should succeed')), + tap(() => { + buildCount++; + switch (phase) { + case 1: + // Original worker content should be there. + workerContent = virtualFs.fileBufferToString(host.scopedSync().read(workerPath)); + expect(workerContent).toContain('bar'); + // Change content of worker dependency. + host.writeMultipleFiles({ 'src/app/dep.ts': `export const foo = 'baz';` }); + phase = 2; + break; + + case 2: + workerContent = virtualFs.fileBufferToString(host.scopedSync().read(workerPath)); + // TODO(filipesilva): Should this change? Check with Jason Miller. + // The worker changes name with each rebuild. But sometimes it also changes from 0 to + // 1 and then back to 0. It's hard to know where the updated content is, but it should + // be in one of these two. + const anotherWorkerPath = join(outputPath, '1.worker.js'); + const anotherWorkerContent = virtualFs.fileBufferToString( + host.scopedSync().read(anotherWorkerPath)); + // Worker content should have changed. + expect( + workerContent.includes('baz') || anotherWorkerContent.includes('baz'), + ).toBeTruthy('Worker bundle did not contain updated content.'); + phase = 3; + break; + } + }), + takeWhile(() => phase < 3), + ).toPromise().then( + () => done(), + () => done.fail(`stuck at phase ${phase} [builds: ${buildCount}]`), + ); + }); +}); diff --git a/packages/ngtools/webpack/src/angular_compiler_plugin.ts b/packages/ngtools/webpack/src/angular_compiler_plugin.ts index 914123c00332..fec4676bbeb6 100644 --- a/packages/ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/ngtools/webpack/src/angular_compiler_plugin.ts @@ -867,6 +867,8 @@ export class AngularCompilerPlugin { // If there is no compiler host at this point, it means that the environment hook did not run. // This happens in child compilations that inherit the parent compilation file system. + // Node: child compilations also do not run most webpack compiler hooks, including almost all + // we use here. The child compiler will always run as if it was the first build. if (this._compilerHost === undefined) { const inputFs = compilation.compiler.inputFileSystem as VirtualFileSystemDecorator; if (!inputFs.getWebpackCompilerHost) { diff --git a/tests/legacy-cli/e2e/tests/experimental/worker.ts b/tests/legacy-cli/e2e/tests/experimental/worker.ts new file mode 100644 index 000000000000..65dfd107f439 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/experimental/worker.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { appendToFile, createDir, replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; + + +export default async function () { + // console.warn has to be used because chrome only captures warnings and errors by default + // https://github.com/angular/protractor/issues/2207 + await createDir('./src/worker'); + await writeMultipleFiles({ + './src/app/dep.ts': `export const foo = 'bar';`, + './src/app/app.worker.ts': ` + import 'typescript/lib/lib.webworker'; + import { foo } from './dep'; + console.warn('hello from worker'); + addEventListener('message', ({ data }) => { + console.warn(\`worker got message: \${ data }\`); + if (data === 'hello') { + postMessage(foo); + } + }); + `, + './src/tsconfig.worker.json': ` + { + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/worker", + "lib": [ + "es2018", + "webworker" + ], + "types": [] + }, + "include": [ + "**/*.worker.ts", + ] + }`, + './src/tsconfig.app.json': ` + { + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/worker", + "types": [] + }, + "exclude": [ + "test.ts", + "**/*.spec.ts", + "**/*.worker.ts", + ] + }`, + './e2e/app.e2e-spec.ts': ` + import { browser } from 'protractor'; + + describe('worker bundle', function() { + it('should log worker messages', () => { + page.navigateTo(); + const logs = await browser.manage().logs().get(logging.Type.BROWSER); + expect(logs.length).toEqual(3); + expect(logs[0].message).toContain('hello from worker'); + expect(logs[1].message).toContain('worker got message: hello'); + expect(logs[2].message).toContain('page got message: bar'); + }); + }); + `, + }); + + await appendToFile('./src/main.ts', ` + const worker = new Worker('./app/app.worker.ts', { type: 'module' }); + worker.onmessage = ({ data }) => { + console.warn(\`page got message: \${ data }\`); + }; + worker.postMessage('hello'); + `); + + await replaceInFile('./angular.json',`"tsConfig": "src/tsconfig.app.json",`, + `"tsConfig": "src/tsconfig.app.json", + "experimentalWebWorkerTsConfig": "src/tsconfig.worker.json",`, + ); + + await ng('e2e'); +} diff --git a/tests/legacy-cli/e2e/tests/misc/bundle-worker.ts b/tests/legacy-cli/e2e/tests/misc/bundle-worker.ts deleted file mode 100644 index 22f600e6aaf8..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/bundle-worker.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { writeMultipleFiles, appendToFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function () { - // console.warn has to be used because chrome only captures warnings and errors by default - // https://github.com/angular/protractor/issues/2207 - await writeMultipleFiles({ - './src/dep.js': `export const foo = 'bar';`, - './src/worker.js': ` - import { foo } from './dep'; - - console.warn('hello from worker'); - - addEventListener('message', ({ data }) => { - console.warn(\`worker got message: \${ data }\`); - if (data === 'hello') { - postMessage(foo); - } - }); - `, - './e2e/app.e2e-spec.ts': ` - import { browser } from 'protractor'; - - describe('worker bundle', function() { - it('should log worker messages', () => { - page.navigateTo(); - const logs = await browser.manage().logs().get(logging.Type.BROWSER); - expect(logs.length).toEqual(3); - expect(logs[0].message).toContain('hello from worker'); - expect(logs[1].message).toContain('worker got message: hello'); - expect(logs[2].message).toContain('page got message: bar'); - }); - }); - `, - }); - - await appendToFile('./src/main.ts', ` - const worker = new Worker('./worker', { type: 'module' }); - worker.onmessage = ({ data }) => { - console.warn(\`page got message: \${ data }\`); - }; - worker.postMessage('hello'); - `); - - await ng('e2e'); -} From f294851b75df9fe991bb04fb60f9d8bcac0afefd Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Thu, 28 Feb 2019 17:07:22 +0000 Subject: [PATCH 5/6] feat(@schematics/angular): add web worker schematics --- packages/schematics/angular/collection.json | 6 + .../angular/utility/workspace-models.ts | 1 + .../project-tsconfig/tsconfig.json.template | 10 + .../tsconfig.worker.json.template | 14 ++ .../__name@dasherize__.worker.ts.template | 4 + .../schematics/angular/web-worker/index.ts | 210 ++++++++++++++++++ .../angular/web-worker/index_spec.ts | 85 +++++++ .../schematics/angular/web-worker/schema.json | 45 ++++ tests/legacy-cli/e2e/tests/build/worker.ts | 45 ++++ .../e2e/tests/experimental/worker.ts | 87 -------- 10 files changed, 420 insertions(+), 87 deletions(-) create mode 100644 packages/schematics/angular/web-worker/files/project-tsconfig/tsconfig.json.template create mode 100644 packages/schematics/angular/web-worker/files/worker-tsconfig/tsconfig.worker.json.template create mode 100644 packages/schematics/angular/web-worker/files/worker/__name@dasherize__.worker.ts.template create mode 100644 packages/schematics/angular/web-worker/index.ts create mode 100644 packages/schematics/angular/web-worker/index_spec.ts create mode 100644 packages/schematics/angular/web-worker/schema.json create mode 100644 tests/legacy-cli/e2e/tests/build/worker.ts delete mode 100644 tests/legacy-cli/e2e/tests/experimental/worker.ts diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 682667e73f02..9145af5e4840 100644 --- a/packages/schematics/angular/collection.json +++ b/packages/schematics/angular/collection.json @@ -100,6 +100,12 @@ "factory": "./library", "schema": "./library/schema.json", "description": "Generate a library project for Angular." + }, + "webWorker": { + "aliases": ["web-worker", "worker"], + "factory": "./web-worker", + "schema": "./web-worker/schema.json", + "description": "Create a Web Worker ." } } } diff --git a/packages/schematics/angular/utility/workspace-models.ts b/packages/schematics/angular/utility/workspace-models.ts index 3d226000317b..9c8eebdcf78f 100644 --- a/packages/schematics/angular/utility/workspace-models.ts +++ b/packages/schematics/angular/utility/workspace-models.ts @@ -61,6 +61,7 @@ export interface BrowserBuilderOptions extends BrowserBuilderBaseOptions { maximumError?: string; }[]; es5BrowserSupport?: boolean; + webWorkerTsConfig?: string; } export interface ServeBuilderOptions { diff --git a/packages/schematics/angular/web-worker/files/project-tsconfig/tsconfig.json.template b/packages/schematics/angular/web-worker/files/project-tsconfig/tsconfig.json.template new file mode 100644 index 000000000000..b170f6d1d0e5 --- /dev/null +++ b/packages/schematics/angular/web-worker/files/project-tsconfig/tsconfig.json.template @@ -0,0 +1,10 @@ +{ + "extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json", + "compilerOptions": { + "lib": [ + "es2018", + "dom", + "webworker" + ], + } +} diff --git a/packages/schematics/angular/web-worker/files/worker-tsconfig/tsconfig.worker.json.template b/packages/schematics/angular/web-worker/files/worker-tsconfig/tsconfig.worker.json.template new file mode 100644 index 000000000000..33b92e255762 --- /dev/null +++ b/packages/schematics/angular/web-worker/files/worker-tsconfig/tsconfig.worker.json.template @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/worker", + "lib": [ + "es2018", + "webworker" + ], + "types": [] + }, + "include": [ + "**/*.worker.ts" + ] +} diff --git a/packages/schematics/angular/web-worker/files/worker/__name@dasherize__.worker.ts.template b/packages/schematics/angular/web-worker/files/worker/__name@dasherize__.worker.ts.template new file mode 100644 index 000000000000..b88e1b10b6ce --- /dev/null +++ b/packages/schematics/angular/web-worker/files/worker/__name@dasherize__.worker.ts.template @@ -0,0 +1,4 @@ +addEventListener('message', ({ data }) => { + const response = `worker response to ${data}`; + postMessage(response); +}); \ No newline at end of file diff --git a/packages/schematics/angular/web-worker/index.ts b/packages/schematics/angular/web-worker/index.ts new file mode 100644 index 000000000000..352c8d889de5 --- /dev/null +++ b/packages/schematics/angular/web-worker/index.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { JsonParseMode, parseJsonAst, strings, tags } from '@angular-devkit/core'; +import { + Rule, SchematicContext, SchematicsException, Tree, + apply, applyTemplates, chain, mergeWith, move, noop, url, +} from '@angular-devkit/schematics'; +import { getWorkspace, updateWorkspace } from '../utility/config'; +import { appendValueInAstArray, findPropertyInAstObject } from '../utility/json-utils'; +import { parseName } from '../utility/parse-name'; +import { buildDefaultPath, getProject } from '../utility/project'; +import { getProjectTargets } from '../utility/project-targets'; +import { + BrowserBuilderOptions, + BrowserBuilderTarget, + WorkspaceSchema, +} from '../utility/workspace-models'; +import { Schema as WebWorkerOptions } from './schema'; + +function getProjectConfiguration( + workspace: WorkspaceSchema, + options: WebWorkerOptions, +): BrowserBuilderOptions { + if (!options.target) { + throw new SchematicsException('Option (target) is required.'); + } + + const projectTargets = getProjectTargets(workspace, options.project); + if (!projectTargets[options.target]) { + throw new Error(`Target is not defined for this project.`); + } + + const target = projectTargets[options.target] as BrowserBuilderTarget; + + return target.options; +} + +function addConfig(options: WebWorkerOptions, root: string): Rule { + return (host: Tree, context: SchematicContext) => { + context.logger.debug('updating project configuration.'); + const workspace = getWorkspace(host); + const config = getProjectConfiguration(workspace, options); + + if (config.webWorkerTsConfig) { + // Don't do anything if the configuration is already there. + return; + } + + const tsConfigRules = []; + + // Add tsconfig.worker.json. + const relativePathToWorkspaceRoot = root.split('/').map(x => '..').join('/'); + tsConfigRules.push(mergeWith(apply(url('./files/worker-tsconfig'), [ + applyTemplates({ ...options, relativePathToWorkspaceRoot }), + move(root), + ]))); + + // Add build-angular config flag. + config.webWorkerTsConfig = `${root.endsWith('/') ? root : root + '/'}tsconfig.worker.json`; + + // Add project tsconfig.json. + // The project level tsconfig.json with webworker lib is for editor support since + // the dom and webworker libs are mutually exclusive. + // Note: this schematic does not change other tsconfigs to use the project-level tsconfig. + const projectTsConfigPath = `${root}/tsconfig.json`; + if (host.exists(projectTsConfigPath)) { + // If the file already exists, alter it. + const buffer = host.read(projectTsConfigPath); + if (buffer) { + const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose); + if (tsCfgAst.kind != 'object') { + throw new SchematicsException('Invalid tsconfig. Was expecting an object'); + } + const optsAstNode = findPropertyInAstObject(tsCfgAst, 'compilerOptions'); + if (optsAstNode && optsAstNode.kind != 'object') { + throw new SchematicsException( + 'Invalid tsconfig "compilerOptions" property; Was expecting an object.'); + } + const libAstNode = findPropertyInAstObject(tsCfgAst, 'lib'); + if (libAstNode && libAstNode.kind != 'array') { + throw new SchematicsException('Invalid tsconfig "lib" property; expected an array.'); + } + const newLibProp = 'webworker'; + if (libAstNode && !libAstNode.value.includes(newLibProp)) { + const recorder = host.beginUpdate(projectTsConfigPath); + appendValueInAstArray(recorder, libAstNode, newLibProp); + host.commitUpdate(recorder); + } + } + } else { + // Otherwise create it. + tsConfigRules.push(mergeWith(apply(url('./files/project-tsconfig'), [ + applyTemplates({ ...options, relativePathToWorkspaceRoot }), + move(root), + ]))); + } + + // Add worker glob exclusion to tsconfig.app.json. + const workerGlob = '**/*.worker.ts'; + const tsConfigPath = config.tsConfig; + const buffer = host.read(tsConfigPath); + if (buffer) { + const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose); + if (tsCfgAst.kind != 'object') { + throw new SchematicsException('Invalid tsconfig. Was expecting an object'); + } + const filesAstNode = findPropertyInAstObject(tsCfgAst, 'exclude'); + if (filesAstNode && filesAstNode.kind != 'array') { + throw new SchematicsException('Invalid tsconfig "exclude" property; expected an array.'); + } + + if (filesAstNode && filesAstNode.value.indexOf(workerGlob) == -1) { + const recorder = host.beginUpdate(tsConfigPath); + appendValueInAstArray(recorder, filesAstNode, workerGlob); + host.commitUpdate(recorder); + } + } + + return chain([ + // Add tsconfigs. + ...tsConfigRules, + // Add workspace configuration. + updateWorkspace(workspace), + ]); + }; +} + +function addSnippet(options: WebWorkerOptions): Rule { + return (host: Tree, context: SchematicContext) => { + context.logger.debug('Updating appmodule'); + + if (options.path === undefined) { + return; + } + + const siblingModules = host.getDir(options.path).subfiles + // Find all files that start with the same name, are ts files, and aren't spec files. + .filter(f => f.startsWith(options.name) && f.endsWith('.ts') && !f.endsWith('spec.ts')) + // Sort alphabetically for consistency. + .sort(); + + if (siblingModules.length === 0) { + // No module to add in. + return; + } + + const siblingModulePath = `${options.path}/${siblingModules[0]}`; + const workerCreationSnippet = tags.stripIndent` + if (typeof Worker !== 'undefined') { + // Create a new + const worker = new Worker('./${options.name}.worker', { type: 'module' }); + worker.onmessage = ({ data }) => { + console.log('page got message: $\{data\}'); + }; + worker.postMessage('hello'); + } else { + // Web Workers are not supported in this environment. + // You should add a fallback so that your program still executes correctly. + } + `; + + // Append the worker creation snippet. + const originalContent = host.read(siblingModulePath); + host.overwrite(siblingModulePath, originalContent + '\n' + workerCreationSnippet); + + return host; + }; +} + +export default function (options: WebWorkerOptions): Rule { + return (host: Tree, context: SchematicContext) => { + const project = getProject(host, options.project); + if (!options.project) { + throw new SchematicsException('Option "project" is required.'); + } + if (!project) { + throw new SchematicsException(`Invalid project name (${options.project})`); + } + if (project.projectType !== 'application') { + throw new SchematicsException(`Web Worker requires a project type of "application".`); + } + + if (options.path === undefined) { + options.path = buildDefaultPath(project); + } + const parsedPath = parseName(options.path, options.name); + options.name = parsedPath.name; + options.path = parsedPath.path; + const root = project.root || project.sourceRoot || ''; + + const templateSource = apply(url('./files/worker'), [ + applyTemplates({ ...options, ...strings }), + move(parsedPath.path), + ]); + + return chain([ + // Add project configuration. + addConfig(options, root), + // Create the worker in a sibling module. + options.snippet ? addSnippet(options) : noop(), + // Add the worker. + mergeWith(templateSource), + ]); + }; +} diff --git a/packages/schematics/angular/web-worker/index_spec.ts b/packages/schematics/angular/web-worker/index_spec.ts new file mode 100644 index 000000000000..940d890d2f97 --- /dev/null +++ b/packages/schematics/angular/web-worker/index_spec.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { Schema as ApplicationOptions } from '../application/schema'; +import { Schema as WorkspaceOptions } from '../workspace/schema'; +import { Schema as WebWorkerOptions } from './schema'; + + +describe('Service Worker Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@schematics/angular', + require.resolve('../collection.json'), + ); + const defaultOptions: WebWorkerOptions = { + project: 'bar', + target: 'build', + name: 'app', + // path: 'src/app', + snippet: true, + }; + + let appTree: UnitTestTree; + + const workspaceOptions: WorkspaceOptions = { + name: 'workspace', + newProjectRoot: 'projects', + version: '8.0.0', + }; + + const appOptions: ApplicationOptions = { + name: 'bar', + inlineStyle: false, + inlineTemplate: false, + routing: false, + skipTests: false, + skipPackageJson: false, + }; + + beforeEach(() => { + appTree = schematicRunner.runSchematic('workspace', workspaceOptions); + appTree = schematicRunner.runSchematic('application', appOptions, appTree); + }); + + it('should put the worker file in the project root', () => { + const tree = schematicRunner.runSchematic('web-worker', defaultOptions, appTree); + const path = '/projects/bar/src/app/app.worker.ts'; + expect(tree.exists(path)).toEqual(true); + }); + + it('should put a new tsconfig.json file in the project root', () => { + const tree = schematicRunner.runSchematic('web-worker', defaultOptions, appTree); + const path = '/projects/bar/tsconfig.json'; + expect(tree.exists(path)).toEqual(true); + }); + + it('should put the tsconfig.worker.json file in the project root', () => { + const tree = schematicRunner.runSchematic('web-worker', defaultOptions, appTree); + const path = '/projects/bar/tsconfig.worker.json'; + expect(tree.exists(path)).toEqual(true); + }); + + it('should add the webWorkerTsConfig option to workspace', () => { + const tree = schematicRunner.runSchematic('web-worker', defaultOptions, appTree); + const { projects } = JSON.parse(tree.readContent('/angular.json')); + expect(projects.bar.architect.build.options.webWorkerTsConfig) + .toBe('projects/bar/tsconfig.worker.json'); + }); + + it('should add exclusions to tsconfig.app.json', () => { + const tree = schematicRunner.runSchematic('web-worker', defaultOptions, appTree); + const { exclude } = JSON.parse(tree.readContent('/projects/bar/tsconfig.app.json')); + expect(exclude).toContain('**/*.worker.ts'); + }); + + it('should add snippet to sibling file', () => { + const tree = schematicRunner.runSchematic('web-worker', defaultOptions, appTree); + const appComponent = tree.readContent('/projects/bar/src/app/app.component.ts'); + expect(appComponent).toContain(`new Worker('./${defaultOptions.name}.worker`); + }); +}); diff --git a/packages/schematics/angular/web-worker/schema.json b/packages/schematics/angular/web-worker/schema.json new file mode 100644 index 000000000000..43ac37caa5f7 --- /dev/null +++ b/packages/schematics/angular/web-worker/schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsAngularWebWorker", + "title": "Angular Web Worker Options Schema", + "type": "object", + "description": "Pass this schematic to the \"run\" command to create a Web Worker", + "properties": { + "path": { + "type": "string", + "format": "path", + "description": "The path at which to create the worker file, relative to the current workspace.", + "visible": false + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + }, + "target": { + "type": "string", + "description": "The target to apply service worker to.", + "default": "build" + }, + "name": { + "type": "string", + "description": "The name of the worker.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the worker?" + }, + "snippet": { + "type": "boolean", + "default": true, + "description": "Add a worker creation snippet in a sibling file of the same name." + } + }, + "required": [ + "name", + "project" + ] +} diff --git a/tests/legacy-cli/e2e/tests/build/worker.ts b/tests/legacy-cli/e2e/tests/build/worker.ts new file mode 100644 index 000000000000..7c9398687af6 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/worker.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { join } from 'path'; +import { expectFileToExist, expectFileToMatch, replaceInFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; + + +export default async function () { + + const workerPath = join('src', 'app', 'app.worker.ts'); + const snippetPath = join('src', 'app', 'app.component.ts'); + const projectTsConfig = join('src', 'tsconfig.json'); + const workerTsConfig = join('src', 'tsconfig.worker.json'); + + await ng('generate', 'web-worker', 'app'); + await expectFileToExist(workerPath); + await expectFileToExist(projectTsConfig); + await expectFileToExist(workerTsConfig); + await expectFileToMatch(snippetPath, `new Worker('./app.worker', { type: 'module' })`); + + // console.warn has to be used because chrome only captures warnings and errors by default + // https://github.com/angular/protractor/issues/2207 + await replaceInFile('src/app/app.component.ts', 'console.log', 'console.warn'); + + await writeFile('e2e/app.e2e-spec.ts', ` + import { AppPage } from './app.po'; + import { browser, logging } from 'protractor'; + describe('worker bundle', () => { + it('should log worker messages', async () => { + const page = new AppPage();; + page.navigateTo(); + const logs = await browser.manage().logs().get(logging.Type.BROWSER); + expect(logs.length).toEqual(1); + expect(logs[0].message).toContain('page got message: worker response to hello'); + }); + }); + `); + + await ng('e2e'); +} diff --git a/tests/legacy-cli/e2e/tests/experimental/worker.ts b/tests/legacy-cli/e2e/tests/experimental/worker.ts deleted file mode 100644 index 65dfd107f439..000000000000 --- a/tests/legacy-cli/e2e/tests/experimental/worker.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { appendToFile, createDir, replaceInFile, writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function () { - // console.warn has to be used because chrome only captures warnings and errors by default - // https://github.com/angular/protractor/issues/2207 - await createDir('./src/worker'); - await writeMultipleFiles({ - './src/app/dep.ts': `export const foo = 'bar';`, - './src/app/app.worker.ts': ` - import 'typescript/lib/lib.webworker'; - import { foo } from './dep'; - console.warn('hello from worker'); - addEventListener('message', ({ data }) => { - console.warn(\`worker got message: \${ data }\`); - if (data === 'hello') { - postMessage(foo); - } - }); - `, - './src/tsconfig.worker.json': ` - { - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/worker", - "lib": [ - "es2018", - "webworker" - ], - "types": [] - }, - "include": [ - "**/*.worker.ts", - ] - }`, - './src/tsconfig.app.json': ` - { - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/worker", - "types": [] - }, - "exclude": [ - "test.ts", - "**/*.spec.ts", - "**/*.worker.ts", - ] - }`, - './e2e/app.e2e-spec.ts': ` - import { browser } from 'protractor'; - - describe('worker bundle', function() { - it('should log worker messages', () => { - page.navigateTo(); - const logs = await browser.manage().logs().get(logging.Type.BROWSER); - expect(logs.length).toEqual(3); - expect(logs[0].message).toContain('hello from worker'); - expect(logs[1].message).toContain('worker got message: hello'); - expect(logs[2].message).toContain('page got message: bar'); - }); - }); - `, - }); - - await appendToFile('./src/main.ts', ` - const worker = new Worker('./app/app.worker.ts', { type: 'module' }); - worker.onmessage = ({ data }) => { - console.warn(\`page got message: \${ data }\`); - }; - worker.postMessage('hello'); - `); - - await replaceInFile('./angular.json',`"tsConfig": "src/tsconfig.app.json",`, - `"tsConfig": "src/tsconfig.app.json", - "experimentalWebWorkerTsConfig": "src/tsconfig.worker.json",`, - ); - - await ng('e2e'); -} From 4c92bad88fc699c0fa132ddd1187175d5219307d Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Thu, 28 Mar 2019 16:21:08 +0000 Subject: [PATCH 6/6] refactor(@angular-devkit/build-angular): support web worker in new architect --- .../test/browser/web-worker_spec_large.ts | 102 +++++++++--------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/packages/angular_devkit/build_angular/test/browser/web-worker_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/web-worker_spec_large.ts index 73526ee403d6..7ea911f2f337 100644 --- a/packages/angular_devkit/build_angular/test/browser/web-worker_spec_large.ts +++ b/packages/angular_devkit/build_angular/test/browser/web-worker_spec_large.ts @@ -6,15 +6,22 @@ * found in the LICENSE file at https://angular.io/license */ -import { DefaultTimeout, TestLogger, runTargetSpec } from '@angular-devkit/architect/testing'; +import { Architect } from '@angular-devkit/architect/src/index2'; +import { TestLogger } from '@angular-devkit/architect/testing'; import { join, virtualFs } from '@angular-devkit/core'; import { debounceTime, takeWhile, tap } from 'rxjs/operators'; -import { browserTargetSpec, host, outputPath } from '../utils'; +import { browserBuild, createArchitect, host, outputPath } from '../utils'; describe('Browser Builder Web Worker support', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); const workerFiles: { [k: string]: string } = { 'src/app/dep.ts': `export const foo = 'bar';`, @@ -77,62 +84,52 @@ describe('Browser Builder Web Worker support', () => { }`, }; - it('bundles TS worker', (done) => { - const logger = new TestLogger('worker-warnings'); + it('bundles TS worker', async () => { host.writeMultipleFiles(workerFiles); + const logger = new TestLogger('worker-warnings'); const overrides = { webWorkerTsConfig: 'src/tsconfig.worker.json' }; - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout, logger).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const workerContent = virtualFs.fileBufferToString( - host.scopedSync().read(join(outputPath, '0.worker.js')), - ); - // worker bundle contains worker code. - expect(workerContent).toContain('hello from worker'); - expect(workerContent).toContain('bar'); - - const mainContent = virtualFs.fileBufferToString( - host.scopedSync().read(join(outputPath, 'main.js')), - ); - // main bundle references worker. - expect(mainContent).toContain('0.worker.js'); - }), - // Doesn't show any warnings. - tap(() => expect(logger.includes('WARNING')).toBe(false, 'Should show no warnings.')), - ).toPromise().then(done, done.fail); + await browserBuild(architect, host, target, overrides, { logger }); + + // Worker bundle contains worker code. + const workerContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, '0.worker.js'))); + expect(workerContent).toContain('hello from worker'); + expect(workerContent).toContain('bar'); + + // Main bundle references worker. + const mainContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, 'main.js'))); + expect(mainContent).toContain('0.worker.js'); + expect(logger.includes('WARNING')).toBe(false, 'Should show no warnings.'); }); - it('minimizes and hashes worker', (done) => { + it('minimizes and hashes worker', async () => { host.writeMultipleFiles(workerFiles); const overrides = { webWorkerTsConfig: 'src/tsconfig.worker.json', outputHashing: 'all', optimization: true, }; - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const workerBundle = host.fileMatchExists(outputPath, - /0\.[0-9a-f]{20}\.worker\.js/) as string; - expect(workerBundle).toBeTruthy('workerBundle should exist'); - const workerContent = virtualFs.fileBufferToString( - host.scopedSync().read(join(outputPath, workerBundle)), - ); - expect(workerContent).toContain('hello from worker'); - expect(workerContent).toContain('bar'); - expect(workerContent).toContain('"hello"===t&&postMessage'); - - const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{20}\.js/) as string; - expect(mainBundle).toBeTruthy('mainBundle should exist'); - const mainContent = virtualFs.fileBufferToString( - host.scopedSync().read(join(outputPath, mainBundle)), - ); - expect(mainContent).toContain(workerBundle); - }), - ).toPromise().then(done, done.fail); + await browserBuild(architect, host, target, overrides); + + // Worker bundle should have hash and minified code. + const workerBundle = host.fileMatchExists(outputPath, /0\.[0-9a-f]{20}\.worker\.js/) as string; + expect(workerBundle).toBeTruthy('workerBundle should exist'); + const workerContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, workerBundle))); + expect(workerContent).toContain('hello from worker'); + expect(workerContent).toContain('bar'); + expect(workerContent).toContain('"hello"===t&&postMessage'); + + // Main bundle should reference hashed worker bundle. + const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{20}\.js/) as string; + expect(mainBundle).toBeTruthy('mainBundle should exist'); + const mainContent = virtualFs.fileBufferToString( + host.scopedSync().read(join(outputPath, mainBundle))); + expect(mainContent).toContain(workerBundle); }); - it('rebuilds TS worker', (done) => { + it('rebuilds TS worker', async () => { host.writeMultipleFiles(workerFiles); const overrides = { webWorkerTsConfig: 'src/tsconfig.worker.json', @@ -144,7 +141,8 @@ describe('Browser Builder Web Worker support', () => { const workerPath = join(outputPath, '0.worker.js'); let workerContent = ''; - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe( + const run = await architect.scheduleTarget(target, overrides); + await run.output.pipe( // Wait for files to be written to disk. debounceTime(1000), tap((buildEvent) => expect(buildEvent.success).toBe(true, 'build should succeed')), @@ -178,9 +176,7 @@ describe('Browser Builder Web Worker support', () => { } }), takeWhile(() => phase < 3), - ).toPromise().then( - () => done(), - () => done.fail(`stuck at phase ${phase} [builds: ${buildCount}]`), - ); + ).toPromise(); + await run.stop(); }); });