import { spawn, spawnSync } from 'node:child_process' import { promises as fs } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const uiRoot = path.resolve(__dirname, '..') const repoRoot = path.resolve(uiRoot, '..') const lockPath = path.join(uiRoot, 'node_modules', '.cache', 'familiaros-website-dev-server.lock.json') const defaultMaxOldSpaceMb = 1536 function processIsAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false try { process.kill(pid, 0) return true } catch { return false } } function parsePowerShellJson(stdout) { const text = String(stdout ?? '').trim() if (!text) return [] try { const parsed = JSON.parse(text) return Array.isArray(parsed) ? parsed : [parsed] } catch { return [] } } function normalizeForSearch(value) { return String(value ?? '').replaceAll('\\', '/').toLowerCase() } function queryProcessMetadata(pid) { if (!Number.isInteger(pid) || pid <= 0) return null if (process.platform === 'win32') { const script = [ "$ErrorActionPreference = 'SilentlyContinue'", `$proc = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object ProcessId, Name, CommandLine, ExecutablePath`, '$proc | ConvertTo-Json -Compress', ].join('; ') const result = spawnSync( 'powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { encoding: 'utf8' } ) return parsePowerShellJson(result.stdout)[0] ?? null } const result = spawnSync('ps', ['-p', String(pid), '-o', 'comm=', '-o', 'args='], { encoding: 'utf8', }) const output = String(result.stdout ?? '').trim() if (!output) return null const [firstLine] = output.split(/\r?\n/) const trimmed = firstLine.trim() if (!trimmed) return null const [name = '', ...rest] = trimmed.split(/\s+/) return { ProcessId: pid, Name: path.basename(name), CommandLine: rest.join(' '), ExecutablePath: name, } } function isExpectedDevProcessName(name) { const normalized = String(name ?? '').trim().toLowerCase() return [ 'node', 'node.exe', 'cmd.exe', 'npm', 'npm.cmd', 'vite', 'vite.cmd', 'esbuild', 'esbuild.exe', 'sh', 'bash', 'powershell', 'powershell.exe', ].includes(normalized) } function isOwnedDevProcess(pid) { if (!processIsAlive(pid)) return false const metadata = queryProcessMetadata(pid) if (!metadata) return false const name = String(metadata.Name ?? '') if (!isExpectedDevProcessName(name)) return false const haystack = normalizeForSearch([ metadata.CommandLine, metadata.ExecutablePath, name, ].filter(Boolean).join(' ')) const uiRootToken = normalizeForSearch(uiRoot) const repoRootToken = normalizeForSearch(repoRoot) const pathMatches = haystack.includes(uiRootToken) || haystack.includes(repoRootToken) const commandMatches = haystack.includes('vite') || haystack.includes('dev:raw') || haystack.includes('dev-singleton.mjs') || haystack.includes('familiaros-website') return pathMatches || commandMatches } async function readLock() { try { const raw = await fs.readFile(lockPath, 'utf8') return JSON.parse(raw) } catch { return null } } async function writeLock(payload) { await fs.mkdir(path.dirname(lockPath), { recursive: true }) await fs.writeFile(lockPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') } async function clearLock() { try { await fs.unlink(lockPath) } catch { // Ignore missing lock file. } } function killProcessTree(pid) { if (!Number.isInteger(pid) || pid <= 0) return if (process.platform === 'win32') { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) return } try { process.kill(-pid, 'SIGTERM') } catch { try { process.kill(pid, 'SIGTERM') } catch { // Ignore missing process. } } } function viteBinaryPath() { return process.platform === 'win32' ? path.join(uiRoot, 'node_modules', '.bin', 'vite.cmd') : path.join(uiRoot, 'node_modules', '.bin', 'vite') } function npmBinaryPath() { return process.platform === 'win32' ? 'npm.cmd' : 'npm' } function shellEscape(value) { if (/^[a-zA-Z0-9_./:=+-]+$/.test(value)) return value return `"${String(value).replaceAll('"', '\\"')}"` } function resolveNodeOptions() { const existing = String(process.env.NODE_OPTIONS ?? '').trim() const hasMaxOldSpace = /\b--max-old-space-size=\d+\b/.test(existing) const requestedFromEnv = Number(process.env.FAMILIAROS_WEBSITE_NODE_MAX_OLD_SPACE_MB ?? defaultMaxOldSpaceMb) const requested = Number.isFinite(requestedFromEnv) && requestedFromEnv > 0 ? Math.max(512, Math.floor(requestedFromEnv)) : defaultMaxOldSpaceMb if (hasMaxOldSpace) return existing return [existing, `--max-old-space-size=${requested}`].filter(Boolean).join(' ') } async function main() { const existing = await readLock() const existingPid = existing?.childPid ?? existing?.wrapperPid ?? null if (existingPid && isOwnedDevProcess(existingPid)) { console.error( `[dev-singleton] Refusing to start duplicate dev server. Existing PID ${existingPid} is active.` ) console.error('[dev-singleton] Use `npm run dev:clean` to stop stale Vite/esbuild/node processes.') process.exit(1) } if (existingPid && processIsAlive(existingPid) && !isOwnedDevProcess(existingPid)) { console.warn( `[dev-singleton] Ignoring stale lock PID ${existingPid}; the live process no longer looks like this repo's dev server.` ) } if (existing) { await clearLock() } const args = process.argv.slice(2) const nodeOptions = resolveNodeOptions() const npmBin = npmBinaryPath() const command = [npmBin, 'run', 'dev:raw', '--', ...args].map(shellEscape).join(' ') const child = spawn(command, { cwd: uiRoot, stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: nodeOptions, }, detached: process.platform !== 'win32', shell: true, }) await writeLock({ wrapperPid: process.pid, childPid: child.pid ?? null, startedAt: new Date().toISOString(), command: `vite ${args.join(' ')}`.trim(), }) let shuttingDown = false const shutdown = async (exitCode = 0) => { if (shuttingDown) return shuttingDown = true if (child.pid) { killProcessTree(child.pid) } await clearLock() process.exit(exitCode) } process.on('SIGINT', () => { void shutdown(0) }) process.on('SIGTERM', () => { void shutdown(0) }) child.on('error', async (error) => { console.error(`[dev-singleton] Failed to start Vite: ${error.message}`) await clearLock() process.exit(1) }) child.on('exit', async (code) => { await clearLock() process.exit(code ?? 0) }) } main().catch(async (error) => { console.error(`[dev-singleton] ${error.message}`) await clearLock() process.exit(1) })