Skip to content

Commit 1a508bf

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`. 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 204c3db commit 1a508bf

File tree

9 files changed

+361
-38
lines changed

9 files changed

+361
-38
lines changed

packages/babel/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ 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`
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 (4).
144+
145+
This option cannot be used alongside custom overrides or non-serializable Babel options.
146+
138147
### External dependencies
139148

140149
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/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: 82 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import * as babel from '@babel/core';
22
import { createFilter } from '@rollup/pluginutils';
33

44
import { BUNDLED, HELPERS } from './constants.js';
5-
import bundledHelpersPlugin from './bundledHelpersPlugin.js';
6-
import preflightCheck from './preflightCheck.js';
75
import transformCode from './transformCode.js';
8-
import { addBabelPlugin, escapeRegExpCharacters, warnOnce } from './utils.js';
6+
import { escapeRegExpCharacters, warnOnce } from './utils.js';
7+
import WorkerPool from './workerPool.js';
98

109
const unpackOptions = ({
1110
extensions = babel.DEFAULT_EXTENSIONS,
@@ -100,6 +99,24 @@ const returnObject = () => {
10099
return {};
101100
};
102101

102+
function isSerializable(value) {
103+
if (value === null) {
104+
return true;
105+
} else if (Array.isArray(value)) {
106+
return value.every(isSerializable);
107+
}
108+
switch (typeof value) {
109+
case 'string':
110+
case 'number':
111+
case 'boolean':
112+
return true;
113+
case 'object':
114+
return Object.keys(value).every((key) => isSerializable(value[key]));
115+
default:
116+
return false;
117+
}
118+
}
119+
103120
function createBabelInputPluginFactory(customCallback = returnObject) {
104121
const overrides = customCallback(babel);
105122

@@ -109,13 +126,16 @@ function createBabelInputPluginFactory(customCallback = returnObject) {
109126
overrides
110127
);
111128

129+
let workerPool;
130+
112131
const {
113132
exclude,
114133
extensions,
115134
babelHelpers,
116135
include,
117136
filter: customFilter,
118137
skipPreflightCheck,
138+
parallel,
119139
...babelOptions
120140
} = unpackInputPluginOptions(pluginOptionsWithOverrides);
121141

@@ -129,6 +149,23 @@ function createBabelInputPluginFactory(customCallback = returnObject) {
129149
typeof customFilter === 'function' ? customFilter : createFilter(include, exclude);
130150
const filter = (id, code) => extensionRegExp.test(id) && userDefinedFilter(id, code);
131151

152+
if (parallel) {
153+
const parallelAllowed =
154+
isSerializable(babelOptions) && !overrides?.config && !overrides?.result;
155+
156+
if (!parallelAllowed) {
157+
throw new Error(
158+
'Cannot use "parallel" mode alongside custom overrides or non-serializable Babel options.'
159+
);
160+
}
161+
162+
const parallelWorkerCount = typeof parallel === 'number' ? parallel : 4;
163+
workerPool = new WorkerPool(
164+
new URL('./worker.js', import.meta.url).pathname,
165+
parallelWorkerCount
166+
);
167+
}
168+
132169
const helpersFilter = { id: new RegExp(`^${escapeRegExpCharacters(HELPERS)}$`) };
133170

134171
return {
@@ -162,22 +199,39 @@ function createBabelInputPluginFactory(customCallback = returnObject) {
162199
if (!(await filter(filename, code))) return null;
163200
if (filename === HELPERS) return null;
164201

165-
return transformCode(
166-
code,
167-
{ ...babelOptions, filename },
168-
overrides,
202+
if (parallel) {
203+
return workerPool.runTask({
204+
inputCode: code,
205+
babelOptions: { ...babelOptions, filename },
206+
runPreflightCheck: !skipPreflightCheck,
207+
babelHelpers
208+
});
209+
}
210+
211+
return transformCode({
212+
inputCode: code,
213+
babelOptions: { ...babelOptions, filename },
214+
overrides: {
215+
config: overrides.config?.bind(this),
216+
result: overrides.result?.bind(this)
217+
},
169218
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-
);
219+
error: this.error.bind(this),
220+
runPreflightCheck: !skipPreflightCheck,
221+
babelHelpers
222+
});
223+
}
224+
},
225+
226+
async closeBundle() {
227+
if (parallel && !this.meta.watchMode) {
228+
await workerPool.terminate();
229+
}
230+
},
231+
232+
async closeWatcher() {
233+
if (parallel) {
234+
await workerPool.terminate();
181235
}
182236
}
183237
};
@@ -257,7 +311,16 @@ function createBabelOutputPluginFactory(customCallback = returnObject) {
257311
}
258312
}
259313

260-
return transformCode(code, babelOptions, overrides, customOptions, this);
314+
return transformCode({
315+
inputCode: code,
316+
babelOptions,
317+
overrides: {
318+
config: overrides.config?.bind(this),
319+
result: overrides.result?.bind(this)
320+
},
321+
customOptions,
322+
error: this.error.bind(this)
323+
});
261324
}
262325
};
263326
};

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+
runPreflightCheck,
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 (runPreflightCheck) {
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,

packages/babel/src/worker.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { parentPort } from 'worker_threads';
2+
3+
import transformCode from './transformCode.js';
4+
5+
parentPort.on('message', async (opts) => {
6+
try {
7+
const result = await transformCode({
8+
...opts,
9+
error: (msg) => {
10+
throw new Error(msg);
11+
}
12+
});
13+
parentPort.postMessage({
14+
result
15+
});
16+
} catch (error) {
17+
parentPort.postMessage({
18+
error: {
19+
message: error.message,
20+
stack: error.stack,
21+
name: error.name
22+
}
23+
});
24+
}
25+
});

0 commit comments

Comments
 (0)