Skip to content

Web Worker support #13700

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 6 commits into from
Apr 2, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/angular/cli/lib/config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,10 @@
"type": "boolean",
"default": false,
"x-deprecated": true
},
"webWorkerTsConfig": {
"type": "string",
"description": "TypeScript configuration for Web Worker modules."
}
},
"additionalProperties": false,
Expand Down
3 changes: 2 additions & 1 deletion packages/angular_devkit/build_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface BuildOptions {
namedChunks?: boolean;
subresourceIntegrity?: boolean;
serviceWorker?: boolean;
webWorkerTsConfig?: string;
skipAppShell?: boolean;
statsJson: boolean;
forkTypeChecker: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './test';
export * from './typescript';
export * from './utils';
export * from './stats';
export * from './worker';
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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)],
})],
};
}
5 changes: 5 additions & 0 deletions packages/angular_devkit/build_angular/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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']) {
Expand Down
4 changes: 4 additions & 0 deletions packages/angular_devkit/build_angular/src/browser/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -320,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* @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 { 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 { browserBuild, createArchitect, host, outputPath } from '../utils';


describe('Browser Builder Web Worker support', () => {
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';`,
'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', async () => {
host.writeMultipleFiles(workerFiles);
const logger = new TestLogger('worker-warnings');
const overrides = { webWorkerTsConfig: 'src/tsconfig.worker.json' };
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', async () => {
host.writeMultipleFiles(workerFiles);
const overrides = {
webWorkerTsConfig: 'src/tsconfig.worker.json',
outputHashing: 'all',
optimization: true,
};
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', async () => {
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 = '';

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')),
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.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@developit figured out the rebuild issue. It's because the worker changes name on rebuilds. Is this intended?

Copy link
Contributor Author

@filipesilva filipesilva Mar 27, 2019

Choose a reason for hiding this comment

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

I'm also seeing a weird behaviour where sometimes the child compilation picks up on changed files, and sometimes it doesn't.

In this test I have seen all these outputs just by running the rebuild test repeatedly with no code changes:

  • webpack loader is called for src/app/dep.ts twice and registers the change from bar to baz, baz ends up in 0.worker.js
  • same as above but baz ends up in 1.worker.js
  • loader is only called once (initially), neither 0.worker.js nor 1.worker.js contain baz

Do you have any idea of what's happening here? Is there some cache inside worker-plugin that might be acting strange?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the worker changing names may be somewhat unavoidable. There's a filename option that would make it possible to use a static name, but that doesn't seem like the right solution here either.

There's no caching enabled in worker-plugin, so it's likely something deeper in Webpack.

I'm going to add some rebuild tests to the plugin to see if I can trigger this in isolation. My hunch is that the parser hook's instance counter isn't being reset because the parser is reused across webpack compilations. It would certainly explain the 0 --> 1 bug there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Been trying to debug further and have some findings.

In our test suite we run webpack several times in the same thread. I think the test order affects the result of rebuilds with worker-plugin.

To test this I added a lot of debug logs and tried running the basic test and the rebuild test in different orders. Then I collected the logs just from the rebuild test.

One of the things I logged was the entry for the child compilation: https://github.com/GoogleChromeLabs/worker-plugin/blob/7ea61fb83a02170d0235e4590c4deb617874e70a/src/loader.js#L77. The others were build count, changed file being loaded, and which bundle contains the changed content.

If I run the rebuild test first and the basic test second I see:

  • dep.ts is loaded with bar
  • child compilation finishes for entry 0.worker.js
  • first build finishes
  • dep.ts is loaded with baz
  • child compilation finishes for entry 1.worker.js
  • second build finishes
  • 0.worker.js does not contains baz
  • 1.worker.js contains baz

If I run the rebuild test first and the basic test second I see:

  • dep.ts is loaded with bar
  • child compilation finishes for entry 0.worker.js
  • child compilation finishes for entry 1.worker.js
  • first build finishes
  • dep.ts is loaded with baz
  • child compilation finishes for entry 0.worker.js
  • second build finishes
  • 0.worker.js contains baz
  • 1.worker.js does not contains baz

So the end result is that the order of the tests seems to affect which bundle ends up containing the changed code.

But more interestingly, when the rebuild test is second, before the first build finishes there are a total of two child compilations that run. That part I find rather odd. I don't understand why that would happen. Even weirder is that the next child compilation is again the 0 one.

// 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();
await run.stop();
});
});
28 changes: 26 additions & 2 deletions packages/ngtools/webpack/src/angular_compiler_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 = [];
Expand Down Expand Up @@ -859,6 +865,24 @@ 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.
// 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) {
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;
Expand Down
4 changes: 4 additions & 0 deletions packages/ngtools/webpack/src/virtual_file_system_decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export class VirtualFileSystemDecorator implements InputFileSystem {
private _webpackCompilerHost: WebpackCompilerHost,
) { }

getWebpackCompilerHost() {
return this._webpackCompilerHost;
}

getVirtualFilesPaths() {
return this._webpackCompilerHost.getNgFactoryPaths();
}
Expand Down
6 changes: 6 additions & 0 deletions packages/schematics/angular/collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
}
}
}
1 change: 1 addition & 0 deletions packages/schematics/angular/utility/workspace-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface BrowserBuilderOptions extends BrowserBuilderBaseOptions {
maximumError?: string;
}[];
es5BrowserSupport?: boolean;
webWorkerTsConfig?: string;
}

export interface ServeBuilderOptions {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
"compilerOptions": {
"lib": [
"es2018",
"dom",
"webworker"
],
}
}
Loading