Files
krow_employer/scripts/dev.mjs
2026-09-07 20:36:18 +05:30

239 lines
8.2 KiB
JavaScript

#!/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 <krow-backend> && 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)));