From 626262cd9b43363e8501405bf5aedd58c440a392 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/4] 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 a692412..d40074f 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 17dc7e3..beac793 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 @@ -3138,6 +3141,9 @@ packages: engines: {node: '>=20.11'} 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'} @@ -4970,6 +4976,8 @@ snapshots: why-is-node-running@3.2.1: {} + workerpool@10.0.3: {} + yaml@1.10.2: {} yoctocolors@2.1.2: {} From 619c1e5fc76a4433ef1672a71ad82cbe5ffcd0bd 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/4] 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. */ From d7fb6dc3bfd2f6dbf5cf335a299f6682cc76cfd5 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:26:58 -0400 Subject: [PATCH 3/4] refactor(babel): use `artichokie` for the worker pool and `structuredClone` for the option check Review follow-up. `workerpool`, `tinypool`, and `artichokie` give the same build time in the benchmark, so the plugin uses `artichokie`, the smallest package. The check for options that cannot go to a worker now uses `structuredClone`. It runs only after a worker message fails with a `DataCloneError`, so builds with valid options do not pay for the clone. Co-Authored-By: Claude Opus 5.5 --- packages/babel/package.json | 4 +- packages/babel/src/cloneable.ts | 27 ++----------- packages/babel/src/index.test.ts | 7 +++- packages/babel/src/index.ts | 32 ++++++++-------- packages/babel/src/parallel.ts | 65 ++++++++++++++++++++++++-------- packages/babel/src/worker.ts | 43 +++++++++++++-------- pnpm-lock.yaml | 41 ++++++++++++++++---- 7 files changed, 137 insertions(+), 82 deletions(-) diff --git a/packages/babel/package.json b/packages/babel/package.json index d40074f..6e7dedb 100644 --- a/packages/babel/package.json +++ b/packages/babel/package.json @@ -30,8 +30,8 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { - "picomatch": "^4.0.7", - "workerpool": "^10.0.3" + "artichokie": "^0.4.5", + "picomatch": "^4.0.7" }, "devDependencies": { "@babel/core": "^8.0.6", diff --git a/packages/babel/src/cloneable.ts b/packages/babel/src/cloneable.ts index 34362ad..32a0200 100644 --- a/packages/babel/src/cloneable.ts +++ b/packages/babel/src/cloneable.ts @@ -2,31 +2,12 @@ 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 - } + try { + structuredClone(value) return true + } catch { + return false } - 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( diff --git a/packages/babel/src/index.test.ts b/packages/babel/src/index.test.ts index 4747652..3549d7a 100644 --- a/packages/babel/src/index.test.ts +++ b/packages/babel/src/index.test.ts @@ -765,10 +765,13 @@ describe('parallel', () => { test('rejects options that cannot be sent to a worker', async () => { await expect( - babelPlugin({ parallel: true, plugins: [identifierReplaceBabelPlugin('foo', true)] }), + build('foo.js', 'export const result = foo', { + parallel: true, + plugins: [identifierReplaceBabelPlugin('foo', true)], + }), ).rejects.toThrow('"plugins" cannot be sent to a worker thread') await expect( - babelPlugin({ + build('foo.js', 'export const result = foo', { parallel: true, overrides: [{ presets: [{ preset: () => ({}), rolldown: {} }] }], }), diff --git a/packages/babel/src/index.ts b/packages/babel/src/index.ts index 0c7154c..52b16b9 100644 --- a/packages/babel/src/index.ts +++ b/packages/babel/src/index.ts @@ -7,8 +7,12 @@ import { resolveOptions, type PluginOptions, } from './options.ts' -import type { Pool } from 'workerpool' -import { createWorkerPool, resolveParallelOption } from './parallel.ts' +import { + createWorkerPool, + describeCloneError, + resolveParallelOption, + type WorkerPool, +} from './parallel.ts' import { transformWithBabel, type TransformResult } from './transform.ts' import type { PartialEnvironment, PresetConversionContext } from './rolldownPreset.ts' import { calculatePluginFilters } from './filter.ts' @@ -28,11 +32,10 @@ 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 + let workerPool: WorkerPool | undefined + function stopWorkerPool() { + workerPool?.stop() workerPool = undefined - await pool?.terminate() } let configFilteredOptions: PluginOptions | undefined @@ -88,16 +91,13 @@ async function babelPlugin(rawOptions: PluginOptions): Promise { try { if (maxWorkers) { workerPool ??= createWorkerPool(maxWorkers) - result = await workerPool.exec('transform', [ - code, - id, - babelOptions, - rawOptions.runtimeVersion, - ]) + result = await workerPool.run(code, id, babelOptions, rawOptions.runtimeVersion) } else { result = await transformWithBabel(code, id, babelOptions, rawOptions.runtimeVersion) } } catch (err: any) { + const cloneError = describeCloneError(err, rawOptions) + if (cloneError) this.error({ message: cloneError, cause: err }) this.error({ message: `[BabelError] ${err.message}`, loc: err.loc, @@ -115,11 +115,11 @@ async function babelPlugin(rawOptions: PluginOptions): Promise { } }, }, - async closeBundle() { - if (!this.meta.watchMode) await terminateWorkerPool() + closeBundle() { + if (!this.meta.watchMode) stopWorkerPool() }, - async closeWatcher() { - await terminateWorkerPool() + closeWatcher() { + stopWorkerPool() }, } satisfies VitePlugin diff --git a/packages/babel/src/parallel.ts b/packages/babel/src/parallel.ts index bf9c724..77f245b 100644 --- a/packages/babel/src/parallel.ts +++ b/packages/babel/src/parallel.ts @@ -1,13 +1,17 @@ import { availableParallelism } from 'node:os' -import { fileURLToPath } from 'node:url' -import workerpool, { type Pool } from 'workerpool' +import { Worker } from 'artichokie' import type { PluginOptions } from './options.ts' import { findUncloneableOption } from './cloneable.ts' +import type { transformWithBabel, TransformResult } from './transform.ts' +import type { WorkerResponse } from './worker.ts' + +type TransformArgs = Parameters // The source worker is used when this file runs unbundled, for example in tests. -const WORKER_PATH = fileURLToPath( - new URL(import.meta.url.endsWith('.ts') ? './worker.ts' : './worker.mjs', import.meta.url), -) +const WORKER_URL = new URL( + import.meta.url.endsWith('.ts') ? './worker.ts' : './worker.mjs', + import.meta.url, +).href // More workers rarely help, because each worker loads babel and the plugins again. const DEFAULT_MAX_WORKERS = 4 @@ -24,19 +28,50 @@ export function resolveParallelOption(options: PluginOptions): number | undefine } if (!parallel) return - const uncloneable = findUncloneableOption(options) - if (uncloneable) { - throw new Error( - `Cannot use the "parallel" option, because "${uncloneable}" cannot be sent to a worker thread. ` + - 'Refer to plugins and presets by module name instead of by function or object.', - ) - } - return typeof parallel === 'number' ? parallel : Math.min(availableParallelism(), DEFAULT_MAX_WORKERS) } -export function createWorkerPool(maxWorkers: number): Pool { - return workerpool.pool(WORKER_PATH, { maxWorkers, workerType: 'thread' }) +export interface WorkerPool { + run(...args: TransformArgs): Promise + stop(): void +} + +export function createWorkerPool(maxWorkers: number): WorkerPool { + // artichokie sends this function to the worker as a string, so it cannot use outer variables. + const worker = new Worker( + () => + async (workerUrl: string, args: TransformArgs): Promise => { + const { transform } = await import(workerUrl) + return transform(args) + }, + { max: maxWorkers }, + ) + + return { + async run(...args) { + const response = await worker.run(WORKER_URL, args) + if ('error' in response) { + throw Object.assign(new Error(response.error.message), response.error) + } + return response.result + }, + stop() { + worker.stop() + }, + } +} + +/** + * Returns a message that names the option, if `err` says that a value cannot go to a worker. + */ +export function describeCloneError(err: unknown, options: PluginOptions): string | undefined { + if (!(err instanceof Error) || err.name !== 'DataCloneError') return + const uncloneable = findUncloneableOption(options) + if (!uncloneable) return + return ( + `Cannot use the "parallel" option, because "${uncloneable}" cannot be sent to a worker thread. ` + + 'Refer to plugins and presets by module name instead of by function or object.' + ) } diff --git a/packages/babel/src/worker.ts b/packages/babel/src/worker.ts index ab09fee..7570739 100644 --- a/packages/babel/src/worker.ts +++ b/packages/babel/src/worker.ts @@ -1,21 +1,32 @@ -import workerpool from 'workerpool' -import { transformWithBabel } from './transform.ts' +import { transformWithBabel, type TransformResult } from './transform.ts' -async function transform(...args: Parameters) { +export interface WorkerError { + message: string + stack: string | undefined + code: string | undefined + reasonCode: string | undefined + pos: number | undefined + loc: { line: number; column: number } | undefined +} + +export type WorkerResponse = { result: TransformResult | undefined } | { error: WorkerError } + +export async function transform( + args: Parameters, +): Promise { try { - return await transformWithBabel(...args) + return { result: 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 + // The structured clone of an error drops fields such as `loc`, so send a plain object. + return { + error: { + 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 }, + }, + } } } - -workerpool.worker({ transform }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beac793..4bb9305 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,12 +362,12 @@ importers: packages/babel: dependencies: + artichokie: + specifier: ^0.4.5 + version: 0.4.5 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 @@ -394,6 +394,30 @@ importers: specifier: 'catalog:' version: 8.3.2(@types/node@24.19.1)(esbuild@0.27.3) + packages/babel/benchmark: + devDependencies: + '@babel/core': + specifier: ^8.0.6 + version: 8.0.6 + '@babel/plugin-proposal-decorators': + specifier: ^8.0.2 + version: 8.0.2(@babel/core@8.0.6) + '@oxc-node/cli': + specifier: 'catalog:' + version: 0.1.3 + '@rolldown/benchmark-utils': + specifier: workspace:* + version: link:../../../internal-packages/benchmark-utils + '@rolldown/plugin-babel': + specifier: workspace:* + version: link:.. + '@types/node': + specifier: ^24.19.1 + version: 24.19.1 + rolldown: + specifier: 'catalog:' + version: 1.2.12 + packages/emotion: dependencies: '@emotion/hash': @@ -2336,6 +2360,10 @@ packages: resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} engines: {node: '>=22'} + artichokie@0.4.5: + resolution: {integrity: sha512-ma9643oYgSgA6CeDvKIU0zxL1eKOkbgH8/v83xFpFYB2VOPFoEmmg1iC+ooHFjLIJ3qdtfwPbEKpIW3/YxPovg==} + engines: {node: ^18.0.0 || >=20.0.0} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3141,9 +3169,6 @@ packages: engines: {node: '>=20.11'} 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'} @@ -4269,6 +4294,8 @@ snapshots: argue-cli@3.1.0: {} + artichokie@0.4.5: {} + assertion-error@2.0.1: {} babel-plugin-macros@3.1.0: @@ -4976,8 +5003,6 @@ snapshots: why-is-node-running@3.2.1: {} - workerpool@10.0.3: {} - yaml@1.10.2: {} yoctocolors@2.1.2: {} From 3eff2768f60c14641d49e133c35cd5bebe1ba76b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:26:58 -0400 Subject: [PATCH 4/4] chore(babel): add a benchmark for the `parallel` option Compares `parallel: false` and `parallel: true` on 100 generated modules with decorators. Co-Authored-By: Claude Opus 5.5 --- packages/babel/benchmark/.gitignore | 5 + packages/babel/benchmark/bench/babel.bench.ts | 39 ++++++ packages/babel/benchmark/bench/babel.test.ts | 21 ++++ packages/babel/benchmark/configs/parallel.ts | 4 + packages/babel/benchmark/configs/serial.ts | 4 + packages/babel/benchmark/configs/shared.ts | 18 +++ packages/babel/benchmark/package.json | 21 ++++ .../babel/benchmark/scripts/generate-app.ts | 114 ++++++++++++++++++ packages/babel/benchmark/vitest.config.ts | 7 ++ 9 files changed, 233 insertions(+) create mode 100644 packages/babel/benchmark/.gitignore create mode 100644 packages/babel/benchmark/bench/babel.bench.ts create mode 100644 packages/babel/benchmark/bench/babel.test.ts create mode 100644 packages/babel/benchmark/configs/parallel.ts create mode 100644 packages/babel/benchmark/configs/serial.ts create mode 100644 packages/babel/benchmark/configs/shared.ts create mode 100644 packages/babel/benchmark/package.json create mode 100644 packages/babel/benchmark/scripts/generate-app.ts create mode 100644 packages/babel/benchmark/vitest.config.ts diff --git a/packages/babel/benchmark/.gitignore b/packages/babel/benchmark/.gitignore new file mode 100644 index 0000000..108cd17 --- /dev/null +++ b/packages/babel/benchmark/.gitignore @@ -0,0 +1,5 @@ +# Build outputs +dist/ + +# Generated app (regenerated with pnpm generate) +shared-app/ diff --git a/packages/babel/benchmark/bench/babel.bench.ts b/packages/babel/benchmark/bench/babel.bench.ts new file mode 100644 index 0000000..b185290 --- /dev/null +++ b/packages/babel/benchmark/bench/babel.bench.ts @@ -0,0 +1,39 @@ +import { describe, test } from 'vitest' +import { execSync } from 'node:child_process' +import { existsSync, readdirSync, rmSync } from 'node:fs' +import { resolve } from 'node:path' +import { runBuild } from '@rolldown/benchmark-utils/run-build' + +const baseDir = resolve(import.meta.dirname, '..') +const distBase = resolve(baseDir, 'dist') +const modulesDir = resolve(baseDir, 'shared-app/src/modules') +const expectedModules = 100 + +const currentModules = existsSync(modulesDir) + ? readdirSync(modulesDir).filter((f) => f.endsWith('.js')).length + : 0 +if (currentModules !== expectedModules) { + execSync('pnpm generate', { cwd: baseDir, stdio: 'inherit' }) +} + +function cleanDist(name: string) { + const dir = resolve(distBase, name) + if (existsSync(dir)) { + rmSync(dir, { recursive: true }) + } +} + +describe('Babel Parallel Benchmark', () => { + // Each build takes seconds, so the default of 64 samples is too slow. + test('comparison', { timeout: 300_000 }, async ({ bench }) => { + await bench.compare( + bench('parallel: false', { afterAll: () => cleanDist('serial') }, () => { + runBuild('serial', baseDir) + }), + bench('parallel: true', { afterAll: () => cleanDist('parallel') }, () => { + runBuild('parallel', baseDir) + }), + { iterations: 10, warmupIterations: 1 }, + ) + }) +}) diff --git a/packages/babel/benchmark/bench/babel.test.ts b/packages/babel/benchmark/bench/babel.test.ts new file mode 100644 index 0000000..67df938 --- /dev/null +++ b/packages/babel/benchmark/bench/babel.test.ts @@ -0,0 +1,21 @@ +import { describe, test } from 'vitest' +import { execSync } from 'node:child_process' +import { resolve } from 'node:path' +import { runBuild } from '@rolldown/benchmark-utils/run-build' + +const baseDir = resolve(import.meta.dirname, '..') + +execSync('pnpm generate -- --total=10 --silent', { + cwd: baseDir, + stdio: 'inherit', +}) + +describe('Babel build', () => { + test.for(['serial', 'parallel'] as const)( + 'build:%s exits with code 0', + { timeout: 30_000 }, + (name) => { + runBuild(name, baseDir) + }, + ) +}) diff --git a/packages/babel/benchmark/configs/parallel.ts b/packages/babel/benchmark/configs/parallel.ts new file mode 100644 index 0000000..80b308d --- /dev/null +++ b/packages/babel/benchmark/configs/parallel.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'rolldown' +import { createConfig } from './shared.ts' + +export default defineConfig(createConfig('parallel', true)) diff --git a/packages/babel/benchmark/configs/serial.ts b/packages/babel/benchmark/configs/serial.ts new file mode 100644 index 0000000..393b8e9 --- /dev/null +++ b/packages/babel/benchmark/configs/serial.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'rolldown' +import { createConfig } from './shared.ts' + +export default defineConfig(createConfig('serial', false)) diff --git a/packages/babel/benchmark/configs/shared.ts b/packages/babel/benchmark/configs/shared.ts new file mode 100644 index 0000000..378ce83 --- /dev/null +++ b/packages/babel/benchmark/configs/shared.ts @@ -0,0 +1,18 @@ +import { resolve } from 'node:path' +import type { RolldownOptions } from 'rolldown' +import babel from '@rolldown/plugin-babel' + +export function createConfig(name: string, parallel: boolean): RolldownOptions { + return { + input: resolve(import.meta.dirname, '../shared-app/src/index.js'), + output: { + dir: resolve(import.meta.dirname, '../dist', name), + }, + plugins: [ + babel({ + parallel, + plugins: [['@babel/plugin-proposal-decorators', { version: '2023-11' }]], + }), + ], + } +} diff --git a/packages/babel/benchmark/package.json b/packages/babel/benchmark/package.json new file mode 100644 index 0000000..260133a --- /dev/null +++ b/packages/babel/benchmark/package.json @@ -0,0 +1,21 @@ +{ + "name": "@rolldown/benchmark-babel", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "generate": "oxnode scripts/generate-app.ts", + "bench": "vitest bench --run", + "build:serial": "rolldown -c configs/serial.ts", + "build:parallel": "rolldown -c configs/parallel.ts" + }, + "devDependencies": { + "@babel/core": "^8.0.6", + "@babel/plugin-proposal-decorators": "^8.0.2", + "@oxc-node/cli": "catalog:", + "@rolldown/benchmark-utils": "workspace:*", + "@rolldown/plugin-babel": "workspace:*", + "@types/node": "^24.19.1", + "rolldown": "catalog:" + } +} diff --git a/packages/babel/benchmark/scripts/generate-app.ts b/packages/babel/benchmark/scripts/generate-app.ts new file mode 100644 index 0000000..c344dbd --- /dev/null +++ b/packages/babel/benchmark/scripts/generate-app.ts @@ -0,0 +1,114 @@ +/** + * App generator for the babel `parallel` benchmark. + * Generates ~100 JS files with classes that use decorators. + * Uses seeded random (seed=42) for deterministic generation. + */ + +import { writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { SeededRandom } from '@rolldown/benchmark-utils/seeded-random' + +const rng = new SeededRandom(42) + +const DECORATORS = `export function logged(value, context) { + if (context.kind !== 'method') return + return function (...args) { + return value.apply(this, args) + } +} + +export function tracked(value, context) { + if (context.kind !== 'accessor') return + return { + get() { + return value.get.call(this) + }, + set(next) { + value.set.call(this, next) + }, + } +} + +export function registered(value, context) { + context.addInitializer(function () { + this.registeredName = context.name + }) + return value +} +` + +const FIELD_NAMES = ['count', 'label', 'items', 'status', 'owner', 'total', 'offset', 'limit'] +const METHOD_NAMES = ['load', 'save', 'reset', 'render', 'update', 'remove', 'select', 'toggle'] + +function generateClass(name: string): string { + const fields = rng.pickN(FIELD_NAMES, rng.nextInt(4) + 3) + const methods = rng.pickN(METHOD_NAMES, rng.nextInt(4) + 3) + + const lines = [`@registered\nclass ${name} {`] + for (const field of fields) { + lines.push(` @tracked accessor ${field} = ${rng.nextInt(100)}`) + } + for (const method of methods) { + const field = rng.pick(fields) + lines.push( + ` @logged\n ${method}(input) {`, + ` if (input === undefined) return this.${field}`, + ` this.${field} = input + ${rng.nextInt(100)}`, + ` return this.${field}`, + ` }`, + ) + } + lines.push(`}`) + return lines.join('\n') +} + +function generateModule(index: number, classesPerModule: number): string { + const names: string[] = [] + const classes: string[] = [] + for (let i = 0; i < classesPerModule; i++) { + const name = `Model${index}_${i}` + names.push(name) + classes.push(generateClass(name)) + } + return [ + `import { logged, tracked, registered } from '../decorators.js'`, + ...classes, + `export { ${names.join(', ')} }`, + '', + ].join('\n\n') +} + +function readNumberArg(name: string, fallback: number): number { + const prefix = `--${name}=` + const arg = process.argv.find((a) => a.startsWith(prefix)) + return arg ? Number.parseInt(arg.slice(prefix.length), 10) : fallback +} + +function main() { + const appDir = join(import.meta.dirname, '../shared-app/src') + if (existsSync(appDir)) rmSync(appDir, { recursive: true }) + mkdirSync(join(appDir, 'modules'), { recursive: true }) + + const TOTAL = readNumberArg('total', 100) + const CLASSES_PER_MODULE = readNumberArg('classes', 10) + const silent = process.argv.includes('--silent') + + writeFileSync(join(appDir, 'decorators.js'), DECORATORS) + + const imports: string[] = [] + for (let i = 0; i < TOTAL; i++) { + const filename = `module${i + 1}.js` + writeFileSync(join(appDir, 'modules', filename), generateModule(i + 1, CLASSES_PER_MODULE)) + imports.push( + `import * as module${i + 1} from './modules/${filename}'\nconsole.log(module${i + 1})`, + ) + } + writeFileSync(join(appDir, 'index.js'), imports.join('\n') + '\n') + + if (!silent) { + console.log(`Generated ${TOTAL} modules in ${appDir}/modules/`) + console.log(` Classes per module: ${CLASSES_PER_MODULE}`) + } +} + +main() diff --git a/packages/babel/benchmark/vitest.config.ts b/packages/babel/benchmark/vitest.config.ts new file mode 100644 index 0000000..bf33560 --- /dev/null +++ b/packages/babel/benchmark/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'benchmark-babel', + }, +})