Skip to content

Commit 7471ca1

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

File tree

10 files changed

+322
-63
lines changed

10 files changed

+322
-63
lines changed

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;

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) => {

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';

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+
}

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) {

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
*/

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."

packages/angular_devkit/build_angular/test/browser/bundle-worker_spec_large.ts

+181-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/app/dep.js': `export const foo = 'bar';`,
21+
'src/app/app.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,187 @@ 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('./app/app.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.ts files.
46+
// The final place for this tsconfig must take into consideration editor tooling, unit
47+
// tests, and integration with other build targets.
48+
'./src/tsconfig.worker.json': `
49+
{
50+
"extends": "../tsconfig.json",
51+
"compilerOptions": {
52+
"outDir": "../out-tsc/worker",
53+
"lib": [
54+
"es2018",
55+
"webworker"
56+
],
57+
"types": []
58+
},
59+
"include": [
60+
"**/*.worker.ts",
61+
]
62+
}`,
63+
// Alter the app tsconfig to not include *.worker.ts files.
64+
'./src/tsconfig.app.json': `
65+
{
66+
"extends": "../tsconfig.json",
67+
"compilerOptions": {
68+
"outDir": "../out-tsc/worker",
69+
"types": []
70+
},
71+
"exclude": [
72+
"test.ts",
73+
"**/*.spec.ts",
74+
"**/*.worker.ts",
75+
]
76+
}`,
4077
};
4178

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

0 commit comments

Comments
 (0)