diff --git a/packages/angular/build/src/private.ts b/packages/angular/build/src/private.ts index 315636decae2..9225c2f9d686 100644 --- a/packages/angular/build/src/private.ts +++ b/packages/angular/build/src/private.ts @@ -35,7 +35,7 @@ export { export type { ExternalResultMetadata } from './tools/esbuild/bundler-execution-result'; export { emitFilesToDisk } from './tools/esbuild/utils'; export { transformSupportedBrowsersToTargets } from './tools/esbuild/target'; -export { SassWorkerImplementation } from './tools/sass/sass-worker-implementation'; +export { SassCompiler } from './tools/sass/sass-service'; export { SourceFileCache } from './tools/esbuild/angular/source-file-cache'; export { Cache } from './tools/esbuild/cache'; diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts index 92c75d76d7ec..ad2961cc2b2e 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts @@ -10,13 +10,12 @@ import type { OnLoadResult, PartialMessage, PartialNote, ResolveResult } from 'e import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import type { CanonicalizeContext, CompileResult, Exception, Syntax } from 'sass-embedded'; -import { useSassWorker } from '../../../utils/environment-options'; -import type { SassServiceImplementation } from '../../sass/sass-service'; +import type { SassCompiler } from '../../sass/sass-service'; import { MemoryCache } from '../cache'; import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; -let sassService: SassServiceImplementation | undefined; -let sassServicePromise: Promise | undefined; +let sassService: SassCompiler | undefined; +let sassServicePromise: Promise | undefined; function isSassException(error: unknown): error is Exception { return !!error && typeof error === 'object' && 'sassMessage' in error; @@ -81,13 +80,9 @@ async function compileString( // Lazily load Sass when a Sass file is found if (sassService === undefined) { if (sassServicePromise === undefined) { - sassServicePromise = useSassWorker - ? import('../../sass/sass-worker-implementation').then( - (sassService) => new sassService.SassWorkerImplementation(true), - ) - : import('../../sass/sass-async-compiler-implementation').then( - (sassService) => new sassService.SassAsyncCompilerImplementation(), - ); + sassServicePromise = import('../../sass/sass-service').then( + (sassService) => new sassService.SassCompiler(true), + ); } try { sassService = await sassServicePromise; diff --git a/packages/angular/build/src/tools/sass/rebasing-importer.ts b/packages/angular/build/src/tools/sass/rebasing-importer.ts index e9509574f455..7198d8b74f1e 100644 --- a/packages/angular/build/src/tools/sass/rebasing-importer.ts +++ b/packages/angular/build/src/tools/sass/rebasing-importer.ts @@ -45,7 +45,9 @@ abstract class UrlRebasingImporter implements Importer<'sync'> { constructor( private entryDirectory: string, private rebaseSourceMaps?: Map, - ) {} + ) { + this.load = this.load.bind(this); + } abstract canonicalize(url: string, options: { fromImport: boolean }): URL | null; @@ -140,6 +142,7 @@ export class RelativeUrlRebasingImporter extends UrlRebasingImporter { rebaseSourceMaps?: Map, ) { super(entryDirectory, rebaseSourceMaps); + this.canonicalize = this.canonicalize.bind(this); } canonicalize(url: string, options: { fromImport: boolean }): URL | null { @@ -316,33 +319,6 @@ export class RelativeUrlRebasingImporter extends UrlRebasingImporter { } } -/** - * Provides the Sass importer logic to resolve module (npm package) stylesheet imports via both import and - * use rules and also rebase any `url()` function usage within those stylesheets. The rebasing will ensure that - * the URLs in the output of the Sass compiler reflect the final filesystem location of the output CSS file. - */ -export class ModuleUrlRebasingImporter extends RelativeUrlRebasingImporter { - constructor( - entryDirectory: string, - directoryCache: Map, - rebaseSourceMaps: Map | undefined, - private finder: (specifier: string, options: CanonicalizeContext) => URL | null, - ) { - super(entryDirectory, directoryCache, rebaseSourceMaps); - } - - override canonicalize(url: string, options: CanonicalizeContext): URL | null { - if (url.startsWith('file://')) { - return super.canonicalize(url, options); - } - - let result = this.finder(url, options); - result &&= super.canonicalize(result.href, options); - - return result; - } -} - /** * Provides the Sass importer logic to resolve module (npm package) stylesheet imports asynchronously * and also rebase any `url()` function usage within those stylesheets. @@ -364,6 +340,8 @@ export class AsyncModuleUrlRebasingImporter implements Importer<'async'> { directoryCache, rebaseSourceMaps, ); + this.canonicalize = this.canonicalize.bind(this); + this.load = this.load.bind(this); } async canonicalize(url: string, options: CanonicalizeContext): Promise { @@ -412,16 +390,3 @@ export class LoadPathsUrlRebasingImporter extends RelativeUrlRebasingImporter { return result; } } - -/** - * Workaround for Sass not calling instance methods with `this`. - * The `canonicalize` and `load` methods will be bound to the class instance. - * @param importer A Sass importer to bind. - * @returns The bound Sass importer. - */ -export function sassBindWorkaround(importer: T): T { - importer.canonicalize = importer.canonicalize.bind(importer); - importer.load = importer.load.bind(importer); - - return importer; -} diff --git a/packages/angular/build/src/tools/sass/sass-async-compiler-implementation.ts b/packages/angular/build/src/tools/sass/sass-async-compiler-implementation.ts deleted file mode 100644 index cdf483994f12..000000000000 --- a/packages/angular/build/src/tools/sass/sass-async-compiler-implementation.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * @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.dev/license - */ - -import mergeSourceMaps, { type DecodedSourceMap, type RawSourceMap } from '@ampproject/remapping'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { - AsyncCompiler, - CanonicalizeContext, - CompileResult, - FileImporter, - Importer, - NodePackageImporter, - StringOptions, -} from 'sass-embedded'; -import { - AsyncModuleUrlRebasingImporter, - DirectoryEntry, - LoadPathsUrlRebasingImporter, - RelativeUrlRebasingImporter, -} from './rebasing-importer'; -import { type SassServiceImplementation, isFileImporter } from './sass-service'; - -/** - * A Sass renderer implementation that uses the persistent Dart Sass embedded compiler - * daemon (`sass-embedded`) communicating over standard input/output with protocol buffers. - */ -export class SassAsyncCompilerImplementation implements SassServiceImplementation { - #asyncCompiler: AsyncCompiler | undefined; - #asyncCompilerPromise: Promise | undefined; - - async #ensureAsyncCompiler(): Promise { - if (this.#asyncCompiler) { - return this.#asyncCompiler; - } - - // Import and initialize the async compiler on the main thread. - this.#asyncCompilerPromise ??= import('sass-embedded').then(({ initAsyncCompiler }) => - initAsyncCompiler(), - ); - - try { - this.#asyncCompiler = await this.#asyncCompilerPromise; - } finally { - this.#asyncCompilerPromise = undefined; - } - - return this.#asyncCompiler; - } - - /** - * Provides information about the Sass implementation. - * This mimics enough of the `sass-embedded` value to be used with the `sass-loader`. - */ - get info(): string { - return 'sass-embedded\tasync-compiler'; - } - - /** - * The synchronous render function is not used by the `sass-loader`. - */ - compileString(): never { - throw new Error('Sass compileString is not supported.'); - } - - /** - * Asynchronously request a Sass stylesheet to be rendered using the native embedded compiler. - * - * @param source The contents to compile. - * @param options The `sass-embedded` options to use when rendering the stylesheet. - */ - async compileStringAsync( - source: string, - options: StringOptions<'async'>, - ): Promise { - // The CLI's configuration does not use or expose the ability to define custom Sass functions - if (options.functions && Object.keys(options.functions).length > 0) { - throw new Error('Sass custom functions are not supported.'); - } - - const { functions, importers, importer, url, logger, ...serializableOptions } = options; - - let finalImporters: - (Importer<'async'> | FileImporter<'async'> | NodePackageImporter)[] | undefined; - let loadPaths = options.loadPaths; - const entryDirectory = url ? dirname(fileURLToPath(url)) : process.cwd(); - const directoryCache = new Map(); - const rebaseSourceMaps = options.sourceMap ? new Map() : undefined; - - if (importers?.length) { - for (const importer of importers) { - if (!isFileImporter(importer)) { - throw new Error('Only File Importers are supported.'); - } - } - - finalImporters = [ - new AsyncModuleUrlRebasingImporter( - entryDirectory, - directoryCache, - rebaseSourceMaps, - async (specifier: string, options: CanonicalizeContext): Promise => { - for (const importer of importers) { - const result = await (importer as FileImporter<'async'>).findFileUrl( - specifier, - options, - ); - if (result) { - return result; - } - } - - return null; - }, - ), - ]; - } - - if (loadPaths?.length) { - finalImporters ??= []; - finalImporters.push( - new LoadPathsUrlRebasingImporter( - entryDirectory, - directoryCache, - rebaseSourceMaps, - loadPaths, - ), - ); - loadPaths = undefined; - } - - const relativeImporter = new RelativeUrlRebasingImporter( - entryDirectory, - directoryCache, - rebaseSourceMaps, - ); - - const compiler = await this.#ensureAsyncCompiler(); - const result = await compiler.compileStringAsync(source, { - ...serializableOptions, - url, - loadPaths, - importers: finalImporters, - importer: relativeImporter, - logger, - }); - - if (result.sourceMap && rebaseSourceMaps?.size) { - result.sourceMap = mergeSourceMaps( - result.sourceMap as unknown as RawSourceMap, - (file, context) => (file !== context.importer ? rebaseSourceMaps.get(file) : null), - ) as unknown as typeof result.sourceMap; - } - - return result; - } - - /** - * Shutdown the native embedded Sass compiler daemon. - * @returns A void promise that resolves when closing is complete. - */ - async close(): Promise { - if (this.#asyncCompilerPromise) { - try { - await this.#ensureAsyncCompiler(); - } catch { - // Ignore compiler initialization failures on shutdown - } - } - - if (this.#asyncCompiler) { - const compiler = this.#asyncCompiler; - this.#asyncCompiler = undefined; - await compiler.dispose(); - } - } -} diff --git a/packages/angular/build/src/tools/sass/sass-service.ts b/packages/angular/build/src/tools/sass/sass-service.ts index 1a7c3b7866d8..c3d6cf991526 100644 --- a/packages/angular/build/src/tools/sass/sass-service.ts +++ b/packages/angular/build/src/tools/sass/sass-service.ts @@ -6,33 +6,204 @@ * found in the LICENSE file at https://angular.dev/license */ +import mergeSourceMaps, { type DecodedSourceMap, type RawSourceMap } from '@ampproject/remapping'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { + AsyncCompiler, + CanonicalizeContext, CompileResult, FileImporter, Importer, NodePackageImporter, StringOptions, } from 'sass-embedded'; +import { useSassEmbedded } from '../../utils/environment-options'; +import { + AsyncModuleUrlRebasingImporter, + DirectoryEntry, + LoadPathsUrlRebasingImporter, + RelativeUrlRebasingImporter, +} from './rebasing-importer'; -/** - * Common interface for Sass service implementations. - */ -export interface SassServiceImplementation { - readonly info: string; - compileStringAsync(source: string, options: StringOptions<'async'>): Promise; - close(): Promise; -} - -/** - * All available importer types. - */ -export type Importers = +type Importers = | Importer<'sync'> | Importer<'async'> | FileImporter<'sync'> | FileImporter<'async'> | NodePackageImporter; -export function isFileImporter(value: Importers): value is FileImporter { +function isFileImporter(value: Importers): value is FileImporter { return 'findFileUrl' in value; } + +/** + * A Sass renderer implementation that uses the persistent Dart Sass embedded compiler + * daemon (`sass-embedded`) communicating over standard input/output with protocol buffers, + * or falls back to the pure-JS Dart Sass async compiler (`sass.initAsyncCompiler()`). + */ +export class SassCompiler { + #asyncCompiler: AsyncCompiler | undefined; + #asyncCompilerPromise: Promise | undefined; + + constructor(private readonly rebase = false) {} + + async #createAsyncCompiler(): Promise { + if (useSassEmbedded) { + const { initAsyncCompiler } = await import('sass-embedded'); + + return initAsyncCompiler(); + } + + const { initAsyncCompiler } = await import('sass'); + + return initAsyncCompiler() as unknown as Promise; + } + + async #ensureAsyncCompiler(): Promise { + if (this.#asyncCompiler) { + return this.#asyncCompiler; + } + + this.#asyncCompilerPromise ??= this.#createAsyncCompiler(); + + try { + this.#asyncCompiler = await this.#asyncCompilerPromise; + } finally { + this.#asyncCompilerPromise = undefined; + } + + return this.#asyncCompiler; + } + + /** + * Provides information about the Sass implementation. + * This mimics enough of the `sass-embedded` or `sass` value to be used with the `sass-loader`. + */ + get info(): string { + return useSassEmbedded ? 'sass-embedded\tasync-compiler' : 'dart-sass\tasync-compiler'; + } + + /** + * The synchronous render function is not used by the `sass-loader`. + */ + compileString(): never { + throw new Error('Sass compileString is not supported.'); + } + + /** + * Asynchronously request a Sass stylesheet to be rendered using the native embedded compiler + * or the pure-JS async compiler fallback. + * + * @param source The contents to compile. + * @param options The `sass` / `sass-embedded` options to use when rendering the stylesheet. + */ + async compileStringAsync( + source: string, + options: StringOptions<'async'>, + ): Promise { + // The CLI's configuration does not use or expose the ability to define custom Sass functions + if (options.functions && Object.keys(options.functions).length > 0) { + throw new Error('Sass custom functions are not supported.'); + } + + const compiler = await this.#ensureAsyncCompiler(); + + if (!this.rebase) { + return compiler.compileStringAsync(source, options); + } + + const { functions, importers, importer, url, logger, ...serializableOptions } = options; + + let finalImporters: + (Importer<'async'> | FileImporter<'async'> | NodePackageImporter)[] | undefined; + let loadPaths = options.loadPaths; + const entryDirectory = url ? dirname(fileURLToPath(url)) : process.cwd(); + const directoryCache = new Map(); + const rebaseSourceMaps = options.sourceMap ? new Map() : undefined; + + if (importers?.length) { + if (importers.some((i) => !isFileImporter(i))) { + throw new Error('Only File Importers are supported.'); + } + + finalImporters = [ + new AsyncModuleUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + async (specifier: string, options: CanonicalizeContext): Promise => { + for (const importer of importers) { + const result = await (importer as FileImporter<'async'>).findFileUrl( + specifier, + options, + ); + if (result) { + return result; + } + } + + return null; + }, + ), + ]; + } + + if (loadPaths?.length) { + finalImporters ??= []; + finalImporters.push( + new LoadPathsUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + loadPaths, + ), + ); + loadPaths = undefined; + } + + const relativeImporter = new RelativeUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + ); + + const result = await compiler.compileStringAsync(source, { + ...serializableOptions, + url, + loadPaths, + importers: finalImporters, + importer: relativeImporter, + logger, + }); + + if (result.sourceMap && rebaseSourceMaps?.size) { + result.sourceMap = mergeSourceMaps( + result.sourceMap as unknown as RawSourceMap, + (file, context) => (file !== context.importer ? rebaseSourceMaps.get(file) : null), + ) as unknown as typeof result.sourceMap; + } + + return result; + } + + /** + * Shutdown the Sass compiler. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + if (this.#asyncCompilerPromise) { + try { + await this.#ensureAsyncCompiler(); + } catch { + // Ignore compiler initialization failures on shutdown + } + } + + if (this.#asyncCompiler) { + const compiler = this.#asyncCompiler; + this.#asyncCompiler = undefined; + await compiler.dispose(); + } + } +} diff --git a/packages/angular/build/src/tools/sass/sass-worker-implementation.ts b/packages/angular/build/src/tools/sass/sass-worker-implementation.ts deleted file mode 100644 index 9208bef3a803..000000000000 --- a/packages/angular/build/src/tools/sass/sass-worker-implementation.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * @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.dev/license - */ - -import assert from 'node:assert'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { MessageChannel } from 'node:worker_threads'; -import type { - CanonicalizeContext, - CompileResult, - Deprecation, - Exception, - SourceSpan, - StringOptions, -} from 'sass-embedded'; -import { maxWorkers } from '../../utils/environment-options'; -import { WorkerPool } from '../../utils/worker-pool'; -import { type Importers, type SassServiceImplementation, isFileImporter } from './sass-service'; - -// Polyfill Symbol.dispose if not present -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(Symbol as any).dispose ??= Symbol('Symbol Dispose'); - -/** - * The maximum number of Workers that will be created to execute render requests. - */ -const MAX_RENDER_WORKERS = maxWorkers; - -export interface SerializableVersion { - major: number; - minor: number; - patch: number; -} - -export interface SerializableDeprecation extends Omit { - /** The version this deprecation first became active in. */ - deprecatedIn: SerializableVersion | null; - - /** The version this deprecation became obsolete in. */ - obsoleteIn: SerializableVersion | null; -} - -export type SerializableWarningMessage = ( - | { - deprecation: true; - deprecationType: SerializableDeprecation; - } - | { deprecation: false } -) & { - message: string; - span?: Omit & { url?: string }; - stack?: string; -}; - -/** - * A response from the Sass render Worker containing the result of the operation. - */ -export interface RenderResponseMessage { - error?: Exception; - result?: Omit & { loadedUrls: string[] }; - warnings?: SerializableWarningMessage[]; -} - -/** - * A Sass renderer implementation that provides an interface that can be used by Webpack's - * `sass-loader` or as a fallback in environments that do not support native binaries. - * The implementation uses a Worker thread pool to perform Sass rendering with the pure-JS - * `sass` package. - */ -export class SassWorkerImplementation implements SassServiceImplementation { - #workerPool: WorkerPool | undefined; - - constructor( - private readonly rebase = false, - readonly maxThreads = MAX_RENDER_WORKERS, - ) {} - - #ensureWorkerPool(): WorkerPool { - this.#workerPool ??= new WorkerPool({ - filename: require.resolve('./worker'), - maxThreads: this.maxThreads, - }); - - return this.#workerPool; - } - - /** - * Provides information about the Sass implementation. - * This mimics enough of the `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`. - */ - compileString(): never { - throw new Error('Sass compileString is not supported.'); - } - - /** - * Asynchronously request a Sass stylesheet to be rendered using worker threads. - * - * @param source The contents to compile. - * @param options The Sass options to use when rendering the stylesheet. - */ - async compileStringAsync( - source: string, - options: StringOptions<'async'>, - ): Promise { - // The CLI's configuration does not use or expose the ability to define custom Sass functions - if (options.functions && Object.keys(options.functions).length > 0) { - throw new Error('Sass custom functions are not supported.'); - } - - const { functions, importers, importer, url, logger, ...serializableOptions } = options; - using importerChannel = importers?.length ? this.#createImporterChannel(importers) : undefined; - - const response = (await this.#ensureWorkerPool().run( - { - source, - importerChannel, - hasLogger: !!logger, - rebase: this.rebase, - options: { - ...serializableOptions, - // URL is not serializable so to convert to string here and back to URL in the worker. - url: url ? fileURLToPath(url) : undefined, - }, - }, - { - transferList: importerChannel ? [importerChannel.port] : undefined, - }, - )) as RenderResponseMessage; - - const { result, error, warnings } = response; - - if (warnings && logger?.warn) { - for (const { message, span, ...options } of warnings) { - logger.warn(message, { - ...options, - span: span && { - ...span, - url: span.url ? pathToFileURL(span.url) : undefined, - }, - }); - } - } - - if (error) { - // Convert stringified url value required for cloning back to a URL object - const url = error.span?.url as string | undefined; - if (url) { - error.span.url = pathToFileURL(url); - } - - throw error; - } - - assert(result, 'Sass render worker should always return a result or an error'); - - return { - ...result, - // URL is not serializable so in the worker we convert to string and here back to URL. - loadedUrls: result.loadedUrls.map((p) => pathToFileURL(p)), - }; - } - - /** - * Shutdown the Sass render worker. - * Executing this method will stop any pending render requests. - * @returns A void promise that resolves when closing is complete. - */ - async close(): Promise { - if (this.#workerPool) { - const pool = this.#workerPool; - this.#workerPool = undefined; - await pool.destroy(); - } - } - - #createImporterChannel(importers: Iterable) { - const { port1: mainImporterPort, port2: workerImporterPort } = new MessageChannel(); - const importerSignal = new Int32Array(new SharedArrayBuffer(4)); - - mainImporterPort.on( - 'message', - ({ url, options }: { url: string; options: CanonicalizeContext }) => { - this.processImporters(importers, url, { - ...options, - // URL is not serializable so in the worker we convert to string and here back to URL. - containingUrl: options.containingUrl - ? pathToFileURL(options.containingUrl as unknown as string) - : null, - }) - .then((result) => { - mainImporterPort.postMessage(result); - }) - .catch((error) => { - mainImporterPort.postMessage(error); - }) - .finally(() => { - Atomics.store(importerSignal, 0, 1); - Atomics.notify(importerSignal, 0); - }); - }, - ); - - mainImporterPort.unref(); - - return { - port: workerImporterPort, - signal: importerSignal, - [Symbol.dispose]() { - mainImporterPort.close(); - }, - }; - } - - private async processImporters( - importers: Iterable, - url: string, - options: CanonicalizeContext, - ): Promise { - for (const importer of importers) { - if (!isFileImporter(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) { - return fileURLToPath(result); - } - } - - return null; - } -} diff --git a/packages/angular/build/src/tools/sass/worker.ts b/packages/angular/build/src/tools/sass/worker.ts deleted file mode 100644 index 2416fe21e5cd..000000000000 --- a/packages/angular/build/src/tools/sass/worker.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * @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.dev/license - */ - -import mergeSourceMaps, { type DecodedSourceMap, type RawSourceMap } from '@ampproject/remapping'; -import { dirname } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { MessagePort, receiveMessageOnPort } from 'node:worker_threads'; -import { - Deprecation, - Exception, - FileImporter, - SourceSpan, - StringOptions, - compileString, -} from 'sass'; -import { - DirectoryEntry, - LoadPathsUrlRebasingImporter, - ModuleUrlRebasingImporter, - RelativeUrlRebasingImporter, - sassBindWorkaround, -} from './rebasing-importer'; -import type { - SerializableDeprecation, - SerializableWarningMessage, -} from './sass-worker-implementation'; - -/** - * A request to render a Sass stylesheet using the supplied options. - */ -interface RenderRequestMessage { - /** - * The contents to compile. - */ - 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. - */ - importerChannel?: { - port: MessagePort; - signal: Int32Array; - }; - - /** - * Indicates the request has a custom logger for warning messages. - */ - hasLogger: boolean; - - /** - * Indicates paths within url() CSS functions should be rebased. - */ - rebase: boolean; -} - -interface RenderResult { - warnings: SerializableWarningMessage[] | undefined; - result: { - css: string; - loadedUrls: string[]; - sourceMap?: RawSourceMap; - }; -} - -interface RenderError { - warnings: SerializableWarningMessage[] | undefined; - error: { - message: string; - stack?: string; - span?: Omit & { url?: string }; - sassMessage?: string; - sassStack?: string; - }; -} - -export default async function renderSassStylesheet( - request: RenderRequestMessage, -): Promise { - const { importerChannel, hasLogger, source, options, rebase } = request; - - const entryDirectory = dirname(options.url); - let warnings: SerializableWarningMessage[] | undefined; - try { - const directoryCache = new Map(); - const rebaseSourceMaps = options.sourceMap ? new Map() : undefined; - if (importerChannel) { - // 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. - const proxyImporter: FileImporter<'sync'> = { - findFileUrl: (url, { fromImport, containingUrl }) => { - Atomics.store(importerChannel.signal, 0, 0); - importerChannel.port.postMessage({ - url, - options: { - fromImport, - containingUrl: containingUrl ? fileURLToPath(containingUrl) : null, - }, - }); - // Wait for the main thread to set the signal to 1 and notify, which tells - // us that a message can be received on the port. - // If the main thread is fast, the signal will already be set to 1, and no - // sleep/notify is necessary. - // However, there can be a race condition here: - // - the main thread sets the signal to 1, but does not get to the notify instruction yet - // - the worker does not pause because the signal is set to 1 - // - the worker very soon enters this method again - // - this method sets the signal to 0 and sends the message - // - the signal is 0 and so the `Atomics.wait` call blocks - // - only now the main thread runs the `notify` from the first invocation, so the - // worker continues. - // - but there is no message yet in the port, because the thread should not have been - // waken up yet. - // To combat this, wait for a non-0 value _twice_. - // Almost every time, this immediately continues with "not-equal", because - // the signal is still set to 1, except during the race condition, when the second - // wait will wait for the correct notify. - Atomics.wait(importerChannel.signal, 0, 0); - Atomics.wait(importerChannel.signal, 0, 0); - - const result = receiveMessageOnPort(importerChannel.port)?.message as string | null; - - return result ? pathToFileURL(result) : null; - }, - }; - options.importers = [ - rebase - ? sassBindWorkaround( - new ModuleUrlRebasingImporter( - entryDirectory, - directoryCache, - rebaseSourceMaps, - proxyImporter.findFileUrl, - ), - ) - : proxyImporter, - ]; - } - - if (rebase && options.loadPaths?.length) { - options.importers ??= []; - options.importers.push( - sassBindWorkaround( - new LoadPathsUrlRebasingImporter( - entryDirectory, - directoryCache, - rebaseSourceMaps, - options.loadPaths, - ), - ), - ); - options.loadPaths = undefined; - } - - let relativeImporter; - if (rebase) { - relativeImporter = sassBindWorkaround( - new RelativeUrlRebasingImporter(entryDirectory, directoryCache, rebaseSourceMaps), - ); - } - - // The synchronous Sass render function can be up to two times faster than the async variant - const result = compileString(source, { - ...options, - // URL is not serializable so to convert to string in the parent and back to URL here. - url: pathToFileURL(options.url), - // The `importer` option (singular) handles relative imports - importer: relativeImporter, - logger: hasLogger - ? { - warn(message, warnOptions) { - warnings ??= []; - warnings.push({ - ...warnOptions, - message, - span: warnOptions.span && convertSourceSpan(warnOptions.span), - ...convertDeprecation( - warnOptions.deprecation, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (warnOptions as any).deprecationType, - ), - }); - }, - } - : undefined, - }); - - if (result.sourceMap && rebaseSourceMaps?.size) { - // Merge the intermediate rebasing source maps into the final Sass generated source map. - // Casting is required due to small but compatible differences in typings between the packages. - result.sourceMap = mergeSourceMaps( - result.sourceMap as unknown as RawSourceMap, - // To prevent an infinite lookup loop, skip getting the source when the rebasing source map - // is referencing its original self. - (file, context) => (file !== context.importer ? rebaseSourceMaps.get(file) : null), - ) as unknown as typeof result.sourceMap; - } - - return { - warnings, - result: { - ...result, - sourceMap: result.sourceMap as unknown as RawSourceMap | undefined, - // 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. - if (error instanceof Exception) { - const { span, message, stack, sassMessage, sassStack } = error; - - return { - warnings, - error: { - span: convertSourceSpan(span), - message, - stack, - sassMessage, - sassStack, - }, - }; - } else if (error instanceof Error) { - const { message, stack } = error; - - return { warnings, error: { message, stack } }; - } else { - return { - warnings, - error: { message: 'An unknown error has occurred.' }, - }; - } - } -} - -/** - * Converts a Sass SourceSpan object into a serializable form. - * The SourceSpan object contains a URL property which must be converted into a string. - * Also, most of the interface's properties are get accessors and are not automatically - * serialized when sent back from the worker. - * - * @param span The Sass SourceSpan object to convert. - * @returns A serializable form of the SourceSpan object. - */ -function convertSourceSpan(span: SourceSpan): Omit & { url?: string } { - return { - text: span.text, - context: span.context, - end: { - column: span.end.column, - offset: span.end.offset, - line: span.end.line, - }, - start: { - column: span.start.column, - offset: span.start.offset, - line: span.start.line, - }, - url: span.url ? fileURLToPath(span.url) : undefined, - }; -} - -function convertDeprecation( - deprecation: boolean, - deprecationType: Deprecation | undefined, -): { deprecation: false } | { deprecation: true; deprecationType: SerializableDeprecation } { - if (!deprecation || !deprecationType) { - return { deprecation: false }; - } - - const { obsoleteIn, deprecatedIn, ...rest } = deprecationType; - - return { - deprecation: true, - deprecationType: { - ...rest, - obsoleteIn: obsoleteIn - ? { - major: obsoleteIn.major, - minor: obsoleteIn.minor, - patch: obsoleteIn.patch, - } - : null, - deprecatedIn: deprecatedIn - ? { - major: deprecatedIn.major, - minor: deprecatedIn.minor, - patch: deprecatedIn.patch, - } - : null, - }, - }; -} diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index c9ac132e7887..f008673a1552 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -202,11 +202,12 @@ export const usePartialSsrBuild = parseTristate(process.env['NG_BUILD_PARTIAL_SS export const useBabelLinker = parseTristate(process.env['NG_BUILD_BABEL_LINKER']) === true; /** - * When `NG_BUILD_SASS_WORKER` is enabled (`1` or `true`), the worker-based - * Sass implementation will be used instead of the native asynchronous compiler. + * When `NG_BUILD_SASS_EMBEDDED` is set to `0` or `false`, or when running within a + * WebContainer environment, the native embedded Sass compiler is disabled + * and the pure-JavaScript Sass compiler is used instead. */ -export const useSassWorker = - !!process.versions.webcontainer || parseTristate(process.env['NG_BUILD_SASS_WORKER']) === true; +export const useSassEmbedded = + !process.versions.webcontainer && parseTristate(process.env['NG_BUILD_SASS_EMBEDDED']) !== false; const bazelBinDirectory = process.env['BAZEL_BINDIR']; const bazelExecRoot = process.env['JS_BINARY__EXECROOT']; diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index 4b9531956a86..3399c58fa5eb 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -46,7 +46,6 @@ "postcss-loader": "8.2.1", "resolve-url-loader": "5.0.0", "rxjs": "7.8.2", - "sass": "1.103.1", "sass-loader": "17.0.0", "semver": "7.8.5", "source-map-loader": "5.0.0", @@ -67,6 +66,7 @@ "@angular/ssr": "workspace:*", "browser-sync": "3.0.4", "ng-packagr": "22.2.0-next.3", + "sass": "1.103.1", "undici": "8.10.0" }, "peerDependencies": { diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/configs/styles.ts b/packages/angular_devkit/build_angular/src/tools/webpack/configs/styles.ts index e9d7be5cbef1..cd541faa9900 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/configs/styles.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/configs/styles.ts @@ -7,7 +7,7 @@ */ import { - SassWorkerImplementation, + SassCompiler, findTailwindConfiguration, generateSearchDirectories, loadPostcssConfiguration, @@ -66,11 +66,11 @@ export async function getStylesConfig(wco: WebpackConfigOptions): Promise { + compiler.hooks.shutdown.tap('sass-service', () => { void sassImplementation.close(); }); }, @@ -332,7 +332,7 @@ export async function getStylesConfig(wco: WebpackConfigOptions): Promise