#!/usr/bin/env node /** * Development launcher. * * Why this exists * --------------- * `vite` alone serves the frontend and proxies `/api` and `/health` to the Go * API (see vite.config.js). Nothing in that arrangement checks that the API is * actually running. When it is not, the proxy's connection is refused and every * call — `POST /api/v1/auth/login` first of all — comes back as a bare * `502 Bad Gateway` in the browser. The status says "the gateway got a bad * response", which points at the backend's *response*; the truth is that there * was no backend to respond to at all. That misdirection is what made this * recur: the frontend looks healthy, so the missing process is the last thing * anyone checks. * * So the dev server no longer starts without a reachable API. If the proxy * target is loopback and nothing is listening, this starts the API, waits for * `/health`, and only then hands over to Vite. If it cannot, it says exactly * what is wrong instead of letting Vite boot into a guaranteed 502. * * Escape hatches: * npm run dev:vite frontend only, no API check (offline UI work) * KROW_BACKEND_DIR=/path/to the API repo, if it is not ../krow-backend * KROW_SKIP_API_CHECK=1 skip the preflight entirely */ import { spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const c = { dim: (s) => `\x1b[2m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`, }; const log = (m) => console.log(`${c.dim('[dev]')} ${m}`); const warn = (m) => console.log(`${c.yellow('[dev]')} ${m}`); const err = (m) => console.error(`${c.red('[dev]')} ${m}`); /** * Read a dotenv file the way Vite's `loadEnv` does for our purposes: plain * KEY=VALUE, `#` comments, optional surrounding quotes. Deliberately minimal — * this only needs to find one variable, and pulling in a parser to do it would * be a dependency for nothing. */ function readEnvFile(file) { if (!existsSync(file)) return {}; const out = {}; for (const raw of readFileSync(file, 'utf8').split('\n')) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const eq = line.indexOf('='); if (eq === -1) continue; let value = line.slice(eq + 1).trim().replace(/\s+#.*$/, ''); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } out[line.slice(0, eq).trim()] = value; } return out; } // Same precedence and the same default as vite.config.js. If these two ever // disagree the preflight would check one host while the proxy forwards to // another, which is worse than no check at all. const fileEnv = { ...readEnvFile(path.join(ROOT, '.env')), ...readEnvFile(path.join(ROOT, '.env.local')), }; const TARGET = process.env.VITE_API_PROXY_TARGET || fileEnv.VITE_API_PROXY_TARGET || 'https://mcp.krowforce.com'; let targetURL; try { targetURL = new URL(TARGET); } catch { err(`VITE_API_PROXY_TARGET is not a valid URL: ${JSON.stringify(TARGET)}`); process.exit(1); } const HEALTH = new URL('/health', targetURL).href; const IS_LOCAL = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(targetURL.hostname); const BACKEND_DIR = process.env.KROW_BACKEND_DIR || path.resolve(ROOT, '..', 'krow-backend'); async function apiIsUp(timeoutMs = 2500) { try { const res = await fetch(HEALTH, { signal: AbortSignal.timeout(timeoutMs) }); return res.ok; } catch { return false; } } /** The API process this script started, if any. Left null when one was already * running, so that Ctrl-C never kills a server it did not launch. */ let api = null; const apiLog = []; function startApi() { // `make run` is `cd go-api && go run ./cmd/api`; running it directly skips a // make → go → binary process layer that only makes the tree harder to kill. api = spawn('go', ['run', './cmd/api'], { cwd: path.join(BACKEND_DIR, 'go-api'), stdio: ['ignore', 'pipe', 'pipe'], // Own process group, so shutdown can signal `go run` *and* the binary it // execs. Signalling only the parent leaves the child holding port 8080, // and the next `npm run dev` then fails to bind for reasons that look // nothing like the original problem. detached: true, }); const capture = (chunk) => { for (const line of String(chunk).split('\n')) { if (line.trim()) apiLog.push(line); } if (apiLog.length > 200) apiLog.splice(0, apiLog.length - 200); }; api.stdout.on('data', capture); api.stderr.on('data', capture); api.on('error', (e) => { err(`could not launch the API: ${e.message}`); if (e.code === 'ENOENT') err('`go` is not on PATH. Install Go, or start the API yourself.'); }); } function tailApiLog(n = 15) { if (!apiLog.length) return; err('last output from the API:'); for (const line of apiLog.slice(-n)) console.error(` ${c.dim(line)}`); } async function ensureApi() { if (process.env.KROW_SKIP_API_CHECK === '1') { warn('KROW_SKIP_API_CHECK=1 — skipping the API preflight. /api calls will 502 if it is down.'); return; } if (await apiIsUp()) { log(`API reachable at ${c.bold(TARGET)}`); return; } // A remote target is not ours to start, and quietly proceeding is fine — // it may simply be gated, or briefly redeploying. if (!IS_LOCAL) { warn(`API at ${c.bold(TARGET)} did not answer /health. Starting Vite anyway; /api may 502.`); return; } if (!existsSync(path.join(BACKEND_DIR, 'go-api'))) { err(`Nothing is listening on ${c.bold(TARGET)} and the API repo was not found.`); err(`Looked in: ${BACKEND_DIR}`); err('Set KROW_BACKEND_DIR to the krow-backend checkout, or start the API yourself:'); err(c.bold(' cd && make run')); process.exit(1); } log(`Nothing on ${c.bold(TARGET)} — starting the API from ${BACKEND_DIR}`); startApi(); // A cold `go run` compiles the module first, which is comfortably the slowest // step here; 90s is sized for that, not for the HTTP request. const deadline = Date.now() + 90_000; let announced = false; while (Date.now() < deadline) { if (api?.exitCode !== null && api?.exitCode !== undefined) { err(`the API exited with code ${api.exitCode} before it became healthy.`); tailApiLog(); process.exit(1); } if (await apiIsUp(1500)) { log(c.green(`API is up at ${TARGET}`)); return; } if (!announced && Date.now() > deadline - 80_000) { log(c.dim('compiling the API (first run takes a moment)...')); announced = true; } await new Promise((r) => setTimeout(r, 1000)); } err(`the API did not become healthy within 90s (${HEALTH}).`); tailApiLog(); err('Common causes: PostgreSQL is not running, or krow-backend/.env is wrong.'); err(c.bold(' pg_isready') + c.dim(' # should say "accepting connections"')); shutdown(1); } let shuttingDown = false; function shutdown(code) { if (shuttingDown) return; shuttingDown = true; if (api?.pid) { log('stopping the API it started...'); // Negative pid signals the whole group — see the `detached` note above. try { process.kill(-api.pid, 'SIGTERM'); } catch { /* already gone */ } } // Vite is a child of this process, so a Ctrl-C in a terminal reaches it on // its own via the foreground process group. A bare SIGTERM to this script // does not, and the orphan keeps holding 5173 — after which the next // `npm run dev` quietly moves to 5174 and any bookmark, or anything relying // on the port, points at a dead server. if (vite?.pid && vite.exitCode === null) { try { vite.kill('SIGTERM'); } catch { /* already gone */ } } process.exit(code); } process.on('SIGINT', () => shutdown(0)); process.on('SIGTERM', () => shutdown(0)); await ensureApi(); let vite = null; vite = spawn( process.execPath, [path.join(ROOT, 'node_modules', 'vite', 'bin', 'vite.js'), ...process.argv.slice(2)], { cwd: ROOT, stdio: 'inherit' }, ); vite.on('exit', (code, signal) => shutdown(signal ? 0 : (code ?? 0)));