From 69c9aeaed863b2338ad6cde39657323a91c37341 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:59:32 -0400 Subject: [PATCH 1/2] feat(babel): add `parallel` option to transform in worker threads Port of the `parallel` option from @rollup/plugin-babel (rollup/plugins#1956). Files go to a `workerpool` thread pool. Default worker count is the CPU count, with a maximum of 4. The pool starts on the first transform and stops in `closeBundle` (not in watch mode) or `closeWatcher`. Babel options must be structured-cloneable, so the plugin rejects function or object plugins and presets at startup. Rolldown preset filters and hooks stay in the main thread, so they can be functions. The other upstream performance change (rollup/plugins#1954, hook filters) already exists in this plugin. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/babel/README.md | 18 +++ packages/babel/package.json | 3 +- .../fixtures/identifier-replace-plugin.mjs | 11 ++ .../fixtures/identifier-replace-preset.mjs | 5 + packages/babel/src/index.test.ts | 62 +++++++++++ packages/babel/src/index.ts | 74 ++++++------- packages/babel/src/options.ts | 14 ++- packages/babel/src/parallel.ts | 104 ++++++++++++++++++ packages/babel/src/transform.ts | 59 ++++++++++ packages/babel/src/worker.ts | 21 ++++ packages/babel/tsdown.config.ts | 2 +- pnpm-lock.yaml | 8 ++ 12 files changed, 335 insertions(+), 46 deletions(-) create mode 100644 packages/babel/src/fixtures/identifier-replace-plugin.mjs create mode 100644 packages/babel/src/fixtures/identifier-replace-preset.mjs create mode 100644 packages/babel/src/parallel.ts create mode 100644 packages/babel/src/transform.ts create mode 100644 packages/babel/src/worker.ts 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/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: {} From b06cc6fe51d456d126e587d555fae18a5817446d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:30:07 -0400 Subject: [PATCH 2/2] refactor(babel): move cloneable option checks to `cloneable.ts` Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/babel/src/cloneable.ts | 64 +++++++++++++++++++++++++++++++++ packages/babel/src/parallel.ts | 64 +-------------------------------- 2 files changed, 65 insertions(+), 63 deletions(-) create mode 100644 packages/babel/src/cloneable.ts 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/parallel.ts b/packages/babel/src/parallel.ts index 6196096..bf9c724 100644 --- a/packages/babel/src/parallel.ts +++ b/packages/babel/src/parallel.ts @@ -2,7 +2,7 @@ import { availableParallelism } from 'node:os' import { fileURLToPath } from 'node:url' import workerpool, { type Pool } from 'workerpool' import type { PluginOptions } from './options.ts' -import type { RolldownBabelPresetItem } from './rolldownPreset.ts' +import { findUncloneableOption } from './cloneable.ts' // The source worker is used when this file runs unbundled, for example in tests. const WORKER_PATH = fileURLToPath( @@ -12,68 +12,6 @@ const WORKER_PATH = fileURLToPath( // More workers rarely help, because each worker loads babel and the plugins again. const DEFAULT_MAX_WORKERS = 4 -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 - } -} - /** * Returns the worker count, or `undefined` when parallel mode is off. */