Skip to content

Commit 95787cf

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

File tree

11 files changed

+300
-114
lines changed

11 files changed

+300
-114
lines changed

packages/angular/cli/lib/config/schema.json

+4
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,10 @@
887887
"type": "boolean",
888888
"default": false,
889889
"x-deprecated": true
890+
},
891+
"experimentalWebWorkerTsConfig": {
892+
"type": "string",
893+
"description": "TypeScript configuration for Web Worker modules."
890894
}
891895
},
892896
"additionalProperties": false,

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export interface BuildOptions {
5454
namedChunks?: boolean;
5555
subresourceIntegrity?: boolean;
5656
serviceWorker?: boolean;
57-
autoBundleWorkerModules?: boolean;
57+
experimentalWebWorkerTsConfig?: string;
5858
skipAppShell?: boolean;
5959
statsJson: boolean;
6060
forkTypeChecker: boolean;

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

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

+21
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,24 @@ 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+
// Run no transformers.
140+
platformTransformers: [],
141+
// Don't attempt lazy route discovery.
142+
discoverLazyRoutes: false,
143+
};
144+
145+
return new AngularCompilerPlugin(pluginOptions);
146+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
if (!buildOptions.experimentalWebWorkerTsConfig) {
19+
throw new Error('The `experimentalWebWorkerTsConfig` must be a string.');
20+
}
21+
22+
const workerTsConfigPath = resolve(wco.root, buildOptions.experimentalWebWorkerTsConfig);
23+
24+
return {
25+
plugins: [new WorkerPlugin({
26+
globalObject: false,
27+
plugins: [getTypescriptWorkerPlugin(wco, workerTsConfigPath)],
28+
})],
29+
};
30+
}

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';
@@ -154,6 +155,10 @@ export class BrowserBuilder implements Builder<BrowserBuilderSchema> {
154155
webpackConfigs.push(typescriptConfigPartial);
155156
}
156157

158+
if (wco.buildOptions.experimentalWebWorkerTsConfig) {
159+
webpackConfigs.push(getWorkerConfig(wco));
160+
}
161+
157162
const webpackConfig = webpackMerge(webpackConfigs);
158163

159164
if (options.profile) {

packages/angular_devkit/build_angular/src/browser/schema.json

+4-5
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,6 @@
257257
"description": "Generates a service worker config for production builds.",
258258
"default": false
259259
},
260-
"autoBundleWorkerModules": {
261-
"type": "boolean",
262-
"description": "Automatically bundle new Worker('..', { type:'module' })",
263-
"default": true
264-
},
265260
"ngswConfigPath": {
266261
"type": "string",
267262
"description": "Path to ngsw-config.json."
@@ -317,6 +312,10 @@
317312
"type": "boolean",
318313
"default": false,
319314
"x-deprecated": true
315+
},
316+
"experimentalWebWorkerTsConfig": {
317+
"type": "string",
318+
"description": "TypeScript configuration for Web Worker modules."
320319
}
321320
},
322321
"additionalProperties": false,

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

+147-55
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,22 @@
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

15-
describe('Browser Builder bundle worker', () => {
15+
describe('Browser Builder Web Worker support', () => {
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.ts': `export const foo = 'bar';`,
21+
'src/app/app.worker.ts': `
22+
import 'typescript/lib/lib.webworker';
2223
import { foo } from './dep';
23-
2424
console.log('hello from worker');
25-
2625
addEventListener('message', ({ data }) => {
2726
console.log('worker got message:', data);
2827
if (data === 'hello') {
@@ -31,61 +30,154 @@ describe('Browser Builder bundle worker', () => {
3130
});
3231
`,
3332
'src/main.ts': `
34-
const worker = new Worker('./worker', { type: 'module' });
33+
import { enableProdMode } from '@angular/core';
34+
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
35+
import { AppModule } from './app/app.module';
36+
import { environment } from './environments/environment';
37+
if (environment.production) { enableProdMode(); }
38+
platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.error(err));
39+
40+
const worker = new Worker('./app/app.worker', { type: 'module' });
3541
worker.onmessage = ({ data }) => {
3642
console.log('page got message:', data);
3743
};
3844
worker.postMessage('hello');
3945
`,
46+
// Make a new tsconfig for the *.worker.ts files.
47+
// The final place for this tsconfig must take into consideration editor tooling, unit
48+
// tests, and integration with other build targets.
49+
'./src/tsconfig.worker.json': `
50+
{
51+
"extends": "../tsconfig.json",
52+
"compilerOptions": {
53+
"outDir": "../out-tsc/worker",
54+
"lib": [
55+
"es2018",
56+
"webworker"
57+
],
58+
"types": []
59+
},
60+
"include": [
61+
"**/*.worker.ts",
62+
]
63+
}`,
64+
// Alter the app tsconfig to not include *.worker.ts files.
65+
'./src/tsconfig.app.json': `
66+
{
67+
"extends": "../tsconfig.json",
68+
"compilerOptions": {
69+
"outDir": "../out-tsc/worker",
70+
"types": []
71+
},
72+
"exclude": [
73+
"test.ts",
74+
"**/*.spec.ts",
75+
"**/*.worker.ts",
76+
]
77+
}`,
4078
};
4179

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');
80+
it('bundles TS worker', (done) => {
81+
const logger = new TestLogger('worker-warnings');
82+
host.writeMultipleFiles(workerFiles);
83+
const overrides = { experimentalWebWorkerTsConfig: 'src/tsconfig.worker.json' };
84+
runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout, logger).pipe(
85+
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
86+
tap(() => {
87+
const workerContent = virtualFs.fileBufferToString(
88+
host.scopedSync().read(join(outputPath, '0.worker.js')),
89+
);
90+
// worker bundle contains worker code.
91+
expect(workerContent).toContain('hello from worker');
92+
expect(workerContent).toContain('bar');
93+
94+
const mainContent = virtualFs.fileBufferToString(
95+
host.scopedSync().read(join(outputPath, 'main.js')),
96+
);
97+
// main bundle references worker.
98+
expect(mainContent).toContain('0.worker.js');
99+
}),
100+
// Doesn't show any warnings.
101+
tap(() => expect(logger.includes('WARNING')).toBe(false, 'Should show no warnings.')),
102+
).toPromise().then(done, done.fail);
103+
});
104+
105+
it('minimizes and hashes worker', (done) => {
106+
host.writeMultipleFiles(workerFiles);
107+
const overrides = {
108+
experimentalWebWorkerTsConfig: 'src/tsconfig.worker.json',
109+
outputHashing: 'all',
110+
optimization: true,
111+
};
112+
runTargetSpec(host, browserTargetSpec, overrides).pipe(
113+
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
114+
tap(() => {
115+
const workerBundle = host.fileMatchExists(outputPath,
116+
/0\.[0-9a-f]{20}\.worker\.js/) as string;
117+
expect(workerBundle).toBeTruthy('workerBundle should exist');
118+
const workerContent = virtualFs.fileBufferToString(
119+
host.scopedSync().read(join(outputPath, workerBundle)),
120+
);
121+
expect(workerContent).toContain('hello from worker');
122+
expect(workerContent).toContain('bar');
123+
expect(workerContent).toContain('"hello"===t&&postMessage(o.foo)');
124+
125+
const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{20}\.js/) as string;
126+
expect(mainBundle).toBeTruthy('mainBundle should exist');
127+
const mainContent = virtualFs.fileBufferToString(
128+
host.scopedSync().read(join(outputPath, mainBundle)),
129+
);
130+
expect(mainContent).toContain(workerBundle);
131+
}),
132+
).toPromise().then(done, done.fail);
133+
});
55134

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-
});
135+
it('rebuilds TS worker', (done) => {
136+
host.writeMultipleFiles(workerFiles);
137+
const overrides = {
138+
experimentalWebWorkerTsConfig: 'src/tsconfig.worker.json',
139+
watch: true,
140+
};
64141

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")');
142+
let buildCount = 0;
143+
let phase = 1;
144+
const workerPath = join(outputPath, '0.worker.js');
145+
let workerContent = '';
80146

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-
});
147+
runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe(
148+
// Note(filipesilva): Wait for files to be written to disk... and something else?
149+
// 1s should be enough for files to be written to disk.
150+
// However, with a 1s to 3s delay, I sometimes (roughly 1 in 3 tests) saw TS compilation
151+
// succeeding and emitting updated content, but the TS loader never called for
152+
// 'src/worker/dep.ts'.
153+
// But increasing this delay to 5s lead to no failed test in over 40 runs.
154+
// I think there might be a race condition related to child compilers somewhere in webpack.
155+
debounceTime(5000),
156+
tap((buildEvent) => expect(buildEvent.success).toBe(true, 'build should succeed')),
157+
tap(() => {
158+
buildCount++;
159+
switch (phase) {
160+
case 1:
161+
// Original worker content should be there.
162+
workerContent = virtualFs.fileBufferToString(host.scopedSync().read(workerPath));
163+
expect(workerContent).toContain('bar');
164+
// Change content of worker dependency.
165+
host.writeMultipleFiles({ 'src/app/dep.ts': `export const foo = 'baz';` });
166+
phase = 2;
167+
break;
168+
169+
case 2:
170+
// Worker content should have changed.
171+
workerContent = virtualFs.fileBufferToString(host.scopedSync().read(workerPath));
172+
expect(workerContent).toContain('baz');
173+
phase = 3;
174+
break;
175+
}
176+
}),
177+
takeWhile(() => phase < 3),
178+
).toPromise().then(
179+
() => done(),
180+
() => done.fail(`stuck at phase ${phase} [builds: ${buildCount}]`),
181+
);
90182
});
91183
});

0 commit comments

Comments
 (0)