Skip to content

Commit 7b775f8

Browse files
committed
feat(babel): add parallel processing via worker threads
Add a `parallel` option that processes files concurrently using Node.js worker threads. This reduces build times for large projects where Babel transformation is a bottleneck. This is similar to the existing parallel behavior of `@rollup/plugin-terser`, but uses the `workerpool` package instead of a custom implementation. This required some fairly significant refactoring, because we can only pass serializable objects between the main thread and the worker threads. It also required changes to the plugin's own build config, so that we can generate a dedicated worker entrypoint. Validations are added to ensure that unserializable config (e.g. inline babel plugins) cannot be used alongside the new parallel mode. For people using dedicated babel config files, this isn't a problem, because they are loaded directly by babel in the worker thread itself. The worker threads do have a setup cost, so this only makes sense for large projects. In Discourse, enabling this parallel mode cuts our overall vite (rolldown) build time by about 45% (from ~11s to ~6s) on my machine.
1 parent 5cc7fc5 commit 7b775f8

File tree

11 files changed

+400
-39
lines changed

11 files changed

+400
-39
lines changed

packages/babel/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ Default: `false`
135135

136136
Before transpiling your input files this plugin also transpile a short piece of code **for each** input file. This is used to validate some misconfiguration errors, but for sufficiently big projects it can slow your build times so if you are confident about your configuration then you might disable those checks with this option.
137137

138+
### `parallel`
139+
140+
Type: `Boolean | number`<br>
141+
Default: `false`
142+
143+
Enable parallel processing of files in worker threads. This has a setup cost, so is best suited for larger projects. Pass an integer to set the number of workers. Set `true` for the default number of workers (based on CPU cores, capped at 4).
144+
145+
This option is available for both the input plugin (`babel()`) and the output plugin (`getBabelOutputPlugin()`).
146+
147+
This option cannot be used alongside custom overrides or non-serializable Babel options.
148+
138149
### External dependencies
139150

140151
Ideally, you should only be transforming your source code, rather than running all of your external dependencies through Babel (to ignore external dependencies from being handled by this plugin you might use `exclude: 'node_modules/**'` option). If you have a dependency that exposes untranspiled ES6 source code that doesn't run in your target environment, then you may need to break this rule, but it often causes problems with unusual `.babelrc` files or mismatched versions of Babel.

packages/babel/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@
6767
},
6868
"dependencies": {
6969
"@babel/helper-module-imports": "^7.18.6",
70-
"@rollup/pluginutils": "^5.0.1"
70+
"@rollup/pluginutils": "^5.0.1",
71+
"workerpool": "^9.0.0"
7172
},
7273
"devDependencies": {
7374
"@babel/core": "^7.19.1",

packages/babel/rollup.config.mjs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
11
import { readFileSync } from 'fs';
22

3-
import { createConfig } from '../../shared/rollup.config.mjs';
3+
import { createConfig, emitModulePackageFile } from '../../shared/rollup.config.mjs';
44

55
import { babel } from './src/index.js';
66

77
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
88

99
export default {
1010
...createConfig({ pkg }),
11-
input: './src/index.js',
11+
input: {
12+
index: './src/index.js',
13+
worker: './src/worker.js'
14+
},
15+
output: [
16+
{
17+
format: 'cjs',
18+
dir: 'dist/cjs',
19+
exports: 'named',
20+
footer(chunkInfo) {
21+
if (chunkInfo.name === 'index') {
22+
return 'module.exports = Object.assign(exports.default, exports);';
23+
}
24+
return null;
25+
},
26+
sourcemap: true
27+
},
28+
{
29+
format: 'es',
30+
dir: 'dist/es',
31+
plugins: [emitModulePackageFile()],
32+
sourcemap: true
33+
}
34+
],
1235
plugins: [
1336
babel({
1437
presets: [['@babel/preset-env', { targets: { node: 14 } }]],

packages/babel/src/index.js

Lines changed: 140 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import { cpus } from 'os';
2+
import { fileURLToPath } from 'url';
3+
14
import * as babel from '@babel/core';
25
import { createFilter } from '@rollup/pluginutils';
6+
import workerpool from 'workerpool';
37

48
import { BUNDLED, HELPERS } from './constants.js';
5-
import bundledHelpersPlugin from './bundledHelpersPlugin.js';
6-
import preflightCheck from './preflightCheck.js';
79
import transformCode from './transformCode.js';
8-
import { addBabelPlugin, escapeRegExpCharacters, warnOnce } from './utils.js';
10+
import { escapeRegExpCharacters, warnOnce } from './utils.js';
911

1012
const unpackOptions = ({
1113
extensions = babel.DEFAULT_EXTENSIONS,
@@ -100,6 +102,68 @@ const returnObject = () => {
100102
return {};
101103
};
102104

105+
function findNonSerializableOption(obj) {
106+
const isSerializable = (value) => {
107+
if (value === null) return true;
108+
if (Array.isArray(value)) return value.every(isSerializable);
109+
switch (typeof value) {
110+
case 'undefined':
111+
case 'string':
112+
case 'number':
113+
case 'boolean':
114+
return true;
115+
case 'object':
116+
return Object.values(value).every(isSerializable);
117+
default:
118+
return false;
119+
}
120+
};
121+
122+
for (const key of Object.keys(obj)) {
123+
if (!isSerializable(obj[key])) return key;
124+
}
125+
return null;
126+
}
127+
128+
const WORKER_PATH = fileURLToPath(new URL('./worker.js', import.meta.url));
129+
130+
function createParallelWorkerPool(parallel, overrides) {
131+
if (typeof parallel === 'number' && (!Number.isInteger(parallel) || parallel < 1)) {
132+
throw new Error(
133+
'The "parallel" option must be true or a positive integer specifying the number of workers.'
134+
);
135+
}
136+
137+
if (!parallel) return null;
138+
139+
if (overrides?.config) {
140+
throw new Error('Cannot use "parallel" mode with a custom "config" override.');
141+
}
142+
if (overrides?.result) {
143+
throw new Error('Cannot use "parallel" mode with a custom "result" override.');
144+
}
145+
146+
// Default limits to 4 workers. Benefits diminish after this point, because of the setup cost.
147+
const workerCount = typeof parallel === 'number' ? parallel : Math.min(cpus().length, 4);
148+
return workerpool.pool(WORKER_PATH, {
149+
maxWorkers: workerCount,
150+
workerType: 'thread'
151+
});
152+
}
153+
154+
function transformWithWorkerPool(workerPool, context, transformOpts, babelOptions) {
155+
const nonSerializableKey = findNonSerializableOption(babelOptions);
156+
if (nonSerializableKey) {
157+
return Promise.reject(
158+
new Error(
159+
`Cannot use "parallel" mode because the "${nonSerializableKey}" option is not serializable.`
160+
)
161+
);
162+
}
163+
164+
return workerPool.exec('transform', [transformOpts]).catch((err) => context.error(err.message));
165+
}
166+
103167
function createBabelInputPluginFactory(customCallback = returnObject) {
104168
const overrides = customCallback(babel);
105169

@@ -116,9 +180,12 @@ function createBabelInputPluginFactory(customCallback = returnObject) {
116180
include,
117181
filter: customFilter,
118182
skipPreflightCheck,
183+
parallel,
119184
...babelOptions
120185
} = unpackInputPluginOptions(pluginOptionsWithOverrides);
121186

187+
const workerPool = createParallelWorkerPool(parallel, overrides);
188+
122189
const extensionRegExp = new RegExp(
123190
`(${extensions.map(escapeRegExpCharacters).join('|')})(\\?.*)?(#.*)?$`
124191
);
@@ -162,23 +229,45 @@ function createBabelInputPluginFactory(customCallback = returnObject) {
162229
if (!(await filter(filename, code))) return null;
163230
if (filename === HELPERS) return null;
164231

165-
return transformCode(
166-
code,
167-
{ ...babelOptions, filename },
168-
overrides,
232+
const resolvedBabelOptions = { ...babelOptions, filename };
233+
234+
if (workerPool) {
235+
return transformWithWorkerPool(
236+
workerPool,
237+
this,
238+
{
239+
inputCode: code,
240+
babelOptions: resolvedBabelOptions,
241+
skipPreflightCheck,
242+
babelHelpers
243+
},
244+
resolvedBabelOptions
245+
);
246+
}
247+
248+
return transformCode({
249+
inputCode: code,
250+
babelOptions: resolvedBabelOptions,
251+
overrides: {
252+
config: overrides.config?.bind(this),
253+
result: overrides.result?.bind(this)
254+
},
169255
customOptions,
170-
this,
171-
async (transformOptions) => {
172-
if (!skipPreflightCheck) {
173-
await preflightCheck(this, babelHelpers, transformOptions);
174-
}
175-
176-
return babelHelpers === BUNDLED
177-
? addBabelPlugin(transformOptions, bundledHelpersPlugin)
178-
: transformOptions;
179-
}
180-
);
256+
error: this.error.bind(this),
257+
skipPreflightCheck,
258+
babelHelpers
259+
});
260+
}
261+
},
262+
263+
async closeBundle() {
264+
if (!this.meta.watchMode) {
265+
await workerPool?.terminate();
181266
}
267+
},
268+
269+
async closeWatcher() {
270+
await workerPool?.terminate();
182271
}
183272
};
184273
};
@@ -207,6 +296,8 @@ function createBabelOutputPluginFactory(customCallback = returnObject) {
207296
overrides
208297
);
209298

299+
const workerPool = createParallelWorkerPool(pluginOptionsWithOverrides.parallel, overrides);
300+
210301
// cache for chunk name filter (includeChunks/excludeChunks)
211302
let chunkNameFilter;
212303

@@ -242,6 +333,7 @@ function createBabelOutputPluginFactory(customCallback = returnObject) {
242333
externalHelpers,
243334
externalHelpersWhitelist,
244335
include,
336+
parallel,
245337
runtimeHelpers,
246338
...babelOptions
247339
} = unpackOutputPluginOptions(pluginOptionsWithOverrides, outputOptions);
@@ -257,7 +349,36 @@ function createBabelOutputPluginFactory(customCallback = returnObject) {
257349
}
258350
}
259351

260-
return transformCode(code, babelOptions, overrides, customOptions, this);
352+
if (workerPool) {
353+
return transformWithWorkerPool(
354+
workerPool,
355+
this,
356+
{
357+
inputCode: code,
358+
babelOptions,
359+
skipPreflightCheck: true
360+
},
361+
babelOptions
362+
);
363+
}
364+
365+
return transformCode({
366+
inputCode: code,
367+
babelOptions,
368+
overrides: {
369+
config: overrides.config?.bind(this),
370+
result: overrides.result?.bind(this)
371+
},
372+
customOptions,
373+
error: this.error.bind(this),
374+
skipPreflightCheck: true
375+
});
376+
},
377+
378+
async generateBundle() {
379+
if (!this.meta.watchMode) {
380+
await workerPool?.terminate();
381+
}
261382
}
262383
};
263384
};

packages/babel/src/preflightCheck.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,27 +37,27 @@ const mismatchError = (actual, expected, filename) =>
3737
// Revert to /\/helpers\/(esm\/)?inherits/ when Babel 8 gets released, this was fixed in https://github.com/babel/babel/issues/14185
3838
const inheritsHelperRe = /[\\/]+helpers[\\/]+(esm[\\/]+)?inherits/;
3939

40-
export default async function preflightCheck(ctx, babelHelpers, transformOptions) {
40+
export default async function preflightCheck(error, babelHelpers, transformOptions) {
4141
const finalOptions = addBabelPlugin(transformOptions, helpersTestTransform);
4242
const check = (await babel.transformAsync(PREFLIGHT_INPUT, finalOptions)).code;
4343

4444
// Babel sometimes splits ExportDefaultDeclaration into 2 statements, so we also check for ExportNamedDeclaration
4545
if (!/export (d|{)/.test(check)) {
46-
ctx.error(MODULE_ERROR);
46+
error(MODULE_ERROR);
4747
}
4848

4949
if (inheritsHelperRe.test(check)) {
5050
if (babelHelpers === RUNTIME) {
5151
return;
5252
}
53-
ctx.error(mismatchError(RUNTIME, babelHelpers, transformOptions.filename));
53+
error(mismatchError(RUNTIME, babelHelpers, transformOptions.filename));
5454
}
5555

5656
if (check.includes('babelHelpers.inherits')) {
5757
if (babelHelpers === EXTERNAL) {
5858
return;
5959
}
60-
ctx.error(mismatchError(EXTERNAL, babelHelpers, transformOptions.filename));
60+
error(mismatchError(EXTERNAL, babelHelpers, transformOptions.filename));
6161
}
6262

6363
// test unminifiable string content
@@ -66,12 +66,12 @@ export default async function preflightCheck(ctx, babelHelpers, transformOptions
6666
return;
6767
}
6868
if (babelHelpers === RUNTIME && !transformOptions.plugins.length) {
69-
ctx.error(
69+
error(
7070
`You must use the \`@babel/plugin-transform-runtime\` plugin when \`babelHelpers\` is "${RUNTIME}".\n`
7171
);
7272
}
73-
ctx.error(mismatchError(INLINE, babelHelpers, transformOptions.filename));
73+
error(mismatchError(INLINE, babelHelpers, transformOptions.filename));
7474
}
7575

76-
ctx.error(UNEXPECTED_ERROR);
76+
error(UNEXPECTED_ERROR);
7777
}

packages/babel/src/transformCode.js

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import * as babel from '@babel/core';
22

3-
export default async function transformCode(
3+
import bundledHelpersPlugin from './bundledHelpersPlugin.js';
4+
import preflightCheck from './preflightCheck.js';
5+
import { BUNDLED } from './constants.js';
6+
import { addBabelPlugin } from './utils.js';
7+
8+
export default async function transformCode({
49
inputCode,
510
babelOptions,
611
overrides,
712
customOptions,
8-
ctx,
9-
finalizeOptions
10-
) {
13+
error,
14+
skipPreflightCheck,
15+
babelHelpers
16+
}) {
1117
// loadPartialConfigAsync has become available in @babel/core@7.8.0
1218
const config = await (babel.loadPartialConfigAsync || babel.loadPartialConfig)(babelOptions);
1319

@@ -16,18 +22,23 @@ export default async function transformCode(
1622
return null;
1723
}
1824

19-
let transformOptions = !overrides.config
25+
let transformOptions = !overrides?.config
2026
? config.options
21-
: await overrides.config.call(ctx, config, {
27+
: await overrides.config(config, {
2228
code: inputCode,
2329
customOptions
2430
});
2531

26-
if (finalizeOptions) {
27-
transformOptions = await finalizeOptions(transformOptions);
32+
if (!skipPreflightCheck) {
33+
await preflightCheck(error, babelHelpers, transformOptions);
2834
}
2935

30-
if (!overrides.result) {
36+
transformOptions =
37+
babelHelpers === BUNDLED
38+
? addBabelPlugin(transformOptions, bundledHelpersPlugin)
39+
: transformOptions;
40+
41+
if (!overrides?.result) {
3142
const { code, map } = await babel.transformAsync(inputCode, transformOptions);
3243
return {
3344
code,
@@ -36,7 +47,7 @@ export default async function transformCode(
3647
}
3748

3849
const result = await babel.transformAsync(inputCode, transformOptions);
39-
const { code, map } = await overrides.result.call(ctx, result, {
50+
const { code, map } = await overrides.result(result, {
4051
code: inputCode,
4152
customOptions,
4253
config,

0 commit comments

Comments
 (0)