Files
Maciej Lisiewski 7062229090 chore(js): switch the workspaces from npm to pnpm
pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.

Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).

The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.

User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
2026-11-06 04:08:24 +01:00

162 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Pure dashboard compilation; icon/dependency preparation belongs to callers.
import { cpSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { frontendArgs, isMain, productOutput, repoRoot, withProduct, workspaceTool } from './frontend-common.mjs'
import { recordProduct, buildInputs } from './freshness.mjs'
// --- Bounded build (#63338) -------------------------------------------------
// `pnpm run build` drives Vite 8's Rust-native bundler (Rolldown), whose rayon
// thread pool saturates every CPU on small hosts (200%+ `top` readings, OOMs
// and frozen SSH sessions on 2–4 vCPU VPS boxes). Vite exposes no worker knob,
// but Rolldown's native binding honours RAYON_NUM_THREADS, and V8's heap is
// capped with --max-old-space-size. `--max-old-space-size` cannot affect an
// already-running process, so the default heap cap is enforced with a single
// re-exec (spawnSync → exit); the thread cap is set before vite is imported.
// User-set env (NODE_OPTIONS heap flag, RAYON_NUM_THREADS) always wins, and
// HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE / HERMES_WEB_BUILD_THREADS tailor the
// caps; HERMES_WEB_BUILD_LIGHT=1 tightens them (1 thread, minimum heap) for
// hosts that cannot spare full CPU during the build.
const MIN_HEAP_MB = 1024
const MAX_HEAP_MB = 4096
const MAX_THREADS = 8
const TRUTHY = new Set(['1', 'true', 'yes', 'on'])
const webBuildLight = () => TRUTHY.has(String(process.env.HERMES_WEB_BUILD_LIGHT ?? '').toLowerCase())
function availableCores() {
try {
return Math.max(1, os.availableParallelism?.() ?? (os.cpus().length || 1))
} catch {
return 1
}
}
// Half the cores (the dashboard graph is small), clamped to [1, MAX_THREADS].
function boundedThreads() {
return Math.max(1, Math.min(MAX_THREADS, Math.ceil(availableCores() / 2)))
}
// cgroup v2/v1 memory limit in MB, or null when unconstrained.
function readCgroupLimitMb() {
for (const cgroupPath of ['/sys/fs/cgroup/memory.max', '/sys/fs/cgroup/memory/memory.limit_in_bytes']) {
try {
const raw = String(readFileSync(cgroupPath, 'utf-8')).trim()
if (raw === 'max' || !/^\d+$/.test(raw)) continue
const value = Number(raw)
if (value <= 0 || value >= 2 ** 50) return null // the v1 "unlimited" sentinel
return Math.floor(value / (1024 * 1024))
} catch { /* unconstrained or unsupported */ }
}
return null
}
// 75% of the cgroup limit when constrained (mirrors the TUI launcher's heap
// sizing), else the conservative ceiling. Never below the GC-thrash floor.
function boundedHeapMb() {
const limitMb = readCgroupLimitMb()
const sized = limitMb ? Math.floor(limitMb * 0.75) : MAX_HEAP_MB
return Math.max(MIN_HEAP_MB, Math.min(MAX_HEAP_MB, sized))
}
function applyThreadCap() {
if (process.env.RAYON_NUM_THREADS) return
const explicit = Number.parseInt(process.env.HERMES_WEB_BUILD_THREADS ?? '', 10)
const threads = Number.isFinite(explicit) && explicit > 0 ? explicit
: (webBuildLight() ? 1 : boundedThreads())
process.env.RAYON_NUM_THREADS = String(Math.max(1, threads))
}
// Re-exec with the heap cap appended to NODE_OPTIONS unless the user already
// set one. Runs only for direct invocation (isMain), before vite is imported.
async function ensureHeapCapForFreshProcess() {
const tokens = (process.env.NODE_OPTIONS ?? '').split(/\s+/).filter(Boolean)
if (tokens.some(token => token.startsWith('--max-old-space-size='))) return
const explicit = Number.parseInt(process.env.HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE ?? '', 10)
const heapMb = Number.isFinite(explicit) && explicit > 0
? Math.max(MIN_HEAP_MB, explicit)
: (webBuildLight() ? MIN_HEAP_MB : boundedHeapMb())
tokens.push(`--max-old-space-size=${heapMb}`)
const { spawnSync } = await import('node:child_process')
const result = spawnSync(process.execPath, process.argv.slice(1), {
stdio: 'inherit',
env: { ...process.env, NODE_OPTIONS: tokens.join(' ') },
})
process.exit(result.status ?? 1)
}
if (isMain(import.meta.url)) {
applyThreadCap()
await ensureHeapCapForFreshProcess()
}
function typecheck(ts, root, scratch) {
const diagnostics = []
const host = ts.createSolutionBuilderHost(ts.sys, undefined, diagnostic => diagnostics.push(diagnostic))
// Use the existing tsc -b project graph, with build state isolated from the
// prepared source. force also prevents stale source buildinfo skipping checks.
const buildInfo = new Map()
host.writeFile = (file, data) => {
if (!file.endsWith('.tsbuildinfo')) throw new Error(`Unexpected TypeScript emit: ${file}`)
if (!buildInfo.has(file)) buildInfo.set(file, path.join(scratch, `ts-${buildInfo.size}.tsbuildinfo`))
ts.sys.writeFile(buildInfo.get(file), data)
}
const status = ts.createSolutionBuilder(host, [path.join(root, 'tsconfig.json')], { force: true }).build()
if (status !== 0) {
const message = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCurrentDirectory: () => root,
getCanonicalFileName: file => file,
getNewLine: () => '\n'
})
throw new Error(`TypeScript build failed (${status})\n${message}`)
}
}
export async function buildWeb(options) {
const { source, out } = productOutput(options.source, options.out, ['web', 'apps/shared', 'node_modules'])
if (!options.icons) throw new Error('Prepared icons are required (--icons)')
const publicIcons = path.resolve(options.icons, 'web/public')
const favicon = path.join(publicIcons, 'favicon.ico')
if (!existsSync(favicon) || !statSync(favicon).isFile()) throw new Error(`Missing prepared icon: ${favicon}`)
// Icon inputs can be outside source but are still read-only build inputs.
productOutput(options.icons, out, ['web/public'])
const root = path.join(source, 'web')
const inputs = buildInputs(source, 'web', { icons: publicIcons })
const tsModule = await import(pathToFileURL(workspaceTool(source, 'web', 'typescript')).href)
const { build } = await import(pathToFileURL(workspaceTool(source, 'web', 'vite')).href)
await withProduct(out, async (product, scratch) => {
typecheck(tsModule.default ?? tsModule, root, scratch)
const publicDir = path.join(scratch, 'public')
mkdirSync(publicDir)
if (existsSync(path.join(root, 'public'))) cpSync(path.join(root, 'public'), publicDir, { recursive: true })
cpSync(publicIcons, publicDir, { recursive: true })
await build({
root,
configFile: path.join(root, 'vite.config.ts'),
// Vite's default config bundler writes into source node_modules/.vite-temp.
configLoader: 'runner',
cacheDir: path.join(scratch, 'vite-cache'),
publicDir,
build: { outDir: product, emptyOutDir: true }
})
if (!existsSync(path.join(product, 'index.html'))) throw new Error('Web build did not produce index.html')
recordProduct({ source, product: 'web', out: product, inputs })
}, { source })
return { out, index: path.join(out, 'index.html') }
}
if (isMain(import.meta.url)) {
try {
const options = process.argv.length === 2
? { source: repoRoot, icons: repoRoot, out: path.join(repoRoot, 'hermes_cli/web_dist') }
: frontendArgs(process.argv.slice(2), { icons: { type: 'string' } })
const result = await buildWeb(options)
console.log(`built ${result.index}`)
} catch (error) {
console.error(error)
process.exitCode = 1
}
}