Skip to content

Commit c748753

Browse files
committed
feat(@angular-devkit/build-angular): switch to use Sass modern API
Sass modern API provides faster compilations times when used in an async manner. Users can temporary opt-out from using the modern API by setting `NG_BUILD_LEGACY_SASS` to `true` or `1`. Application compilation duration | Sass API and Compiler -- | -- 60852ms | dart-sass legacy sync API 52666ms | dart-sass modern API Note: https://github.com/johannesjo/super-productivity was used for benchmarking. Prior art: http://docs/document/d/1CvEceWMpBoEBd8SfvksGMdVHxaZMH93b0EGS3XbR3_Q?resourcekey=0-vFm-xMspT65FZLIyX7xWFQ
1 parent 88c3b71 commit c748753

File tree

8 files changed

+646
-174
lines changed

8 files changed

+646
-174
lines changed

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

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

9-
import type { Plugin, PluginBuild } from 'esbuild';
10-
import type { LegacyResult } from 'sass';
11-
import { SassWorkerImplementation } from '../../sass/sass-service';
9+
import type { PartialMessage, Plugin, PluginBuild } from 'esbuild';
10+
import type { CompileResult } from 'sass';
11+
import { fileURLToPath } from 'url';
1212

13-
export function createSassPlugin(options: { sourcemap: boolean; includePaths?: string[] }): Plugin {
13+
export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: string[] }): Plugin {
1414
return {
1515
name: 'angular-sass',
1616
setup(build: PluginBuild): void {
17-
let sass: SassWorkerImplementation;
17+
let sass: typeof import('sass');
1818

19-
build.onStart(() => {
20-
sass = new SassWorkerImplementation();
19+
build.onStart(async () => {
20+
// Lazily load Sass
21+
sass = await import('sass');
2122
});
2223

23-
build.onEnd(() => {
24-
sass?.close();
25-
});
26-
27-
build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => {
28-
const result = await new Promise<LegacyResult>((resolve, reject) => {
29-
sass.render(
30-
{
31-
file: args.path,
32-
includePaths: options.includePaths,
33-
indentedSyntax: args.path.endsWith('.sass'),
34-
outputStyle: 'expanded',
35-
sourceMap: options.sourcemap,
36-
sourceMapContents: options.sourcemap,
37-
sourceMapEmbed: options.sourcemap,
38-
quietDeps: true,
39-
},
40-
(error, result) => {
41-
if (error) {
42-
reject(error);
43-
}
44-
if (result) {
45-
resolve(result);
46-
}
24+
build.onLoad({ filter: /\.s[ac]ss$/ }, (args) => {
25+
try {
26+
const warnings: PartialMessage[] = [];
27+
// Use sync version as async version is slower.
28+
const { css, sourceMap, loadedUrls } = sass.compile(args.path, {
29+
style: 'expanded',
30+
loadPaths: options.loadPaths,
31+
sourceMap: options.sourcemap,
32+
sourceMapIncludeSources: options.sourcemap,
33+
quietDeps: true,
34+
logger: {
35+
warn: (text, _options) => {
36+
warnings.push({
37+
text,
38+
});
39+
},
4740
},
48-
);
49-
});
50-
51-
return {
52-
contents: result.css,
53-
loader: 'css',
54-
watchFiles: result.stats.includedFiles,
55-
};
41+
});
42+
43+
return {
44+
loader: 'css',
45+
contents: css + sourceMapToUrlComment(sourceMap),
46+
watchFiles: loadedUrls.map((url) => fileURLToPath(url)),
47+
warnings,
48+
};
49+
} catch (error) {
50+
if (error instanceof sass.Exception) {
51+
const file = error.span.url ? fileURLToPath(error.span.url) : undefined;
52+
53+
return {
54+
loader: 'css',
55+
errors: [
56+
{
57+
text: error.toString(),
58+
},
59+
],
60+
watchFiles: file ? [file] : undefined,
61+
};
62+
}
63+
64+
throw error;
65+
}
5666
});
5767
},
5868
};
5969
}
70+
71+
function sourceMapToUrlComment(sourceMap: CompileResult['sourceMap']): string {
72+
if (!sourceMap) {
73+
return '';
74+
}
75+
76+
const urlSourceMap = Buffer.from(JSON.stringify(sourceMap), 'utf-8').toString('base64');
77+
78+
return `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${urlSourceMap}`;
79+
}

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ async function bundleStylesheet(
2424
entry: Required<Pick<BuildOptions, 'stdin'> | Pick<BuildOptions, 'entryPoints'>>,
2525
options: BundleStylesheetOptions,
2626
) {
27+
const loadPaths = options.includePaths ?? [];
28+
// Needed to resolve node packages.
29+
loadPaths.push(path.join(options.workspaceRoot, 'node_modules'));
30+
2731
// Execute esbuild
2832
const result = await bundle({
2933
...entry,
@@ -40,9 +44,7 @@ async function bundleStylesheet(
4044
preserveSymlinks: options.preserveSymlinks,
4145
conditions: ['style', 'sass'],
4246
mainFields: ['style', 'sass'],
43-
plugins: [
44-
createSassPlugin({ sourcemap: !!options.sourcemap, includePaths: options.includePaths }),
45-
],
47+
plugins: [createSassPlugin({ sourcemap: !!options.sourcemap, loadPaths })],
4648
});
4749

4850
// Extract the result of the bundling from the output files
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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 {
10+
LegacyAsyncImporter as AsyncImporter,
11+
LegacyResult as CompileResult,
12+
LegacyException as Exception,
13+
LegacyImporterResult as ImporterResult,
14+
LegacyImporterThis as ImporterThis,
15+
LegacyOptions as Options,
16+
LegacySyncImporter as SyncImporter,
17+
} from 'sass';
18+
import { MessageChannel, Worker } from 'worker_threads';
19+
import { maxWorkers } from '../../utils/environment-options';
20+
21+
/**
22+
* The maximum number of Workers that will be created to execute render requests.
23+
*/
24+
const MAX_RENDER_WORKERS = maxWorkers;
25+
26+
/**
27+
* The callback type for the `dart-sass` asynchronous render function.
28+
*/
29+
type RenderCallback = (error?: Exception, result?: CompileResult) => void;
30+
31+
/**
32+
* An object containing the contextual information for a specific render request.
33+
*/
34+
interface RenderRequest {
35+
id: number;
36+
workerIndex: number;
37+
callback: RenderCallback;
38+
importers?: (SyncImporter | AsyncImporter)[];
39+
}
40+
41+
/**
42+
* A response from the Sass render Worker containing the result of the operation.
43+
*/
44+
interface RenderResponseMessage {
45+
id: number;
46+
error?: Exception;
47+
result?: CompileResult;
48+
}
49+
50+
/**
51+
* A Sass renderer implementation that provides an interface that can be used by Webpack's
52+
* `sass-loader`. The implementation uses a Worker thread to perform the Sass rendering
53+
* with the `dart-sass` package. The `dart-sass` synchronous render function is used within
54+
* the worker which can be up to two times faster than the asynchronous variant.
55+
*/
56+
export class SassWorkerImplementationLegacy {
57+
private readonly workers: Worker[] = [];
58+
private readonly availableWorkers: number[] = [];
59+
private readonly requests = new Map<number, RenderRequest>();
60+
private idCounter = 1;
61+
private nextWorkerIndex = 0;
62+
63+
/**
64+
* Provides information about the Sass implementation.
65+
* This mimics enough of the `dart-sass` value to be used with the `sass-loader`.
66+
*/
67+
get info(): string {
68+
return 'dart-sass\tworker';
69+
}
70+
71+
/**
72+
* The synchronous render function is not used by the `sass-loader`.
73+
*/
74+
renderSync(): never {
75+
throw new Error('Sass renderSync is not supported.');
76+
}
77+
78+
/**
79+
* Asynchronously request a Sass stylesheet to be renderered.
80+
*
81+
* @param options The `dart-sass` options to use when rendering the stylesheet.
82+
* @param callback The function to execute when the rendering is complete.
83+
*/
84+
render(options: Options<'async'>, callback: RenderCallback): void {
85+
// The `functions`, `logger` and `importer` options are JavaScript functions that cannot be transferred.
86+
// If any additional function options are added in the future, they must be excluded as well.
87+
const { functions, importer, logger, ...serializableOptions } = options;
88+
89+
// The CLI's configuration does not use or expose the ability to defined custom Sass functions
90+
if (functions && Object.keys(functions).length > 0) {
91+
throw new Error('Sass custom functions are not supported.');
92+
}
93+
94+
let workerIndex = this.availableWorkers.pop();
95+
if (workerIndex === undefined) {
96+
if (this.workers.length < MAX_RENDER_WORKERS) {
97+
workerIndex = this.workers.length;
98+
this.workers.push(this.createWorker());
99+
} else {
100+
workerIndex = this.nextWorkerIndex++;
101+
if (this.nextWorkerIndex >= this.workers.length) {
102+
this.nextWorkerIndex = 0;
103+
}
104+
}
105+
}
106+
107+
const request = this.createRequest(workerIndex, callback, importer);
108+
this.requests.set(request.id, request);
109+
110+
this.workers[workerIndex].postMessage({
111+
id: request.id,
112+
hasImporter: !!importer,
113+
options: serializableOptions,
114+
});
115+
}
116+
117+
/**
118+
* Shutdown the Sass render worker.
119+
* Executing this method will stop any pending render requests.
120+
*/
121+
close(): void {
122+
for (const worker of this.workers) {
123+
try {
124+
void worker.terminate();
125+
} catch {}
126+
}
127+
this.requests.clear();
128+
}
129+
130+
private createWorker(): Worker {
131+
const { port1: mainImporterPort, port2: workerImporterPort } = new MessageChannel();
132+
const importerSignal = new Int32Array(new SharedArrayBuffer(4));
133+
134+
const workerPath = require.resolve('./worker');
135+
const worker = new Worker(workerPath, {
136+
workerData: { workerImporterPort, importerSignal },
137+
transferList: [workerImporterPort],
138+
});
139+
140+
worker.on('message', (response: RenderResponseMessage) => {
141+
const request = this.requests.get(response.id);
142+
if (!request) {
143+
return;
144+
}
145+
146+
this.requests.delete(response.id);
147+
this.availableWorkers.push(request.workerIndex);
148+
149+
if (response.result) {
150+
// The results are expected to be Node.js `Buffer` objects but will each be transferred as
151+
// a Uint8Array that does not have the expected `toString` behavior of a `Buffer`.
152+
const { css, map, stats } = response.result;
153+
const result: CompileResult = {
154+
// This `Buffer.from` override will use the memory directly and avoid making a copy
155+
css: Buffer.from(css.buffer, css.byteOffset, css.byteLength),
156+
stats,
157+
};
158+
if (map) {
159+
// This `Buffer.from` override will use the memory directly and avoid making a copy
160+
result.map = Buffer.from(map.buffer, map.byteOffset, map.byteLength);
161+
}
162+
request.callback(undefined, result);
163+
} else {
164+
request.callback(response.error);
165+
}
166+
});
167+
168+
mainImporterPort.on(
169+
'message',
170+
({
171+
id,
172+
url,
173+
prev,
174+
fromImport,
175+
}: {
176+
id: number;
177+
url: string;
178+
prev: string;
179+
fromImport: boolean;
180+
}) => {
181+
const request = this.requests.get(id);
182+
if (!request?.importers) {
183+
mainImporterPort.postMessage(null);
184+
Atomics.store(importerSignal, 0, 1);
185+
Atomics.notify(importerSignal, 0);
186+
187+
return;
188+
}
189+
190+
this.processImporters(request.importers, url, prev, fromImport)
191+
.then((result) => {
192+
mainImporterPort.postMessage(result);
193+
})
194+
.catch((error) => {
195+
mainImporterPort.postMessage(error);
196+
})
197+
.finally(() => {
198+
Atomics.store(importerSignal, 0, 1);
199+
Atomics.notify(importerSignal, 0);
200+
});
201+
},
202+
);
203+
204+
mainImporterPort.unref();
205+
206+
return worker;
207+
}
208+
209+
private async processImporters(
210+
importers: Iterable<SyncImporter | AsyncImporter>,
211+
url: string,
212+
prev: string,
213+
fromImport: boolean,
214+
): Promise<ImporterResult> {
215+
let result = null;
216+
for (const importer of importers) {
217+
result = await new Promise<ImporterResult>((resolve) => {
218+
// Importers can be both sync and async
219+
const innerResult = (importer as AsyncImporter).call(
220+
{ fromImport } as ImporterThis,
221+
url,
222+
prev,
223+
resolve,
224+
);
225+
if (innerResult !== undefined) {
226+
resolve(innerResult);
227+
}
228+
});
229+
230+
if (result) {
231+
break;
232+
}
233+
}
234+
235+
return result;
236+
}
237+
238+
private createRequest(
239+
workerIndex: number,
240+
callback: RenderCallback,
241+
importer: SyncImporter | AsyncImporter | (SyncImporter | AsyncImporter)[] | undefined,
242+
): RenderRequest {
243+
return {
244+
id: this.idCounter++,
245+
workerIndex,
246+
callback,
247+
importers: !importer || Array.isArray(importer) ? importer : [importer],
248+
};
249+
}
250+
}

0 commit comments

Comments
 (0)