Skip to content

Commit 9d83fb9

Browse files
clydindgp1130
authored andcommitted
perf(@angular-devkit/build-angular): use Sass worker pool for Sass support in esbuild builder
When using the experimental esbuild-based browser application builder, Sass stylesheets will now be processed using a worker pool that is currently also used by the default Webpack-based builder. This allows up to four stylesheets to be processed in parallel and keeps the main thread available for other build tasks. On projects with a large amount of Sass stylesheets, this change provided up to a 25% improvement in build times based on initial testing. (cherry picked from commit e1ca878)
1 parent fb4ead2 commit 9d83fb9

File tree

4 files changed

+125
-32
lines changed

4 files changed

+125
-32
lines changed

packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { SourceFileCache, createCompilerPlugin } from './compiler-plugin';
2323
import { bundle, logMessages } from './esbuild';
2424
import { logExperimentalWarnings } from './experimental-warnings';
2525
import { NormalizedBrowserOptions, normalizeOptions } from './options';
26+
import { shutdownSassWorkerPool } from './sass-plugin';
2627
import { Schema as BrowserBuilderOptions } from './schema';
2728
import { bundleStylesheetText } from './stylesheets';
2829
import { ChangedFiles, createWatcher } from './watcher';
@@ -437,6 +438,8 @@ export async function* buildEsbuildBrowser(
437438

438439
// Finish if watch mode is not enabled
439440
if (!initialOptions.watch) {
441+
shutdownSassWorkerPool();
442+
440443
return;
441444
}
442445

@@ -476,6 +479,7 @@ export async function* buildEsbuildBrowser(
476479
await watcher.close();
477480
// Cleanup incremental rebuild state
478481
result.dispose();
482+
shutdownSassWorkerPool();
479483
}
480484
}
481485

packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,52 @@
77
*/
88

99
import type { PartialMessage, Plugin, PluginBuild } from 'esbuild';
10+
import { readFile } from 'node:fs/promises';
1011
import { dirname, relative } from 'node:path';
11-
import { fileURLToPath } from 'node:url';
12-
import type { CompileResult } from 'sass';
12+
import { fileURLToPath, pathToFileURL } from 'node:url';
13+
import type { CompileResult, Exception } from 'sass';
14+
import { SassWorkerImplementation } from '../../sass/sass-service';
15+
16+
let sassWorkerPool: SassWorkerImplementation;
17+
18+
function isSassException(error: unknown): error is Exception {
19+
return !!error && typeof error === 'object' && 'sassMessage' in error;
20+
}
21+
22+
export function shutdownSassWorkerPool(): void {
23+
sassWorkerPool?.close();
24+
}
1325

1426
export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: string[] }): Plugin {
1527
return {
1628
name: 'angular-sass',
1729
setup(build: PluginBuild): void {
18-
let sass: typeof import('sass');
19-
2030
build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => {
2131
// Lazily load Sass when a Sass file is found
22-
sass ??= await import('sass');
32+
sassWorkerPool ??= new SassWorkerImplementation();
2333

34+
const warnings: PartialMessage[] = [];
2435
try {
25-
const warnings: PartialMessage[] = [];
26-
// Use sync version as async version is slower.
27-
const { css, sourceMap, loadedUrls } = sass.compile(args.path, {
36+
const data = await readFile(args.path, 'utf-8');
37+
const { css, sourceMap, loadedUrls } = await sassWorkerPool.compileStringAsync(data, {
38+
url: pathToFileURL(args.path),
2839
style: 'expanded',
2940
loadPaths: options.loadPaths,
3041
sourceMap: options.sourcemap,
3142
sourceMapIncludeSources: options.sourcemap,
3243
quietDeps: true,
3344
logger: {
34-
warn: (text, _options) => {
45+
warn: (text, { deprecation, span }) => {
3546
warnings.push({
36-
text,
47+
text: deprecation ? 'Deprecation' : text,
48+
location: span && {
49+
file: span.url && fileURLToPath(span.url),
50+
lineText: span.context,
51+
// Sass line numbers are 0-based while esbuild's are 1-based
52+
line: span.start.line + 1,
53+
column: span.start.column,
54+
},
55+
notes: deprecation ? [{ text }] : undefined,
3756
});
3857
},
3958
},
@@ -48,16 +67,17 @@ export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: stri
4867
warnings,
4968
};
5069
} catch (error) {
51-
if (error instanceof sass.Exception) {
70+
if (isSassException(error)) {
5271
const file = error.span.url ? fileURLToPath(error.span.url) : undefined;
5372

5473
return {
5574
loader: 'css',
5675
errors: [
5776
{
58-
text: error.toString(),
77+
text: error.message,
5978
},
6079
],
80+
warnings,
6181
watchFiles: file ? [file] : undefined,
6282
};
6383
}

packages/angular_devkit/build_angular/src/sass/sass-service.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
Exception,
1515
FileImporter,
1616
Importer,
17+
Logger,
18+
SourceSpan,
1719
StringOptionsWithImporter,
1820
StringOptionsWithoutImporter,
1921
} from 'sass';
@@ -48,6 +50,7 @@ interface RenderRequest {
4850
id: number;
4951
workerIndex: number;
5052
callback: RenderCallback;
53+
logger?: Logger;
5154
importers?: Importers[];
5255
previousResolvedModules?: Set<string>;
5356
}
@@ -68,6 +71,12 @@ interface RenderResponseMessage {
6871
id: number;
6972
error?: Exception;
7073
result?: Omit<CompileResult, 'loadedUrls'> & { loadedUrls: string[] };
74+
warnings?: {
75+
message: string;
76+
deprecation: boolean;
77+
stack?: string;
78+
span?: Omit<SourceSpan, 'url'> & { url?: string };
79+
}[];
7180
}
7281

7382
/**
@@ -153,13 +162,14 @@ export class SassWorkerImplementation {
153162
resolve(result);
154163
};
155164

156-
const request = this.createRequest(workerIndex, callback, importers);
165+
const request = this.createRequest(workerIndex, callback, logger, importers);
157166
this.requests.set(request.id, request);
158167

159168
this.workers[workerIndex].postMessage({
160169
id: request.id,
161170
source,
162171
hasImporter: !!importers?.length,
172+
hasLogger: !!logger,
163173
options: {
164174
...serializableOptions,
165175
// URL is not serializable so to convert to string here and back to URL in the worker.
@@ -200,6 +210,18 @@ export class SassWorkerImplementation {
200210
this.requests.delete(response.id);
201211
this.availableWorkers.push(request.workerIndex);
202212

213+
if (response.warnings && request.logger?.warn) {
214+
for (const { message, span, ...options } of response.warnings) {
215+
request.logger.warn(message, {
216+
...options,
217+
span: span && {
218+
...span,
219+
url: span.url ? pathToFileURL(span.url) : undefined,
220+
},
221+
});
222+
}
223+
}
224+
203225
if (response.result) {
204226
request.callback(undefined, {
205227
...response.result,
@@ -274,12 +296,14 @@ export class SassWorkerImplementation {
274296
private createRequest(
275297
workerIndex: number,
276298
callback: RenderCallback,
299+
logger: Logger | undefined,
277300
importers: Importers[] | undefined,
278301
): RenderRequest {
279302
return {
280303
id: this.idCounter++,
281304
workerIndex,
282305
callback,
306+
logger,
283307
importers,
284308
};
285309
}

packages/angular_devkit/build_angular/src/sass/worker.ts

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { Exception, StringOptionsWithImporter, compileString } from 'sass';
9+
import { Exception, SourceSpan, StringOptionsWithImporter, compileString } from 'sass';
1010
import { fileURLToPath, pathToFileURL } from 'url';
1111
import { MessagePort, parentPort, receiveMessageOnPort, workerData } from 'worker_threads';
1212

@@ -31,6 +31,10 @@ interface RenderRequestMessage {
3131
* Indicates the request has a custom importer function on the main thread.
3232
*/
3333
hasImporter: boolean;
34+
/**
35+
* Indicates the request has a custom logger for warning messages.
36+
*/
37+
hasLogger: boolean;
3438
}
3539

3640
if (!parentPort || !workerData) {
@@ -43,11 +47,20 @@ const { workerImporterPort, importerSignal } = workerData as {
4347
importerSignal: Int32Array;
4448
};
4549

46-
parentPort.on('message', ({ id, hasImporter, source, options }: RenderRequestMessage) => {
50+
parentPort.on('message', (message: RenderRequestMessage) => {
4751
if (!parentPort) {
4852
throw new Error('"parentPort" is not defined. Sass worker must be executed as a Worker.');
4953
}
5054

55+
const { id, hasImporter, hasLogger, source, options } = message;
56+
let warnings:
57+
| {
58+
message: string;
59+
deprecation: boolean;
60+
stack?: string;
61+
span?: Omit<SourceSpan, 'url'> & { url?: string };
62+
}[]
63+
| undefined;
5164
try {
5265
if (hasImporter) {
5366
// When a custom importer function is present, the importer request must be proxied
@@ -75,10 +88,24 @@ parentPort.on('message', ({ id, hasImporter, source, options }: RenderRequestMes
7588
...options,
7689
// URL is not serializable so to convert to string in the parent and back to URL here.
7790
url: options.url ? pathToFileURL(options.url) : undefined,
91+
logger: hasLogger
92+
? {
93+
warn(message, { deprecation, span, stack }) {
94+
warnings ??= [];
95+
warnings.push({
96+
message,
97+
deprecation,
98+
stack,
99+
span: span && convertSourceSpan(span),
100+
});
101+
},
102+
}
103+
: undefined,
78104
});
79105

80106
parentPort.postMessage({
81107
id,
108+
warnings,
82109
result: {
83110
...result,
84111
// URL is not serializable so to convert to string here and back to URL in the parent.
@@ -91,22 +118,9 @@ parentPort.on('message', ({ id, hasImporter, source, options }: RenderRequestMes
91118
const { span, message, stack, sassMessage, sassStack } = error;
92119
parentPort.postMessage({
93120
id,
121+
warnings,
94122
error: {
95-
span: {
96-
text: span.text,
97-
context: span.context,
98-
end: {
99-
column: span.end.column,
100-
offset: span.end.offset,
101-
line: span.end.line,
102-
},
103-
start: {
104-
column: span.start.column,
105-
offset: span.start.offset,
106-
line: span.start.line,
107-
},
108-
url: span.url ? fileURLToPath(span.url) : undefined,
109-
},
123+
span: convertSourceSpan(span),
110124
message,
111125
stack,
112126
sassMessage,
@@ -115,9 +129,40 @@ parentPort.on('message', ({ id, hasImporter, source, options }: RenderRequestMes
115129
});
116130
} else if (error instanceof Error) {
117131
const { message, stack } = error;
118-
parentPort.postMessage({ id, error: { message, stack } });
132+
parentPort.postMessage({ id, warnings, error: { message, stack } });
119133
} else {
120-
parentPort.postMessage({ id, error: { message: 'An unknown error has occurred.' } });
134+
parentPort.postMessage({
135+
id,
136+
warnings,
137+
error: { message: 'An unknown error has occurred.' },
138+
});
121139
}
122140
}
123141
});
142+
143+
/**
144+
* Converts a Sass SourceSpan object into a serializable form.
145+
* The SourceSpan object contains a URL property which must be converted into a string.
146+
* Also, most of the interface's properties are get accessors and are not automatically
147+
* serialized when sent back from the worker.
148+
*
149+
* @param span The Sass SourceSpan object to convert.
150+
* @returns A serializable form of the SourceSpan object.
151+
*/
152+
function convertSourceSpan(span: SourceSpan): Omit<SourceSpan, 'url'> & { url?: string } {
153+
return {
154+
text: span.text,
155+
context: span.context,
156+
end: {
157+
column: span.end.column,
158+
offset: span.end.offset,
159+
line: span.end.line,
160+
},
161+
start: {
162+
column: span.start.column,
163+
offset: span.start.offset,
164+
line: span.start.line,
165+
},
166+
url: span.url ? fileURLToPath(span.url) : undefined,
167+
};
168+
}

0 commit comments

Comments
 (0)