diff --git a/package.json b/package.json index d08f927851f2..20a3dfdfe4b9 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,6 @@ "eslint-plugin-header": "3.1.1", "eslint-plugin-import": "2.26.0", "express": "4.18.1", - "font-awesome": "^4.7.0", "glob": "8.0.3", "http-proxy": "^1.18.1", "https-proxy-agent": "5.0.1", diff --git a/packages/angular_devkit/build_angular/BUILD.bazel b/packages/angular_devkit/build_angular/BUILD.bazel index 2465b9bf7ff2..10c3b41567ea 100644 --- a/packages/angular_devkit/build_angular/BUILD.bazel +++ b/packages/angular_devkit/build_angular/BUILD.bazel @@ -341,7 +341,6 @@ LARGE_SPECS = { "@npm//@angular/animations", "@npm//@angular/material", "@npm//bootstrap", - "@npm//font-awesome", "@npm//jquery", "@npm//popper.js", ], diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts index dfafa49049e4..c0c0a4304f83 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts @@ -6,54 +6,74 @@ * found in the LICENSE file at https://angular.io/license */ -import type { Plugin, PluginBuild } from 'esbuild'; -import type { LegacyResult } from 'sass'; -import { SassWorkerImplementation } from '../../sass/sass-service'; +import type { PartialMessage, Plugin, PluginBuild } from 'esbuild'; +import type { CompileResult } from 'sass'; +import { fileURLToPath } from 'url'; -export function createSassPlugin(options: { sourcemap: boolean; includePaths?: string[] }): Plugin { +export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: string[] }): Plugin { return { name: 'angular-sass', setup(build: PluginBuild): void { - let sass: SassWorkerImplementation; + let sass: typeof import('sass'); - build.onStart(() => { - sass = new SassWorkerImplementation(); + build.onStart(async () => { + // Lazily load Sass + sass = await import('sass'); }); - build.onEnd(() => { - sass?.close(); - }); - - build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => { - const result = await new Promise((resolve, reject) => { - sass.render( - { - file: args.path, - includePaths: options.includePaths, - indentedSyntax: args.path.endsWith('.sass'), - outputStyle: 'expanded', - sourceMap: options.sourcemap, - sourceMapContents: options.sourcemap, - sourceMapEmbed: options.sourcemap, - quietDeps: true, - }, - (error, result) => { - if (error) { - reject(error); - } - if (result) { - resolve(result); - } + build.onLoad({ filter: /\.s[ac]ss$/ }, (args) => { + try { + const warnings: PartialMessage[] = []; + // Use sync version as async version is slower. + const { css, sourceMap, loadedUrls } = sass.compile(args.path, { + style: 'expanded', + loadPaths: options.loadPaths, + sourceMap: options.sourcemap, + sourceMapIncludeSources: options.sourcemap, + quietDeps: true, + logger: { + warn: (text, _options) => { + warnings.push({ + text, + }); + }, }, - ); - }); - - return { - contents: result.css, - loader: 'css', - watchFiles: result.stats.includedFiles, - }; + }); + + return { + loader: 'css', + contents: css + sourceMapToUrlComment(sourceMap), + watchFiles: loadedUrls.map((url) => fileURLToPath(url)), + warnings, + }; + } catch (error) { + if (error instanceof sass.Exception) { + const file = error.span.url ? fileURLToPath(error.span.url) : undefined; + + return { + loader: 'css', + errors: [ + { + text: error.toString(), + }, + ], + watchFiles: file ? [file] : undefined, + }; + } + + throw error; + } }); }, }; } + +function sourceMapToUrlComment(sourceMap: CompileResult['sourceMap']): string { + if (!sourceMap) { + return ''; + } + + const urlSourceMap = Buffer.from(JSON.stringify(sourceMap), 'utf-8').toString('base64'); + + return `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${urlSourceMap}`; +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts index 81ef5c5286f7..901f0af03cfd 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts @@ -24,6 +24,10 @@ async function bundleStylesheet( entry: Required | Pick>, options: BundleStylesheetOptions, ) { + const loadPaths = options.includePaths ?? []; + // Needed to resolve node packages. + loadPaths.push(path.join(options.workspaceRoot, 'node_modules')); + // Execute esbuild const result = await bundle({ ...entry, @@ -40,9 +44,7 @@ async function bundleStylesheet( preserveSymlinks: options.preserveSymlinks, conditions: ['style', 'sass'], mainFields: ['style', 'sass'], - plugins: [ - createSassPlugin({ sourcemap: !!options.sourcemap, includePaths: options.includePaths }), - ], + plugins: [createSassPlugin({ sourcemap: !!options.sourcemap, loadPaths })], }); // Extract the result of the bundling from the output files diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/styles_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/styles_spec.ts index 6c48c0b1eb34..b245a7c52d1d 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/specs/styles_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/styles_spec.ts @@ -7,6 +7,7 @@ */ import { Architect } from '@angular-devkit/architect'; +import { TestProjectHost } from '@angular-devkit/architect/testing'; import { normalize, tags } from '@angular-devkit/core'; import { dirname } from 'path'; import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; @@ -259,7 +260,19 @@ describe('Browser Builder styles', () => { }); }); + /** + * font-awesome mock to avoid having an extra dependency. + */ + function mockFontAwesomePackage(host: TestProjectHost): void { + host.writeMultipleFiles({ + 'node_modules/font-awesome/scss/font-awesome.scss': ` + * { color: red } + `, + }); + } + it(`supports font-awesome imports`, async () => { + mockFontAwesomePackage(host); host.writeMultipleFiles({ 'src/styles.scss': ` @import "font-awesome/scss/font-awesome"; @@ -271,6 +284,7 @@ describe('Browser Builder styles', () => { }); it(`supports font-awesome imports (tilde)`, async () => { + mockFontAwesomePackage(host); host.writeMultipleFiles({ 'src/styles.scss': ` $fa-font-path: "~font-awesome/fonts"; diff --git a/packages/angular_devkit/build_angular/src/sass/legacy/sass-service.ts b/packages/angular_devkit/build_angular/src/sass/legacy/sass-service.ts new file mode 100644 index 000000000000..d1c0876bac3d --- /dev/null +++ b/packages/angular_devkit/build_angular/src/sass/legacy/sass-service.ts @@ -0,0 +1,250 @@ +/** + * @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 { + LegacyAsyncImporter as AsyncImporter, + LegacyResult as CompileResult, + LegacyException as Exception, + LegacyImporterResult as ImporterResult, + LegacyImporterThis as ImporterThis, + LegacyOptions as Options, + LegacySyncImporter as SyncImporter, +} from 'sass'; +import { MessageChannel, Worker } from 'worker_threads'; +import { maxWorkers } from '../../utils/environment-options'; + +/** + * The maximum number of Workers that will be created to execute render requests. + */ +const MAX_RENDER_WORKERS = maxWorkers; + +/** + * The callback type for the `dart-sass` asynchronous render function. + */ +type RenderCallback = (error?: Exception, result?: CompileResult) => void; + +/** + * An object containing the contextual information for a specific render request. + */ +interface RenderRequest { + id: number; + workerIndex: number; + callback: RenderCallback; + importers?: (SyncImporter | AsyncImporter)[]; +} + +/** + * A response from the Sass render Worker containing the result of the operation. + */ +interface RenderResponseMessage { + id: number; + error?: Exception; + result?: CompileResult; +} + +/** + * 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 SassWorkerImplementationLegacy { + private readonly workers: Worker[] = []; + private readonly availableWorkers: number[] = []; + private readonly requests = new Map(); + private idCounter = 1; + private nextWorkerIndex = 0; + + /** + * 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<'async'>, callback: RenderCallback): void { + // The `functions`, `logger` 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, logger, ...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.'); + } + + let workerIndex = this.availableWorkers.pop(); + if (workerIndex === undefined) { + if (this.workers.length < MAX_RENDER_WORKERS) { + workerIndex = this.workers.length; + this.workers.push(this.createWorker()); + } else { + workerIndex = this.nextWorkerIndex++; + if (this.nextWorkerIndex >= this.workers.length) { + this.nextWorkerIndex = 0; + } + } + } + + const request = this.createRequest(workerIndex, callback, importer); + this.requests.set(request.id, request); + + this.workers[workerIndex].postMessage({ + id: request.id, + hasImporter: !!importer, + options: serializableOptions, + }); + } + + /** + * Shutdown the Sass render worker. + * Executing this method will stop any pending render requests. + */ + close(): void { + for (const worker of this.workers) { + try { + void worker.terminate(); + } catch {} + } + 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); + this.availableWorkers.push(request.workerIndex); + + 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: CompileResult = { + // 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, + fromImport, + }: { + id: number; + url: string; + prev: string; + fromImport: boolean; + }) => { + 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, fromImport) + .then((result) => { + mainImporterPort.postMessage(result); + }) + .catch((error) => { + mainImporterPort.postMessage(error); + }) + .finally(() => { + Atomics.store(importerSignal, 0, 1); + Atomics.notify(importerSignal, 0); + }); + }, + ); + + mainImporterPort.unref(); + + return worker; + } + + private async processImporters( + importers: Iterable, + url: string, + prev: string, + fromImport: boolean, + ): Promise { + let result = null; + for (const importer of importers) { + result = await new Promise((resolve) => { + // Importers can be both sync and async + const innerResult = (importer as AsyncImporter).call( + { fromImport } as ImporterThis, + url, + prev, + resolve, + ); + if (innerResult !== undefined) { + resolve(innerResult); + } + }); + + if (result) { + break; + } + } + + return result; + } + + private createRequest( + workerIndex: number, + callback: RenderCallback, + importer: SyncImporter | AsyncImporter | (SyncImporter | AsyncImporter)[] | undefined, + ): RenderRequest { + return { + id: this.idCounter++, + workerIndex, + callback, + importers: !importer || Array.isArray(importer) ? importer : [importer], + }; + } +} diff --git a/packages/angular_devkit/build_angular/src/sass/legacy/worker.ts b/packages/angular_devkit/build_angular/src/sass/legacy/worker.ts new file mode 100644 index 000000000000..bcd978b3258b --- /dev/null +++ b/packages/angular_devkit/build_angular/src/sass/legacy/worker.ts @@ -0,0 +1,69 @@ +/** + * @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 { ImporterResult, LegacyOptions as 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<'sync'>; + /** + * 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 = function (url, prev) { + Atomics.store(importerSignal, 0, 0); + const { fromImport } = this; + workerImporterPort.postMessage({ id, url, prev, fromImport }); + Atomics.wait(importerSignal, 0, 0); + + return receiveMessageOnPort(workerImporterPort)?.message as ImporterResult; + }; + } + + // The synchronous Sass render function can be up to two times faster than the async variant + const result = renderSync(options); + + parentPort?.postMessage({ id, result }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + // Needed because V8 will only serialize the message and stack properties of an Error instance. + const { formatted, file, line, column, message, stack } = error; + parentPort?.postMessage({ id, error: { formatted, file, line, column, message, stack } }); + } +}); diff --git a/packages/angular_devkit/build_angular/src/sass/sass-service.ts b/packages/angular_devkit/build_angular/src/sass/sass-service.ts index 4a40334412d8..515c3910a5e3 100644 --- a/packages/angular_devkit/build_angular/src/sass/sass-service.ts +++ b/packages/angular_devkit/build_angular/src/sass/sass-service.ts @@ -7,14 +7,14 @@ */ import { - LegacyAsyncImporter as AsyncImporter, - LegacyResult as CompileResult, - LegacyException as Exception, - LegacyImporterResult as ImporterResult, - LegacyImporterThis as ImporterThis, - LegacyOptions as Options, - LegacySyncImporter as SyncImporter, + CompileResult, + Exception, + FileImporter, + Importer, + StringOptionsWithImporter, + StringOptionsWithoutImporter, } from 'sass'; +import { fileURLToPath, pathToFileURL } from 'url'; import { MessageChannel, Worker } from 'worker_threads'; import { maxWorkers } from '../utils/environment-options'; @@ -28,6 +28,8 @@ const MAX_RENDER_WORKERS = maxWorkers; */ type RenderCallback = (error?: Exception, result?: CompileResult) => void; +type FileImporterOptions = Parameters[1]; + /** * An object containing the contextual information for a specific render request. */ @@ -35,16 +37,25 @@ interface RenderRequest { id: number; workerIndex: number; callback: RenderCallback; - importers?: (SyncImporter | AsyncImporter)[]; + importers?: Importers[]; } +/** + * All available importer types. + */ +type Importers = + | Importer<'sync'> + | Importer<'async'> + | FileImporter<'sync'> + | FileImporter<'async'>; + /** * A response from the Sass render Worker containing the result of the operation. */ interface RenderResponseMessage { id: number; error?: Exception; - result?: CompileResult; + result?: Omit & { loadedUrls: string[] }; } /** @@ -71,46 +82,77 @@ export class SassWorkerImplementation { /** * The synchronous render function is not used by the `sass-loader`. */ - renderSync(): never { - throw new Error('Sass renderSync is not supported.'); + compileString(): never { + throw new Error('Sass compileString is not supported.'); } /** * Asynchronously request a Sass stylesheet to be renderered. * + * @param source The contents to compile. * @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<'async'>, callback: RenderCallback): void { + compileStringAsync( + source: string, + options: StringOptionsWithImporter<'async'> | StringOptionsWithoutImporter<'async'>, + ): Promise { // The `functions`, `logger` 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, logger, ...serializableOptions } = options; + const { functions, importers, url, logger, ...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.'); } - let workerIndex = this.availableWorkers.pop(); - if (workerIndex === undefined) { - if (this.workers.length < MAX_RENDER_WORKERS) { - workerIndex = this.workers.length; - this.workers.push(this.createWorker()); - } else { - workerIndex = this.nextWorkerIndex++; - if (this.nextWorkerIndex >= this.workers.length) { - this.nextWorkerIndex = 0; + return new Promise((resolve, reject) => { + let workerIndex = this.availableWorkers.pop(); + if (workerIndex === undefined) { + if (this.workers.length < MAX_RENDER_WORKERS) { + workerIndex = this.workers.length; + this.workers.push(this.createWorker()); + } else { + workerIndex = this.nextWorkerIndex++; + if (this.nextWorkerIndex >= this.workers.length) { + this.nextWorkerIndex = 0; + } } } - } - const request = this.createRequest(workerIndex, callback, importer); - this.requests.set(request.id, request); + const callback: RenderCallback = (error, result) => { + if (error) { + const url = error?.span.url as string | undefined; + if (url) { + error.span.url = pathToFileURL(url); + } - this.workers[workerIndex].postMessage({ - id: request.id, - hasImporter: !!importer, - options: serializableOptions, + reject(error); + + return; + } + + if (!result) { + reject('No result.'); + + return; + } + + resolve(result); + }; + + const request = this.createRequest(workerIndex, callback, importers); + this.requests.set(request.id, request); + + this.workers[workerIndex].postMessage({ + id: request.id, + source, + hasImporter: !!importers?.length, + options: { + ...serializableOptions, + // URL is not serializable so to convert to string here and back to URL in the worker. + url: url ? fileURLToPath(url) : undefined, + }, + }); }); } @@ -147,19 +189,11 @@ export class SassWorkerImplementation { this.availableWorkers.push(request.workerIndex); 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: CompileResult = { - // 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); + request.callback(undefined, { + ...response.result, + // URL is not serializable so in the worker we convert to string and here back to URL. + loadedUrls: response.result.loadedUrls.map((p) => pathToFileURL(p)), + }); } else { request.callback(response.error); } @@ -167,17 +201,7 @@ export class SassWorkerImplementation { mainImporterPort.on( 'message', - ({ - id, - url, - prev, - fromImport, - }: { - id: number; - url: string; - prev: string; - fromImport: boolean; - }) => { + ({ id, url, options }: { id: number; url: string; options: FileImporterOptions }) => { const request = this.requests.get(id); if (!request?.importers) { mainImporterPort.postMessage(null); @@ -187,7 +211,7 @@ export class SassWorkerImplementation { return; } - this.processImporters(request.importers, url, prev, fromImport) + this.processImporters(request.importers, url, options) .then((result) => { mainImporterPort.postMessage(result); }) @@ -207,44 +231,40 @@ export class SassWorkerImplementation { } private async processImporters( - importers: Iterable, + importers: Iterable, url: string, - prev: string, - fromImport: boolean, - ): Promise { - let result = null; + options: FileImporterOptions, + ): Promise { for (const importer of importers) { - result = await new Promise((resolve) => { - // Importers can be both sync and async - const innerResult = (importer as AsyncImporter).call( - { fromImport } as ImporterThis, - url, - prev, - resolve, - ); - if (innerResult !== undefined) { - resolve(innerResult); - } - }); + if (this.isImporter(importer)) { + // Importer + throw new Error('Only File Importers are supported.'); + } + // File importer (Can be sync or aync). + const result = await importer.findFileUrl(url, options); if (result) { - break; + return fileURLToPath(result); } } - return result; + return null; } private createRequest( workerIndex: number, callback: RenderCallback, - importer: SyncImporter | AsyncImporter | (SyncImporter | AsyncImporter)[] | undefined, + importers: Importers[] | undefined, ): RenderRequest { return { id: this.idCounter++, workerIndex, callback, - importers: !importer || Array.isArray(importer) ? importer : [importer], + importers, }; } + + private isImporter(value: Importers): value is Importer { + return 'canonicalize' in value && 'load' in value; + } } diff --git a/packages/angular_devkit/build_angular/src/sass/worker.ts b/packages/angular_devkit/build_angular/src/sass/worker.ts index bcd978b3258b..65d168e009d0 100644 --- a/packages/angular_devkit/build_angular/src/sass/worker.ts +++ b/packages/angular_devkit/build_angular/src/sass/worker.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import { ImporterResult, LegacyOptions as Options, renderSync } from 'sass'; +import { Exception, StringOptionsWithImporter, compileString } from 'sass'; +import { fileURLToPath, pathToFileURL } from 'url'; import { MessagePort, parentPort, receiveMessageOnPort, workerData } from 'worker_threads'; /** @@ -19,9 +20,13 @@ interface RenderRequestMessage { */ id: number; /** - * The Sass options to provide to the `dart-sass` render function. + * The contents to compile. */ - options: Options<'sync'>; + source: string; + /** + * The Sass options to provide to the `dart-sass` compile function. + */ + options: Omit, 'url'> & { url?: string }; /** * Indicates the request has a custom importer function on the main thread. */ @@ -38,7 +43,11 @@ const { workerImporterPort, importerSignal } = workerData as { importerSignal: Int32Array; }; -parentPort.on('message', ({ id, hasImporter, options }: RenderRequestMessage) => { +parentPort.on('message', ({ id, hasImporter, source, options }: RenderRequestMessage) => { + if (!parentPort) { + throw new Error('"parentPort" is not defined. Sass worker must be executed as a Worker.'); + } + try { if (hasImporter) { // When a custom importer function is present, the importer request must be proxied @@ -46,24 +55,58 @@ parentPort.on('message', ({ id, hasImporter, options }: RenderRequestMessage) => // 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 = function (url, prev) { - Atomics.store(importerSignal, 0, 0); - const { fromImport } = this; - workerImporterPort.postMessage({ id, url, prev, fromImport }); - Atomics.wait(importerSignal, 0, 0); + options.importers = [ + { + findFileUrl: (url, options) => { + Atomics.store(importerSignal, 0, 0); + workerImporterPort.postMessage({ id, url, options }); + Atomics.wait(importerSignal, 0, 0); + + const result = receiveMessageOnPort(workerImporterPort)?.message as string | null; - return receiveMessageOnPort(workerImporterPort)?.message as ImporterResult; - }; + return result ? pathToFileURL(result) : null; + }, + }, + ]; } // The synchronous Sass render function can be up to two times faster than the async variant - const result = renderSync(options); + const result = compileString(source, { + ...options, + // URL is not serializable so to convert to string in the parent and back to URL here. + url: options.url ? pathToFileURL(options.url) : undefined, + }); - parentPort?.postMessage({ id, result }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { + parentPort.postMessage({ + id, + result: { + ...result, + // URL is not serializable so to convert to string here and back to URL in the parent. + loadedUrls: result.loadedUrls.map((p) => fileURLToPath(p)), + }, + }); + } catch (error) { // Needed because V8 will only serialize the message and stack properties of an Error instance. - const { formatted, file, line, column, message, stack } = error; - parentPort?.postMessage({ id, error: { formatted, file, line, column, message, stack } }); + if (error instanceof Exception) { + const { span, message, stack, sassMessage, sassStack } = error; + parentPort.postMessage({ + id, + error: { + span: { + ...span, + url: span.url ? fileURLToPath(span.url) : undefined, + }, + message, + stack, + sassMessage, + sassStack, + }, + }); + } else if (error instanceof Error) { + const { message, stack } = error; + parentPort.postMessage({ id, error: { message, stack } }); + } else { + parentPort.postMessage({ id, error: { message: 'An unknown error has occurred.' } }); + } } }); diff --git a/packages/angular_devkit/build_angular/src/utils/environment-options.ts b/packages/angular_devkit/build_angular/src/utils/environment-options.ts index d896cef0d87b..5f04e548da6d 100644 --- a/packages/angular_devkit/build_angular/src/utils/environment-options.ts +++ b/packages/angular_devkit/build_angular/src/utils/environment-options.ts @@ -75,3 +75,6 @@ export const allowMinify = debugOptimize.minify; */ const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS']; export const maxWorkers = isPresent(maxWorkersVariable) ? +maxWorkersVariable : 4; + +const legacySassVariable = process.env['NG_BUILD_LEGACY_SASS']; +export const useLegacySass = isPresent(legacySassVariable) && isEnabled(legacySassVariable); 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 f522790b68b6..8879f473fef0 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts @@ -9,10 +9,14 @@ import * as fs from 'fs'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import * as path from 'path'; +import type { LegacyOptions, StringOptionsWithoutImporter } from 'sass'; +import { pathToFileURL } from 'url'; import { Configuration, RuleSetUseItem } from 'webpack'; import { StyleElement } from '../../builders/browser/schema'; +import { SassWorkerImplementationLegacy } from '../../sass/legacy/sass-service'; import { SassWorkerImplementation } from '../../sass/sass-service'; import { WebpackConfigOptions } from '../../utils/build-options'; +import { useLegacySass } from '../../utils/environment-options'; import { AnyComponentStyleBudgetChecker, PostcssCliResources, @@ -79,7 +83,7 @@ export function getStylesConfig(wco: WebpackConfigOptions): Configuration { const postcssImports = require('postcss-import'); const postcssPresetEnv: typeof import('postcss-preset-env') = require('postcss-preset-env'); - const { root, buildOptions } = wco; + const { root, projectRoot, buildOptions } = wco; const extraPlugins: Configuration['plugins'] = []; extraPlugins.push(new AnyComponentStyleBudgetChecker(buildOptions.budgets)); @@ -111,7 +115,10 @@ export function getStylesConfig(wco: WebpackConfigOptions): Configuration { ); } - const sassImplementation = new SassWorkerImplementation(); + const sassImplementation = useLegacySass + ? new SassWorkerImplementationLegacy() + : new SassWorkerImplementation(); + extraPlugins.push({ apply(compiler) { compiler.hooks.shutdown.tap('sass-worker', () => { @@ -270,24 +277,14 @@ export function getStylesConfig(wco: WebpackConfigOptions): Configuration { }, { loader: require.resolve('sass-loader'), - options: { - 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, - // Use expanded as otherwise sass will remove comments that are needed for autoprefixer - // Ex: /* autoprefixer grid: autoplace */ - // See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70 - outputStyle: 'expanded', - // Silences compiler warnings from 3rd party stylesheets - quietDeps: !buildOptions.verbose, - verbose: buildOptions.verbose, - }, - }, + options: getSassLoaderOptions( + root, + projectRoot, + sassImplementation, + includePaths, + false, + !buildOptions.verbose, + ), }, ], }, @@ -302,25 +299,14 @@ export function getStylesConfig(wco: WebpackConfigOptions): Configuration { }, { loader: require.resolve('sass-loader'), - options: { - implementation: sassImplementation, - sourceMap: true, - sassOptions: { - // Prevent use of `fibers` package as it no longer works in newer Node.js versions - fiber: false, - indentedSyntax: true, - // bootstrap-sass requires a minimum precision of 8 - precision: 8, - includePaths, - // Use expanded as otherwise sass will remove comments that are needed for autoprefixer - // Ex: /* autoprefixer grid: autoplace */ - // See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70 - outputStyle: 'expanded', - // Silences compiler warnings from 3rd party stylesheets - quietDeps: !buildOptions.verbose, - verbose: buildOptions.verbose, - }, - }, + options: getSassLoaderOptions( + root, + projectRoot, + sassImplementation, + includePaths, + true, + !buildOptions.verbose, + ), }, ], }, @@ -415,3 +401,82 @@ function getTailwindConfigPath({ projectRoot, root }: WebpackConfigOptions): str return undefined; } + +function getSassCompilerOptions( + root: string, + projectRoot: string, + verbose: boolean, + includePaths: string[], + indentedSyntax: boolean, +): + | StringOptionsWithoutImporter<'async'> + | (LegacyOptions<'async'> & { fiber?: boolean; precision?: number }) { + return useLegacySass + ? { + // 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, + // Use expanded as otherwise sass will remove comments that are needed for autoprefixer + // Ex: /* autoprefixer grid: autoplace */ + // See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70 + outputStyle: 'expanded', + // Silences compiler warnings from 3rd party stylesheets + quietDeps: !verbose, + verbose, + indentedSyntax, + } + : { + loadPaths: [ + ...includePaths, + // Needed to resolve node packages and retain the same behaviour of with the legacy API as sass-loader resolves + // scss also from the cwd and project root. + // See: https://github.com/webpack-contrib/sass-loader/blob/997f3eb41d86dd00d5fa49c395a1aeb41573108c/src/utils.js#L307 + projectRoot, + path.join(root, 'node_modules'), + ], + // Use expanded as otherwise sass will remove comments that are needed for autoprefixer + // Ex: /* autoprefixer grid: autoplace */ + // See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70 + style: 'expanded', + // Silences compiler warnings from 3rd party stylesheets + quietDeps: !verbose, + verbose, + syntax: indentedSyntax ? 'indented' : 'scss', + importers: [ + { + // An importer that redirects relative URLs starting with "~" to + // `node_modules`. + // See: https://sass-lang.com/documentation/js-api/interfaces/FileImporter + findFileUrl: (url) => { + if (url.charAt(0) !== '~') { + return null; + } + + // TODO: issue warning. + return new URL(url.substring(1), pathToFileURL(path.join(root, 'node_modules/'))); + }, + }, + ], + }; +} + +function getSassLoaderOptions( + root: string, + projectRoot: string, + implementation: SassWorkerImplementationLegacy | SassWorkerImplementation, + includePaths: string[], + indentedSyntax: boolean, + verbose: boolean, +): Record { + return { + sourceMap: true, + api: useLegacySass ? 'legacy' : 'modern', + implementation, + // Webpack importer is only implemented in the legacy API. + // See: https://github.com/webpack-contrib/sass-loader/blob/997f3eb41d86dd00d5fa49c395a1aeb41573108c/src/utils.js#L642-L651 + webpackImporter: useLegacySass, + sassOptions: getSassCompilerOptions(root, projectRoot, verbose, includePaths, indentedSyntax), + }; +} diff --git a/scripts/validate-licenses.ts b/scripts/validate-licenses.ts index c72145e2b478..63721d379985 100644 --- a/scripts/validate-licenses.ts +++ b/scripts/validate-licenses.ts @@ -75,9 +75,6 @@ const ignoredPackages = [ 'pako@1.0.11', // MIT but broken license in package.json 'fs-monkey@1.0.1', // Unlicense but missing license field (PR: https://github.com/streamich/fs-monkey/pull/209) 'memfs@3.2.0', // Unlicense but missing license field (PR: https://github.com/streamich/memfs/pull/594) - - // * Other - 'font-awesome@4.7.0', // (OFL-1.1 AND MIT) ]; // Ignore own packages (all MIT) diff --git a/yarn.lock b/yarn.lock index 4e35adc8b6d9..3c767ac4ce46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -177,7 +177,6 @@ "@angular/dev-infra-private@https://github.com/angular/dev-infra-private-builds.git#b2da73b3dddddd6a253ee8ea48ef387b20f5aedf": version "0.0.0-114c5a9e0c063e65dc42ded4d2ae07a3cca5418a" - uid b2da73b3dddddd6a253ee8ea48ef387b20f5aedf resolved "https://github.com/angular/dev-infra-private-builds.git#b2da73b3dddddd6a253ee8ea48ef387b20f5aedf" dependencies: "@angular-devkit/build-angular" "14.1.0-rc.3" @@ -5875,11 +5874,6 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== -font-awesome@^4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" - integrity sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg== - foreground-child@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" @@ -9997,7 +9991,6 @@ sass@1.54.0: "sauce-connect-proxy@https://saucelabs.com/downloads/sc-4.7.1-linux.tar.gz": version "0.0.0" - uid e5d7f82ad98251a653d1b0537f1103e49eda5e11 resolved "https://saucelabs.com/downloads/sc-4.7.1-linux.tar.gz#e5d7f82ad98251a653d1b0537f1103e49eda5e11" saucelabs@^1.5.0: