UNPKG

@kv-systems/ng-packagr

Version:

Compile and package Angular libraries in Angular Package Format (APF)

262 lines (226 loc) 7.63 kB
import assert from 'node:assert'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { MessageChannel } from 'node:worker_threads'; import type { CanonicalizeContext, CompileResult, Deprecation, Exception, FileImporter, Importer, NodePackageImporter, SourceSpan, StringOptions, } from 'sass'; import { WorkerPool } from '../worker-pool'; const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS']; const maxWorkers = typeof maxWorkersVariable === 'string' && maxWorkersVariable !== '' ? +maxWorkersVariable : 4; // 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; /** * All available importer types. */ type Importers = | Importer<'sync'> | Importer<'async'> | FileImporter<'sync'> | FileImporter<'async'> | NodePackageImporter; export interface SerializableVersion { major: number; minor: number; patch: number; } export interface SerializableDeprecation extends Omit<Deprecation, 'obsoleteIn' | 'deprecatedIn'> { /** 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<SourceSpan, 'url'> & { url?: string }; stack?: string; }; /** * A response from the Sass render Worker containing the result of the operation. */ interface RenderResponseMessage { error?: Exception; result?: Omit<CompileResult, 'loadedUrls'> & { loadedUrls: string[] }; warnings?: SerializableWarningMessage[]; } /** * 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 { #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 `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`. */ 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. */ async compileStringAsync( source: string, options: StringOptions<'async'>, ): Promise<CompileResult> { // 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, importers, url, logger, ...serializableOptions } = options; // The CLI's configuration does not use or expose the ability to define custom Sass functions if (functions && Object.keys(functions).length > 0) { throw new Error('Sass custom functions are not supported.'); } 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 unknown 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<void> { if (this.#workerPool) { try { await this.#workerPool.destroy(); } finally { this.#workerPool = undefined; } } } #createImporterChannel(importers: Iterable<Importers>) { 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<Importers>, url: string, options: CanonicalizeContext, ): Promise<string | null> { for (const importer of importers) { if (!this.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; } private isFileImporter(value: Importers): value is FileImporter { return 'findFileUrl' in value; } }