Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/babel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion packages/babel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions packages/babel/src/cloneable.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk if there is a better way to do this, but the purpose of doing this much work to see if a config is serializable is to provide a better error to consumers when part of their config is not serializable

Original file line number Diff line number Diff line change
@@ -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
}
}
11 changes: 11 additions & 0 deletions packages/babel/src/fixtures/identifier-replace-plugin.mjs
Original file line number Diff line number Diff line change
@@ -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))
}
},
},
}
}
5 changes: 5 additions & 0 deletions packages/babel/src/fixtures/identifier-replace-preset.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import identifierReplacePlugin from './identifier-replace-plugin.mjs'

export default function identifierReplacePreset(_api, options) {
return { plugins: [[identifierReplacePlugin, options]] }
}
62 changes: 62 additions & 0 deletions packages/babel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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]
}
Expand Down
74 changes: 31 additions & 43 deletions packages/babel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -24,6 +26,15 @@ async function babelPlugin(rawOptions: PluginOptions): Promise<Plugin> {
}
}

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<string | undefined, ReturnType<typeof createBabelOptionsConverter>>()

Expand Down Expand Up @@ -72,49 +83,20 @@ async function babelPlugin(rawOptions: PluginOptions): Promise<Plugin> {
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}`,
Expand All @@ -126,13 +108,19 @@ async function babelPlugin(rawOptions: PluginOptions): Promise<Plugin> {
}
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
Expand Down
14 changes: 13 additions & 1 deletion packages/babel/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ export interface PluginOptions extends Omit<InnerTransformOptions, 'include' | '
*/
runtimeVersion?: string

/**
* Transform files in worker threads.
* Pass a number to set the worker count.
* `true` uses the number of CPU cores, with a maximum of 4.
*
* All babel options must be structured-cloneable:
* refer to plugins and presets by name, not by function.
* Rolldown preset filters and hooks stay in the main thread.
* @default false
*/
parallel?: boolean | number

/**
* If specified, only files matching the pattern will be processed by babel.
* @default `/\.(?:[jt]sx?|[cm][jt]s)(?:$|\?)/`
Expand Down Expand Up @@ -188,7 +200,7 @@ export function createBabelOptionsConverter(options: ResolvedPluginOptions) {

return function (ctx: PresetConversionContext): babel.InputOptions {
// Strip plugin-level options that babel doesn't understand
const { runtimeVersion: _, ...babelOptions } = options
const { runtimeVersion: _, parallel: __, ...babelOptions } = options
return {
...babelOptions,
// sourcemap collapsing is handled by Rolldown
Expand Down
42 changes: 42 additions & 0 deletions packages/babel/src/parallel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { availableParallelism } from 'node:os'
import { fileURLToPath } from 'node:url'
import workerpool, { type Pool } from 'workerpool'
import type { PluginOptions } from './options.ts'
import { findUncloneableOption } from './cloneable.ts'

// 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),
)

// More workers rarely help, because each worker loads babel and the plugins again.
const DEFAULT_MAX_WORKERS = 4

/**
* Returns the worker count, or `undefined` when parallel mode is off.
*/
export function resolveParallelOption(options: PluginOptions): number | undefined {
const { parallel } = options
if (typeof parallel === 'number' && (!Number.isInteger(parallel) || parallel < 1)) {
throw new Error(
'The "parallel" option must be true or a positive integer that sets the worker count.',
)
}
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' })
}
Loading