266 lines
6.9 KiB
JavaScript
Executable file
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)
|
|
})
|