singularity-forge/scripts/dev-server.js
2026-05-05 14:31:16 +02:00

209 lines
4.6 KiB
JavaScript

#!/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);
}