/ scripts / run-next-build.mjs
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  }