diff --git a/packages/babel/README.md b/packages/babel/README.md index 46e5a45..53fe6df 100644 --- a/packages/babel/README.md +++ b/packages/babel/README.md @@ -114,6 +114,24 @@ export default { } ``` +### `parallel` + +- **Type:** `boolean | number` +- **Default:** `false` + +Transform files in worker threads. Set `true` to use one worker per CPU core, with a maximum of 4. Set a number to choose the worker count. + +Workers take time to start, so this option helps large projects most. + +All Babel options must be able to go to a worker thread. Refer to plugins and presets by module name, not by function or object. The `rolldown` part of a [Rolldown Babel Preset](#rolldown-babel-presets) stays in the main thread, so its filters and hooks can be functions. + +```js +babel({ + parallel: true, + plugins: [['@babel/plugin-proposal-decorators', { version: '2023-11' }]], +}) +``` + ### Other Babel options The following [Babel options](https://babeljs.io/docs/options) are forwarded directly: diff --git a/packages/babel/package.json b/packages/babel/package.json index 0371842..ba93c0d 100644 --- a/packages/babel/package.json +++ b/packages/babel/package.json @@ -30,7 +30,8 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { - "picomatch": "^4.0.7" + "picomatch": "^4.0.7", + "workerpool": "^10.0.3" }, "devDependencies": { "@babel/core": "^8.0.6", diff --git a/packages/babel/src/cloneable.ts b/packages/babel/src/cloneable.ts new file mode 100644 index 0000000..34362ad --- /dev/null +++ b/packages/babel/src/cloneable.ts @@ -0,0 +1,64 @@ +import type { PluginOptions } from './options.ts' +import type { RolldownBabelPresetItem } from './rolldownPreset.ts' + +function isCloneable(value: unknown): boolean { + if (value === null) return true + switch (typeof value) { + case 'undefined': + case 'string': + case 'number': + case 'boolean': + return true + case 'object': + break + default: + return false + } + if (value instanceof RegExp) return true + if (Array.isArray(value)) { + for (const item of value) { + if (!isCloneable(item)) return false + } + return true + } + const proto = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) return false + for (const item of Object.values(value)) { + if (!isCloneable(item)) return false + } + return true +} + +function findUncloneablePreset( + presets: RolldownBabelPresetItem[] | undefined, + path: string, +): string | undefined { + if (!presets) return + for (let i = 0; i < presets.length; i++) { + const preset = presets[i] + // The `rolldown` part of a preset (filters and hooks) is only used in the main thread. + const babelPreset = typeof preset === 'object' && 'rolldown' in preset ? preset.preset : preset + if (!isCloneable(babelPreset)) return `${path}[${i}]` + } +} + +/** + * Returns the path of the first option that cannot be sent to a worker. + */ +export function findUncloneableOption(options: PluginOptions): string | undefined { + const { presets, overrides, ...rest } = options + for (const [key, value] of Object.entries(rest)) { + if (!isCloneable(value)) return key + } + const presetPath = findUncloneablePreset(presets, 'presets') + if (presetPath) return presetPath + if (!overrides) return + for (let i = 0; i < overrides.length; i++) { + const { presets: overridePresets, ...overrideRest } = overrides[i] + for (const [key, value] of Object.entries(overrideRest)) { + if (!isCloneable(value)) return `overrides[${i}].${key}` + } + const overridePresetPath = findUncloneablePreset(overridePresets, `overrides[${i}].presets`) + if (overridePresetPath) return overridePresetPath + } +} diff --git a/packages/babel/src/fixtures/identifier-replace-plugin.mjs b/packages/babel/src/fixtures/identifier-replace-plugin.mjs new file mode 100644 index 0000000..4e1a293 --- /dev/null +++ b/packages/babel/src/fixtures/identifier-replace-plugin.mjs @@ -0,0 +1,11 @@ +export default function identifierReplacePlugin({ types: t }, { name, value }) { + return { + visitor: { + Identifier(p) { + if (p.node.name === name) { + p.replaceWith(t.booleanLiteral(value)) + } + }, + }, + } +} diff --git a/packages/babel/src/fixtures/identifier-replace-preset.mjs b/packages/babel/src/fixtures/identifier-replace-preset.mjs new file mode 100644 index 0000000..6f83bdf --- /dev/null +++ b/packages/babel/src/fixtures/identifier-replace-preset.mjs @@ -0,0 +1,5 @@ +import identifierReplacePlugin from './identifier-replace-plugin.mjs' + +export default function identifierReplacePreset(_api, options) { + return { plugins: [[identifierReplacePlugin, options]] } +} diff --git a/packages/babel/src/index.test.ts b/packages/babel/src/index.test.ts index f431b7b..4747652 100644 --- a/packages/babel/src/index.test.ts +++ b/packages/babel/src/index.test.ts @@ -4,6 +4,7 @@ import * as babel from './babelCompat.ts' import { rolldown, type OutputChunk } from 'rolldown' import { build as viteBuild, createBuilder, type Rollup } from 'vite' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { collectOptimizeDepsInclude, type PluginOptions } from './options.ts' import type { RolldownBabelPreset } from './rolldownPreset.ts' import { stripVTControlCharacters } from 'node:util' @@ -720,6 +721,66 @@ export const decorated = new Dep() expect(chunk.code).toContain('@babel/runtime') }) +describe('parallel', () => { + const replacePluginPath = fileURLToPath( + new URL('./fixtures/identifier-replace-plugin.mjs', import.meta.url), + ) + const replacePresetPath = fileURLToPath( + new URL('./fixtures/identifier-replace-preset.mjs', import.meta.url), + ) + + test('transforms files in worker threads', async () => { + const result = await build('foo.ts', 'export const result: boolean = foo', { + parallel: 2, + plugins: [[replacePluginPath, { name: 'foo', value: true }]], + }) + expect(result.code).toContain('const result = true') + }) + + test('keeps rolldown preset filters and hooks in the main thread', async () => { + const preset: RolldownBabelPreset = { + preset: [replacePresetPath, { name: 'foo', value: true }], + rolldown: { + filter: { code: /foo/ }, + applyToEnvironmentHook: () => true, + }, + } + const result = await build('foo.js', 'export const result = foo', { + parallel: true, + presets: [preset], + }) + expect(result.code).toContain('const result = true') + }) + + test('passes babel errors from the worker to rolldown', async () => { + const err = await build('foo.js', 'export const = ;', { + parallel: 1, + plugins: [[replacePluginPath, { name: 'foo', value: true }]], + }).catch((e) => e) + const message = stripVTControlCharacters(err.message) + expect(message).toContain('foo.js:1:13') + expect(message).toContain('[BabelError]') + expect(message).toContain('Unexpected token (1:13)') + }) + + test('rejects options that cannot be sent to a worker', async () => { + await expect( + babelPlugin({ parallel: true, plugins: [identifierReplaceBabelPlugin('foo', true)] }), + ).rejects.toThrow('"plugins" cannot be sent to a worker thread') + await expect( + babelPlugin({ + parallel: true, + overrides: [{ presets: [{ preset: () => ({}), rolldown: {} }] }], + }), + ).rejects.toThrow('"overrides[0].presets[0]" cannot be sent to a worker thread') + }) + + test('rejects a worker count that is not a positive integer', async () => { + await expect(babelPlugin({ parallel: 0 })).rejects.toThrow('positive integer') + await expect(babelPlugin({ parallel: 1.5 })).rejects.toThrow('positive integer') + }) +}) + describe('optimizeDeps.include', () => { test('collectOptimizeDepsInclude merges from presets and overrides', () => { const topPreset: RolldownBabelPreset = { @@ -858,6 +919,7 @@ async function build( ], }) const { output } = await bundle.generate({ sourcemap, sourcemapExcludeSources }) + await bundle.close() assert(output[0].type === 'chunk') return output[0] } diff --git a/packages/babel/src/index.ts b/packages/babel/src/index.ts index c0dfcf8..0c7154c 100644 --- a/packages/babel/src/index.ts +++ b/packages/babel/src/index.ts @@ -7,7 +7,9 @@ import { resolveOptions, type PluginOptions, } from './options.ts' -import * as babel from './babelCompat.ts' +import type { Pool } from 'workerpool' +import { createWorkerPool, resolveParallelOption } from './parallel.ts' +import { transformWithBabel, type TransformResult } from './transform.ts' import type { PartialEnvironment, PresetConversionContext } from './rolldownPreset.ts' import { calculatePluginFilters } from './filter.ts' import type { ResolvedConfig, Plugin as VitePlugin } from 'vite' @@ -24,6 +26,15 @@ async function babelPlugin(rawOptions: PluginOptions): Promise { } } + const maxWorkers = resolveParallelOption(rawOptions) + // Created on first use, so that builds with no babel work do not start workers. + let workerPool: Pool | undefined + async function terminateWorkerPool() { + const pool = workerPool + workerPool = undefined + await pool?.terminate() + } + let configFilteredOptions: PluginOptions | undefined const envState = new Map>() @@ -72,49 +83,20 @@ async function babelPlugin(rawOptions: PluginOptions): Promise { code, } const babelOptions = convertToBabelOptions(conversionContext) - const loadedOptions = await babel.loadOptionsAsync({ - ...babelOptions, - babelrc: false, - configFile: false, - parserOpts: { - sourceType: 'module', - allowAwaitOutsideFunction: true, - ...babelOptions.parserOpts, - }, - overrides: [ - { - test: /\.jsx(?:$|\?)/, - parserOpts: { plugins: ['jsx'] }, - }, - { - test: /\.ts(?:$|\?)/, - parserOpts: { plugins: ['typescript'] }, - }, - { - test: /\.tsx(?:$|\?)/, - parserOpts: { plugins: ['typescript', 'jsx'] }, - }, - ...(babelOptions.overrides ?? []), - ], - filename: id, - }) - if (!loadedOptions || loadedOptions.plugins.length === 0) { - // No plugins to run — @babel/plugin-transform-runtime only affects - // how other plugins' helpers are emitted, so skip it too. - return - } - - if (rawOptions.runtimeVersion) { - loadedOptions.plugins ??= [] - loadedOptions.plugins.push([ - '@babel/plugin-transform-runtime', - { version: rawOptions.runtimeVersion }, - ]) - } - let result: babel.FileResult | null + let result: TransformResult | undefined try { - result = await babel.transformAsync(code, loadedOptions) + if (maxWorkers) { + workerPool ??= createWorkerPool(maxWorkers) + result = await workerPool.exec('transform', [ + code, + id, + babelOptions, + rawOptions.runtimeVersion, + ]) + } else { + result = await transformWithBabel(code, id, babelOptions, rawOptions.runtimeVersion) + } } catch (err: any) { this.error({ message: `[BabelError] ${err.message}`, @@ -126,13 +108,19 @@ async function babelPlugin(rawOptions: PluginOptions): Promise { } if (result) { return { - code: result.code ?? undefined, + code: result.code, // oxlint-disable-next-line typescript/no-unsafe-type-assertion map: result.map as SourceMapInput, } } }, }, + async closeBundle() { + if (!this.meta.watchMode) await terminateWorkerPool() + }, + async closeWatcher() { + await terminateWorkerPool() + }, } satisfies VitePlugin return plugin as Plugin diff --git a/packages/babel/src/options.ts b/packages/babel/src/options.ts index 70beb99..02df07e 100644 --- a/packages/babel/src/options.ts +++ b/packages/babel/src/options.ts @@ -48,6 +48,18 @@ export interface PluginOptions extends Omit { + const loadedOptions = await babel.loadOptionsAsync({ + ...babelOptions, + babelrc: false, + configFile: false, + parserOpts: { + sourceType: 'module', + allowAwaitOutsideFunction: true, + ...babelOptions.parserOpts, + }, + overrides: [ + { + test: /\.jsx(?:$|\?)/, + parserOpts: { plugins: ['jsx'] }, + }, + { + test: /\.ts(?:$|\?)/, + parserOpts: { plugins: ['typescript'] }, + }, + { + test: /\.tsx(?:$|\?)/, + parserOpts: { plugins: ['typescript', 'jsx'] }, + }, + ...(babelOptions.overrides ?? []), + ], + filename: id, + }) + if (!loadedOptions || loadedOptions.plugins.length === 0) { + // No plugins to run — @babel/plugin-transform-runtime only affects + // how other plugins' helpers are emitted, so skip it too. + return + } + + if (runtimeVersion) { + loadedOptions.plugins ??= [] + loadedOptions.plugins.push(['@babel/plugin-transform-runtime', { version: runtimeVersion }]) + } + + const result = await babel.transformAsync(code, loadedOptions) + if (result) { + return { code: result.code ?? undefined, map: result.map } + } +} diff --git a/packages/babel/src/worker.ts b/packages/babel/src/worker.ts new file mode 100644 index 0000000..ab09fee --- /dev/null +++ b/packages/babel/src/worker.ts @@ -0,0 +1,21 @@ +import workerpool from 'workerpool' +import { transformWithBabel } from './transform.ts' + +async function transform(...args: Parameters) { + try { + return await transformWithBabel(...args) + } catch (err: any) { + // workerpool drops nested fields such as `loc.line` unless the error has a `toJSON`. + err.toJSON = () => ({ + message: err.message, + stack: err.stack, + code: err.code, + reasonCode: err.reasonCode, + pos: err.pos, + loc: err.loc && { line: err.loc.line, column: err.loc.column }, + }) + throw err + } +} + +workerpool.worker({ transform }) diff --git a/packages/babel/tsdown.config.ts b/packages/babel/tsdown.config.ts index 851e8b5..8c48b75 100644 --- a/packages/babel/tsdown.config.ts +++ b/packages/babel/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ - entry: './src/index.ts', + entry: ['./src/index.ts', './src/worker.ts'], dts: { tsconfig: '../../tsconfig.common.json', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcf6786..77a5bd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,9 @@ importers: picomatch: specifier: ^4.0.7 version: 4.0.7 + workerpool: + specifier: ^10.0.3 + version: 10.0.3 devDependencies: '@babel/core': specifier: ^8.0.6 @@ -3137,6 +3140,9 @@ packages: engines: {node: '>=8'} hasBin: true + workerpool@10.0.3: + resolution: {integrity: sha512-6z2Iis68Wqth93/G/wJP9u+R3O+d2XTlgWChGCwuT1qLbBsOYueGRZuJ++v3mtDP5KjYdy+WzvWC+VWETSVXJA==} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -5060,6 +5066,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + workerpool@10.0.3: {} + yaml@1.10.2: {} yoctocolors@2.1.2: {}