Ctrl K
ComunidadGuías…

apps/web/scripts/run-dev-stack.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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>([^<]+)<\/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:<port>`` and ``http://localhost:<port>`` (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);
});