#!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import { existsSync, readdirSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; const __dirname = import.meta.dirname; const root = resolve(__dirname, ".."); const sourceBinPath = resolve(root, "bin", "sf-from-source"); const ensureResourcesPath = resolve( root, "scripts", "ensure-source-resources.cjs", ); const daemonCliPath = resolve(root, "packages", "daemon", "src", "cli-dev.ts"); const resolveTsPath = resolve( root, "src", "resources", "extensions", "sf", "tests", "resolve-ts.mjs", ); const WATCH_INTERVAL_MS = Number( process.env.SF_DEV_SERVER_WATCH_INTERVAL_MS ?? 2_000, ); const RESTART_GRACE_MS = Number( process.env.SF_DEV_SERVER_RESTART_GRACE_MS ?? 5_000, ); const passthroughArgs = process.argv.slice(2); const oneShot = passthroughArgs.some((arg) => ["--help", "-h", "--status", "--install", "--uninstall"].includes(arg), ); const watchEnabled = process.env.SF_DEV_SERVER_WATCH !== "0" && !oneShot && WATCH_INTERVAL_MS > 0; const watchedRoots = [ resolve(root, "packages", "daemon", "src"), resolve(root, "packages", "daemon", "package.json"), resolve(root, "scripts", "dev-server.js"), resolve(root, "scripts", "copy-resources.cjs"), resolve(root, "scripts", "ensure-source-resources.cjs"), resolve(root, "package.json"), ]; function newestMtimeMs(path) { let latest = 0; const stack = [path]; const skip = new Set(["dist", "node_modules", ".git", ".sf"]); while (stack.length > 0) { const current = stack.pop(); if (!current || !existsSync(current)) continue; let stat; try { stat = statSync(current); } catch { continue; } latest = Math.max(latest, stat.mtimeMs); if (!stat.isDirectory()) continue; let entries; try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (skip.has(entry.name)) continue; stack.push(join(current, entry.name)); } } return latest; } function sourceEpoch() { return Math.max(...watchedRoots.map(newestMtimeMs)); } const resourceBuild = spawnSync(process.execPath, [ensureResourcesPath], { cwd: root, stdio: "inherit", env: process.env, }); if (resourceBuild.status !== 0) { process.exit(resourceBuild.status ?? 1); } let child; let stopping = false; let restarting = false; let currentEpoch = sourceEpoch(); let restartTimer; function childEnv() { return { ...process.env, SF_SOURCE_ROOT: process.env.SF_SOURCE_ROOT || root, SF_RUNTIME_SOURCE_ROOT: process.env.SF_RUNTIME_SOURCE_ROOT || root, SF_BIN_PATH: process.env.SF_BIN_PATH || resolve(root, "dist", "loader.js"), SF_CLI_PATH: process.env.SF_CLI_PATH || sourceBinPath, }; } function spawnDaemon() { child = spawn( process.execPath, [ "--import", resolveTsPath, "--experimental-strip-types", "--no-warnings", daemonCliPath, ...passthroughArgs, ], { cwd: process.cwd(), stdio: "inherit", env: childEnv(), }, ); child.on("error", (error) => { console.error( `[forge] Failed to launch local dev server: ${error instanceof Error ? error.message : String(error)}`, ); if (!watchEnabled) process.exit(1); }); child.on("exit", (code, signal) => { child = undefined; if (stopping) { if (signal) { process.kill(process.pid, signal); return; } process.exit(code ?? 0); } if (restarting) { restarting = false; spawnDaemon(); return; } if (!watchEnabled) { if (signal) { process.kill(process.pid, signal); return; } process.exit(code ?? 0); } console.error( `[forge] sf-server exited (${signal ?? `code ${code ?? 0}`}); restarting in 2s...`, ); setTimeout(spawnDaemon, 2_000); }); } function requestRestart(reason) { if (stopping || restarting) return; restarting = true; console.error(`[forge] ${reason}; restarting sf-server dev child...`); if (!child || child.killed) { restarting = false; spawnDaemon(); return; } const victim = child; victim.kill("SIGTERM"); restartTimer = setTimeout(() => { if (victim.exitCode == null && victim.signalCode == null) { victim.kill("SIGKILL"); } }, RESTART_GRACE_MS); } function stop(signal) { stopping = true; if (restartTimer) clearTimeout(restartTimer); if (!child || child.killed) { process.exit(0); return; } child.kill(signal); } process.on("SIGINT", () => stop("SIGINT")); process.on("SIGTERM", () => stop("SIGTERM")); spawnDaemon(); if (watchEnabled) { setInterval(() => { const nextEpoch = sourceEpoch(); if (nextEpoch <= currentEpoch) return; currentEpoch = nextEpoch; requestRestart("daemon/dev source changed"); }, WATCH_INTERVAL_MS); }