From acd795f5528fdf9abce3ae478e51a369da40a70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0=20/=20green?= Date: Mon, 22 Jan 2024 19:27:09 +0900 Subject: [PATCH] perf: use thread for preprocessors (#13584) --- docs/config/shared-options.md | 35 +- packages/vite/LICENSE.md | 59 +- packages/vite/package.json | 2 +- packages/vite/src/node/plugins/css.ts | 885 +++++++++++++++-------- packages/vite/src/node/plugins/terser.ts | 23 +- playground/css/vite.config.js | 1 + pnpm-lock.yaml | 32 +- 7 files changed, 674 insertions(+), 363 deletions(-) diff --git a/docs/config/shared-options.md b/docs/config/shared-options.md index e6ac34f36e2ef5..d2eae4ec2d9722 100644 --- a/docs/config/shared-options.md +++ b/docs/config/shared-options.md @@ -222,17 +222,12 @@ Specify options to pass to CSS pre-processors. The file extensions are used as k - `less` - [Options](https://lesscss.org/usage/#less-options). - `styl`/`stylus` - Only [`define`](https://stylus-lang.com/docs/js.html#define-name-node) is supported, which can be passed as an object. -All preprocessor options also support the `additionalData` option, which can be used to inject extra code for each style content. Note that if you include actual styles and not just variables, those styles will be duplicated in the final bundle. - -Example: +**Example:** ```js export default defineConfig({ css: { preprocessorOptions: { - scss: { - additionalData: `$injectedColor: orange;`, - }, less: { math: 'parens-division', }, @@ -246,6 +241,34 @@ export default defineConfig({ }) ``` +### css.preprocessorOptions[extension].additionalData + +- **Type:** `string | ((source: string, filename: string) => (string | { content: string; map?: SourceMap }))` + +This option can be used to inject extra code for each style content. Note that if you include actual styles and not just variables, those styles will be duplicated in the final bundle. + +**Example:** + +```js +export default defineConfig({ + css: { + preprocessorOptions: { + scss: { + additionalData: `$injectedColor: orange;`, + }, + }, + }, +}) +``` + +## css.preprocessorMaxWorkers + +- **Experimental:** [Give Feedback](TODO: update) +- **Type:** `number | true` +- **Default:** `0` (does not create any workers and run in the main thread) + +If this option is set, CSS preprocessors will run in workers when possible. `true` means the number of CPUs minus 1. + ## css.devSourcemap - **Experimental:** [Give Feedback](https://github.com/vitejs/vite/discussions/13845) diff --git a/packages/vite/LICENSE.md b/packages/vite/LICENSE.md index fd81a463773c66..8914a690d75577 100644 --- a/packages/vite/LICENSE.md +++ b/packages/vite/LICENSE.md @@ -679,6 +679,36 @@ Repository: https://github.com/micromatch/anymatch --------------------------------------- +## artichokie +License: MIT +By: sapphi-red, Evan You +Repository: git+https://github.com/sapphi-red/artichokie.git + +> MIT License +> +> Copyright (c) 2020-present, Yuxi (Evan) You +> Copyright (c) 2023-present, sapphi-red +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +--------------------------------------- + ## astring License: MIT By: David Bonnet @@ -2312,35 +2342,6 @@ Repository: sindresorhus/object-assign --------------------------------------- -## okie -License: MIT -By: Evan You -Repository: git+https://github.com/yyx990803/okie.git - -> MIT License -> -> Copyright (c) 2020-present, Yuxi (Evan) You -> -> Permission is hereby granted, free of charge, to any person obtaining a copy -> of this software and associated documentation files (the "Software"), to deal -> in the Software without restriction, including without limitation the rights -> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -> copies of the Software, and to permit persons to whom the Software is -> furnished to do so, subject to the following conditions: -> -> The above copyright notice and this permission notice shall be included in all -> copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -> SOFTWARE. - ---------------------------------------- - ## on-finished License: MIT By: Douglas Christopher Wilson, Jonathan Ong diff --git a/packages/vite/package.json b/packages/vite/package.json index 3281805976adbe..5906713db3311c 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -96,6 +96,7 @@ "@types/pnpapi": "^0.0.5", "acorn": "^8.11.3", "acorn-walk": "^8.3.2", + "artichokie": "^0.2.0", "cac": "^6.7.14", "chokidar": "^3.5.3", "connect": "^3.7.0", @@ -118,7 +119,6 @@ "micromatch": "^4.0.5", "mlly": "^1.5.0", "mrmime": "^2.0.0", - "okie": "^1.0.1", "open": "^8.4.2", "parse5": "^7.1.2", "periscopic": "^4.0.2", diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 75e43f137c8f0c..a65be349b2d74d 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -24,6 +24,7 @@ import type { LightningCSSOptions } from 'dep-types/lightningcss' import type { TransformOptions } from 'esbuild' import { formatMessages, transform } from 'esbuild' import type { RawSourceMap } from '@ampproject/remapping' +import { WorkerWithFallback } from 'artichokie' import { getCodeWithSourcemap, injectSourcesContent } from '../server/sourcemap' import type { ModuleNode } from '../server/moduleGraph' import type { ResolveFn, ViteDevServer } from '../' @@ -97,7 +98,21 @@ export interface CSSOptions { * https://github.com/css-modules/postcss-modules */ modules?: CSSModulesOptions | false + /** + * Options for preprocessors. + * + * In addition to options specific to each processors, Vite supports `additionalData` option. + * The `additionalData` option can be used to inject extra code for each style content. + */ preprocessorOptions?: Record + /** + * If this option is set, preprocessors will run in workers when possible. + * `true` means the number of CPUs minus 1. + * + * @default 0 + * @experimental + */ + preprocessorMaxWorkers?: number | true postcss?: | string | (PostCSS.ProcessOptions & { @@ -245,6 +260,8 @@ export function cssPlugin(config: ResolvedConfig): Plugin { extensions: [], }) + let preprocessorWorkerController: PreprocessorWorkerController | undefined + // warm up cache for resolved postcss config if (config.css?.transformer !== 'lightningcss') { resolvePostcssConfig(config) @@ -259,6 +276,18 @@ export function cssPlugin(config: ResolvedConfig): Plugin { cssModulesCache.set(config, moduleCache) removedPureCssFilesCache.set(config, new Map()) + + preprocessorWorkerController = createPreprocessorWorkerController( + normalizeMaxWorkers(config.css.preprocessorMaxWorkers), + ) + preprocessorWorkerControllerCache.set( + config, + preprocessorWorkerController, + ) + }, + + buildEnd() { + preprocessorWorkerController?.close() }, async load(id) { @@ -333,7 +362,13 @@ export function cssPlugin(config: ResolvedConfig): Plugin { modules, deps, map, - } = await compileCSS(id, raw, config, urlReplacer) + } = await compileCSS( + id, + raw, + config, + preprocessorWorkerController!, + urlReplacer, + ) if (modules) { moduleCache.set(id, modules) } @@ -1061,11 +1096,12 @@ async function compileCSSPreprocessors( lang: PreprocessLang, code: string, config: ResolvedConfig, + workerController: PreprocessorWorkerController, ): Promise<{ code: string; map?: ExistingRawSourceMap; deps?: Set }> { const { preprocessorOptions, devSourcemap } = config.css ?? {} const atImportResolvers = getAtImportResolvers(config) - const preProcessor = preProcessors[lang] + const preProcessor = workerController[lang] let opts = (preprocessorOptions && preprocessorOptions[lang]) || {} // support @import from node dependencies by default switch (lang) { @@ -1139,6 +1175,7 @@ async function compileCSS( id: string, code: string, config: ResolvedConfig, + workerController: PreprocessorWorkerController, urlReplacer?: CssUrlReplacer, ): Promise<{ code: string @@ -1182,6 +1219,7 @@ async function compileCSS( lang, code, config, + workerController, ) code = preprocessorResult.code preprocessorMap = preprocessorResult.map @@ -1230,7 +1268,13 @@ async function compileCSS( const code = await fs.promises.readFile(id, 'utf-8') const lang = id.match(CSS_LANGS_RE)?.[1] as CssLang | undefined if (isPreProcessor(lang)) { - const result = await compileCSSPreprocessors(id, lang, code, config) + const result = await compileCSSPreprocessors( + id, + lang, + code, + config, + workerController, + ) result.deps?.forEach((dep) => deps.add(dep)) // TODO: support source map return result.code @@ -1297,10 +1341,7 @@ async function compileCSS( // postcss is an unbundled dep and should be lazy imported postcssResult = await postcss.default(postcssPlugins).process(code, { ...postcssOptions, - parser: - lang === 'sss' - ? loadPreprocessor(PostCssDialectLang.sss, config.root) - : postcssOptions.parser, + parser: lang === 'sss' ? loadSss(config.root) : postcssOptions.parser, to: source, from: source, ...(devSourcemap @@ -1409,6 +1450,14 @@ const importPostcssImport = createCachedImport(() => import('postcss-import')) const importPostcssModules = createCachedImport(() => import('postcss-modules')) const importPostcss = createCachedImport(() => import('postcss')) +const preprocessorWorkerControllerCache = new WeakMap< + ResolvedConfig, + PreprocessorWorkerController +>() +let alwaysFakeWorkerWorkerControllerCache: + | PreprocessorWorkerController + | undefined + export interface PreprocessCSSResult { code: string map?: SourceMapInput @@ -1424,7 +1473,17 @@ export async function preprocessCSS( filename: string, config: ResolvedConfig, ): Promise { - return await compileCSS(filename, code, config) + let workerController = preprocessorWorkerControllerCache.get(config) + + if (!workerController) { + // if workerController doesn't exist, create a workerController that always uses fake workers + // because fake workers doesn't require calling `.close` unlike real workers + alwaysFakeWorkerWorkerControllerCache ||= + createPreprocessorWorkerController(0) + workerController = alwaysFakeWorkerWorkerControllerCache + } + + return await compileCSS(filename, code, config, workerController) } export async function formatPostcssSourceMap( @@ -1846,6 +1905,7 @@ type PreprocessorAdditionalData = type StylePreprocessorOptions = { [key: string]: any additionalData?: PreprocessorAdditionalData + maxWorkers?: number | true filename: string alias: Alias[] enableSourcemap: boolean @@ -1857,26 +1917,35 @@ type StylusStylePreprocessorOptions = StylePreprocessorOptions & { define?: Record } -type StylePreprocessor = ( - source: string, - root: string, - options: StylePreprocessorOptions, - resolvers: CSSAtImportResolvers, -) => StylePreprocessorResults | Promise +type StylePreprocessor = { + process: ( + source: string, + root: string, + options: StylePreprocessorOptions, + resolvers: CSSAtImportResolvers, + ) => StylePreprocessorResults | Promise + close: () => void +} -type SassStylePreprocessor = ( - source: string, - root: string, - options: SassStylePreprocessorOptions, - resolvers: CSSAtImportResolvers, -) => StylePreprocessorResults | Promise +type SassStylePreprocessor = { + process: ( + source: string, + root: string, + options: SassStylePreprocessorOptions, + resolvers: CSSAtImportResolvers, + ) => StylePreprocessorResults | Promise + close: () => void +} -type StylusStylePreprocessor = ( - source: string, - root: string, - options: StylusStylePreprocessorOptions, - resolvers: CSSAtImportResolvers, -) => StylePreprocessorResults | Promise +type StylusStylePreprocessor = { + process: ( + source: string, + root: string, + options: StylusStylePreprocessorOptions, + resolvers: CSSAtImportResolvers, + ) => StylePreprocessorResults | Promise + close: () => void +} export interface StylePreprocessorResults { code: string @@ -1886,34 +1955,21 @@ export interface StylePreprocessorResults { deps: string[] } -const loadedPreprocessors: Partial< - Record +const loadedPreprocessorPath: Partial< + Record > = {} -// TODO: use dynamic import -const _require = createRequire(import.meta.url) - -function loadPreprocessor(lang: PreprocessLang.scss, root: string): typeof Sass -function loadPreprocessor(lang: PreprocessLang.sass, root: string): typeof Sass -function loadPreprocessor(lang: PreprocessLang.less, root: string): typeof Less -function loadPreprocessor( - lang: PreprocessLang.stylus, - root: string, -): typeof Stylus -function loadPreprocessor( - lang: PostCssDialectLang.sss, - root: string, -): PostCSS.Parser -function loadPreprocessor( +function loadPreprocessorPath( lang: PreprocessLang | PostCssDialectLang, root: string, -): any { - if (lang in loadedPreprocessors) { - return loadedPreprocessors[lang] +): string { + const cached = loadedPreprocessorPath[lang] + if (cached) { + return cached } try { const resolved = requireResolveFromRootWithFallback(root, lang) - return (loadedPreprocessors[lang] = _require(resolved)) + return (loadedPreprocessorPath[lang] = resolved) } catch (e) { if (e.code === 'MODULE_NOT_FOUND') { const installCommand = getPackageManagerCommand('install') @@ -1930,6 +1986,15 @@ function loadPreprocessor( } } +let cachedSss: any +function loadSss(root: string) { + if (cachedSss) return cachedSss + + const sssPath = loadPreprocessorPath(PostCssDialectLang.sss, root) + cachedSss = createRequire(import.meta.url)(sssPath) + return cachedSss +} + declare const window: unknown | undefined declare const location: { href: string } | undefined @@ -1969,101 +2034,172 @@ function fixScssBugImportValue( } // .scss/.sass processor -const scss: SassStylePreprocessor = async ( - source, - root, - options, - resolvers, +const makeScssWorker = ( + resolvers: CSSAtImportResolvers, + alias: Alias[], + maxWorkers: number | undefined, ) => { - const render = loadPreprocessor(PreprocessLang.sass, root).render - // NOTE: `sass` always runs it's own importer first, and only falls back to - // the `importer` option when it can't resolve a path - const internalImporter: Sass.Importer = (url, importer, done) => { + const internalImporter = async ( + url: string, + importer: string, + filename: string, + ) => { importer = cleanScssBugUrl(importer) - resolvers.sass(url, importer).then((resolved) => { - if (resolved) { - rebaseUrls( + const resolved = await resolvers.sass(url, importer) + if (resolved) { + try { + const data = await rebaseUrls( resolved, - options.filename, - options.alias, + filename, + alias, '$', resolvers.sass, ) - .then((data) => done?.(fixScssBugImportValue(data))) - .catch((data) => done?.(data)) - } else { - done?.(null) + return fixScssBugImportValue(data) + } catch (data) { + return data } - }) - } - const importer = [internalImporter] - if (options.importer) { - Array.isArray(options.importer) - ? importer.unshift(...options.importer) - : importer.unshift(options.importer) + } else { + return null + } } - const { content: data, map: additionalMap } = await getSource( - source, - options.filename, - options.additionalData, - options.enableSourcemap, - ) - const finalOptions: Sass.Options = { - ...options, - data, - file: options.filename, - outFile: options.filename, - importer, - ...(options.enableSourcemap - ? { - sourceMap: true, - omitSourceMapUrl: true, - sourceMapRoot: path.dirname(options.filename), + const worker = new WorkerWithFallback( + () => + async ( + sassPath: string, + data: string, + // additionalData can a function that is not cloneable but it won't be used + options: SassStylePreprocessorOptions & { additionalData: undefined }, + ) => { + // eslint-disable-next-line no-restricted-globals -- this function runs inside a cjs worker + const sass: typeof Sass = require(sassPath) + // eslint-disable-next-line no-restricted-globals + const path = require('node:path') + + // NOTE: `sass` always runs it's own importer first, and only falls back to + // the `importer` option when it can't resolve a path + const _internalImporter: Sass.Importer = (url, importer, done) => { + internalImporter(url, importer, options.filename).then((data) => + done?.(data), + ) } - : {}), - } - - try { - const result = await new Promise((resolve, reject) => { - render(finalOptions, (err, res) => { - if (err) { - reject(err) - } else { - resolve(res) + const importer = [_internalImporter] + if (options.importer) { + Array.isArray(options.importer) + ? importer.unshift(...options.importer) + : importer.unshift(options.importer) } - }) - }) - const deps = result.stats.includedFiles.map((f) => cleanScssBugUrl(f)) - const map: ExistingRawSourceMap | undefined = result.map - ? JSON.parse(result.map.toString()) - : undefined - return { - code: result.css.toString(), - map, - additionalMap, - deps, - } - } catch (e) { - // normalize SASS error - e.message = `[sass] ${e.message}` - e.id = e.file - e.frame = e.formatted - return { code: '', error: e, deps: [] } - } -} - -const sass: SassStylePreprocessor = (source, root, options, aliasResolver) => - scss( - source, - root, + const finalOptions: Sass.Options = { + ...options, + data, + file: options.filename, + outFile: options.filename, + importer, + ...(options.enableSourcemap + ? { + sourceMap: true, + omitSourceMapUrl: true, + sourceMapRoot: path.dirname(options.filename), + } + : {}), + } + return new Promise<{ + css: string + map?: string | undefined + stats: Sass.Result['stats'] + }>((resolve, reject) => { + sass.render(finalOptions, (err, res) => { + if (err) { + reject(err) + } else { + resolve({ + css: res.css.toString(), + map: res.map?.toString(), + stats: res.stats, + }) + } + }) + }) + }, { - ...options, - indentedSyntax: true, + parentFunctions: { internalImporter }, + shouldUseFake(_sassPath, _data, options) { + // functions and importer is a function and is not serializable + // in that case, fallback to running in main thread + return !!( + (options.functions && Object.keys(options.functions).length > 0) || + (options.importer && + (!Array.isArray(options.importer) || options.importer.length > 0)) + ) + }, + max: maxWorkers, }, - aliasResolver, ) + return worker +} + +const scssProcessor = ( + maxWorkers: number | undefined, +): SassStylePreprocessor => { + const workerMap = new Map>() + + return { + close() { + for (const worker of workerMap.values()) { + worker.stop() + } + }, + async process(source, root, options, resolvers) { + const sassPath = loadPreprocessorPath(PreprocessLang.sass, root) + + if (!workerMap.has(options.alias)) { + workerMap.set( + options.alias, + makeScssWorker(resolvers, options.alias, maxWorkers), + ) + } + const worker = workerMap.get(options.alias)! + + const { content: data, map: additionalMap } = await getSource( + source, + options.filename, + options.additionalData, + options.enableSourcemap, + ) + + const optionsWithoutAdditionalData = { + ...options, + additionalData: undefined, + } + try { + const result = await worker.run( + sassPath, + data, + optionsWithoutAdditionalData, + ) + const deps = result.stats.includedFiles.map((f) => cleanScssBugUrl(f)) + const map: ExistingRawSourceMap | undefined = result.map + ? JSON.parse(result.map.toString()) + : undefined + + return { + code: result.css.toString(), + map, + additionalMap, + deps, + } + } catch (e) { + // normalize SASS error + e.message = `[sass] ${e.message}` + e.id = e.file + e.frame = e.formatted + return { code: '', error: e, deps: [] } + } + }, + } +} /** * relative url() inside \@imported sass and less files must be rebased to use @@ -2134,188 +2270,304 @@ async function rebaseUrls( } // .less -const less: StylePreprocessor = async (source, root, options, resolvers) => { - const nodeLess = loadPreprocessor(PreprocessLang.less, root) - const viteResolverPlugin = createViteLessPlugin( - nodeLess, - options.filename, - options.alias, - resolvers, - ) - const { content, map: additionalMap } = await getSource( - source, - options.filename, - options.additionalData, - options.enableSourcemap, - ) - - let result: Less.RenderOutput | undefined - try { - result = await nodeLess.render(content, { - ...options, - plugins: [viteResolverPlugin, ...(options.plugins || [])], - ...(options.enableSourcemap - ? { - sourceMap: { - outputSourceFiles: true, - sourceMapFileInline: false, - }, - } - : {}), - }) - } catch (e) { - const error = e as Less.RenderError - // normalize error info - const normalizedError: RollupError = new Error( - `[less] ${error.message || error.type}`, - ) as RollupError - normalizedError.loc = { - file: error.filename || options.filename, - line: error.line, - column: error.column, +const makeLessWorker = ( + resolvers: CSSAtImportResolvers, + alias: Alias[], + maxWorkers: number | undefined, +) => { + const viteLessResolve = async ( + filename: string, + dir: string, + rootFile: string, + ) => { + const resolved = await resolvers.less(filename, path.join(dir, '*')) + if (!resolved) return undefined + + const result = await rebaseUrls( + resolved, + rootFile, + alias, + '@', + resolvers.less, + ) + if (result) { + return { + resolved, + contents: 'contents' in result ? result.contents : undefined, + } } - return { code: '', error: normalizedError, deps: [] } + return result } - const map: ExistingRawSourceMap = result.map && JSON.parse(result.map) - if (map) { - delete map.sourcesContent - } + const worker = new WorkerWithFallback( + () => { + // eslint-disable-next-line no-restricted-globals -- this function runs inside a cjs worker + const fsp = require('node:fs/promises') + // eslint-disable-next-line no-restricted-globals + const path = require('node:path') - return { - code: result.css.toString(), - map, - additionalMap, - deps: result.imports, - } + let ViteLessManager: any + const createViteLessPlugin = ( + less: typeof Less, + rootFile: string, + ): Less.Plugin => { + const { FileManager } = less + ViteLessManager ??= class ViteManager extends FileManager { + rootFile + constructor(rootFile: string) { + super() + this.rootFile = rootFile + } + override supports(filename: string) { + return !/^(?:https?:)?\/\//.test(filename) + } + override supportsSync() { + return false + } + override async loadFile( + filename: string, + dir: string, + opts: any, + env: any, + ): Promise { + const result = await viteLessResolve(filename, dir, this.rootFile) + if (result) { + return { + filename: path.resolve(result.resolved), + contents: + result.contents ?? + (await fsp.readFile(result.resolved, 'utf-8')), + } + } else { + return super.loadFile(filename, dir, opts, env) + } + } + } + + return { + install(_, pluginManager) { + pluginManager.addFileManager(new ViteLessManager(rootFile)) + }, + minVersion: [3, 0, 0], + } + } + + return async ( + lessPath: string, + content: string, + // additionalData can a function that is not cloneable but it won't be used + options: StylePreprocessorOptions & { additionalData: undefined }, + ) => { + // eslint-disable-next-line no-restricted-globals -- this function runs inside a cjs worker + const nodeLess: typeof Less = require(lessPath) + const viteResolverPlugin = createViteLessPlugin( + nodeLess, + options.filename, + ) + const result = await nodeLess.render(content, { + ...options, + plugins: [viteResolverPlugin, ...(options.plugins || [])], + ...(options.enableSourcemap + ? { + sourceMap: { + outputSourceFiles: true, + sourceMapFileInline: false, + }, + } + : {}), + }) + return result + } + }, + { + parentFunctions: { viteLessResolve }, + shouldUseFake(_lessPath, _content, options) { + // plugins are a function and is not serializable + // in that case, fallback to running in main thread + return options.plugins?.length > 0 + }, + max: maxWorkers, + }, + ) + return worker } -/** - * Less manager, lazy initialized - */ -let ViteLessManager: any +const lessProcessor = (maxWorkers: number | undefined): StylePreprocessor => { + const workerMap = new Map>() -function createViteLessPlugin( - less: typeof Less, - rootFile: string, - alias: Alias[], - resolvers: CSSAtImportResolvers, -): Less.Plugin { - if (!ViteLessManager) { - ViteLessManager = class ViteManager extends less.FileManager { - resolvers - rootFile - alias - constructor( - rootFile: string, - resolvers: CSSAtImportResolvers, - alias: Alias[], - ) { - super() - this.rootFile = rootFile - this.resolvers = resolvers - this.alias = alias + return { + close() { + for (const worker of workerMap.values()) { + worker.stop() } - override supports(filename: string) { - return !isExternalUrl(filename) + }, + async process(source, root, options, resolvers) { + const lessPath = loadPreprocessorPath(PreprocessLang.less, root) + + if (!workerMap.has(options.alias)) { + workerMap.set( + options.alias, + makeLessWorker(resolvers, options.alias, maxWorkers), + ) } - override supportsSync() { - return false + const worker = workerMap.get(options.alias)! + + const { content, map: additionalMap } = await getSource( + source, + options.filename, + options.additionalData, + options.enableSourcemap, + ) + + let result: Less.RenderOutput | undefined + const optionsWithoutAdditionalData = { + ...options, + additionalData: undefined, } - override async loadFile( - filename: string, - dir: string, - opts: any, - env: any, - ): Promise { - const resolved = await this.resolvers.less( - filename, - path.join(dir, '*'), + try { + result = await worker.run( + lessPath, + content, + optionsWithoutAdditionalData, ) - if (resolved) { - const result = await rebaseUrls( - resolved, - this.rootFile, - this.alias, - '@', - this.resolvers.less, - ) - let contents: string - if (result && 'contents' in result) { - contents = result.contents - } else { - contents = await fsp.readFile(resolved, 'utf-8') - } - return { - filename: path.resolve(resolved), - contents, - } - } else { - return super.loadFile(filename, dir, opts, env) + } catch (e) { + const error = e as Less.RenderError + // normalize error info + const normalizedError: RollupError = new Error( + `[less] ${error.message || error.type}`, + ) as RollupError + normalizedError.loc = { + file: error.filename || options.filename, + line: error.line, + column: error.column, } + return { code: '', error: normalizedError, deps: [] } } - } - } - return { - install(_, pluginManager) { - pluginManager.addFileManager( - new ViteLessManager(rootFile, resolvers, alias), - ) + const map: ExistingRawSourceMap = result.map && JSON.parse(result.map) + if (map) { + delete map.sourcesContent + } + + return { + code: result.css.toString(), + map, + additionalMap, + deps: result.imports, + } }, - minVersion: [3, 0, 0], } } // .styl -const styl: StylusStylePreprocessor = async (source, root, options) => { - const nodeStylus = loadPreprocessor(PreprocessLang.stylus, root) - // Get source with preprocessor options.additionalData. Make sure a new line separator - // is added to avoid any render error, as added stylus content may not have semi-colon separators - const { content, map: additionalMap } = await getSource( - source, - options.filename, - options.additionalData, - options.enableSourcemap, - '\n', - ) - // Get preprocessor options.imports dependencies as stylus - // does not return them with its builtin `.deps()` method - const importsDeps = (options.imports ?? []).map((dep: string) => - path.resolve(dep), - ) - try { - const ref = nodeStylus(content, options) - if (options.define) { - for (const key in options.define) { - ref.define(key, options.define[key]) - } - } - if (options.enableSourcemap) { - ref.set('sourcemap', { - comment: false, - inline: false, - basePath: root, - }) - } +const makeStylWorker = (maxWorkers: number | undefined) => { + const worker = new WorkerWithFallback( + () => { + return async ( + stylusPath: string, + content: string, + root: string, + // additionalData can a function that is not cloneable but it won't be used + options: StylusStylePreprocessorOptions & { additionalData: undefined }, + ) => { + // eslint-disable-next-line no-restricted-globals -- this function runs inside a cjs worker + const nodeStylus: typeof Stylus = require(stylusPath) - const result = ref.render() + const ref = nodeStylus(content, options) + if (options.define) { + for (const key in options.define) { + ref.define(key, options.define[key]) + } + } + if (options.enableSourcemap) { + ref.set('sourcemap', { + comment: false, + inline: false, + basePath: root, + }) + } - // Concat imports deps with computed deps - const deps = [...ref.deps(), ...importsDeps] + return { + code: ref.render(), + // @ts-expect-error sourcemap exists + map: ref.sourcemap as ExistingRawSourceMap | undefined, + deps: ref.deps(), + } + } + }, + { + shouldUseFake(_stylusPath, _content, _root, options) { + // define can include functions and those are not serializable + // in that case, fallback to running in main thread + return !!( + options.define && + Object.values(options.define).some((d) => typeof d === 'function') + ) + }, + max: maxWorkers, + }, + ) + return worker +} - // @ts-expect-error sourcemap exists - const map: ExistingRawSourceMap | undefined = ref.sourcemap +const stylProcessor = ( + maxWorkers: number | undefined, +): StylusStylePreprocessor => { + const workerMap = new Map>() - return { - code: result, - map: formatStylusSourceMap(map, root), - additionalMap, - deps, - } - } catch (e) { - e.message = `[stylus] ${e.message}` - return { code: '', error: e, deps: [] } + return { + close() { + for (const worker of workerMap.values()) { + worker.stop() + } + }, + async process(source, root, options, resolvers) { + const stylusPath = loadPreprocessorPath(PreprocessLang.stylus, root) + + if (!workerMap.has(options.alias)) { + workerMap.set(options.alias, makeStylWorker(maxWorkers)) + } + const worker = workerMap.get(options.alias)! + + // Get source with preprocessor options.additionalData. Make sure a new line separator + // is added to avoid any render error, as added stylus content may not have semi-colon separators + const { content, map: additionalMap } = await getSource( + source, + options.filename, + options.additionalData, + options.enableSourcemap, + '\n', + ) + // Get preprocessor options.imports dependencies as stylus + // does not return them with its builtin `.deps()` method + const importsDeps = (options.imports ?? []).map((dep: string) => + path.resolve(dep), + ) + const optionsWithoutAdditionalData = { + ...options, + additionalData: undefined, + } + try { + const { code, map, deps } = await worker.run( + stylusPath, + content, + root, + optionsWithoutAdditionalData, + ) + return { + code, + map: formatStylusSourceMap(map, root), + additionalMap, + // Concat imports deps with computed deps + deps: [...deps, ...importsDeps], + } + } catch (e) { + const wrapped = new Error(`[stylus] ${e.message}`) + wrapped.name = e.name + wrapped.stack = e.stack + return { code: '', error: wrapped, deps: [] } + } + }, } } @@ -2371,16 +2623,61 @@ async function getSource( } } -const preProcessors = Object.freeze({ - [PreprocessLang.less]: less, - [PreprocessLang.sass]: sass, - [PreprocessLang.scss]: scss, - [PreprocessLang.styl]: styl, - [PreprocessLang.stylus]: styl, -}) +const createPreprocessorWorkerController = (maxWorkers: number | undefined) => { + const scss = scssProcessor(maxWorkers) + const less = lessProcessor(maxWorkers) + const styl = stylProcessor(maxWorkers) + + const sassProcess: StylePreprocessor['process'] = ( + source, + root, + options, + resolvers, + ) => { + return scss.process( + source, + root, + { ...options, indentedSyntax: true }, + resolvers, + ) + } + + const close = () => { + less.close() + scss.close() + styl.close() + } + + return { + [PreprocessLang.less]: less.process, + [PreprocessLang.scss]: scss.process, + [PreprocessLang.sass]: sassProcess, + [PreprocessLang.styl]: styl.process, + [PreprocessLang.stylus]: styl.process, + close, + } as const satisfies { [K in PreprocessLang | 'close']: unknown } +} + +const normalizeMaxWorkers = (maxWorker: number | true | undefined) => { + if (maxWorker === undefined) return 0 + if (maxWorker === true) return undefined + return maxWorker +} + +type PreprocessorWorkerController = ReturnType< + typeof createPreprocessorWorkerController +> + +const preprocessorSet = new Set([ + PreprocessLang.less, + PreprocessLang.sass, + PreprocessLang.scss, + PreprocessLang.styl, + PreprocessLang.stylus, +] as const) function isPreProcessor(lang: any): lang is PreprocessLang { - return lang && lang in preProcessors + return lang && preprocessorSet.has(lang) } const importLightningCSS = createCachedImport(() => import('lightningcss')) diff --git a/packages/vite/src/node/plugins/terser.ts b/packages/vite/src/node/plugins/terser.ts index c1522065750dbf..90c29b26c7501e 100644 --- a/packages/vite/src/node/plugins/terser.ts +++ b/packages/vite/src/node/plugins/terser.ts @@ -1,5 +1,5 @@ -import { Worker } from 'okie' import type { Terser } from 'dep-types/terser' +import { Worker } from 'artichokie' import type { Plugin } from '../plugin' import type { ResolvedConfig } from '..' import { requireResolveFromRootWithFallback } from '../utils' @@ -38,16 +38,17 @@ export function terserPlugin(config: ResolvedConfig): Plugin { const makeWorker = () => new Worker( - async ( - terserPath: string, - code: string, - options: Terser.MinifyOptions, - ) => { - // test fails when using `import`. maybe related: https://github.com/nodejs/node/issues/43205 - // eslint-disable-next-line no-restricted-globals -- this function runs inside cjs - const terser = require(terserPath) - return terser.minify(code, options) as Terser.MinifyOutput - }, + () => + async ( + terserPath: string, + code: string, + options: Terser.MinifyOptions, + ) => { + // test fails when using `import`. maybe related: https://github.com/nodejs/node/issues/43205 + // eslint-disable-next-line no-restricted-globals -- this function runs inside cjs + const terser = require(terserPath) + return terser.minify(code, options) as Terser.MinifyOutput + }, { max: maxWorkers, }, diff --git a/playground/css/vite.config.js b/playground/css/vite.config.js index 3d301ae03bec3e..5ac9d448a2734a 100644 --- a/playground/css/vite.config.js +++ b/playground/css/vite.config.js @@ -82,5 +82,6 @@ export default defineConfig({ }, }, }, + preprocessorMaxWorkers: true, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66cfa9efcd4f3c..2e0157b26f2f89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,9 @@ importers: acorn-walk: specifier: ^8.3.2 version: 8.3.2(acorn@8.11.3) + artichokie: + specifier: ^0.2.0 + version: 0.2.0 cac: specifier: ^6.7.14 version: 6.7.14 @@ -354,9 +357,6 @@ importers: mrmime: specifier: ^2.0.0 version: 2.0.0 - okie: - specifier: ^1.0.1 - version: 1.0.1 open: specifier: ^8.4.2 version: 8.4.2 @@ -4597,17 +4597,6 @@ packages: acorn: 8.11.3 dev: true - /acorn-walk@8.3.1(acorn@8.11.3): - resolution: {integrity: sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==} - engines: {node: '>=0.4.0'} - peerDependencies: - acorn: '*' - peerDependenciesMeta: - acorn: - optional: true - dependencies: - acorn: 8.11.3 - /acorn-walk@8.3.2(acorn@8.11.3): resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} engines: {node: '>=0.4.0'} @@ -4618,7 +4607,6 @@ packages: optional: true dependencies: acorn: 8.11.3 - dev: true /acorn@7.4.1: resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} @@ -4761,6 +4749,11 @@ packages: engines: {node: '>=8'} dev: true + /artichokie@0.2.0: + resolution: {integrity: sha512-LXtOFWUNABHEo49FJpwOf8VLzOJ1iGV9xu9ezwnveI75LIqGhUDDjMFo3MkUmtc+t3oDZRMATuVMrt6d8FCvrQ==} + engines: {node: ^18.0.0 || >=20.0.0} + dev: true + /as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} dependencies: @@ -7268,7 +7261,7 @@ packages: dependencies: '@cspotcode/source-map-support': 0.8.1 acorn: 8.11.3 - acorn-walk: 8.3.1(acorn@8.11.3) + acorn-walk: 8.3.2(acorn@8.11.3) capnp-ts: 0.7.0 exit-hook: 2.2.1 glob-to-regexp: 0.4.1 @@ -7590,11 +7583,6 @@ packages: /object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} - /okie@1.0.1: - resolution: {integrity: sha512-JQh5TdSYhzXSuKN3zzX8Rw9Q/Tec1fm0jwP/k9+cBDk6tyLjlARVu936MLY//2NZp76UGHH+5gXPzRejU1bTjQ==} - engines: {node: '>=12.0.0'} - dev: true - /on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -9072,7 +9060,7 @@ packages: '@tsconfig/node16': 1.0.2 '@types/node': 20.11.1 acorn: 8.11.3 - acorn-walk: 8.3.1(acorn@8.11.3) + acorn-walk: 8.3.2(acorn@8.11.3) arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2