first commit
This commit is contained in:
238
scripts/dev.mjs
Normal file
238
scripts/dev.mjs
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/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)));
|
||||
Reference in New Issue
Block a user