Skip to content

Commit 0baac68

Browse files
committed
perf(@angular-devkit/build-angular): execute dart-sass in a worker
The dart-sass Sass implementation will now be executed in a separate worker thread. The wrapper worker implementation provides an interface that can be directly used by Webpack's `sass-loader`. The worker implementation allows dart-sass to be executed in its synchronous mode which can be up to two times faster than its asynchronous mode. The worker thread also allows Webpack to continue other bundling tasks while the Sass stylesheets are being processed.
1 parent 50f456a commit 0baac68

File tree

6 files changed

+218
-1
lines changed

6 files changed

+218
-1
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
"@types/progress": "^2.0.3",
120120
"@types/resolve": "^1.17.1",
121121
"@types/rimraf": "^3.0.0",
122+
"@types/sass": "^1.16.0",
122123
"@types/semver": "^7.0.0",
123124
"@types/text-table": "^0.2.1",
124125
"@types/uuid": "^8.0.0",

packages/angular_devkit/build_angular/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ ts_library(
132132
"@npm//@types/parse5-html-rewriting-stream",
133133
"@npm//@types/postcss-preset-env",
134134
"@npm//@types/rimraf",
135+
"@npm//@types/sass",
135136
"@npm//@types/semver",
136137
"@npm//@types/text-table",
137138
"@npm//@types/webpack-dev-server",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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+
9+
import { Importer, ImporterReturnType, Options, Result, SassException } from 'sass';
10+
import { MessageChannel, Worker } from 'worker_threads';
11+
12+
type RenderCallback = (error?: SassException, result?: Result) => void;
13+
14+
interface RenderRequest {
15+
id: number;
16+
callback: RenderCallback;
17+
importers?: Importer[];
18+
}
19+
20+
interface RenderResponse {
21+
id: number;
22+
error?: SassException;
23+
result?: Result;
24+
}
25+
26+
export class SassWorkerImplementation {
27+
#worker?: Worker;
28+
readonly #requests = new Map<number, RenderRequest>();
29+
#idCounter = 1;
30+
31+
get info(): string {
32+
return 'dart-sass\tworker';
33+
}
34+
35+
renderSync(): never {
36+
throw new Error('Sass renderSync is not supported.');
37+
}
38+
39+
render(options: Options, callback: RenderCallback): void {
40+
const { functions, importer, ...serializableOptions } = options;
41+
42+
if (functions && Object.keys(functions).length > 0) {
43+
throw new Error('Sass custom functions are not supported.');
44+
}
45+
46+
if (!this.#worker) {
47+
this.#worker = this.createWorker();
48+
}
49+
50+
const request = this.createRequest(callback, importer);
51+
this.#requests.set(request.id, request);
52+
53+
this.#worker.postMessage({
54+
id: request.id,
55+
hasImporter: !!importer,
56+
options: serializableOptions,
57+
});
58+
}
59+
60+
close(): void {
61+
this.#worker?.terminate();
62+
}
63+
64+
private createWorker(): Worker {
65+
const { port1: mainImporterPort, port2: workerImporterPort } = new MessageChannel();
66+
const importerSignal = new Int32Array(new SharedArrayBuffer(4));
67+
const workerPath = require.resolve('./worker');
68+
const worker = new Worker(workerPath, {
69+
workerData: { workerImporterPort, importerSignal },
70+
transferList: [workerImporterPort],
71+
});
72+
73+
worker.on('message', (response: RenderResponse) => {
74+
const request = this.#requests.get(response.id);
75+
if (!request) {
76+
return;
77+
}
78+
79+
this.#requests.delete(response.id);
80+
81+
if (response.result) {
82+
const { css, map, stats } = response.result;
83+
const result: Result = {
84+
css: Buffer.from(css.buffer, css.byteOffset, css.byteLength),
85+
stats,
86+
};
87+
if (map) {
88+
result.map = Buffer.from(map.buffer, map.byteOffset, map.byteLength);
89+
}
90+
request.callback(undefined, result);
91+
} else {
92+
request.callback(response.error);
93+
}
94+
});
95+
96+
mainImporterPort.on(
97+
'message',
98+
({ id, url, prev }: { id: number; url: string; prev: string }) => {
99+
const request = this.#requests.get(id);
100+
if (!request?.importers) {
101+
mainImporterPort.postMessage(null);
102+
Atomics.store(importerSignal, 0, 1);
103+
Atomics.notify(importerSignal, 0);
104+
105+
return;
106+
}
107+
108+
this.processImporters(request.importers, url, prev)
109+
.then((result) => {
110+
mainImporterPort.postMessage(result);
111+
})
112+
.catch((error) => {
113+
mainImporterPort.postMessage(error);
114+
})
115+
.finally(() => {
116+
Atomics.store(importerSignal, 0, 1);
117+
Atomics.notify(importerSignal, 0);
118+
});
119+
},
120+
);
121+
122+
worker.unref();
123+
mainImporterPort.unref();
124+
125+
return worker;
126+
}
127+
128+
private async processImporters(
129+
importers: Iterable<Importer>,
130+
url: string,
131+
prev: string,
132+
): Promise<ImporterReturnType> {
133+
let result = null;
134+
for (const importer of importers) {
135+
result = await new Promise<ImporterReturnType>((resolve) => {
136+
// Importers can be both sync and async
137+
const innerResult = importer(url, prev, resolve);
138+
if (innerResult !== undefined) {
139+
resolve(innerResult);
140+
}
141+
});
142+
143+
if (result) {
144+
break;
145+
}
146+
}
147+
148+
return result;
149+
}
150+
151+
private createRequest(
152+
callback: RenderCallback,
153+
importer: Importer | Importer[] | undefined,
154+
): RenderRequest {
155+
return {
156+
id: this.#idCounter++,
157+
callback,
158+
importers: !importer || Array.isArray(importer) ? importer : [importer],
159+
};
160+
}
161+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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+
9+
import { ImporterReturnType, Options, renderSync } from 'sass';
10+
import { MessagePort, parentPort, receiveMessageOnPort, workerData } from 'worker_threads';
11+
12+
interface RenderRequestMessage {
13+
id: number;
14+
options: Options;
15+
hasImporter: boolean;
16+
}
17+
18+
if (!parentPort || !workerData) {
19+
throw new Error('Sass worker must be executed as a Worker.');
20+
}
21+
22+
const { workerImporterPort, importerSignal } = workerData as {
23+
workerImporterPort: MessagePort;
24+
importerSignal: Int32Array;
25+
};
26+
27+
parentPort.on('message', ({ id, hasImporter, options }: RenderRequestMessage) => {
28+
try {
29+
if (hasImporter) {
30+
options.importer = (url, prev) => {
31+
Atomics.store(importerSignal, 0, 0);
32+
workerImporterPort.postMessage({ id, url, prev });
33+
Atomics.wait(importerSignal, 0, 0);
34+
35+
return receiveMessageOnPort(workerImporterPort)?.message as ImporterReturnType;
36+
};
37+
}
38+
39+
const result = renderSync(options);
40+
parentPort?.postMessage({ id, result });
41+
} catch (error) {
42+
parentPort?.postMessage({ id, error });
43+
}
44+
});

packages/angular_devkit/build_angular/src/webpack/configs/styles.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as fs from 'fs';
1010
import * as path from 'path';
1111
import * as webpack from 'webpack';
1212
import { ExtraEntryPoint } from '../../browser/schema';
13+
import { SassWorkerImplementation } from '../../sass/sass-service';
1314
import { BuildBrowserFeatures } from '../../utils/build-browser-features';
1415
import { WebpackConfigOptions } from '../../utils/build-options';
1516
import {
@@ -114,7 +115,7 @@ export function getStylesConfig(wco: WebpackConfigOptions): webpack.Configuratio
114115
`To opt-out of the deprecated behaviour and start using 'sass' uninstall 'node-sass'.`,
115116
);
116117
} catch {
117-
sassImplementation = require('sass');
118+
sassImplementation = new SassWorkerImplementation();
118119
}
119120

120121
const assetNameTemplate = assetNameTemplateFactory(hashFormat);
@@ -288,6 +289,8 @@ export function getStylesConfig(wco: WebpackConfigOptions): webpack.Configuratio
288289
implementation: sassImplementation,
289290
sourceMap: true,
290291
sassOptions: {
292+
// Prevent use of `fibers` package as it no longer works in newer Node.js versions
293+
fiber: false,
291294
// bootstrap-sass requires a minimum precision of 8
292295
precision: 8,
293296
includePaths,

yarn.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1859,6 +1859,13 @@
18591859
"@types/glob" "*"
18601860
"@types/node" "*"
18611861

1862+
"@types/sass@^1.16.0":
1863+
version "1.16.0"
1864+
resolved "https://registry.yarnpkg.com/@types/sass/-/sass-1.16.0.tgz#b41ac1c17fa68ffb57d43e2360486ef526b3d57d"
1865+
integrity sha512-2XZovu4NwcqmtZtsBR5XYLw18T8cBCnU2USFHTnYLLHz9fkhnoEMoDsqShJIOFsFhn5aJHjweiUUdTrDGujegA==
1866+
dependencies:
1867+
"@types/node" "*"
1868+
18621869
"@types/selenium-webdriver@^3.0.0":
18631870
version "3.0.17"
18641871
resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-3.0.17.tgz#50bea0c3c2acc31c959c5b1e747798b3b3d06d4b"

0 commit comments

Comments
 (0)