Skip to content

Commit c4d7750

Browse files
committed
feat(@angular-devkit/build-angular): support TS web workers
1 parent 9e39696 commit c4d7750

File tree

10 files changed

+305
-63
lines changed

10 files changed

+305
-63
lines changed

Diff for: packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface BuildOptions {
6060
forkTypeChecker: boolean;
6161
profile?: boolean;
6262
es5BrowserSupport?: boolean;
63+
workerTsConfig?: string;
6364

6465
main: string;
6566
index: string;

Diff for: packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts

-6
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ const ProgressPlugin = require('webpack/lib/ProgressPlugin');
2222
const CircularDependencyPlugin = require('circular-dependency-plugin');
2323
const TerserPlugin = require('terser-webpack-plugin');
2424
const StatsPlugin = require('stats-webpack-plugin');
25-
const WorkerPlugin = require('worker-plugin');
2625

2726

2827
// tslint:disable-next-line:no-any
@@ -128,11 +127,6 @@ export function getCommonConfig(wco: WebpackConfigOptions) {
128127
});
129128
}
130129

131-
if (buildOptions.autoBundleWorkerModules) {
132-
const workerPluginInstance = new WorkerPlugin({ globalObject: false });
133-
extraPlugins.push(workerPluginInstance);
134-
}
135-
136130
// process asset entries
137131
if (buildOptions.assets) {
138132
const copyWebpackPluginPatterns = buildOptions.assets.map((asset: AssetPatternObject) => {

Diff for: packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './test';
1313
export * from './typescript';
1414
export * from './utils';
1515
export * from './stats';
16+
export * from './worker';

Diff for: packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts

+17
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,20 @@ export function getNonAotTestConfig(wco: WebpackConfigOptions, host: virtualFs.H
123123
plugins: [_createAotPlugin(wco, { tsConfigPath, skipCodeGeneration: true }, host, false)]
124124
};
125125
}
126+
127+
export function getTypescriptWorkerPlugin(wco: WebpackConfigOptions, workerTsConfigPath: string) {
128+
const { buildOptions } = wco;
129+
130+
const pluginOptions: AngularCompilerPluginOptions = {
131+
skipCodeGeneration: true,
132+
tsConfigPath: workerTsConfigPath,
133+
mainPath: undefined,
134+
platform: PLATFORM.Browser,
135+
sourceMap: buildOptions.sourceMap.scripts,
136+
forkTypeChecker: buildOptions.forkTypeChecker,
137+
contextElementDependencyConstructor: require('webpack/lib/dependencies/ContextElementDependency'),
138+
logger: wco.logger,
139+
};
140+
141+
return new AngularCompilerPlugin(pluginOptions);
142+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { resolve } from 'path';
9+
import { Configuration } from 'webpack';
10+
import { WebpackConfigOptions } from '../build-options';
11+
import { getTypescriptWorkerPlugin } from './typescript';
12+
13+
const WorkerPlugin = require('worker-plugin');
14+
15+
16+
export function getWorkerConfig(wco: WebpackConfigOptions): Configuration {
17+
const { buildOptions } = wco;
18+
const workerTsConfigPath = buildOptions.workerTsConfig
19+
? resolve(wco.root, buildOptions.workerTsConfig)
20+
: undefined;
21+
22+
return {
23+
plugins: [new WorkerPlugin({
24+
globalObject: false,
25+
plugins: workerTsConfigPath ? [getTypescriptWorkerPlugin(wco, workerTsConfigPath)] : [],
26+
})],
27+
};
28+
}

Diff for: packages/angular_devkit/build_angular/src/browser/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getNonAotConfig,
2626
getStatsConfig,
2727
getStylesConfig,
28+
getWorkerConfig,
2829
} from '../angular-cli-files/models/webpack-configs';
2930
import { readTsconfig } from '../angular-cli-files/utilities/read-tsconfig';
3031
import { requireProjectModule } from '../angular-cli-files/utilities/require-project-module';
@@ -152,6 +153,10 @@ export class BrowserBuilder implements Builder<BrowserBuilderSchema> {
152153
webpackConfigs.push(typescriptConfigPartial);
153154
}
154155

156+
if (wco.buildOptions.autoBundleWorkerModules) {
157+
webpackConfigs.push(getWorkerConfig(wco));
158+
}
159+
155160
const webpackConfig = webpackMerge(webpackConfigs);
156161

157162
if (options.profile) {

Diff for: packages/angular_devkit/build_angular/src/browser/schema.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,11 @@ export interface BrowserBuilderSchema {
199199
*/
200200
autoBundleWorkerModules: boolean;
201201

202+
/**
203+
* TypeScript configuration for worker modules.
204+
*/
205+
workerTsConfig?: string;
206+
202207
/**
203208
* Path to ngsw-config.json.
204209
*/

Diff for: packages/angular_devkit/build_angular/src/browser/schema.json

+4
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@
260260
"description": "Automatically bundle new Worker('..', { type:'module' })",
261261
"default": true
262262
},
263+
"workerTsConfig": {
264+
"type": "string",
265+
"description": "TypeScript configuration for worker modules."
266+
},
263267
"ngswConfigPath": {
264268
"type": "string",
265269
"description": "Path to ngsw-config.json."

Diff for: packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts

+178-57
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,21 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { runTargetSpec } from '@angular-devkit/architect/testing';
9+
import { DefaultTimeout, TestLogger, runTargetSpec } from '@angular-devkit/architect/testing';
1010
import { join, virtualFs } from '@angular-devkit/core';
11-
import { tap } from 'rxjs/operators';
11+
import { debounceTime, takeWhile, tap } from 'rxjs/operators';
1212
import { browserTargetSpec, host, outputPath } from '../utils';
1313

1414

1515
describe('Browser Builder bundle worker', () => {
1616
beforeEach(done => host.initialize().toPromise().then(done, done.fail));
17-
// afterEach(done => host.restore().toPromise().then(done, done.fail));
17+
afterEach(done => host.restore().toPromise().then(done, done.fail));
1818

19-
const workerFiles = {
20-
'src/dep.js': `export const foo = 'bar';`,
21-
'src/worker.js': `
19+
const workerFiles: { [k: string]: string } = {
20+
'src/worker/dep.js': `export const foo = 'bar';`,
21+
'src/worker/worker.js': `
2222
import { foo } from './dep';
23-
2423
console.log('hello from worker');
25-
2624
addEventListener('message', ({ data }) => {
2725
console.log('worker got message:', data);
2826
if (data === 'hello') {
@@ -31,61 +29,184 @@ describe('Browser Builder bundle worker', () => {
3129
});
3230
`,
3331
'src/main.ts': `
34-
const worker = new Worker('./worker', { type: 'module' });
32+
import { enableProdMode } from '@angular/core';
33+
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
34+
import { AppModule } from './app/app.module';
35+
import { environment } from './environments/environment';
36+
if (environment.production) { enableProdMode(); }
37+
platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.error(err));
38+
39+
const worker = new Worker('./worker/worker.js', { type: 'module' });
3540
worker.onmessage = ({ data }) => {
3641
console.log('page got message:', data);
3742
};
3843
worker.postMessage('hello');
3944
`,
45+
// Make a new tsconfig for the worker folder that includes the webworker lib.
46+
// The final place for this tsconfig must take into consideration editor tooling, unit
47+
// tests, and integration with other build targets.
48+
'./src/worker/tsconfig.json': `
49+
{
50+
"extends": "../../tsconfig.json",
51+
"compilerOptions": {
52+
"outDir": "../../out-tsc/worker",
53+
"lib": [
54+
"es2018",
55+
"webworker"
56+
],
57+
"types": []
58+
}
59+
}`,
60+
// Alter the app tsconfig to not include worker files.
61+
'./src/tsconfig.app.json': `
62+
{
63+
"extends": "../tsconfig.json",
64+
"compilerOptions": {
65+
"outDir": "../out-tsc/worker",
66+
"types": []
67+
},
68+
"exclude": [
69+
"test.ts",
70+
"**/*.spec.ts",
71+
"worker/**/*.ts"
72+
]
73+
}`,
4074
};
4175

42-
describe('js workers', () => {
43-
it('bundles worker', (done) => {
44-
host.writeMultipleFiles(workerFiles);
45-
const overrides = { autoBundleWorkerModules: true };
46-
runTargetSpec(host, browserTargetSpec, overrides).pipe(
47-
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
48-
tap(() => {
49-
const workerContent = virtualFs.fileBufferToString(
50-
host.scopedSync().read(join(outputPath, '0.worker.js')),
51-
);
52-
// worker bundle contains worker code.
53-
expect(workerContent).toContain('hello from worker');
54-
expect(workerContent).toContain('bar');
55-
56-
const mainContent = virtualFs.fileBufferToString(
57-
host.scopedSync().read(join(outputPath, 'main.js')),
58-
);
59-
// main bundle references worker.
60-
expect(mainContent).toContain('0.worker.js');
61-
}),
62-
).toPromise().then(done, done.fail);
63-
});
64-
65-
it('minimizes and hashes worker', (done) => {
66-
host.writeMultipleFiles(workerFiles);
67-
const overrides = { autoBundleWorkerModules: true, outputHashing: 'all', optimization: true };
68-
runTargetSpec(host, browserTargetSpec, overrides).pipe(
69-
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
70-
tap(() => {
71-
const workerBundle = host.fileMatchExists(outputPath,
72-
/0\.[0-9a-f]{20}\.worker\.js/) as string;
73-
expect(workerBundle).toBeTruthy('workerBundle should exist');
74-
const workerContent = virtualFs.fileBufferToString(
75-
host.scopedSync().read(join(outputPath, workerBundle)),
76-
);
77-
expect(workerContent).toContain('hello from worker');
78-
expect(workerContent).toContain('bar');
79-
expect(workerContent).toContain('"hello"===e&&postMessage("bar")');
80-
81-
const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{20}\.js/) as string;
82-
expect(mainBundle).toBeTruthy('mainBundle should exist');
83-
const mainContent = virtualFs.fileBufferToString(
84-
host.scopedSync().read(join(outputPath, mainBundle)),
85-
);
86-
expect(mainContent).toContain(workerBundle);
87-
}),
88-
).toPromise().then(done, done.fail);
89-
});
76+
// Use the same worker file content but in a .ts file name.
77+
const tsWorkerFiles = Object.keys(workerFiles)
78+
.reduce((acc, k) => {
79+
// Replace the .js files with .ts, and also references within the files.
80+
acc[k.replace(/\.js$/, '.ts')] = workerFiles[k].replace(/\.js'/g, `.ts'`);
81+
82+
return acc;
83+
}, {} as { [k: string]: string });
84+
85+
it('bundles worker', (done) => {
86+
host.writeMultipleFiles(workerFiles);
87+
const overrides = { autoBundleWorkerModules: true };
88+
runTargetSpec(host, browserTargetSpec, overrides).pipe(
89+
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
90+
tap(() => {
91+
const workerContent = virtualFs.fileBufferToString(
92+
host.scopedSync().read(join(outputPath, '0.worker.js')),
93+
);
94+
// worker bundle contains worker code.
95+
expect(workerContent).toContain('hello from worker');
96+
expect(workerContent).toContain('bar');
97+
98+
const mainContent = virtualFs.fileBufferToString(
99+
host.scopedSync().read(join(outputPath, 'main.js')),
100+
);
101+
// main bundle references worker.
102+
expect(mainContent).toContain('0.worker.js');
103+
}),
104+
).toPromise().then(done, done.fail);
105+
});
106+
107+
it('minimizes and hashes worker', (done) => {
108+
host.writeMultipleFiles(workerFiles);
109+
const overrides = { autoBundleWorkerModules: true, outputHashing: 'all', optimization: true };
110+
runTargetSpec(host, browserTargetSpec, overrides).pipe(
111+
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
112+
tap(() => {
113+
const workerBundle = host.fileMatchExists(outputPath,
114+
/0\.[0-9a-f]{20}\.worker\.js/) as string;
115+
expect(workerBundle).toBeTruthy('workerBundle should exist');
116+
const workerContent = virtualFs.fileBufferToString(
117+
host.scopedSync().read(join(outputPath, workerBundle)),
118+
);
119+
expect(workerContent).toContain('hello from worker');
120+
expect(workerContent).toContain('bar');
121+
expect(workerContent).toContain('"hello"===e&&postMessage("bar")');
122+
123+
const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{20}\.js/) as string;
124+
expect(mainBundle).toBeTruthy('mainBundle should exist');
125+
const mainContent = virtualFs.fileBufferToString(
126+
host.scopedSync().read(join(outputPath, mainBundle)),
127+
);
128+
expect(mainContent).toContain(workerBundle);
129+
}),
130+
).toPromise().then(done, done.fail);
131+
});
132+
133+
it('bundles TS worker', (done) => {
134+
const logger = new TestLogger('worker-warnings');
135+
host.writeMultipleFiles(tsWorkerFiles);
136+
const overrides = {
137+
autoBundleWorkerModules: true,
138+
workerTsConfig: 'src/worker/tsconfig.json',
139+
};
140+
141+
142+
runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout, logger).pipe(
143+
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
144+
tap(() => {
145+
const workerContent = virtualFs.fileBufferToString(
146+
host.scopedSync().read(join(outputPath, '0.worker.js')),
147+
);
148+
// worker bundle contains worker code.
149+
expect(workerContent).toContain('hello from worker');
150+
expect(workerContent).toContain('bar');
151+
152+
const mainContent = virtualFs.fileBufferToString(
153+
host.scopedSync().read(join(outputPath, 'main.js')),
154+
);
155+
// main bundle references worker.
156+
expect(mainContent).toContain('0.worker.js');
157+
}),
158+
// Doesn't show any warnings.
159+
tap(() => expect(logger.includes('WARNING')).toBe(false, 'Should show no warnings.')),
160+
).toPromise().then(done, done.fail);
161+
});
162+
163+
it('rebuilds TS worker', (done) => {
164+
host.writeMultipleFiles(tsWorkerFiles);
165+
const overrides = {
166+
autoBundleWorkerModules: true,
167+
workerTsConfig: 'src/worker/tsconfig.json',
168+
watch: true,
169+
};
170+
171+
let buildCount = 0;
172+
let phase = 1;
173+
const workerPath = join(outputPath, '0.worker.js');
174+
let workerContent = '';
175+
176+
runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe(
177+
// Note(filipesilva): Wait for files to be written to disk... and something else?
178+
// 1s should be enough for files to be written to disk.
179+
// However, with a 1s to 3s delay, I sometimes (roughly 1 in 3 tests) saw TS compilation
180+
// succeeding and emitting updated content, but the TS loader never called for
181+
// 'src/worker/dep.ts'.
182+
// But increasing this delay to 5s lead to no failed test in over 40 runs.
183+
// I think there might be a race condition related to child compilers somewhere in webpack.
184+
debounceTime(5000),
185+
tap((buildEvent) => expect(buildEvent.success).toBe(true, 'build should succeed')),
186+
tap(() => {
187+
buildCount++;
188+
switch (phase) {
189+
case 1:
190+
// Original worker content should be there.
191+
workerContent = virtualFs.fileBufferToString(host.scopedSync().read(workerPath));
192+
expect(workerContent).toContain('bar');
193+
// Change content of worker dependency.
194+
host.writeMultipleFiles({ 'src/worker/dep.ts': `export const foo = 'baz';` });
195+
phase = 2;
196+
break;
197+
198+
case 2:
199+
// Worker content should have changed.
200+
workerContent = virtualFs.fileBufferToString(host.scopedSync().read(workerPath));
201+
expect(workerContent).toContain('baz');
202+
phase = 3;
203+
break;
204+
}
205+
}),
206+
takeWhile(() => phase < 3),
207+
).toPromise().then(
208+
() => done(),
209+
() => done.fail(`stuck at phase ${phase} [builds: ${buildCount}]`),
210+
);
90211
});
91212
});

0 commit comments

Comments
 (0)