diff --git a/package.json b/package.json index 34c0eea6a689..01b6827863d0 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "@types/progress": "^2.0.3", "@types/resolve": "^1.17.1", "@types/rimraf": "^3.0.0", + "@types/sass": "^1.16.0", "@types/semver": "^7.0.0", "@types/text-table": "^0.2.1", "@types/uuid": "^8.0.0", diff --git a/packages/angular_devkit/build_angular/BUILD.bazel b/packages/angular_devkit/build_angular/BUILD.bazel index 2274d3635272..59196a808dee 100644 --- a/packages/angular_devkit/build_angular/BUILD.bazel +++ b/packages/angular_devkit/build_angular/BUILD.bazel @@ -132,6 +132,7 @@ ts_library( "@npm//@types/parse5-html-rewriting-stream", "@npm//@types/postcss-preset-env", "@npm//@types/rimraf", + "@npm//@types/sass", "@npm//@types/semver", "@npm//@types/text-table", "@npm//@types/webpack-dev-server", diff --git a/packages/angular_devkit/build_angular/src/sass/sass-service.ts b/packages/angular_devkit/build_angular/src/sass/sass-service.ts new file mode 100644 index 000000000000..5c3a760b9fe5 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/sass/sass-service.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Importer, ImporterReturnType, Options, Result, SassException } from 'sass'; +import { MessageChannel, Worker } from 'worker_threads'; + +/** + * The callback type for the `dart-sass` asynchronous render function. + */ +type RenderCallback = (error?: SassException, result?: Result) => void; + +/** + * An object containing the contextual information for a specific render request. + */ +interface RenderRequest { + id: number; + callback: RenderCallback; + importers?: Importer[]; +} + +/** + * A response from the Sass render Worker containing the result of the operation. + */ +interface RenderResponseMessage { + id: number; + error?: SassException; + result?: Result; +} + +/** + * A Sass renderer implementation that provides an interface that can be used by Webpack's + * `sass-loader`. The implementation uses a Worker thread to perform the Sass rendering + * with the `dart-sass` package. The `dart-sass` synchronous render function is used within + * the worker which can be up to two times faster than the asynchronous variant. + */ +export class SassWorkerImplementation { + private worker?: Worker; + private readonly requests = new Map(); + private idCounter = 1; + + /** + * Provides information about the Sass implementation. + * This mimics enough of the `dart-sass` value to be used with the `sass-loader`. + */ + get info(): string { + return 'dart-sass\tworker'; + } + + /** + * The synchronous render function is not used by the `sass-loader`. + */ + renderSync(): never { + throw new Error('Sass renderSync is not supported.'); + } + + /** + * Asynchronously request a Sass stylesheet to be renderered. + * + * @param options The `dart-sass` options to use when rendering the stylesheet. + * @param callback The function to execute when the rendering is complete. + */ + render(options: Options, callback: RenderCallback): void { + // The `functions` and `importer` options are JavaScript functions that cannot be transferred. + // If any additional function options are added in the future, they must be excluded as well. + const { functions, importer, ...serializableOptions } = options; + + // The CLI's configuration does not use or expose the ability to defined custom Sass functions + if (functions && Object.keys(functions).length > 0) { + throw new Error('Sass custom functions are not supported.'); + } + + if (!this.worker) { + this.worker = this.createWorker(); + } + + const request = this.createRequest(callback, importer); + this.requests.set(request.id, request); + + this.worker.postMessage({ + id: request.id, + hasImporter: !!importer, + options: serializableOptions, + }); + } + + /** + * Shutdown the Sass render worker. + * Executing this method will stop any pending render requests. + * + * The worker is unreferenced upon creation and will not block application exit. This method + * is only needed if early cleanup is needed. + */ + close(): void { + this.worker?.terminate(); + this.requests.clear(); + } + + private createWorker(): Worker { + const { port1: mainImporterPort, port2: workerImporterPort } = new MessageChannel(); + const importerSignal = new Int32Array(new SharedArrayBuffer(4)); + + const workerPath = require.resolve('./worker'); + const worker = new Worker(workerPath, { + workerData: { workerImporterPort, importerSignal }, + transferList: [workerImporterPort], + }); + + worker.on('message', (response: RenderResponseMessage) => { + const request = this.requests.get(response.id); + if (!request) { + return; + } + + this.requests.delete(response.id); + + if (response.result) { + // The results are expected to be Node.js `Buffer` objects but will each be transferred as + // a Uint8Array that does not have the expected `toString` behavior of a `Buffer`. + const { css, map, stats } = response.result; + const result: Result = { + // This `Buffer.from` override will use the memory directly and avoid making a copy + css: Buffer.from(css.buffer, css.byteOffset, css.byteLength), + stats, + }; + if (map) { + // This `Buffer.from` override will use the memory directly and avoid making a copy + result.map = Buffer.from(map.buffer, map.byteOffset, map.byteLength); + } + request.callback(undefined, result); + } else { + request.callback(response.error); + } + }); + + mainImporterPort.on( + 'message', + ({ id, url, prev }: { id: number; url: string; prev: string }) => { + const request = this.requests.get(id); + if (!request?.importers) { + mainImporterPort.postMessage(null); + Atomics.store(importerSignal, 0, 1); + Atomics.notify(importerSignal, 0); + + return; + } + + this.processImporters(request.importers, url, prev) + .then((result) => { + mainImporterPort.postMessage(result); + }) + .catch((error) => { + mainImporterPort.postMessage(error); + }) + .finally(() => { + Atomics.store(importerSignal, 0, 1); + Atomics.notify(importerSignal, 0); + }); + }, + ); + + worker.unref(); + mainImporterPort.unref(); + + return worker; + } + + private async processImporters( + importers: Iterable, + url: string, + prev: string, + ): Promise { + let result = null; + for (const importer of importers) { + result = await new Promise((resolve) => { + // Importers can be both sync and async + const innerResult = importer(url, prev, resolve); + if (innerResult !== undefined) { + resolve(innerResult); + } + }); + + if (result) { + break; + } + } + + return result; + } + + private createRequest( + callback: RenderCallback, + importer: Importer | Importer[] | undefined, + ): RenderRequest { + return { + id: this.idCounter++, + callback, + importers: !importer || Array.isArray(importer) ? importer : [importer], + }; + } +} diff --git a/packages/angular_devkit/build_angular/src/sass/worker.ts b/packages/angular_devkit/build_angular/src/sass/worker.ts new file mode 100644 index 000000000000..137b9e323ebc --- /dev/null +++ b/packages/angular_devkit/build_angular/src/sass/worker.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { ImporterReturnType, Options, renderSync } from 'sass'; +import { MessagePort, parentPort, receiveMessageOnPort, workerData } from 'worker_threads'; + +/** + * A request to render a Sass stylesheet using the supplied options. + */ +interface RenderRequestMessage { + /** + * The unique request identifier that links the render action with a callback and optional + * importer on the main thread. + */ + id: number; + /** + * The Sass options to provide to the `dart-sass` render function. + */ + options: Options; + /** + * Indicates the request has a custom importer function on the main thread. + */ + hasImporter: boolean; +} + +if (!parentPort || !workerData) { + throw new Error('Sass worker must be executed as a Worker.'); +} + +// The importer variables are used to proxy import requests to the main thread +const { workerImporterPort, importerSignal } = workerData as { + workerImporterPort: MessagePort; + importerSignal: Int32Array; +}; + +parentPort.on('message', ({ id, hasImporter, options }: RenderRequestMessage) => { + try { + if (hasImporter) { + // When a custom importer function is present, the importer request must be proxied + // back to the main thread where it can be executed. + // This process must be synchronous from the perspective of dart-sass. The `Atomics` + // functions combined with the shared memory `importSignal` and the Node.js + // `receiveMessageOnPort` function are used to ensure synchronous behavior. + options.importer = (url, prev) => { + Atomics.store(importerSignal, 0, 0); + workerImporterPort.postMessage({ id, url, prev }); + Atomics.wait(importerSignal, 0, 0); + + return receiveMessageOnPort(workerImporterPort)?.message as ImporterReturnType; + }; + } + + // The synchronous Sass render function can be up to two times faster than the async variant + const result = renderSync(options); + + parentPort?.postMessage({ id, result }); + } catch (error) { + parentPort?.postMessage({ id, error }); + } +}); diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts b/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts index b6c81e0bb04e..24deacf4996a 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as webpack from 'webpack'; import { ExtraEntryPoint } from '../../browser/schema'; +import { SassWorkerImplementation } from '../../sass/sass-service'; import { BuildBrowserFeatures } from '../../utils/build-browser-features'; import { WebpackConfigOptions } from '../../utils/build-options'; import { @@ -114,7 +115,7 @@ export function getStylesConfig(wco: WebpackConfigOptions): webpack.Configuratio `To opt-out of the deprecated behaviour and start using 'sass' uninstall 'node-sass'.`, ); } catch { - sassImplementation = require('sass'); + sassImplementation = new SassWorkerImplementation(); } const assetNameTemplate = assetNameTemplateFactory(hashFormat); @@ -288,6 +289,8 @@ export function getStylesConfig(wco: WebpackConfigOptions): webpack.Configuratio implementation: sassImplementation, sourceMap: true, sassOptions: { + // Prevent use of `fibers` package as it no longer works in newer Node.js versions + fiber: false, // bootstrap-sass requires a minimum precision of 8 precision: 8, includePaths, diff --git a/yarn.lock b/yarn.lock index 5bd81e85f386..8f840bd96839 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1974,6 +1974,13 @@ "@types/glob" "*" "@types/node" "*" +"@types/sass@^1.16.0": + version "1.16.0" + resolved "https://registry.yarnpkg.com/@types/sass/-/sass-1.16.0.tgz#b41ac1c17fa68ffb57d43e2360486ef526b3d57d" + integrity sha512-2XZovu4NwcqmtZtsBR5XYLw18T8cBCnU2USFHTnYLLHz9fkhnoEMoDsqShJIOFsFhn5aJHjweiUUdTrDGujegA== + dependencies: + "@types/node" "*" + "@types/selenium-webdriver@^3.0.0": version "3.0.17" resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-3.0.17.tgz#50bea0c3c2acc31c959c5b1e747798b3b3d06d4b"