// Manage a Home Assistant frontend dev server with an agent-friendly interface. // // node build-scripts/dev-server.mjs --suite [mode] [extra args] // // (no mode) Run in the foreground. // --background Start detached, wait until it is ready, print the URL // (when it has one) and pid, then exit and leave it running. // --status Report whether the suite's dev server is running. // --stop Stop a running background dev server. // --logs [--follow] Print (or follow) the background dev server log. // --fetch-translations // Fetch nightly translations before starting (app, // app-serve, demo, and gallery only). // // Extra args (for example -p or -c on app-serve) are forwarded to the underlying // script. Suites use one of two liveness models: // // health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status // endpoint each dev server exposes (see runDevServer in // build-scripts/gulp/rspack.js). // process app (yarn dev) and app-serve (yarn dev:serve): plain yarn dev has // no port, so these treat the first "Build done" log line as ready. import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { LIFECYCLE_MODE_FLAGS, acquireProcessRecord, detectCodingAgent, isProcessRecordAlive, outputLog, offerToStopProcessRecord, processStartTime, readProcessRecord, releaseProcessRecord, runCli, sleep, spawnDetachedToLog, spawnForeground, terminateDetachedProcess, terminateProcess, writeProcessRecord, } from "./managed-process.mjs"; import { buildCacheDir, describeOutputOwner, workflowLockEnv, workflowLockFile, } from "./output-lock.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), ".." ); const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp"); const developAndServeScript = path.join( repoRoot, "script", "develop_and_serve" ); const logDir = path.join(buildCacheDir, "ha-dev-server"); // Each suite names its yarn alias (for hints), a liveness model, and how to // spawn it. health suites carry a fixed port; process suites carry the log line // that means "ready" and, for app-serve, forward extra args to the script. const SUITES = new Map([ [ "e2e-app", { alias: "test:e2e:app:dev", liveness: "health", port: 8095, spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] }, }, ], [ "demo", { alias: "dev:demo", fetchTranslations: true, liveness: "health", port: 8090, spawn: { cmd: gulpBin, args: ["develop-demo"] }, }, ], [ "gallery", { alias: "dev:gallery", fetchTranslations: true, liveness: "health", port: 8100, spawn: { cmd: gulpBin, args: ["develop-gallery"] }, }, ], [ "app", { alias: "dev", fetchTranslations: true, liveness: "process", readyLog: /Build done @/, spawn: { cmd: gulpBin, args: ["develop-app"] }, }, ], [ "app-serve", { alias: "dev:serve", liveness: "process", acceptsArgs: true, fetchTranslations: true, readyLog: /Build done @/, spawn: { cmd: developAndServeScript, args: [] }, }, ], ]); // Cover a cold build on a slow machine before the server starts listening. // Override with HA_DEV_SERVER_TIMEOUT (seconds). const readyTimeoutSeconds = Number(process.env.HA_DEV_SERVER_TIMEOUT || "180"); const READY_TIMEOUT_MS = Number.isFinite(readyTimeoutSeconds) && readyTimeoutSeconds > 0 ? readyTimeoutSeconds * 1000 : 180_000; const usage = () => { const suites = [...SUITES.keys()].join("|"); process.stderr.write( `Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` + `[--background | --status | --stop | --logs [--follow]] ` + `[--fetch-translations]\n` ); }; const parseArgs = (argv) => { const args = { fetchTranslations: false, mode: "foreground", follow: false, modes: [], suite: undefined, passthrough: [], }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; switch (arg) { case "--suite": args.suite = argv[++i]; break; case "--follow": args.follow = true; break; case "--fetch-translations": args.fetchTranslations = true; break; default: if (LIFECYCLE_MODE_FLAGS.has(arg)) { args.mode = LIFECYCLE_MODE_FLAGS.get(arg); args.modes.push(arg); } else { // Anything unrecognised is forwarded to the underlying script. args.passthrough.push(arg); } } } return args; }; const translationPrebuildEnv = (token) => { const env = workflowLockEnv(token); delete env.SKIP_FETCH_NIGHTLY_TRANSLATIONS; return env; }; const suiteEnv = (token, fetchTranslations = false) => ({ ...workflowLockEnv(token), ...(fetchTranslations && { SKIP_FETCH_NIGHTLY_TRANSLATIONS: "1" }), }); const runPrebuild = (token, fetchTranslations = false) => fetchTranslations ? spawnForeground({ cmd: gulpBin, args: ["setup-and-fetch-nightly-translations"], cwd: repoRoot, env: translationPrebuildEnv(token), processGroup: true, }) : Promise.resolve(0); const logFileFor = (suite) => path.join(logDir, `${suite}.log`); const acquireSuite = (suite) => { const token = `${process.pid}-${Date.now()}-${Math.random()}`; const record = { pid: process.pid, startTime: processStartTime(process.pid), processGroup: false, kind: "dev", suite, starting: true, token, }; const result = acquireProcessRecord(workflowLockFile, record); return result.acquired ? { token } : { existing: result.existing }; }; const updateSuite = (suite, token, child, port) => { const existing = readProcessRecord(workflowLockFile); if (existing?.token !== token) { throw Error(`Dev server (${suite}) ownership was lost during startup.`); } writeProcessRecord(workflowLockFile, { ...existing, pid: child.pid, startTime: processStartTime(child.pid), processGroup: true, starting: false, port, }); }; const releaseSuite = (token) => releaseProcessRecord(workflowLockFile, token); const readSuite = (suite) => { const existing = readProcessRecord(workflowLockFile); if (existing?.kind !== "dev" || existing.suite !== suite) { return undefined; } if (isProcessRecordAlive(existing)) { return existing; } releaseSuite(existing.token); return undefined; }; const hints = (suite) => { const alias = `yarn ${SUITES.get(suite).alias}`; return ( ` Stop: ${alias} --stop\n` + ` Status: ${alias} --status\n` + ` Logs: ${alias} --logs\n` ); }; const reportProcessConflict = (suite, existing) => { if (existing?.kind === "output") { process.stdout.write( `${describeOutputOwner(existing)} already owns the app output` + `${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` ); return; } if (existing?.kind === "build") { process.stdout.write( `Frontend build already running${existing.pid ? ` (pid ${existing.pid})` : ""}. ` + "Stop it with yarn build --stop.\n" ); return; } process.stdout.write( `Dev server (${existing?.suite ?? suite}) already running` + `${urlSuffix(existing?.port)} ` + `${existing?.pid ? `(pid ${existing.pid})` : ""}\n` + hints(existing?.suite ?? suite) ); }; const stopCommandFor = (owner) => owner?.kind === "build" ? "yarn build --stop" : owner?.kind === "dev" ? `yarn ${SUITES.get(owner.suite)?.alias ?? "dev"} --stop` : undefined; const acquireSuiteForStart = async (suite) => { const lock = acquireSuite(suite); if (lock.token) { return lock; } reportProcessConflict(suite, lock.existing); if ( await offerToStopProcessRecord({ file: workflowLockFile, owner: lock.existing, ownerDescription: describeOutputOwner(lock.existing), stopCommand: stopCommandFor(lock.existing), }) ) { return acquireSuiteForStart(suite); } return { code: lock.existing?.kind === "dev" && lock.existing.suite === suite && !lock.existing.starting ? 0 : 1, }; }; // --- shared spawning and lifecycle ------------------------------------------ const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : ""); // Poll until the server is ready, the child exits, or we time out. Prints the // progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if // the child dies before it is ready (used to clear a stale pidfile). const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => { let childExited = false; child.on("exit", () => { childExited = true; }); const deadline = Date.now() + READY_TIMEOUT_MS; process.stdout.write(`Starting ${suite} dev server`); const poll = async () => { if (childExited) { process.stdout.write("\n"); process.stderr.write( `Dev server (${suite}) exited before it was ready. See ${logFile}\n` ); onExit?.(); return 1; } if (await isReady()) { process.stdout.write("\n"); process.stdout.write( `Dev server (${suite}) running${urlSuffix(port)} ` + `(pid ${child.pid})\n${hints(suite)}` ); return 0; } if (Date.now() >= deadline) { return undefined; } process.stdout.write("."); await sleep(1000); return poll(); }; const result = await poll(); if (result !== undefined) { return result; } process.stdout.write("\n"); process.stderr.write( `Dev server (${suite}) did not become ready within ${ READY_TIMEOUT_MS / 1000 }s. See ${logFile}\n` ); const stopped = await terminateProcess({ pid: child.pid, isStopped: () => childExited, }); if (stopped) { onExit?.(); } return 1; }; // Stop a running background server: SIGTERM, wait for it to go, then SIGKILL. // isStopped reports when it is gone; onStopped runs on success (pidfile cleanup). const terminate = async (suite, pid, isStopped, onStopped) => { if (!(await terminateProcess({ pid, isStopped }))) { process.stderr.write( `Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n` ); return 1; } onStopped?.(); process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`); return 0; }; // --- health liveness (port + /__ha_dev_status) ------------------------------ /** * Probe the health endpoint. Dev servers bind IPv4 or IPv6 localhost depending * on the OS, so try each; the port is "free" only if every address refuses. * @returns {Promise<{state: "ours" | "foreign" | "free", suite?: string}>} */ const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"]; const probe = async (port, timeoutMs = 1000) => { const probeHost = async (index, sawResponse) => { const host = PROBE_HOSTS[index]; if (!host) { return sawResponse ? { state: "foreign" } : { state: "free" }; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(`http://${host}:${port}/__ha_dev_status`, { signal: controller.signal, }); sawResponse = true; if (res.ok) { const body = await res.json().catch(() => null); if (body && body.server === "ha-frontend-dev") { return { state: "ours", suite: body.suite }; } } } catch { // Try the next address. } finally { clearTimeout(timer); } return probeHost(index + 1, sawResponse); }; return probeHost(0, false); }; const isHttpServing = async (port, timeoutMs = 1000) => { const probeHost = async (index) => { const host = PROBE_HOSTS[index]; if (!host) { return false; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`http://${host}:${port}`, { signal: controller.signal, }); if (response.ok) { return true; } } catch { // Try the next address. } finally { clearTimeout(timer); } return probeHost(index + 1); }; return probeHost(0); }; const runForegroundHealth = async (suite, cfg, fetchTranslations = false) => { const { port } = cfg; const lock = await acquireSuiteForStart(suite); if (!lock.token) { return lock.code; } const status = await probe(port); if (status.state === "ours") { releaseSuite(lock.token); process.stderr.write( `Port ${port} is already serving the ${status.suite ?? "unknown"} dev server.\n` ); return 1; } if (status.state === "foreign") { releaseSuite(lock.token); process.stderr.write( `Port ${port} is in use by another process; not the ${suite} dev server.\n` ); return 1; } try { const prebuildCode = await runPrebuild(lock.token, fetchTranslations); if (prebuildCode !== 0) { return prebuildCode; } return await spawnForeground({ cmd: cfg.spawn.cmd, args: cfg.spawn.args, cwd: repoRoot, env: suiteEnv(lock.token, fetchTranslations), processGroup: true, onSpawn: (child) => updateSuite(suite, lock.token, child, port), }); } finally { releaseSuite(lock.token); } }; const runBackgroundHealth = async (suite, cfg, fetchTranslations = false) => { const { port } = cfg; const lock = await acquireSuiteForStart(suite); if (!lock.token) { return lock.code; } const preflight = await probe(port); if (preflight.state === "ours") { releaseSuite(lock.token); process.stderr.write( `Port ${port} is already serving the ${preflight.suite ?? "unknown"} dev server.\n` ); return 1; } if (preflight.state === "foreign") { releaseSuite(lock.token); process.stderr.write( `Port ${port} is in use by another process; not the ${suite} dev server.\n` ); return 1; } let child; try { const prebuildCode = await runPrebuild(lock.token, fetchTranslations); if (prebuildCode !== 0) { releaseSuite(lock.token); return prebuildCode; } const logFile = logFileFor(suite); child = await spawnDetachedToLog({ cmd: cfg.spawn.cmd, args: cfg.spawn.args, cwd: repoRoot, env: suiteEnv(lock.token, fetchTranslations), logFile, }); updateSuite(suite, lock.token, child, port); return awaitReady({ suite, child, logFile, port, isReady: async () => { const status = await probe(port, 1000); return status.state === "ours" && status.suite === suite; }, onExit: () => releaseSuite(lock.token), }); } catch (err) { if (child) { await terminateDetachedProcess(child); } releaseSuite(lock.token); throw err; } }; const logIsReady = (logFile, readyLog) => { try { return readyLog.test(fs.readFileSync(logFile, "utf8")); } catch { return false; } }; // app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p // the caller passed. Used only to show a URL; liveness comes from the pidfile. const resolveServePort = (passthrough) => { for (let i = passthrough.length - 1; i >= 0; i--) { const arg = passthrough[i]; if (arg === "-p" || arg.startsWith("-p")) { const port = Number(arg === "-p" ? passthrough[i + 1] : arg.slice(2)); if (Number.isInteger(port) && port > 0) { return port; } } } return process.env.DEVCONTAINER ? 8123 : 8124; }; const spawnArgs = (cfg, passthrough) => [ ...cfg.spawn.args, ...(cfg.acceptsArgs ? passthrough : []), ]; const runForegroundProcess = async ( suite, cfg, passthrough, fetchTranslations = false ) => { const lock = await acquireSuiteForStart(suite); if (!lock.token) { return lock.code; } try { const prebuildCode = await runPrebuild(lock.token, fetchTranslations); if (prebuildCode !== 0) { return prebuildCode; } return await spawnForeground({ cmd: cfg.spawn.cmd, args: spawnArgs(cfg, passthrough), cwd: repoRoot, env: suiteEnv(lock.token, fetchTranslations), processGroup: true, onSpawn: (child) => updateSuite(suite, lock.token, child), }); } finally { releaseSuite(lock.token); } }; const runBackgroundProcess = async ( suite, cfg, passthrough, fetchTranslations = false ) => { const lock = await acquireSuiteForStart(suite); if (!lock.token) { return lock.code; } let child; try { const prebuildCode = await runPrebuild(lock.token, fetchTranslations); if (prebuildCode !== 0) { releaseSuite(lock.token); return prebuildCode; } const logFile = logFileFor(suite); child = await spawnDetachedToLog({ cmd: cfg.spawn.cmd, args: spawnArgs(cfg, passthrough), cwd: repoRoot, env: suiteEnv(lock.token, fetchTranslations), logFile, }); const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port; updateSuite(suite, lock.token, child, port); return awaitReady({ suite, child, logFile, port, isReady: async () => logIsReady(logFile, cfg.readyLog) && (!cfg.acceptsArgs || (await isHttpServing(port))), onExit: () => releaseSuite(lock.token), }); } catch (err) { if (child) { await terminateDetachedProcess(child); } releaseSuite(lock.token); throw err; } }; const runStatusSuite = async (suite, cfg) => { const existing = readSuite(suite); if (existing) { process.stdout.write( `Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port ?? cfg.port)} ` + `(pid ${existing.pid})\n` ); } else { process.stdout.write(`Dev server (${suite}) not running.\n`); } return 0; }; const runStopSuite = async (suite) => { const existing = readSuite(suite); if (!existing) { process.stdout.write(`Dev server (${suite}) not running.\n`); return 0; } const { pid } = existing; const activeSuite = existing.suite ?? suite; return terminate( activeSuite, pid, () => !isProcessRecordAlive(existing), () => releaseSuite(existing.token) ); }; // --- shared ----------------------------------------------------------------- const runLogs = (suite, follow) => { const activeSuite = readSuite(suite)?.suite ?? suite; return outputLog( logFileFor(activeSuite), follow, `No log for the ${activeSuite} dev server yet (${logFileFor(activeSuite)}).\n` ); }; const main = async () => { const args = parseArgs(process.argv.slice(2)); const cfg = SUITES.get(args.suite); if (!cfg) { usage(); return 1; } if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) { process.stderr.write("Invalid combination of lifecycle arguments.\n"); usage(); return 1; } if ( args.fetchTranslations && (!["foreground", "background"].includes(args.mode) || !cfg.fetchTranslations) ) { process.stderr.write( "--fetch-translations is only supported when starting app, app-serve, demo, or gallery.\n" ); usage(); return 1; } if (args.passthrough.length && !cfg.acceptsArgs) { process.stderr.write( `Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n` ); } // A plain dev: under a coding agent backgrounds itself; explicit modes // are untouched. let { mode } = args; if ( mode === "foreground" && !["0", "false"].includes(process.env.HA_DEV_BACKGROUND) ) { const agent = detectCodingAgent(); if (agent) { process.stdout.write( `Detected coding agent (${agent}); starting in the background. ` + `Set HA_DEV_BACKGROUND=0 to force foreground.\n` ); mode = "background"; } } if (mode === "logs") { return runLogs(args.suite, args.follow); } if (mode === "status") { return runStatusSuite(args.suite, cfg); } if (mode === "stop") { return runStopSuite(args.suite); } const handlers = cfg.liveness === "health" ? { foreground: () => runForegroundHealth(args.suite, cfg, args.fetchTranslations), background: () => runBackgroundHealth(args.suite, cfg, args.fetchTranslations), } : { foreground: () => runForegroundProcess( args.suite, cfg, args.passthrough, args.fetchTranslations ), background: () => runBackgroundProcess( args.suite, cfg, args.passthrough, args.fetchTranslations ), }; return handlers[mode](); }; runCli(main);