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/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', + }, +}) diff --git a/packages/babel/package.json b/packages/babel/package.json index a692412..6e7dedb 100644 --- a/packages/babel/package.json +++ b/packages/babel/package.json @@ -30,6 +30,7 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { + "artichokie": "^0.4.5", "picomatch": "^4.0.7" }, "devDependencies": { diff --git a/packages/babel/src/cloneable.ts b/packages/babel/src/cloneable.ts new file mode 100644 index 0000000..32a0200 --- /dev/null +++ b/packages/babel/src/cloneable.ts @@ -0,0 +1,45 @@ +import type { PluginOptions } from './options.ts' +import type { RolldownBabelPresetItem } from './rolldownPreset.ts' + +function isCloneable(value: unknown): boolean { + try { + structuredClone(value) + return true + } catch { + return false + } +} + +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/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..3549d7a 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,69 @@ 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( + 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( + build('foo.js', 'export const result = foo', { + 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 +922,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..52b16b9 100644 --- a/packages/babel/src/index.ts +++ b/packages/babel/src/index.ts @@ -7,7 +7,13 @@ import { resolveOptions, type PluginOptions, } from './options.ts' -import * as babel from './babelCompat.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' import type { ResolvedConfig, Plugin as VitePlugin } from 'vite' @@ -24,6 +30,14 @@ 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: WorkerPool | undefined + function stopWorkerPool() { + workerPool?.stop() + workerPool = undefined + } + let configFilteredOptions: PluginOptions | undefined const envState = new Map>() @@ -72,50 +86,18 @@ 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.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, @@ -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, } } }, }, + closeBundle() { + if (!this.meta.watchMode) stopWorkerPool() + }, + closeWatcher() { + stopWorkerPool() + }, } 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 + +// The source worker is used when this file runs unbundled, for example in tests. +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 + +/** + * 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 + + return typeof parallel === 'number' + ? parallel + : Math.min(availableParallelism(), DEFAULT_MAX_WORKERS) +} + +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/transform.ts b/packages/babel/src/transform.ts new file mode 100644 index 0000000..e54725e --- /dev/null +++ b/packages/babel/src/transform.ts @@ -0,0 +1,59 @@ +import * as babel from './babelCompat.ts' + +export interface TransformResult { + code: string | undefined + map: babel.FileResult['map'] +} + +/** + * Runs in the main thread and in parallel workers, + * so every argument must be structured-cloneable when `parallel` is set. + */ +export async function transformWithBabel( + code: string, + id: string, + babelOptions: babel.InputOptions, + runtimeVersion: string | undefined, +): Promise { + 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..7570739 --- /dev/null +++ b/packages/babel/src/worker.ts @@ -0,0 +1,32 @@ +import { transformWithBabel, type TransformResult } from './transform.ts' + +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 { result: await transformWithBabel(...args) } + } catch (err: any) { + // 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 }, + }, + } + } +} 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..4bb9305 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,6 +362,9 @@ importers: packages/babel: dependencies: + artichokie: + specifier: ^0.4.5 + version: 0.4.5 picomatch: specifier: ^4.0.7 version: 4.0.7 @@ -391,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': @@ -2333,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'} @@ -4263,6 +4294,8 @@ snapshots: argue-cli@3.1.0: {} + artichokie@0.4.5: {} + assertion-error@2.0.1: {} babel-plugin-macros@3.1.0: