From 860ac8cfe163db6d93b0a183cfe3e738d041a473 Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Fri, 9 Jan 2026 14:58:55 +0000 Subject: [PATCH 1/5] Parallelize build system --- build.mjs | 430 ++++++++++++++++++++++++++++++++-- utils/rollup-build-target.mjs | 79 ++++--- 2 files changed, 458 insertions(+), 51 deletions(-) diff --git a/build.mjs b/build.mjs index e7e88ad56d6..a77ce37b0dd 100644 --- a/build.mjs +++ b/build.mjs @@ -1,5 +1,5 @@ /** - * Build helper scripts + * Build helper script with parallel execution support * Usage: node build.mjs [options] -- [rollup options] * * Options: @@ -13,33 +13,425 @@ * treenet - Enable treenet build visualization (release only). * treesun - Enable treesun build visualization (release only). * treeflame - Enable treeflame build visualization (release only). + * + * --sequential - Disable parallel builds (for debugging) + * --verbose - Show full rollup output (default: quiet mode) + * + * Watch mode (-w or --watch): + * - Pass -w flag to enable watch mode + * - Watch mode requires a specific target (e.g., target:debug) + * - Watch mode always runs sequentially with full output */ -import { execSync } from 'child_process'; +import { spawn, execSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import { version, revision } from './utils/rollup-version-revision.mjs'; const args = process.argv.slice(2); -const ENV_START_MATCHES = [ - 'target', - 'treemap', - 'treenet', - 'treesun', - 'treeflame' -]; +// ANSI codes +const BLUE = '\x1b[34m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const RED = '\x1b[31m'; +const GRAY = '\x1b[90m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; +const RESET = '\x1b[0m'; +const CLEAR_LINE = '\x1b[2K'; +const CURSOR_UP = '\x1b[1A'; + +// Symbols +const SYM_PENDING = '○'; +const SYM_RUNNING = '◐'; +const SYM_DONE = '●'; +const SYM_FAIL = '✗'; + +// Parse arguments +const ENV_START_MATCHES = ['target', 'treemap', 'treenet', 'treesun', 'treeflame']; +const envArgs = []; +const rollupArgs = []; +let sequential = false; +let verbose = false; -const env = []; for (let i = 0; i < args.length; i++) { - if (ENV_START_MATCHES.some(match => args[i].startsWith(match)) && args[i - 1] !== '--environment') { - env.push(`--environment ${args[i]}`); - args.splice(i, 1); - i--; + if (args[i] === '--sequential') { + sequential = true; + continue; + } + if (args[i] === '--verbose' || args[i] === '-v') { + verbose = true; continue; } + if (ENV_START_MATCHES.some(match => args[i].startsWith(match)) && args[i - 1] !== '--environment') { + envArgs.push(args[i]); + } else { + rollupArgs.push(args[i]); + } } -const cmd = `rollup -c ${args.join(' ')} ${env.join(' ')}`; -try { - execSync(cmd, { stdio: 'inherit' }); -} catch (e) { - console.error(e.message); +const envTarget = envArgs.find(arg => arg.startsWith('target'))?.replace('target:', '').toLowerCase() ?? null; +const hasVisualization = envArgs.some(arg => ['treemap', 'treenet', 'treesun', 'treeflame'].includes(arg)); +const isWatchMode = rollupArgs.includes('-w') || rollupArgs.includes('--watch'); + +// Determine mode +let mode = 'parallel'; +if (sequential) mode = 'sequential'; +if (isWatchMode) mode = 'watch'; +if (hasVisualization) mode = 'sequential'; + +// Print banner +console.log(`${BLUE}${BOLD}PlayCanvas Engine Build${RESET}`); +console.log(`${GRAY}v${version} · ${revision} · ${mode} mode${RESET}\n`); + +// Clean build directory for full builds (not in watch mode) +if (envTarget === null && !isWatchMode && fs.existsSync('build')) { + fs.rmSync('build', { recursive: true }); } + +/** + * Build target definitions with their dependencies + */ +const BUILD_TARGETS = [ + { id: 'umd-release', target: 'umd:release', label: 'UMD Release', dependsOn: [] }, + { id: 'esm-release-unbundled', target: 'esm:release:unbundled', label: 'ESM Release (modules)', dependsOn: [] }, + { id: 'umd-debug', target: 'umd:debug', label: 'UMD Debug', dependsOn: [] }, + { id: 'esm-debug-unbundled', target: 'esm:debug:unbundled', label: 'ESM Debug (modules)', dependsOn: [] }, + { id: 'umd-profiler', target: 'umd:profiler', label: 'UMD Profiler', dependsOn: [] }, + { id: 'esm-profiler-unbundled', target: 'esm:profiler:unbundled', label: 'ESM Profiler (modules)', dependsOn: [] }, + { id: 'esm-release-bundled', target: 'esm:release:bundled', label: 'ESM Release (bundle)', dependsOn: ['esm-release-unbundled'] }, + { id: 'esm-debug-bundled', target: 'esm:debug:bundled', label: 'ESM Debug (bundle)', dependsOn: ['esm-debug-unbundled'] }, + { id: 'esm-profiler-bundled', target: 'esm:profiler:bundled', label: 'ESM Profiler (bundle)', dependsOn: ['esm-profiler-unbundled'] }, + { id: 'umd-min', target: 'umd:min', label: 'UMD Minified', dependsOn: ['umd-release'] }, + { id: 'esm-min', target: 'esm:min', label: 'ESM Minified', dependsOn: ['esm-release-bundled'] }, + { id: 'types', target: 'types', label: 'TypeScript Types', dependsOn: ['esm-release-unbundled'] } +]; + +/** + * Check if a target should be included based on the envTarget filter + */ +function shouldIncludeTarget(targetDef) { + if (envTarget === null) return true; + const { target } = targetDef; + const parts = target.split(':'); + return envTarget === target || + envTarget === parts[0] || + envTarget === parts[1] || + envTarget === parts[2] || + envTarget === `${parts[0]}:${parts[1]}` || + envTarget === `${parts[0]}:${parts[2]}` || + envTarget === `${parts[1]}:${parts[2]}`; +} + +/** + * Run a single rollup build with captured output + */ +function runBuild(targetDef, extraEnvArgs = [], captureOutput = true) { + return new Promise((resolve, reject) => { + const allEnvArgs = [`target:${targetDef.target}`, ...extraEnvArgs]; + const envString = allEnvArgs.map(e => `--environment ${e}`).join(' '); + const cmd = `rollup -c ${rollupArgs.join(' ')} ${envString}`; + + const output = []; + const child = spawn(cmd, { + shell: true, + stdio: captureOutput ? ['pipe', 'pipe', 'pipe'] : 'inherit' + }); + + if (captureOutput) { + child.stdout.on('data', (data) => output.push(data.toString())); + child.stderr.on('data', (data) => output.push(data.toString())); + } + + child.on('close', (code) => { + if (code === 0) { + resolve({ id: targetDef.id, output: output.join('') }); + } else { + reject(new Error(output.join('') || `Exit code ${code}`)); + } + }); + + child.on('error', (err) => reject(err)); + }); +} + +/** + * Run watch mode - passes through to rollup directly + */ +function runWatchMode(extraEnvArgs = []) { + const allEnvArgs = envArgs.concat(extraEnvArgs); + const envString = allEnvArgs.map(e => `--environment ${e}`).join(' '); + const cmd = `rollup -c ${rollupArgs.join(' ')} ${envString}`; + + console.log(`${YELLOW}Starting watch mode...${RESET}\n`); + + try { + execSync(cmd, { stdio: 'inherit' }); + } catch (e) { + if (e.signal !== 'SIGINT') { + console.error(`${RED}Watch mode error: ${e.message}${RESET}`); + } + } +} + +/** + * Status display manager for clean terminal output + */ +class StatusDisplay { + constructor(targets) { + this.targets = targets; + this.status = new Map(targets.map(t => [t.id, 'pending'])); + this.times = new Map(); + this.startTimes = new Map(); + this.errors = new Map(); + this.lineCount = 0; + } + + start(id) { + this.status.set(id, 'running'); + this.startTimes.set(id, performance.now()); + this.render(); + } + + complete(id) { + this.status.set(id, 'done'); + this.times.set(id, ((performance.now() - this.startTimes.get(id)) / 1000).toFixed(1)); + this.render(); + } + + fail(id, error) { + this.status.set(id, 'failed'); + this.times.set(id, ((performance.now() - this.startTimes.get(id)) / 1000).toFixed(1)); + this.errors.set(id, error); + this.render(); + } + + getSymbol(status) { + switch (status) { + case 'pending': return `${GRAY}${SYM_PENDING}${RESET}`; + case 'running': return `${YELLOW}${SYM_RUNNING}${RESET}`; + case 'done': return `${GREEN}${SYM_DONE}${RESET}`; + case 'failed': return `${RED}${SYM_FAIL}${RESET}`; + default: return SYM_PENDING; + } + } + + getElapsed(id) { + const status = this.status.get(id); + if (status === 'pending') return ''; + if (this.times.has(id)) return `${DIM}${this.times.get(id)}s${RESET}`; + const elapsed = ((performance.now() - this.startTimes.get(id)) / 1000).toFixed(0); + return `${DIM}${elapsed}s${RESET}`; + } + + clear() { + for (let i = 0; i < this.lineCount; i++) { + process.stdout.write(CURSOR_UP + CLEAR_LINE); + } + } + + render() { + // Clear previous output + this.clear(); + + // Count by status + const counts = { pending: 0, running: 0, done: 0, failed: 0 }; + for (const status of this.status.values()) { + counts[status]++; + } + + // Header line with counts + const header = [ + counts.running > 0 ? `${YELLOW}${counts.running} building${RESET}` : null, + counts.done > 0 ? `${GREEN}${counts.done} done${RESET}` : null, + counts.failed > 0 ? `${RED}${counts.failed} failed${RESET}` : null, + counts.pending > 0 ? `${GRAY}${counts.pending} pending${RESET}` : null + ].filter(Boolean).join(` ${GRAY}·${RESET} `); + + console.log(header); + + // Render each target on one line + const lines = []; + for (const target of this.targets) { + const status = this.status.get(target.id); + const symbol = this.getSymbol(status); + const elapsed = this.getElapsed(target.id); + const label = status === 'running' ? target.label : (status === 'done' ? `${DIM}${target.label}${RESET}` : target.label); + lines.push(` ${symbol} ${label} ${elapsed}`); + } + console.log(lines.join('\n')); + + this.lineCount = lines.length + 1; + } + + printErrors() { + if (this.errors.size > 0) { + console.log(`\n${RED}${BOLD}Build Errors:${RESET}`); + for (const [id, error] of this.errors) { + const target = this.targets.find(t => t.id === id); + console.log(`\n${RED}${target?.label ?? id}:${RESET}`); + console.log(error); + } + } + } + + printSummary(totalTime) { + console.log(`\n${GREEN}${BOLD}Build complete${RESET} ${DIM}in ${totalTime}s${RESET}`); + } +} + +/** + * Dynamic task pool scheduler with clean status display + */ +async function buildParallel(targets, extraEnvArgs = []) { + const maxConcurrency = Math.max(1, os.cpus().length - 1); + const display = new StatusDisplay(targets); + + const completed = new Set(); + const pending = new Map(targets.map(t => [t.id, t])); + const active = new Map(); + + const startTime = performance.now(); + let failed = null; + + const canStart = (targetDef) => targetDef.dependsOn.every(dep => completed.has(dep)); + + const startBuild = (targetDef) => { + display.start(targetDef.id); + + const promise = runBuild(targetDef, extraEnvArgs, !verbose) + .then(result => ({ id: targetDef.id, success: true, output: result.output })) + .catch(err => ({ id: targetDef.id, success: false, error: err.message })); + + active.set(targetDef.id, promise); + pending.delete(targetDef.id); + }; + + const fillPool = () => { + if (failed) return; + for (const [id, targetDef] of pending) { + if (active.size >= maxConcurrency) break; + if (canStart(targetDef)) { + startBuild(targetDef); + } + } + }; + + // Initial render + display.render(); + fillPool(); + + while (active.size > 0 || pending.size > 0) { + if (active.size === 0 && pending.size > 0) { + throw new Error('Circular dependency detected'); + } + + const result = await Promise.race(active.values()); + active.delete(result.id); + + if (result.success) { + completed.add(result.id); + display.complete(result.id); + fillPool(); + } else { + failed = result.error; + display.fail(result.id, result.error); + + if (active.size > 0) { + await Promise.allSettled(active.values()); + } + + display.printErrors(); + throw new Error(`Build failed: ${result.id}`); + } + } + + const totalTime = ((performance.now() - startTime) / 1000).toFixed(1); + display.printSummary(totalTime); +} + +/** + * Build targets sequentially with full output + */ +async function buildSequential(targets, extraEnvArgs = []) { + const startTime = performance.now(); + + for (const targetDef of targets) { + console.log(`${YELLOW}Building ${targetDef.label}...${RESET}`); + await runBuild(targetDef, extraEnvArgs, false); + console.log(`${GREEN}✓ ${targetDef.label}${RESET}\n`); + } + + const totalTime = ((performance.now() - startTime) / 1000).toFixed(1); + console.log(`${GREEN}${BOLD}Build complete${RESET} ${DIM}in ${totalTime}s${RESET}`); +} + +/** + * Topological sort for dependency ordering + */ +function topologicalSort(targets) { + const sorted = []; + const visited = new Set(); + const visiting = new Set(); + const targetMap = new Map(targets.map(t => [t.id, t])); + + function visit(id) { + if (visited.has(id)) return; + if (visiting.has(id)) throw new Error(`Circular dependency: ${id}`); + const target = targetMap.get(id); + if (!target) return; + visiting.add(id); + for (const dep of target.dependsOn) visit(dep); + visiting.delete(id); + visited.add(id); + sorted.push(target); + } + + for (const target of targets) visit(target.id); + return sorted; +} + +// Main execution +(async () => { + try { + if (isWatchMode) { + runWatchMode(); + return; + } + + const filteredTargets = BUILD_TARGETS.filter(shouldIncludeTarget); + const targetIds = new Set(filteredTargets.map(t => t.id)); + const withDependencies = filteredTargets.slice(); + + for (const target of filteredTargets) { + for (const dep of target.dependsOn) { + if (!targetIds.has(dep)) { + const depTarget = BUILD_TARGETS.find(t => t.id === dep); + if (depTarget) { + withDependencies.unshift(depTarget); + targetIds.add(dep); + } + } + } + } + + const sorted = topologicalSort(withDependencies); + + if (sorted.length === 0) { + console.error(`${RED}${BOLD}No targets found${RESET}`); + process.exit(1); + } + + const vizArgs = envArgs.filter(arg => ['treemap', 'treenet', 'treesun', 'treeflame'].includes(arg)); + + if (mode === 'sequential' || verbose) { + await buildSequential(sorted, vizArgs); + } else { + await buildParallel(sorted, vizArgs); + } + } catch (err) { + console.error(`\n${RED}${BOLD}Build failed:${RESET} ${err.message}`); + process.exit(1); + } +})(); diff --git a/utils/rollup-build-target.mjs b/utils/rollup-build-target.mjs index f96078fa6e1..1f64b7c4ceb 100644 --- a/utils/rollup-build-target.mjs +++ b/utils/rollup-build-target.mjs @@ -75,8 +75,6 @@ const OUT_PREFIX = { min: 'playcanvas.min' }; -const HISTORY = new Map(); - /** * @param {'debug'|'release'|'profiler'} buildType - The build type. * @returns {object} - The JSCC options. @@ -151,12 +149,38 @@ function getOutPlugins(type) { return plugins; } +/** + * Get the output path for a given build configuration. + * This allows parallel builds to reference each other's outputs by computed paths + * rather than relying on a shared HISTORY map. + * + * @param {'debug'|'release'|'profiler'|'min'} buildType - The build type. + * @param {'umd'|'esm'} moduleFormat - The module format. + * @param {boolean} bundled - Whether bundled. + * @param {string} dir - The output directory. + * @returns {{ file?: string, dir?: string }} The output paths. + */ +function getOutputPaths(buildType, moduleFormat, bundled, dir = 'build') { + const isUMD = moduleFormat === 'umd'; + const prefix = OUT_PREFIX[buildType]; + + if (bundled || isUMD) { + return { + file: `${dir}/${prefix}${isUMD ? '.js' : '.mjs'}` + }; + } + return { + dir: `${dir}/${prefix}` + }; +} + /** * Build rollup options for JS (bundled and unbundled). * - * For faster subsequent builds, the unbundled and release builds are cached in the HISTORY map to - * be used for bundled and minified builds. They are stored in the HISTORY map with the key: - * `--`. + * The build system supports parallel execution by computing input paths directly + * rather than relying on a shared state map. Dependencies between builds are: + * - ESM bundled depends on ESM unbundled (uses unbundled output as input) + * - Minified depends on release bundled (uses release output as input) * * @param {object} options - The build target options. * @param {'umd'|'esm'} options.moduleFormat - The module format. @@ -178,20 +202,19 @@ function buildJSOptions({ const isMin = buildType === 'min'; const bundled = isUMD || isMin || bundleState === 'bundled'; - const prefix = `${OUT_PREFIX[buildType]}`; + const prefix = OUT_PREFIX[buildType]; const file = `${prefix}${isUMD ? '.js' : '.mjs'}`; const targets = []; - // bundle from unbundled - if (bundled && HISTORY.has(`${buildType}-${moduleFormat}-false`)) { - const unbundled = HISTORY.get(`${buildType}-${moduleFormat}-false`); + // ESM bundled: bundle from unbundled output + if (!isUMD && !isMin && bundleState === 'bundled') { + // Compute the unbundled output path directly + const unbundledDir = getOutputPaths(buildType, moduleFormat, false, dir).dir; - /** - * @type {RollupOptions} - */ + /** @type {RollupOptions} */ const target = { - input: `${unbundled.output.dir}/src/index.js`, + input: `${unbundledDir}/src/index.js`, output: { banner: getBanner(BANNER[buildType]), format: 'es', @@ -203,23 +226,20 @@ function buildJSOptions({ } }; - HISTORY.set(`${buildType}-${moduleFormat}-true`, target); targets.push(target); - return targets; } - // minify from release build - if (isMin && HISTORY.has(`release-${moduleFormat}-true`)) { - const release = HISTORY.get(`release-${moduleFormat}-true`); + // Minified: minify from release build + if (isMin) { + // Compute the release output path directly + const releaseOutput = getOutputPaths('release', moduleFormat, true, dir); - /** - * @type {RollupOptions} - */ + /** @type {RollupOptions} */ const target = { - input: release.output.file, + input: releaseOutput.file, plugins: [ - swcPlugin({ swc: swcOptions(isDebug, isMin) }) + swcPlugin({ swc: swcOptions(false, true) }) ], output: { banner: isUMD ? getBanner(BANNER[buildType]) : undefined, @@ -228,15 +248,12 @@ function buildJSOptions({ context: isUMD ? 'this' : undefined }; - HISTORY.set(`${buildType}-${moduleFormat}-${bundled}`, target); targets.push(target); - return targets; } - /** - * @type {RollupOptions} - */ + // Primary build from source + /** @type {RollupOptions} */ const target = { input, output: { @@ -259,15 +276,13 @@ function buildJSOptions({ !isDebug ? shaderChunks() : undefined, isDebug ? engineLayerImportValidation(input) : undefined, !isDebug ? strip({ functions: STRIP_FUNCTIONS }) : undefined, - swcPlugin({ swc: swcOptions(isDebug, isMin) }), + swcPlugin({ swc: swcOptions(isDebug, false) }), !isUMD ? dynamicImportBundlerSuppress() : undefined, !isDebug ? spacesToTabs() : undefined ] }; - HISTORY.set(`${buildType}-${moduleFormat}-${bundled}`, target); targets.push(target); - return targets; } @@ -301,4 +316,4 @@ function buildTypesOption({ }; } -export { buildJSOptions, buildTypesOption }; +export { buildJSOptions, buildTypesOption, getOutputPaths }; From 1e08f23fbf4f97677ea5abb7f828207ff33917e1 Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Fri, 9 Jan 2026 17:25:56 +0000 Subject: [PATCH 2/5] Refine implementation --- build.mjs | 105 +++++++++++++++++++++++++++++++++++++--------- rollup.config.mjs | 11 ----- 2 files changed, 85 insertions(+), 31 deletions(-) diff --git a/build.mjs b/build.mjs index a77ce37b0dd..3990ab9c34f 100644 --- a/build.mjs +++ b/build.mjs @@ -14,8 +14,7 @@ * treesun - Enable treesun build visualization (release only). * treeflame - Enable treeflame build visualization (release only). * - * --sequential - Disable parallel builds (for debugging) - * --verbose - Show full rollup output (default: quiet mode) + * --sequential - Run builds sequentially (shows full rollup output) * * Watch mode (-w or --watch): * - Pass -w flag to enable watch mode @@ -30,6 +29,9 @@ import { version, revision } from './utils/rollup-version-revision.mjs'; const args = process.argv.slice(2); +// Auto-detect environment: interactive terminal vs CI/piped output +const isInteractive = process.stdout.isTTY && !process.env.CI; + // ANSI codes const BLUE = '\x1b[34m'; const GREEN = '\x1b[32m'; @@ -53,17 +55,12 @@ const ENV_START_MATCHES = ['target', 'treemap', 'treenet', 'treesun', 'treeflame const envArgs = []; const rollupArgs = []; let sequential = false; -let verbose = false; for (let i = 0; i < args.length; i++) { if (args[i] === '--sequential') { sequential = true; continue; } - if (args[i] === '--verbose' || args[i] === '-v') { - verbose = true; - continue; - } if (ENV_START_MATCHES.some(match => args[i].startsWith(match)) && args[i - 1] !== '--environment') { envArgs.push(args[i]); } else { @@ -82,8 +79,12 @@ if (isWatchMode) mode = 'watch'; if (hasVisualization) mode = 'sequential'; // Print banner -console.log(`${BLUE}${BOLD}PlayCanvas Engine Build${RESET}`); -console.log(`${GRAY}v${version} · ${revision} · ${mode} mode${RESET}\n`); +if (isInteractive) { + console.log(`${BLUE}${BOLD}PlayCanvas Engine Build${RESET}`); + console.log(`${GRAY}v${version} · ${revision} · ${mode} mode${RESET}\n`); +} else { + console.log(`PlayCanvas Engine Build v${version} (${revision}) [${mode} mode]`); +} // Clean build directory for full builds (not in watch mode) if (envTarget === null && !isWatchMode && fs.existsSync('build')) { @@ -125,9 +126,12 @@ function shouldIncludeTarget(targetDef) { } /** - * Run a single rollup build with captured output + * Run a single rollup build + * @param {object} targetDef - Target definition + * @param {string[]} extraEnvArgs - Extra environment arguments + * @param {boolean} quiet - If true, capture output; if false, show in terminal */ -function runBuild(targetDef, extraEnvArgs = [], captureOutput = true) { +function runBuild(targetDef, extraEnvArgs = [], quiet = true) { return new Promise((resolve, reject) => { const allEnvArgs = [`target:${targetDef.target}`, ...extraEnvArgs]; const envString = allEnvArgs.map(e => `--environment ${e}`).join(' '); @@ -136,10 +140,10 @@ function runBuild(targetDef, extraEnvArgs = [], captureOutput = true) { const output = []; const child = spawn(cmd, { shell: true, - stdio: captureOutput ? ['pipe', 'pipe', 'pipe'] : 'inherit' + stdio: quiet ? ['pipe', 'pipe', 'pipe'] : 'inherit' }); - if (captureOutput) { + if (quiet) { child.stdout.on('data', (data) => output.push(data.toString())); child.stderr.on('data', (data) => output.push(data.toString())); } @@ -281,12 +285,67 @@ class StatusDisplay { } } +/** + * CI-friendly linear output display (no cursor manipulation) + */ +class CIDisplay { + constructor(targets) { + this.targets = targets; + this.total = targets.length; + this.started = 0; + this.completed = 0; + this.startTimes = new Map(); + this.errors = new Map(); + } + + start(id) { + this.started++; + this.startTimes.set(id, performance.now()); + const target = this.targets.find(t => t.id === id); + console.log(`[${this.started}/${this.total}] Building ${target?.label ?? id}...`); + } + + complete(id) { + this.completed++; + const elapsed = ((performance.now() - this.startTimes.get(id)) / 1000).toFixed(1); + const target = this.targets.find(t => t.id === id); + console.log(` ${SYM_DONE} ${target?.label ?? id} (${elapsed}s)`); + } + + fail(id, error) { + this.completed++; + const elapsed = ((performance.now() - this.startTimes.get(id)) / 1000).toFixed(1); + const target = this.targets.find(t => t.id === id); + console.log(` ${SYM_FAIL} ${target?.label ?? id} FAILED (${elapsed}s)`); + this.errors.set(id, error); + } + + render() { + // No-op for CI - we print on events instead + } + + printErrors() { + if (this.errors.size > 0) { + console.log('\n=== Build Errors ==='); + for (const [id, error] of this.errors) { + const target = this.targets.find(t => t.id === id); + console.log(`\n${target?.label ?? id}:`); + console.log(error); + } + } + } + + printSummary(totalTime) { + console.log(`\nBuild complete in ${totalTime}s`); + } +} + /** * Dynamic task pool scheduler with clean status display */ async function buildParallel(targets, extraEnvArgs = []) { const maxConcurrency = Math.max(1, os.cpus().length - 1); - const display = new StatusDisplay(targets); + const display = isInteractive ? new StatusDisplay(targets) : new CIDisplay(targets); const completed = new Set(); const pending = new Map(targets.map(t => [t.id, t])); @@ -300,7 +359,7 @@ async function buildParallel(targets, extraEnvArgs = []) { const startBuild = (targetDef) => { display.start(targetDef.id); - const promise = runBuild(targetDef, extraEnvArgs, !verbose) + const promise = runBuild(targetDef, extraEnvArgs, true) .then(result => ({ id: targetDef.id, success: true, output: result.output })) .catch(err => ({ id: targetDef.id, success: false, error: err.message })); @@ -404,34 +463,40 @@ function topologicalSort(targets) { const targetIds = new Set(filteredTargets.map(t => t.id)); const withDependencies = filteredTargets.slice(); - for (const target of filteredTargets) { + // Recursively add all dependencies (transitive) + const addDependencies = (target) => { for (const dep of target.dependsOn) { if (!targetIds.has(dep)) { const depTarget = BUILD_TARGETS.find(t => t.id === dep); if (depTarget) { - withDependencies.unshift(depTarget); targetIds.add(dep); + withDependencies.unshift(depTarget); + addDependencies(depTarget); } } } + }; + + for (const target of filteredTargets) { + addDependencies(target); } const sorted = topologicalSort(withDependencies); if (sorted.length === 0) { - console.error(`${RED}${BOLD}No targets found${RESET}`); + console.error(isInteractive ? `${RED}${BOLD}No targets found${RESET}` : 'ERROR: No targets found'); process.exit(1); } const vizArgs = envArgs.filter(arg => ['treemap', 'treenet', 'treesun', 'treeflame'].includes(arg)); - if (mode === 'sequential' || verbose) { + if (mode === 'sequential') { await buildSequential(sorted, vizArgs); } else { await buildParallel(sorted, vizArgs); } } catch (err) { - console.error(`\n${RED}${BOLD}Build failed:${RESET} ${err.message}`); + console.error(isInteractive ? `\n${RED}${BOLD}Build failed:${RESET} ${err.message}` : `\nBuild failed: ${err.message}`); process.exit(1); } })(); diff --git a/rollup.config.mjs b/rollup.config.mjs index 98aa339171e..7ba76448400 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,13 +1,10 @@ import fs from 'fs'; -import { version, revision } from './utils/rollup-version-revision.mjs'; import { buildJSOptions, buildTypesOption } from './utils/rollup-build-target.mjs'; /** @import { RollupOptions } from 'rollup' */ -const BLUE_OUT = '\x1b[34m'; const RED_OUT = '\x1b[31m'; const BOLD_OUT = '\x1b[1m'; -const REGULAR_OUT = '\x1b[22m'; const RESET_OUT = '\x1b[0m'; const BUILD_TYPES = /** @type {const} */ (['release', 'debug', 'profiler', 'min']); @@ -16,14 +13,6 @@ const BUNDLE_STATES = /** @type {const} */ (['unbundled', 'bundled']); const envTarget = process.env.target ? process.env.target.toLowerCase() : null; -const title = [ - 'Building PlayCanvas Engine', - `version ${BOLD_OUT}v${version}${REGULAR_OUT}`, - `revision ${BOLD_OUT}${revision}${REGULAR_OUT}`, - `target ${BOLD_OUT}${envTarget ?? 'all'}${REGULAR_OUT}` -].join('\n'); -console.log(`${BLUE_OUT}${title}${RESET_OUT}`); - if (envTarget === null && fs.existsSync('build')) { // no targets specified, clean build directory fs.rmSync('build', { recursive: true }); From ea3c4807cf8185bafed7c3ef9edaa5db04ac2f51 Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Fri, 9 Jan 2026 17:32:52 +0000 Subject: [PATCH 3/5] Lint fixes --- build.mjs | 55 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/build.mjs b/build.mjs index 3990ab9c34f..7d6b3921897 100644 --- a/build.mjs +++ b/build.mjs @@ -110,7 +110,10 @@ const BUILD_TARGETS = [ ]; /** - * Check if a target should be included based on the envTarget filter + * Check if a target should be included based on the envTarget filter. + * + * @param {object} targetDef - The build target definition. + * @returns {boolean} True if the target should be included. */ function shouldIncludeTarget(targetDef) { if (envTarget === null) return true; @@ -126,10 +129,12 @@ function shouldIncludeTarget(targetDef) { } /** - * Run a single rollup build - * @param {object} targetDef - Target definition - * @param {string[]} extraEnvArgs - Extra environment arguments - * @param {boolean} quiet - If true, capture output; if false, show in terminal + * Run a single rollup build. + * + * @param {object} targetDef - Target definition. + * @param {string[]} extraEnvArgs - Extra environment arguments. + * @param {boolean} quiet - If true, capture output; if false, show in terminal. + * @returns {Promise<{id: string, output: string}>} Build result. */ function runBuild(targetDef, extraEnvArgs = [], quiet = true) { return new Promise((resolve, reject) => { @@ -144,8 +149,8 @@ function runBuild(targetDef, extraEnvArgs = [], quiet = true) { }); if (quiet) { - child.stdout.on('data', (data) => output.push(data.toString())); - child.stderr.on('data', (data) => output.push(data.toString())); + child.stdout.on('data', data => output.push(data.toString())); + child.stderr.on('data', data => output.push(data.toString())); } child.on('close', (code) => { @@ -156,12 +161,14 @@ function runBuild(targetDef, extraEnvArgs = [], quiet = true) { } }); - child.on('error', (err) => reject(err)); + child.on('error', err => reject(err)); }); } /** - * Run watch mode - passes through to rollup directly + * Run watch mode - passes through to rollup directly. + * + * @param {string[]} extraEnvArgs - Extra environment arguments. */ function runWatchMode(extraEnvArgs = []) { const allEnvArgs = envArgs.concat(extraEnvArgs); @@ -341,7 +348,10 @@ class CIDisplay { } /** - * Dynamic task pool scheduler with clean status display + * Dynamic task pool scheduler with clean status display. + * + * @param {object[]} targets - Array of build target definitions. + * @param {string[]} extraEnvArgs - Extra environment arguments. */ async function buildParallel(targets, extraEnvArgs = []) { const maxConcurrency = Math.max(1, os.cpus().length - 1); @@ -354,14 +364,14 @@ async function buildParallel(targets, extraEnvArgs = []) { const startTime = performance.now(); let failed = null; - const canStart = (targetDef) => targetDef.dependsOn.every(dep => completed.has(dep)); + const canStart = targetDef => targetDef.dependsOn.every(dep => completed.has(dep)); const startBuild = (targetDef) => { display.start(targetDef.id); const promise = runBuild(targetDef, extraEnvArgs, true) - .then(result => ({ id: targetDef.id, success: true, output: result.output })) - .catch(err => ({ id: targetDef.id, success: false, error: err.message })); + .then(result => ({ id: targetDef.id, success: true, output: result.output })) + .catch(err => ({ id: targetDef.id, success: false, error: err.message })); active.set(targetDef.id, promise); pending.delete(targetDef.id); @@ -369,7 +379,7 @@ async function buildParallel(targets, extraEnvArgs = []) { const fillPool = () => { if (failed) return; - for (const [id, targetDef] of pending) { + for (const [, targetDef] of pending) { if (active.size >= maxConcurrency) break; if (canStart(targetDef)) { startBuild(targetDef); @@ -381,12 +391,13 @@ async function buildParallel(targets, extraEnvArgs = []) { display.render(); fillPool(); + // eslint-disable-next-line no-await-in-loop -- intentional: dynamic task pool waits for next completed task while (active.size > 0 || pending.size > 0) { if (active.size === 0 && pending.size > 0) { throw new Error('Circular dependency detected'); } - const result = await Promise.race(active.values()); + const result = await Promise.race(active.values()); // eslint-disable-line no-await-in-loop active.delete(result.id); if (result.success) { @@ -398,7 +409,7 @@ async function buildParallel(targets, extraEnvArgs = []) { display.fail(result.id, result.error); if (active.size > 0) { - await Promise.allSettled(active.values()); + await Promise.allSettled(active.values()); // eslint-disable-line no-await-in-loop } display.printErrors(); @@ -411,14 +422,17 @@ async function buildParallel(targets, extraEnvArgs = []) { } /** - * Build targets sequentially with full output + * Build targets sequentially with full output. + * + * @param {object[]} targets - Array of build target definitions. + * @param {string[]} extraEnvArgs - Extra environment arguments. */ async function buildSequential(targets, extraEnvArgs = []) { const startTime = performance.now(); for (const targetDef of targets) { console.log(`${YELLOW}Building ${targetDef.label}...${RESET}`); - await runBuild(targetDef, extraEnvArgs, false); + await runBuild(targetDef, extraEnvArgs, false); // eslint-disable-line no-await-in-loop console.log(`${GREEN}✓ ${targetDef.label}${RESET}\n`); } @@ -427,7 +441,10 @@ async function buildSequential(targets, extraEnvArgs = []) { } /** - * Topological sort for dependency ordering + * Topological sort for dependency ordering. + * + * @param {object[]} targets - Array of build target definitions. + * @returns {object[]} Sorted array of targets. */ function topologicalSort(targets) { const sorted = []; From 8d645b9ef300983a5bcc2a92c282f814e61dfb9a Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Fri, 9 Jan 2026 17:37:59 +0000 Subject: [PATCH 4/5] Fix examples build --- build.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/build.mjs b/build.mjs index 7d6b3921897..1a1b2fcf98b 100644 --- a/build.mjs +++ b/build.mjs @@ -391,7 +391,6 @@ async function buildParallel(targets, extraEnvArgs = []) { display.render(); fillPool(); - // eslint-disable-next-line no-await-in-loop -- intentional: dynamic task pool waits for next completed task while (active.size > 0 || pending.size > 0) { if (active.size === 0 && pending.size > 0) { throw new Error('Circular dependency detected'); From 950c5d34ee3cb693637c263602805611ef79c60d Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Fri, 9 Jan 2026 17:39:58 +0000 Subject: [PATCH 5/5] Fix examples build --- utils/rollup-build-target.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/utils/rollup-build-target.mjs b/utils/rollup-build-target.mjs index 1f64b7c4ceb..2fd802a985f 100644 --- a/utils/rollup-build-target.mjs +++ b/utils/rollup-build-target.mjs @@ -207,14 +207,16 @@ function buildJSOptions({ const targets = []; - // ESM bundled: bundle from unbundled output + // ESM bundled: bundle from unbundled output (unless input is explicitly provided) if (!isUMD && !isMin && bundleState === 'bundled') { - // Compute the unbundled output path directly + // If input is default 'src/index.js', use unbundled output; otherwise use provided input + const useUnbundledInput = input === 'src/index.js'; const unbundledDir = getOutputPaths(buildType, moduleFormat, false, dir).dir; + const bundleInput = useUnbundledInput ? `${unbundledDir}/src/index.js` : input; /** @type {RollupOptions} */ const target = { - input: `${unbundledDir}/src/index.js`, + input: bundleInput, output: { banner: getBanner(BANNER[buildType]), format: 'es',