run-next-build.mjs
1 #!/usr/bin/env node 2 3 import fs from 'node:fs' 4 import os from 'node:os' 5 import path from 'node:path' 6 import { spawnSync } from 'node:child_process' 7 import { createRequire } from 'node:module' 8 import { pathToFileURL } from 'node:url' 9 10 import { ensureBuildBootstrapPaths } from './build-bootstrap-env.mjs' 11 12 const require = createRequire(import.meta.url) 13 14 export const DEFAULT_MAX_OLD_SPACE_SIZE_MB = '8192' 15 export const MIN_MAX_OLD_SPACE_SIZE_MB = 1024 16 export const FALLBACK_MIN_MAX_OLD_SPACE_SIZE_MB = 512 17 export const RESERVED_BUILD_MEMORY_MB = 768 18 export const MAX_OLD_SPACE_RATIO = 0.75 19 export const LOW_MEMORY_RATIO = 0.6 20 export const BUILD_MAX_OLD_SPACE_SIZE_ENV = 'SWARMCLAW_BUILD_MAX_OLD_SPACE_SIZE_MB' 21 export const CGROUP_MEMORY_LIMIT_PATHS = [ 22 '/sys/fs/cgroup/memory.max', 23 '/sys/fs/cgroup/memory/memory.limit_in_bytes', 24 ] 25 export const UNBOUNDED_MEMORY_LIMIT_BYTES = 1n << 60n 26 export const TRACE_COPY_WARNING = 'Failed to copy traced files' 27 export const NEXT_STANDALONE_METADATA_RELATIVE_DIR = path.join( 28 'node_modules', 29 'next', 30 'dist', 31 'lib', 32 'metadata', 33 ) 34 export const REQUIRED_NEXT_METADATA_FILES = [ 35 'get-metadata-route.js', 36 'is-metadata-route.js', 37 ] 38 export const REQUIRED_STANDALONE_BROWSER_PACKAGES = [ 39 '@playwright/mcp', 40 'playwright', 41 'playwright-core', 42 ] 43 44 function parsePositiveInteger(value) { 45 const parsed = Number.parseInt(String(value ?? '').trim(), 10) 46 return Number.isFinite(parsed) && parsed > 0 ? parsed : null 47 } 48 49 export function readCgroupMemoryLimitBytes( 50 paths = CGROUP_MEMORY_LIMIT_PATHS, 51 existsSync = fs.existsSync, 52 readFileSync = fs.readFileSync, 53 ) { 54 for (const filePath of paths) { 55 if (!existsSync(filePath)) continue 56 57 let raw = '' 58 try { 59 raw = String(readFileSync(filePath, 'utf8')).trim() 60 } catch { 61 continue 62 } 63 64 if (!raw || raw === 'max') continue 65 66 try { 67 const bytes = BigInt(raw) 68 if (bytes <= 0n || bytes >= UNBOUNDED_MEMORY_LIMIT_BYTES) continue 69 return Number(bytes) 70 } catch { 71 continue 72 } 73 } 74 75 return null 76 } 77 78 export function deriveMaxOldSpaceSizeMb(memoryLimitBytes, defaultMaxOldSpaceSizeMb = DEFAULT_MAX_OLD_SPACE_SIZE_MB) { 79 const defaultMb = parsePositiveInteger(defaultMaxOldSpaceSizeMb) ?? Number.parseInt(DEFAULT_MAX_OLD_SPACE_SIZE_MB, 10) 80 const limitMb = Math.floor(Number(memoryLimitBytes) / (1024 * 1024)) 81 if (!Number.isFinite(limitMb) || limitMb <= 0) return String(defaultMb) 82 83 const constrainedCandidate = Math.min( 84 defaultMb, 85 limitMb - RESERVED_BUILD_MEMORY_MB, 86 Math.floor(limitMb * MAX_OLD_SPACE_RATIO), 87 ) 88 if (constrainedCandidate >= MIN_MAX_OLD_SPACE_SIZE_MB) { 89 return String(constrainedCandidate) 90 } 91 92 return String(Math.max( 93 FALLBACK_MIN_MAX_OLD_SPACE_SIZE_MB, 94 Math.min(defaultMb, Math.floor(limitMb * LOW_MEMORY_RATIO)), 95 )) 96 } 97 98 export function resolveNextBuildMaxOldSpaceSizeMb( 99 env = process.env, 100 options = {}, 101 ) { 102 const explicit = parsePositiveInteger(env[BUILD_MAX_OLD_SPACE_SIZE_ENV]) 103 if (explicit) return String(explicit) 104 105 const readLimitBytes = options.readCgroupMemoryLimitBytes ?? readCgroupMemoryLimitBytes 106 const totalMemFn = options.totalMem ?? os.totalmem 107 const memoryLimitBytes = readLimitBytes() ?? totalMemFn() 108 109 return deriveMaxOldSpaceSizeMb(memoryLimitBytes, DEFAULT_MAX_OLD_SPACE_SIZE_MB) 110 } 111 112 export function mergeNodeOptions(nodeOptions = '', maxOldSpaceSizeMb = DEFAULT_MAX_OLD_SPACE_SIZE_MB) { 113 const trimmed = nodeOptions.trim() 114 if (/(^|\s)--max-old-space-size(?:=|\s|$)/.test(trimmed)) return trimmed 115 return trimmed 116 ? `${trimmed} --max-old-space-size=${maxOldSpaceSizeMb}` 117 : `--max-old-space-size=${maxOldSpaceSizeMb}` 118 } 119 120 export function buildNextBuildEnv( 121 env = process.env, 122 maxOldSpaceSizeMb = DEFAULT_MAX_OLD_SPACE_SIZE_MB, 123 cwd = process.cwd(), 124 ) { 125 const bootstrapPaths = ensureBuildBootstrapPaths(cwd) 126 return { 127 ...env, 128 DATA_DIR: bootstrapPaths.dataDir, 129 WORKSPACE_DIR: bootstrapPaths.workspaceDir, 130 BROWSER_PROFILES_DIR: bootstrapPaths.browserProfilesDir, 131 NODE_OPTIONS: mergeNodeOptions(env.NODE_OPTIONS || '', maxOldSpaceSizeMb), 132 SWARMCLAW_BUILD_MODE: env.SWARMCLAW_BUILD_MODE || '1', 133 } 134 } 135 136 export function hasTraceCopyWarning(output = '') { 137 return output.includes(TRACE_COPY_WARNING) 138 } 139 140 function hasRequiredNextMetadataFiles(dir) { 141 return REQUIRED_NEXT_METADATA_FILES.every((fileName) => fs.existsSync(path.join(dir, fileName))) 142 } 143 144 export function repairStandalonePublicAndStatic(cwd = process.cwd()) { 145 const standaloneDir = path.join(cwd, '.next', 'standalone') 146 if (!fs.existsSync(standaloneDir)) return false 147 148 let repaired = false 149 150 // Next.js standalone does not copy public/ or .next/static/ automatically. 151 const publicSrc = path.join(cwd, 'public') 152 const publicDst = path.join(standaloneDir, 'public') 153 if (fs.existsSync(publicSrc) && !fs.existsSync(publicDst)) { 154 fs.cpSync(publicSrc, publicDst, { recursive: true, force: true }) 155 repaired = true 156 } 157 158 const staticSrc = path.join(cwd, '.next', 'static') 159 const staticDst = path.join(standaloneDir, '.next', 'static') 160 if (fs.existsSync(staticSrc) && !fs.existsSync(staticDst)) { 161 fs.cpSync(staticSrc, staticDst, { recursive: true, force: true }) 162 repaired = true 163 } 164 165 return repaired 166 } 167 168 export function repairStandaloneCssTreeData(cwd = process.cwd()) { 169 const standaloneDir = path.join(cwd, '.next', 'standalone') 170 if (!fs.existsSync(standaloneDir)) return false 171 172 let repaired = false 173 174 const cssTreeDst = path.join(standaloneDir, 'node_modules', 'css-tree', 'data') 175 const cssTreeSrc = path.join(cwd, 'node_modules', 'css-tree', 'data') 176 if (!fs.existsSync(cssTreeDst) && fs.existsSync(cssTreeSrc)) { 177 fs.cpSync(cssTreeSrc, cssTreeDst, { recursive: true, force: true }) 178 repaired = true 179 } 180 181 // css-tree's CJS entry calls require('mdn-data/css/*.json') at load time, 182 // and Next's output-tracing does not pull the raw JSON data files into the 183 // standalone tree. Copy them in so jsdom (via css-tree) loads correctly 184 // under the packaged app. 185 const mdnDataDst = path.join(standaloneDir, 'node_modules', 'mdn-data') 186 const mdnDataSrc = path.join(cwd, 'node_modules', 'mdn-data') 187 if (!fs.existsSync(mdnDataDst) && fs.existsSync(mdnDataSrc)) { 188 fs.cpSync(mdnDataSrc, mdnDataDst, { recursive: true, force: true }) 189 repaired = true 190 } 191 192 return repaired 193 } 194 195 export function repairStandaloneBrowserMcpRuntime(cwd = process.cwd()) { 196 const standaloneDir = path.join(cwd, '.next', 'standalone') 197 if (!fs.existsSync(standaloneDir)) return false 198 199 let repaired = false 200 const standaloneNodeModules = path.join(standaloneDir, 'node_modules') 201 for (const packageName of REQUIRED_STANDALONE_BROWSER_PACKAGES) { 202 const sourceDir = path.join(cwd, 'node_modules', ...packageName.split('/')) 203 const targetDir = path.join(standaloneNodeModules, ...packageName.split('/')) 204 if (!fs.existsSync(sourceDir)) { 205 throw new Error(`Missing required browser MCP runtime package under ${sourceDir}.`) 206 } 207 208 fs.mkdirSync(path.dirname(targetDir), { recursive: true }) 209 fs.cpSync(sourceDir, targetDir, { recursive: true, force: true }) 210 repaired = true 211 } 212 213 return repaired 214 } 215 216 export function repairStandaloneNextMetadata(cwd = process.cwd()) { 217 const standaloneDir = path.join(cwd, '.next', 'standalone') 218 if (!fs.existsSync(standaloneDir)) return false 219 220 const standaloneMetadataDir = path.join(standaloneDir, NEXT_STANDALONE_METADATA_RELATIVE_DIR) 221 if (hasRequiredNextMetadataFiles(standaloneMetadataDir)) return false 222 223 const installedMetadataDir = path.join(cwd, 'node_modules', 'next', 'dist', 'lib', 'metadata') 224 if (!hasRequiredNextMetadataFiles(installedMetadataDir)) { 225 throw new Error( 226 `Missing required Next metadata runtime files under ${installedMetadataDir}.`, 227 ) 228 } 229 230 fs.mkdirSync(path.dirname(standaloneMetadataDir), { recursive: true }) 231 fs.cpSync(installedMetadataDir, standaloneMetadataDir, { recursive: true, force: true }) 232 233 if (!hasRequiredNextMetadataFiles(standaloneMetadataDir)) { 234 throw new Error( 235 `Failed to repair Next metadata runtime files under ${standaloneMetadataDir}.`, 236 ) 237 } 238 239 return true 240 } 241 242 export function runNextBuild( 243 args = process.argv.slice(2), 244 env = process.env, 245 cwd = process.cwd(), 246 maxOldSpaceSizeMb = resolveNextBuildMaxOldSpaceSizeMb(env), 247 ) { 248 const nextBin = require.resolve('next/dist/bin/next') 249 return spawnSync(process.execPath, [nextBin, 'build', '--webpack', ...args], { 250 stdio: 'pipe', 251 encoding: 'utf-8', 252 env: buildNextBuildEnv(env, maxOldSpaceSizeMb, cwd), 253 cwd, 254 }) 255 } 256 257 function main() { 258 const result = runNextBuild() 259 if (result.stdout) process.stdout.write(result.stdout) 260 if (result.stderr) process.stderr.write(result.stderr) 261 if (result.error) throw result.error 262 const combinedOutput = `${result.stdout || ''}\n${result.stderr || ''}` 263 if ((result.status ?? 1) === 0 && hasTraceCopyWarning(combinedOutput)) { 264 console.error('Build emitted standalone trace copy warnings; failing to keep CI deterministic.') 265 process.exit(1) 266 } 267 if (typeof result.status === 'number') { 268 if (result.status === 0 && repairStandaloneNextMetadata(process.cwd())) { 269 console.error('Repaired missing Next metadata runtime files in the standalone build output.') 270 } 271 if (result.status === 0 && repairStandalonePublicAndStatic(process.cwd())) { 272 console.error('Copied public/ and .next/static/ into standalone build output.') 273 } 274 if (result.status === 0 && repairStandaloneCssTreeData(process.cwd())) { 275 console.error('Copied css-tree/data/ into standalone build output.') 276 } 277 if (result.status === 0 && repairStandaloneBrowserMcpRuntime(process.cwd())) { 278 console.error('Copied Playwright MCP runtime packages into standalone build output.') 279 } 280 process.exit(result.status) 281 } 282 if (result.signal) { 283 process.kill(process.pid, result.signal) 284 return 285 } 286 process.exit(1) 287 } 288 289 if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { 290 main() 291 }