#!/usr/bin/env node /** * Starts the Weir API, waits until it actually serves traffic, then starts Vite. * Avoids the browser hitting the proxy before the API has finished startup/migrations * (which produced HTTP 500 / "Cannot reach the API" on every dev refresh). * * If something (e.g. a Cursor terminal) already left an API listening on the dev API port * with a healthy ``/health``, we **reuse** it and only start Vite — otherwise a second API * would bind-fail and the web UI would not come up. (``npm run dev`` in ``package.json`` stops * the default API/web ports first so a leftover process is not reused by accident.) * Set ``WEIR_DEV_STACK_ALWAYS_SPAWN_API=1`` to force spawning the API child anyway. * * If ``/health`` works but required routes (e.g. * ``/api/v1/system/suite-configuration-bundle``) return **404**, the default port usually holds an * **older** Weir build. The local folder picker route is also checked because Windows can leave * an unkillable stale listener behind after desktop/dev restarts. We then start this repo's API on * the **next free TCP port** and set ``WEIR_DEV_STACK_API_PROXY_TARGET`` for Vite so the UI still * works without manually killing the stale process. * * If the **web** port from ``dev-ports.json`` is busy (leftover Vite), the next free port is used * and ``WEIR_DEV_WEB_PORT`` is set for Vite (see ``vite.config.ts``). Run ``npm run dev:stop-web`` * to free the default port, or use the URL printed in the log. * * The API validates browser ``Origin``/``Referer`` against ``WEIR_TRUSTED_BROWSER_ORIGINS`` or * ``WEIR_CORS_ORIGINS``. ``localhost`` and ``127.0.0.1`` are different origins, so this script * merges both into the spawned API env to avoid ``403 Origin not allowed`` on login. */ import { spawn } from "node:child_process"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import http from "node:http"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const webDir = path.join(__dirname, ".."); const repoRoot = path.resolve(webDir, "..", ".."); const apiScript = path.join(__dirname, "run-api-dev.mjs"); const viteEntry = path.join(webDir, "node_modules", "vite", "bin", "vite.js"); const portsPath = path.join(repoRoot, "scripts", "dev-ports.json"); const serverPropsPath = path.join(repoRoot, "apps", "server", "Directory.Build.props"); // Recorded so scripts/stop-dev-web-port.mjs can find and verify *this* worktree's Vite process // later, instead of stopping whatever else happens to be listening on the web port (another // worktree's Vite, or an unrelated program — see that script's header). const webPidFilePath = path.join(repoRoot, ".dev-web.pid"); if (!existsSync(viteEntry)) { console.error(`Missing ${viteEntry}. Run npm install (or npm ci) in apps/web.`); process.exit(1); } function removeWebPidFile() { try { rmSync(webPidFilePath, { force: true }); } catch { /* ignore — best-effort cleanup */ } } function readDevPorts() { const raw = readFileSync(portsPath, "utf8"); return JSON.parse(raw).development; } function readExpectedApiVersion() { try { const raw = readFileSync(serverPropsPath, "utf8"); const match = raw.match(/([^<]+)<\/WeirVersion>/); return match?.[1]?.trim() || null; } catch { return null; } } function apiConnectTarget(apiHost, apiPort) { const host = apiHost === "0.0.0.0" || apiHost === "::" || apiHost === "[::]" ? "127.0.0.1" : apiHost; return { hostname: host, port: apiPort }; } /** ``http://127.0.0.1:`` and ``http://localhost:`` (browsers treat these as distinct). */ function devWebBrowserOrigins(webPort) { return [`http://127.0.0.1:${webPort}`, `http://localhost:${webPort}`]; } function mergeCsvUrlEnv(existingCsv, additions) { const seen = new Set(); const out = []; const push = (raw) => { const s = String(raw || "").trim(); if (!s || seen.has(s)) { return; } seen.add(s); out.push(s); }; for (const part of String(existingCsv || "").split(",")) { push(part); } for (const a of additions) { push(a); } return out.join(","); } /** * Ensures the dev UI origin (both hostname spellings) is trusted and allowed by CORS for the * spawned API child process. */ function buildApiDevOriginEnvPatch(webPort) { const extra = devWebBrowserOrigins(webPort); const cors = mergeCsvUrlEnv(process.env.WEIR_CORS_ORIGINS, extra); const patch = { WEIR_CORS_ORIGINS: cors }; const trustedRaw = (process.env.WEIR_TRUSTED_BROWSER_ORIGINS || "").trim(); if (trustedRaw.length > 0) { patch.WEIR_TRUSTED_BROWSER_ORIGINS = mergeCsvUrlEnv(process.env.WEIR_TRUSTED_BROWSER_ORIGINS, extra); } return patch; } /** One-shot probe: same success rule as the API wait loop (2xx–4xx on ``/health``). */ function probeApiAlreadyServing(apiHost, apiPort) { const { hostname, port } = apiConnectTarget(apiHost, apiPort); return new Promise((resolve) => { const req = http.request( { hostname, port, path: "/health", method: "GET", timeout: 2000 }, (res) => { res.resume(); resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 500); }, ); req.on("error", () => resolve(false)); req.on("timeout", () => { req.destroy(); resolve(false); }); req.end(); }); } /** * Configuration bundle route exists on current API builds; unauthenticated requests may return * 401/403, but **404** means the backend is stale and missing export/restore endpoints. */ function probeSuiteConfigurationBundleRouteNotStale(apiHost, apiPort) { const { hostname, port } = apiConnectTarget(apiHost, apiPort); return new Promise((resolve) => { const req = http.request( { hostname, port, path: "/api/v1/system/suite-configuration-bundle", method: "GET", timeout: 3000, }, (res) => { res.resume(); resolve(res.statusCode !== 404); }, ); req.on("error", () => { console.error( "[dev-stack] Could not probe suite configuration-bundle route (network error) — assuming API is OK.", ); resolve(true); }); req.on("timeout", () => { req.destroy(); console.error( "[dev-stack] Timed out probing suite configuration-bundle route — assuming API is OK.", ); resolve(true); }); req.end(); }); } /** * Local directory browser backs Browse buttons; **404** means Vite would proxy Browse clicks to an * old API build. */ function probeDirectoryBrowserRouteNotStale(apiHost, apiPort) { const { hostname, port } = apiConnectTarget(apiHost, apiPort); return new Promise((resolve) => { const req = http.request( { hostname, port, path: "/api/v1/system/directories", method: "GET", timeout: 3000, }, (res) => { res.resume(); resolve(res.statusCode !== 404); }, ); req.on("error", () => { console.error( "[dev-stack] Could not probe directory browser route (network error) - assuming API is OK.", ); resolve(true); }); req.on("timeout", () => { req.destroy(); console.error( "[dev-stack] Timed out probing directory browser route - assuming API is OK.", ); resolve(true); }); req.end(); }); } function probeApiVersionMatchesSource(apiHost, apiPort, expectedVersion) { if (!expectedVersion) { return Promise.resolve(true); } const { hostname, port } = apiConnectTarget(apiHost, apiPort); return new Promise((resolve) => { const req = http.request( { hostname, port, path: "/openapi.json", method: "GET", timeout: 3000, }, (res) => { let body = ""; res.setEncoding("utf8"); res.on("data", (chunk) => { body += chunk; }); res.on("end", () => { try { const parsed = JSON.parse(body); const actual = String(parsed?.info?.version || "").trim(); resolve(actual === expectedVersion); } catch { resolve(false); } }); }, ); req.on("error", () => { console.error( "[dev-stack] Could not probe API version from /openapi.json (network error) - assuming API is OK.", ); resolve(true); }); req.on("timeout", () => { req.destroy(); console.error( "[dev-stack] Timed out probing API version from /openapi.json - assuming API is OK.", ); resolve(true); }); req.end(); }); } /** * First port in ``[startPort, inclusiveMax]`` with nothing answering ``/health`` like our API * probe (frees the slot for a new API process). */ async function findFirstTcpPortWithoutHealthyApi(apiHost, startPort, inclusiveMax) { for (let p = startPort; p <= inclusiveMax; p += 1) { if (!(await probeApiAlreadyServing(apiHost, p))) { return p; } } return null; } async function probeRequiredCurrentApiRoutes(apiHost, apiPort, expectedVersion) { const configOk = await probeSuiteConfigurationBundleRouteNotStale(apiHost, apiPort); const directoryBrowserOk = await probeDirectoryBrowserRouteNotStale(apiHost, apiPort); const versionOk = await probeApiVersionMatchesSource(apiHost, apiPort, expectedVersion); return configOk && directoryBrowserOk && versionOk; } async function findFirstHealthyCurrentApi(apiHost, startPort, inclusiveMax, expectedVersion) { for (let p = startPort; p <= inclusiveMax; p += 1) { if ( (await probeApiAlreadyServing(apiHost, p)) && (await probeRequiredCurrentApiRoutes(apiHost, p, expectedVersion)) ) { return p; } } return null; } /** True if nothing is listening (we can bind a dev server). */ function canBindHostPort(hostname, port) { return new Promise((resolve) => { const srv = net.createServer(); const finish = (ok) => { srv.removeAllListeners("error"); srv.close(() => resolve(ok)); }; srv.once("error", () => finish(false)); srv.listen(port, hostname, () => finish(true)); }); } async function resolveDevWebPort(webHost, configuredPort) { const { hostname } = apiConnectTarget(webHost, configuredPort); const max = Math.min(configuredPort + 40, 65535); for (let p = configuredPort; p <= max; p += 1) { if (await canBindHostPort(hostname, p)) { if (p !== configuredPort) { console.error( `[dev-stack] Web port ${configuredPort} is already in use — starting Vite on ${p}. ` + `Open http://127.0.0.1:${p}/ (or run \`npm run dev:stop-web\` and use ${configuredPort}).`, ); } return p; } } console.error( `[dev-stack] No free TCP port for Vite between ${configuredPort} and ${max}. ` + `From apps/web run: npm run dev:stop-web`, ); process.exit(1); } /** * @param {object} opts * @param {string} opts.hostname * @param {number} opts.port * @param {string} opts.path * @param {string} opts.label * @param {number} opts.timeoutMs * @param {import("node:child_process").ChildProcess | null} [opts.child] * @param {import("node:child_process").ChildProcess[]} [opts.killOnTimeout] * @param {string} [opts.waitHint] */ async function waitForHttpOk(opts) { const { hostname, port, path, label, timeoutMs, child, killOnTimeout, waitHint } = opts; const start = Date.now(); let lastLog = 0; const hint = waitHint ? ` ${waitHint}` : ""; console.error(`[dev-stack] Waiting for ${label} at http://${hostname}:${port}${path}${hint}…`); while (Date.now() - start < timeoutMs) { if (child && child.exitCode !== null) { if (label.includes("API")) { console.error( "[dev-stack] API exited before startup finished. Fix errors above (dotnet SDK, " + "a build error, WEIR_SESSION_SECRET, or the SQLite path under WEIR_HOME).", ); } else { console.error(`[dev-stack] ${label} exited before startup finished — see errors above.`); } process.exit(1); } const ok = await new Promise((resolve) => { const req = http.request( { hostname, port, path, method: "GET", timeout: 2000, }, (res) => { res.resume(); resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 500); }, ); req.on("error", () => resolve(false)); req.on("timeout", () => { req.destroy(); resolve(false); }); req.end(); }); if (ok) { console.error(`[dev-stack] ${label} is ready.`); return; } const elapsed = Date.now() - start; if (elapsed - lastLog > 5000) { console.error(`[dev-stack] Still waiting (${Math.round(elapsed / 1000)}s / ${Math.round(timeoutMs / 1000)}s)…`); lastLog = elapsed; } await new Promise((r) => setTimeout(r, 300)); } console.error( `[dev-stack] Timed out after ${timeoutMs / 1000}s waiting for http://${hostname}:${port}${path}.`, ); const toStop = killOnTimeout ?? (child ? [child] : []); for (const c of toStop) { try { c.kill("SIGTERM"); } catch { /* ignore */ } } process.exit(1); } async function waitForApiHttpReady(apiChild, { apiHost, apiPort, timeoutMs }) { const { hostname, port } = apiConnectTarget(apiHost, apiPort); await waitForHttpOk({ hostname, port, path: "/health", label: "API", timeoutMs, child: apiChild, killOnTimeout: [apiChild], waitHint: "(migrations can take a few seconds)", }); console.error(`[dev-stack] API is ready — starting Vite…`); } async function waitForWebHttpReady(webChild, apiChild, { webHost, webPort, timeoutMs }) { const probeHost = webHost === "0.0.0.0" || webHost === "::" || webHost === "[::]" ? "127.0.0.1" : webHost; const killOnTimeout = [webChild, apiChild].filter((c) => c != null); await waitForHttpOk({ hostname: probeHost, port: webPort, path: "/", label: "Vite (web)", timeoutMs, child: webChild, killOnTimeout, waitHint: "(first compile can take a few seconds)", }); } const node = process.execPath; const { apiHost, apiPort: portFromFile, webHost, webPort: configuredWebPort } = readDevPorts(); const apiPort = process.env.WEIR_DEV_API_PORT?.trim() ? Number(process.env.WEIR_DEV_API_PORT.trim()) : Number(portFromFile); let api = null; let web = null; let stopping = false; let reuseAlternateApi = false; function forwardStop(signal) { try { web?.kill(signal); } catch { /* ignore */ } try { api?.kill(signal); } catch { /* ignore */ } } process.on("SIGINT", () => forwardStop("SIGINT")); process.on("SIGTERM", () => forwardStop("SIGTERM")); process.on("exit", removeWebPidFile); function wireExit(name, child, other) { child.on("exit", (code, signal) => { if (stopping) { return; } stopping = true; try { if (other && !other.killed) { other.kill("SIGTERM"); } } catch { /* ignore */ } if (code !== 0 && code !== null) { console.error(`[${name}] exited with code ${code}${signal ? ` (${signal})` : ""}.`); } if (signal) { process.exit(1); } process.exit(code ?? 0); }); } const waitMs = Number(process.env.WEIR_DEV_STACK_API_WAIT_MS || 120000); const webWaitMs = Number(process.env.WEIR_DEV_STACK_WEB_WAIT_MS || 120000); async function main() { const expectedApiVersion = readExpectedApiVersion(); const forceSpawn = (process.env.WEIR_DEV_STACK_ALWAYS_SPAWN_API || "").trim() === "1"; const healthOk = await probeApiAlreadyServing(apiHost, apiPort); let configBundleRouteOk = true; let directoryBrowserRouteOk = true; let apiVersionOk = true; if (healthOk) { configBundleRouteOk = await probeSuiteConfigurationBundleRouteNotStale(apiHost, apiPort); directoryBrowserRouteOk = await probeDirectoryBrowserRouteNotStale(apiHost, apiPort); apiVersionOk = await probeApiVersionMatchesSource(apiHost, apiPort, expectedApiVersion); } /** Stale build on the configured port: required API routes missing on old server code. */ const staleDefaultApi = healthOk && (!configBundleRouteOk || !directoryBrowserRouteOk || !apiVersionOk); let bindPort = apiPort; /** When set, Vite must proxy ``/api`` here (see ``vite.config.ts``). */ let viteProxyOverride = ""; if (staleDefaultApi) { const { hostname, port: stalePort } = apiConnectTarget(apiHost, apiPort); const maxPort = Math.min(apiPort + 40, 65535); const existingCurrent = await findFirstHealthyCurrentApi(apiHost, apiPort + 1, maxPort, expectedApiVersion); const found = existingCurrent ?? (await findFirstTcpPortWithoutHealthyApi(apiHost, apiPort + 1, maxPort)); if (found == null) { console.error( `[dev-stack] http://${hostname}:${stalePort} is an outdated Weir API. ` + `No free port found between ${apiPort + 1} and ${maxPort} for a new API.\n` + `[dev-stack] Free a port or stop the old process, then run npm run dev again.`, ); process.exit(1); } bindPort = found; const { hostname: bindHost, port: bindProbePort } = apiConnectTarget(apiHost, bindPort); viteProxyOverride = `http://${bindHost}:${bindProbePort}`; if (existingCurrent != null) { reuseAlternateApi = true; console.error( `[dev-stack] Port ${stalePort} has an older Weir API build. Reusing current API on ` + `${bindProbePort} and proxying /api there.`, ); } else { console.error( `[dev-stack] Port ${stalePort} has an older Weir API build. Starting this repo's API on ` + `${bindProbePort} and proxying /api there (close the old terminal when convenient).`, ); } } if (!viteProxyOverride.trim() && bindPort !== Number(portFromFile)) { const { hostname: bindHost, port: bindProbePort } = apiConnectTarget(apiHost, bindPort); viteProxyOverride = `http://${bindHost}:${bindProbePort}`; console.error( `[dev-stack] API port is ${bindProbePort}; proxying Vite /api there instead of default ${portFromFile}.`, ); } const chosenWebPort = await resolveDevWebPort(webHost, configuredWebPort); const apiOriginEnvPatch = buildApiDevOriginEnvPatch(chosenWebPort); const canReuse = !forceSpawn && healthOk && configBundleRouteOk && directoryBrowserRouteOk && apiVersionOk; if (canReuse || reuseAlternateApi) { const reusePort = reuseAlternateApi ? bindPort : apiPort; const { hostname, port } = apiConnectTarget(apiHost, reusePort); console.error( `[dev-stack] API already serving at http://${hostname}:${port}/health — skipping API spawn. ` + `Starting Vite only. (Set WEIR_DEV_STACK_ALWAYS_SPAWN_API=1 to spawn a new API.)`, ); api = null; } else { api = spawn(node, [apiScript], { cwd: webDir, stdio: "inherit", env: { ...process.env, ...apiOriginEnvPatch, WEIR_DEV_API_PORT: String(bindPort) }, }); await waitForApiHttpReady(api, { apiHost, apiPort: bindPort, timeoutMs: waitMs }); } const viteEnv = viteProxyOverride.trim().length > 0 ? { ...process.env, WEIR_DEV_STACK_API_PROXY_TARGET: viteProxyOverride.trim(), VITE_DEV_API_PROXY_TARGET: viteProxyOverride.trim(), } : { ...process.env }; const viteEnvWithPort = { ...viteEnv, WEIR_DEV_WEB_PORT: String(chosenWebPort) }; web = spawn(node, [viteEntry], { cwd: webDir, stdio: "inherit", env: viteEnvWithPort, }); writeFileSync( webPidFilePath, JSON.stringify( { pid: web.pid, viteEntry, webHost, webPort: chosenWebPort, startedAt: new Date().toISOString() }, null, 2, ), ); await waitForWebHttpReady(web, api, { webHost, webPort: chosenWebPort, timeoutMs: webWaitMs }); console.error(`[dev-stack] Web UI (open when ready): http://127.0.0.1:${chosenWebPort}/`); console.error(`[dev-stack] Same UI via localhost: http://localhost:${chosenWebPort}/`); if (viteProxyOverride.trim().length > 0) { console.error(`[dev-stack] Vite /api → ${viteProxyOverride.trim()} (default port still has an old API).`); } if (api) { wireExit("api", api, web); wireExit("web", web, api); } else { wireExit("web", web, null); } } main().catch((err) => { console.error(err); process.exit(1); });