openpetswithchatandmcp/website/scripts/dev-singleton.mjs

266 lines
6.9 KiB
JavaScript
Executable file

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)
})