239 lines
8.2 KiB
JavaScript
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)));
|