diff --git a/src/build.ts b/src/build.ts index 827ef25c7..2b4463914 100644 --- a/src/build.ts +++ b/src/build.ts @@ -49,6 +49,43 @@ export async function build( return buildWithConfigs(configs, configDeps, () => build(inlineConfig)) } +async function mapWithConcurrency( + items: T[], + limit: number, + mapper: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) return [] + const results: R[] = Array.from({ length: items.length }) + let nextIndex = 0 + const worker = async () => { + while (nextIndex < items.length) { + const index = nextIndex++ + results[index] = await mapper(items[index], index) + } + } + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, () => worker()), + ) + return results +} + +function resolveMaxParallel(configs: ResolvedConfig[]): number | undefined { + const configured = configs + .map((config) => config.maxParallel) + .filter((value): value is number => value != null) + + if (configured.length === 0) return undefined + + const resolved = Math.min(...configured) + if (new Set(configured).size > 1) { + globalLogger.warn( + `Conflicting \`maxParallel\` values detected (${configured.join(', ')}). Using the smallest value: ${resolved}.`, + ) + } + + return resolved +} + /** * Build with `ResolvedConfigs`. * @@ -84,21 +121,16 @@ export async function buildWithConfigs( } globalLogger.info('Build start') - const bundles = await Promise.all( - configs.map((options) => { - const isDualFormat = options.pkg - ? configChunksByPkg[options.pkg.packageJsonPath].formats.size > 1 - : true - return buildSingle( - options, - configDeps, - isDualFormat, - clean, - restart, - done, - ) - }), - ) + const maxParallel = resolveMaxParallel(configs) + const buildConfig = (options: ResolvedConfig) => { + const isDualFormat = options.pkg + ? configChunksByPkg[options.pkg.packageJsonPath].formats.size > 1 + : true + return buildSingle(options, configDeps, isDualFormat, clean, restart, done) + } + const bundles = maxParallel + ? await mapWithConcurrency(configs, maxParallel, buildConfig) + : await Promise.all(configs.map(buildConfig)) const firstDevtoolsConfig = configs.find( (config) => config.devtools && config.devtools.ui, diff --git a/src/cli.ts b/src/cli.ts index ab2c57d8c..78c3a6b9d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -73,6 +73,10 @@ cli .option('--root ', 'Root directory of input files') .option('--exe', 'Bundle as executable') .option('-W, --workspace [dir]', 'Enable workspace mode') + .option( + '--max-parallel ', + 'Maximum number of config builds to run in parallel', + ) .option( '-F, --filter ', 'Filter configs (cwd or name), e.g. /pkg-name$/ or pkg-name', diff --git a/src/config/options.ts b/src/config/options.ts index e9a8fc73c..d24e9ce04 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -107,8 +107,12 @@ export async function resolveUserConfig( devtools = false, write = true, exe = false, + maxParallel: maxParallelRaw, } = userConfig + const maxParallel = + maxParallelRaw == null ? undefined : Number(maxParallelRaw) + const pkg = await readPackageJson(cwd) if (workspace) { name ||= pkg?.name @@ -130,6 +134,13 @@ export async function resolveUserConfig( logger.warn('`bundle` option is deprecated. Use `unbundle` instead.') } + if ( + maxParallel != null && + (!Number.isInteger(maxParallel) || maxParallel < 1) + ) { + throw new TypeError('`maxParallel` must be a positive integer.') + } + if (removeNodeProtocol) { if (nodeProtocol) throw new TypeError( @@ -322,6 +333,7 @@ export async function resolveUserConfig( hash, ignoreWatch, logger, + maxParallel, name, nameLabel, nodeProtocol, diff --git a/src/config/types.ts b/src/config/types.ts index 560ed0ddb..9156a2d19 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -685,6 +685,19 @@ export interface UserConfig { * This allows you to build multiple packages in a monorepo. */ workspace?: Workspace | Arrayable | true + + /** + * Limit how many config builds can run at the same time. + * + * This is especially useful in workspace mode where each package can start + * expensive child processes (e.g. `tsgo` via `dts.tsgo: true`). Without a + * limit, all configs start building concurrently, which can spawn many + * subprocesses and exhaust system resources. + * + * When set to a positive integer, at most that many builds run in parallel. + * When omitted or `undefined`, all builds run concurrently (existing behavior). + */ + maxParallel?: number } export interface InlineConfig extends UserConfig { @@ -748,6 +761,7 @@ export type ResolvedConfig = Overwrite< | 'footer' | 'checks' | 'css' + | 'maxParallel' >, { /** diff --git a/tests/issues.test.ts b/tests/issues.test.ts index 145ea65be..2d03c3e04 100644 --- a/tests/issues.test.ts +++ b/tests/issues.test.ts @@ -322,4 +322,54 @@ export const loadDynamic = () => import('./dynamic')`, expect(fileMap['index.mjs']).toContain('polyfill-uuid') expect(fileMap['index.mjs']).not.toMatch(/from ['"]crypto['"]/) }) + + test('#929', async (context) => { + let inFlight = 0 + let peak = 0 + + await testBuild({ + context, + snapshot: false, + files: { + 'package.json': JSON.stringify({ + name: 'issue-929', + private: true, + }), + 'packages/a/package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + }), + 'packages/a/index.ts': 'export const a = 1', + 'packages/b/package.json': JSON.stringify({ + name: 'b', + version: '1.0.0', + }), + 'packages/b/index.ts': 'export const b = 1', + 'packages/c/package.json': JSON.stringify({ + name: 'c', + version: '1.0.0', + }), + 'packages/c/index.ts': 'export const c = 1', + 'packages/d/package.json': JSON.stringify({ + name: 'd', + version: '1.0.0', + }), + 'packages/d/index.ts': 'export const d = 1', + }, + options: { + workspace: 'packages/*', + maxParallel: 2, + hooks: { + 'build:prepare': async () => { + inFlight++ + peak = Math.max(peak, inFlight) + await new Promise((resolve) => setTimeout(resolve, 50)) + inFlight-- + }, + }, + }, + }) + + expect(peak).toBeLessThanOrEqual(2) + }) })