import net from 'node:net'; import path from 'node:path'; import {spawn} from 'node:child_process'; import {existsSync, readFileSync} from 'node:fs'; import {fileURLToPath, pathToFileURL} from 'node:url'; const repoRoot = fileURLToPath(new URL('../', import.meta.url)); const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url)); const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url)); const serverTsxCliPath = fileURLToPath( new URL('../server-node/node_modules/tsx/dist/cli.mjs', import.meta.url), ); const serverTsxLoaderPath = fileURLToPath( new URL('../server-node/node_modules/tsx/dist/loader.mjs', import.meta.url), ); const serverTsxLoaderUrl = pathToFileURL(serverTsxLoaderPath).href; const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url)); const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url)); const bundledNodePath = fileURLToPath( new URL('../.tools/node-v22.22.2-win-x64/node.exe', import.meta.url), ); const bundledNpmCliPath = fileURLToPath( new URL('../.tools/node-v22.22.2-win-x64/node_modules/npm/bin/npm-cli.js', import.meta.url), ); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative'; const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev'; function parseEnvContents(contents) { return contents .split(/\r?\n/u) .reduce((envMap, rawLine) => { const line = rawLine.trim(); if (!line || line.startsWith('#')) { return envMap; } const separatorIndex = line.indexOf('='); if (separatorIndex < 0) { return envMap; } const key = line.slice(0, separatorIndex).trim(); let value = line.slice(separatorIndex + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } envMap[key] = value; return envMap; }, {}); } function readEnvFile(filePath) { if (!existsSync(filePath)) { return {}; } return parseEnvContents(readFileSync(filePath, 'utf8')); } function resolveDatabaseProbeTarget(databaseUrl) { const trimmed = databaseUrl.trim(); if (!trimmed || !/^postgres(?:ql)?:\/\//u.test(trimmed)) { return null; } try { const url = new URL(trimmed); return { host: url.hostname === '0.0.0.0' ? '127.0.0.1' : url.hostname, port: Number(url.port || 5432), }; } catch { return null; } } function checkTcpReachable(target, timeoutMs = 1500) { return new Promise((resolve) => { const socket = net.createConnection(target); let settled = false; const finish = (result) => { if (settled) { return; } settled = true; socket.destroy(); resolve(result); }; socket.setTimeout(timeoutMs); socket.once('connect', () => finish(true)); socket.once('timeout', () => finish(false)); socket.once('error', () => finish(false)); }); } function resolveServerTarget(serverAddr) { const trimmed = serverAddr.trim(); if (!trimmed) { return 'http://127.0.0.1:8081'; } if (/^https?:\/\//u.test(trimmed)) { try { const url = new URL(trimmed); if (url.hostname === '0.0.0.0') { url.hostname = '127.0.0.1'; } return url.toString().replace(/\/$/u, ''); } catch { return trimmed.replace(/\/$/u, ''); } } if (trimmed.startsWith(':')) { return `http://127.0.0.1${trimmed}`; } if (trimmed.startsWith('0.0.0.0:')) { return `http://127.0.0.1:${trimmed.slice('0.0.0.0:'.length)}`; } return `http://${trimmed}`; } function redactDatabaseUrl(databaseUrl) { const trimmed = `${databaseUrl || ''}`.trim(); if (!trimmed) { return '[missing]'; } if (trimmed.startsWith('pg-mem://')) { return trimmed; } try { const url = new URL(trimmed); const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres'; const portSuffix = url.port ? `:${url.port}` : ''; return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`; } catch { return '[configured]'; } } function resolvePathEnvKey(envMap) { return Object.keys(envMap).find((key) => key.toLowerCase() === 'path') || 'PATH'; } function prependEnvPath(envMap, nextEntry) { const pathKey = resolvePathEnvKey(envMap); const currentValue = envMap[pathKey] || ''; const normalizedEntry = path.resolve(nextEntry); const segments = currentValue .split(path.delimiter) .map((entry) => entry.trim()) .filter(Boolean) .filter((entry) => { try { return path.resolve(entry) !== normalizedEntry; } catch { return entry !== nextEntry; } }); envMap[pathKey] = [nextEntry, ...segments].join(path.delimiter); } const exampleEnv = readEnvFile(envExamplePath); const localEnv = readEnvFile(envLocalPath); const mergedEnv = { ...exampleEnv, ...localEnv, ...process.env, }; const runtimeNodePath = existsSync(bundledNodePath) ? bundledNodePath : process.execPath; const runtimeNpmCliPath = existsSync(bundledNpmCliPath) ? bundledNpmCliPath : ''; const runtimeNodeDir = path.dirname(runtimeNodePath); mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot; mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081'; mergedEnv.NODE_SERVER_TARGET = mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR); mergedEnv.DATABASE_URL = mergedEnv.DATABASE_URL || DEFAULT_DEV_DATABASE_URL; mergedEnv.VITE_DEV_HOST = mergedEnv.VITE_DEV_HOST || '127.0.0.1'; prependEnvPath(mergedEnv, runtimeNodeDir); mergedEnv.npm_config_scripts_prepend_node_path = 'true'; const exampleDatabaseUrl = `${exampleEnv.DATABASE_URL || ''}`.trim(); const localDatabaseUrl = `${localEnv.DATABASE_URL || ''}`.trim(); const processDatabaseUrl = `${process.env.DATABASE_URL || ''}`.trim(); const hasExplicitDatabaseUrl = Boolean(processDatabaseUrl) || (Boolean(localDatabaseUrl) && localDatabaseUrl !== exampleDatabaseUrl); if (!hasExplicitDatabaseUrl) { const databaseProbeTarget = resolveDatabaseProbeTarget(mergedEnv.DATABASE_URL); if (databaseProbeTarget) { const isReachable = await checkTcpReachable(databaseProbeTarget); if (!isReachable) { console.warn( `[dev:node] PostgreSQL unavailable at ${databaseProbeTarget.host}:${databaseProbeTarget.port}; falling back to ${DEV_MEMORY_DATABASE_URL} for local dev.`, ); console.warn( '[dev:node] Current session will use in-memory persistence only. Set DATABASE_URL in .env.local to restore PostgreSQL-backed runtime data.', ); mergedEnv.DATABASE_URL = DEV_MEMORY_DATABASE_URL; } } } console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`); console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`); console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`); console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)}`); console.log(`[dev:node] VITE_DEV_HOST=${mergedEnv.VITE_DEV_HOST}`); console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`); const children = new Set(); let shuttingDown = false; let pendingExitCode = 0; function stopChild(child) { if (!child || child.exitCode !== null) { return; } child.kill('SIGTERM'); setTimeout(() => { if (child.exitCode === null) { child.kill('SIGKILL'); } }, 2000).unref(); } function stopAllChildren() { for (const child of children) { stopChild(child); } } function finalizeExit(code = 0) { pendingExitCode = code; if (children.size === 0) { process.exit(pendingExitCode); } } function requestShutdown(code = 0) { if (!shuttingDown) { shuttingDown = true; pendingExitCode = code; stopAllChildren(); } finalizeExit(pendingExitCode); } function registerChild(name, child, siblingProvider) { children.add(child); child.on('error', (error) => { console.error(`[dev:node] ${name} failed to start`, error); requestShutdown(1); }); child.on('exit', (code, signal) => { children.delete(child); if (!shuttingDown) { const resolvedExitCode = code ?? 1; const signalSuffix = signal ? ` (${signal})` : ''; console.error( `[dev:node] ${name} exited with code ${resolvedExitCode}${signalSuffix}`, ); const sibling = siblingProvider(); if (sibling) { stopChild(sibling); } requestShutdown(resolvedExitCode); return; } finalizeExit(pendingExitCode); }); } const serverProcess = existsSync(serverTsxLoaderPath) ? spawn(runtimeNodePath, ['--watch', '--import', serverTsxLoaderUrl, 'src/server.ts'], { cwd: serverRoot, env: mergedEnv, stdio: 'inherit', }) : existsSync(serverTsxCliPath) ? spawn(runtimeNodePath, [serverTsxCliPath, 'watch', 'src/server.ts'], { cwd: serverRoot, env: mergedEnv, stdio: 'inherit', }) : runtimeNpmCliPath ? spawn(runtimeNodePath, [runtimeNpmCliPath, 'run', 'dev'], { cwd: serverRoot, env: mergedEnv, stdio: 'inherit', }) : spawn(npmCommand, ['run', 'dev'], { cwd: serverRoot, env: mergedEnv, shell: process.platform === 'win32', stdio: 'inherit', }); const viteProcess = spawn( runtimeNodePath, [viteCliPath, '--port=3000', `--host=${mergedEnv.VITE_DEV_HOST}`], { cwd: repoRoot, env: mergedEnv, stdio: 'inherit', }, ); registerChild('node server', serverProcess, () => viteProcess); registerChild('vite dev server', viteProcess, () => serverProcess); process.on('SIGINT', () => { console.log('[dev:node] received SIGINT, shutting down...'); requestShutdown(0); }); process.on('SIGTERM', () => { console.log('[dev:node] received SIGTERM, shutting down...'); requestShutdown(0); });