diff --git a/packages/vite/LICENSE.md b/packages/vite/LICENSE.md index 6021274c459860..0fed7f357fb40a 100644 --- a/packages/vite/LICENSE.md +++ b/packages/vite/LICENSE.md @@ -2201,35 +2201,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/src/node/okie.ts b/packages/vite/src/node/okie.ts new file mode 100644 index 00000000000000..6dcf9de27494d7 --- /dev/null +++ b/packages/vite/src/node/okie.ts @@ -0,0 +1,188 @@ +import os from 'node:os' +import { Worker as _Worker } from 'node:worker_threads' + +interface NodeWorker extends _Worker { + currentResolve: ((value: any) => void) | null + currentReject: ((err: Error) => void) | null +} + +export interface Options { + max?: number + parentFunctions?: Record Promise> +} + +export class Worker { + private code: string + private parentFunctions: Record Promise> + private max: number + private pool: NodeWorker[] + private idlePool: NodeWorker[] + private queue: [(worker: NodeWorker) => void, (err: Error) => void][] + + constructor( + fn: (...args: Args) => Promise | Ret, + options: Options = {}, + ) { + this.code = genWorkerCode(fn, options.parentFunctions ?? {}) + this.parentFunctions = options.parentFunctions ?? {} + this.max = options.max || Math.max(1, os.cpus().length - 1) + this.pool = [] + this.idlePool = [] + this.queue = [] + } + + async run(...args: Args): Promise { + const worker = await this._getAvailableWorker() + return new Promise((resolve, reject) => { + worker.currentResolve = resolve + worker.currentReject = reject + worker.postMessage({ type: 'run', args }) + }) + } + + stop(): void { + this.pool.forEach((w) => w.unref()) + this.queue.forEach(([_, reject]) => + reject( + new Error('Main worker pool stopped before a worker was available.'), + ), + ) + this.pool = [] + this.idlePool = [] + this.queue = [] + } + + private async _getAvailableWorker(): Promise { + // has idle one? + if (this.idlePool.length) { + return this.idlePool.shift()! + } + + // can spawn more? + if (this.pool.length < this.max) { + const worker = new _Worker(this.code, { eval: true }) as NodeWorker + + worker.on('message', async (args) => { + if (args.type === 'run') { + if ('result' in args) { + worker.currentResolve && worker.currentResolve(args.result) + worker.currentResolve = null + this._assignDoneWorker(worker) + } else { + worker.currentReject && worker.currentReject(args.error) + worker.currentReject = null + } + } else if (args.type === 'parentFunction') { + if (!(args.name in this.parentFunctions)) { + throw new Error( + `Parent function ${JSON.stringify( + args.name, + )} was not passed to options but was called.`, + ) + } + + try { + const result = await this.parentFunctions[args.name](...args.args) + worker.postMessage({ type: 'parentFunction', id: args.id, result }) + } catch (e) { + worker.postMessage({ + type: 'parentFunction', + id: args.id, + error: e, + }) + } + } + }) + + worker.on('error', (err) => { + worker.currentReject && worker.currentReject(err) + worker.currentReject = null + }) + + worker.on('exit', (code) => { + const i = this.pool.indexOf(worker) + if (i > -1) this.pool.splice(i, 1) + if (code !== 0 && worker.currentReject) { + worker.currentReject( + new Error(`Worker stopped with non-0 exit code ${code}`), + ) + worker.currentReject = null + } + }) + + this.pool.push(worker) + return worker + } + + // no one is available, we have to wait + let resolve: (worker: NodeWorker) => void + let reject: (err: Error) => any + const onWorkerAvailablePromise = new Promise((r, rj) => { + resolve = r + reject = rj + }) + this.queue.push([resolve!, reject!]) + return onWorkerAvailablePromise + } + + private _assignDoneWorker(worker: NodeWorker) { + // someone's waiting already? + if (this.queue.length) { + const [resolve] = this.queue.shift()! + resolve(worker) + return + } + // take a rest. + this.idlePool.push(worker) + } +} + +function genWorkerCode(fn: Function, parentFunctions: Record) { + return ` +let id = 0 +const parentFunctionResolvers = new Map() +const parentFunctionCall = (key) => async (...args) => { + id++ + let resolve, reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + parentFunctionResolvers.set(id, { resolve, reject }) + + parentPort.postMessage({ type: 'parentFunction', id, name: key, args }) + return await promise +} + +const doWork = (() => { + ${Object.keys(parentFunctions) + .map((key) => `const ${key} = parentFunctionCall(${JSON.stringify(key)});`) + .join('\n')} + return ${fn.toString()} +})() + +const { parentPort } = require('worker_threads') + +parentPort.on('message', async (args) => { + if (args.type === 'run') { + try { + const res = await doWork(...args.args) + parentPort.postMessage({ type: 'run', result: res }) + } catch (e) { + parentPort.postMessage({ type: 'run', error: e }) + } + } else if (args.type === 'parentFunction') { + if (parentFunctionResolvers.has(id)) { + const { resolve, reject } = parentFunctionResolvers.get(id) + parentFunctionResolvers.delete(id) + + if ('result' in args) { + resolve(args.result) + } else { + reject(args.error) + } + } + } +}) + ` +} diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index b2465d4a1d45ba..ec9f53f212ca48 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -58,6 +58,7 @@ import { stripBomTag, } from '../utils' import type { Logger } from '../logger' +import { Worker } from '../okie' import { addToHTMLProxyTransformResult } from './html' import { assetUrlRE, @@ -362,6 +363,9 @@ export function cssPlugin(config: ResolvedConfig): Plugin { map, } }, + buildEnd() { + scssWorker.stop() + }, } } @@ -1673,6 +1677,36 @@ function loadPreprocessor( } } +const loadedPreprocessorPath: Partial< + Record +> = {} + +function loadPreprocessorPath( + lang: PreprocessLang | PostCssDialectLang, + root: string, +): string { + const cached = loadedPreprocessorPath[lang] + if (cached) { + return cached + } + try { + const resolved = requireResolveFromRootWithFallback(root, lang) + return (loadedPreprocessorPath[lang] = resolved) + } catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + `Preprocessor dependency "${lang}" not found. Did you install it?`, + ) + } else { + const message = new Error( + `Preprocessor dependency "${lang}" failed to load:\n${e.message}`, + ) + message.stack = e.stack + '\n' + message.stack + throw message + } + } +} + declare const window: unknown | undefined declare const location: { href: string } | undefined @@ -1711,6 +1745,89 @@ function fixScssBugImportValue( return data } +let scssWorker: ReturnType +const makeScssWorker = (resolvers: CSSAtImportResolvers, alias: Alias[]) => { + const internalImporter = async ( + url: string, + importer: string, + filename: string, + ) => { + importer = cleanScssBugUrl(importer) + const resolved = await resolvers.sass(url, importer) + if (resolved) { + try { + const data = await rebaseUrls(resolved, filename, alias, '$') + return fixScssBugImportValue(data) + } catch (data) { + return data + } + } else { + return null + } + } + + const worker = new Worker( + async ( + sassPath: string, + data: string, + options: SassStylePreprocessorOptions, + ) => { + // eslint-disable-next-line no-restricted-globals + 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), + ) + } + const importer = [_internalImporter] + if (options.importer) { + Array.isArray(options.importer) + ? importer.unshift(...options.importer) + : importer.unshift(options.importer) + } + + 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, + }) + } + }) + }) + }, + { parentFunctions: { internalImporter } }, + ) + return worker +} + // .scss/.sass processor const scss: SassStylePreprocessor = async ( source, @@ -1718,27 +1835,8 @@ const scss: SassStylePreprocessor = async ( options, resolvers, ) => { - 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) => { - importer = cleanScssBugUrl(importer) - resolvers.sass(url, importer).then((resolved) => { - if (resolved) { - rebaseUrls(resolved, options.filename, options.alias, '$') - .then((data) => done?.(fixScssBugImportValue(data))) - .catch((data) => done?.(data)) - } else { - done?.(null) - } - }) - } - const importer = [internalImporter] - if (options.importer) { - Array.isArray(options.importer) - ? importer.unshift(...options.importer) - : importer.unshift(options.importer) - } + const sassPath = loadPreprocessorPath(PreprocessLang.sass, root) + scssWorker ||= makeScssWorker(resolvers, options.alias) const { content: data, map: additionalMap } = await getSource( source, @@ -1746,31 +1844,9 @@ const scss: SassStylePreprocessor = async ( 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), - } - : {}), - } try { - const result = await new Promise((resolve, reject) => { - render(finalOptions, (err, res) => { - if (err) { - reject(err) - } else { - resolve(res) - } - }) - }) + const result = await scssWorker.run(sassPath, data, options) const deps = result.stats.includedFiles.map((f) => cleanScssBugUrl(f)) const map: ExistingRawSourceMap | undefined = result.map ? JSON.parse(result.map.toString()) diff --git a/packages/vite/src/node/plugins/terser.ts b/packages/vite/src/node/plugins/terser.ts index 40f28cb9daccaf..03f12ff464e968 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 '../okie' import type { Plugin } from '../plugin' import type { ResolvedConfig } from '..' import { requireResolveFromRootWithFallback } from '../utils'