976 lines
27 KiB
TypeScript
976 lines
27 KiB
TypeScript
import {
|
|
type ChildProcess,
|
|
execFile,
|
|
execSync,
|
|
type SpawnOptions,
|
|
spawn,
|
|
} from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import {
|
|
existsSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { request as httpRequest } from "node:http";
|
|
import { createServer } from "node:net";
|
|
import { join, resolve } from "node:path";
|
|
import {
|
|
appRoot,
|
|
webPidFilePath as defaultWebPidFilePath,
|
|
} from "./app-paths.js";
|
|
|
|
const DEFAULT_HOST = "127.0.0.1";
|
|
const DEFAULT_PACKAGE_ROOT = resolve(import.meta.dirname, "..");
|
|
|
|
/** Open a URL in the user's default browser. */
|
|
function openBrowser(url: string): void {
|
|
if (process.platform === "win32") {
|
|
// PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not.
|
|
execFile(
|
|
"powershell",
|
|
["-c", `Start-Process '${url.replace(/'/g, "''")}'`],
|
|
{ windowsHide: true },
|
|
() => {},
|
|
);
|
|
} else {
|
|
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
execFile(cmd, [url], () => {});
|
|
}
|
|
}
|
|
|
|
type WritableLike = Pick<typeof process.stderr, "write">;
|
|
|
|
type ResourceBootstrapLike = {
|
|
initResources: (agentDir: string) => void;
|
|
};
|
|
|
|
type SpawnedChildLike = Pick<ChildProcess, "once" | "unref" | "pid">;
|
|
|
|
export interface WebModeLaunchOptions {
|
|
cwd: string;
|
|
projectSessionsDir: string;
|
|
agentDir: string;
|
|
packageRoot?: string;
|
|
host?: string;
|
|
port?: number;
|
|
/** Additional allowed origins for CORS (forwarded as SF_WEB_ALLOWED_ORIGINS). */
|
|
allowedOrigins?: string[];
|
|
}
|
|
|
|
export interface ResolvedWebHostBootstrap {
|
|
ok: true;
|
|
kind: "packaged-standalone" | "source-dev";
|
|
packageRoot: string;
|
|
hostRoot: string;
|
|
entryPath: string;
|
|
}
|
|
|
|
export interface UnresolvedWebHostBootstrap {
|
|
ok: false;
|
|
packageRoot: string;
|
|
reason: string;
|
|
candidates: string[];
|
|
}
|
|
|
|
export type WebHostBootstrap =
|
|
| ResolvedWebHostBootstrap
|
|
| UnresolvedWebHostBootstrap;
|
|
|
|
export interface WebModeLaunchSuccess {
|
|
mode: "web";
|
|
ok: true;
|
|
cwd: string;
|
|
projectSessionsDir: string;
|
|
host: string;
|
|
port: number;
|
|
url: string;
|
|
hostKind: ResolvedWebHostBootstrap["kind"];
|
|
hostPath: string;
|
|
hostRoot: string;
|
|
}
|
|
|
|
export interface WebModeLaunchFailure {
|
|
mode: "web";
|
|
ok: false;
|
|
cwd: string;
|
|
projectSessionsDir: string;
|
|
host: string;
|
|
port: number | null;
|
|
url: string | null;
|
|
hostKind: ResolvedWebHostBootstrap["kind"] | "unresolved";
|
|
hostPath: string | null;
|
|
hostRoot: string | null;
|
|
failureReason: string;
|
|
candidates?: string[];
|
|
}
|
|
|
|
export type WebModeLaunchStatus = WebModeLaunchSuccess | WebModeLaunchFailure;
|
|
|
|
export interface WebModeDeps {
|
|
existsSync?: (path: string) => boolean;
|
|
initResources?: (agentDir: string) => void;
|
|
resolvePort?: (host: string) => Promise<number>;
|
|
spawn?: (
|
|
command: string,
|
|
args: readonly string[],
|
|
options: SpawnOptions,
|
|
) => SpawnedChildLike;
|
|
waitForBootReady?: (url: string) => Promise<void>;
|
|
openBrowser?: (url: string) => void;
|
|
stderr?: WritableLike;
|
|
env?: NodeJS.ProcessEnv;
|
|
platform?: NodeJS.Platform;
|
|
execPath?: string;
|
|
pidFilePath?: string;
|
|
writePidFile?: (path: string, pid: number) => void;
|
|
readPidFile?: (path: string) => number | null;
|
|
deletePidFile?: (path: string) => void;
|
|
/** Path to the multi-instance registry JSON (for testing). */
|
|
registryPath?: string;
|
|
}
|
|
|
|
export interface WebModeStopResult {
|
|
ok: boolean;
|
|
reason?: string;
|
|
/** How many instances were stopped (relevant for --all) */
|
|
stoppedCount?: number;
|
|
}
|
|
|
|
// ─── Instance Registry ──────────────────────────────────────────────────────
|
|
|
|
export interface WebInstanceEntry {
|
|
pid: number;
|
|
port: number;
|
|
url: string;
|
|
cwd: string;
|
|
startedAt: string;
|
|
}
|
|
|
|
export type WebInstanceRegistry = Record<string, WebInstanceEntry>;
|
|
|
|
const WEB_INSTANCES_PATH = join(appRoot, "web-instances.json");
|
|
|
|
export function readInstanceRegistry(
|
|
registryPath = WEB_INSTANCES_PATH,
|
|
): WebInstanceRegistry {
|
|
try {
|
|
return JSON.parse(
|
|
readFileSync(registryPath, "utf8"),
|
|
) as WebInstanceRegistry;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function writeInstanceRegistry(
|
|
registry: WebInstanceRegistry,
|
|
registryPath = WEB_INSTANCES_PATH,
|
|
): void {
|
|
writeFileSync(registryPath, JSON.stringify(registry, null, 2), "utf8");
|
|
}
|
|
|
|
export function registerInstance(
|
|
cwd: string,
|
|
entry: Omit<WebInstanceEntry, "cwd" | "startedAt">,
|
|
registryPath = WEB_INSTANCES_PATH,
|
|
): void {
|
|
const registry = readInstanceRegistry(registryPath);
|
|
registry[resolve(cwd)] = {
|
|
...entry,
|
|
cwd: resolve(cwd),
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
writeInstanceRegistry(registry, registryPath);
|
|
}
|
|
|
|
export function unregisterInstance(
|
|
cwd: string,
|
|
registryPath = WEB_INSTANCES_PATH,
|
|
): void {
|
|
const registry = readInstanceRegistry(registryPath);
|
|
delete registry[resolve(cwd)];
|
|
writeInstanceRegistry(registry, registryPath);
|
|
}
|
|
|
|
function killPid(pid: number): "killed" | "already-dead" | { error: string } {
|
|
try {
|
|
process.kill(pid, "SIGTERM");
|
|
return "killed";
|
|
} catch (error) {
|
|
const isAlreadyDead =
|
|
error instanceof Error &&
|
|
"code" in error &&
|
|
(error as NodeJS.ErrnoException).code === "ESRCH";
|
|
if (isAlreadyDead) return "already-dead";
|
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
export function writePidFile(filePath: string, pid: number): void {
|
|
writeFileSync(filePath, String(pid), "utf8");
|
|
}
|
|
|
|
export function readPidFile(filePath: string): number | null {
|
|
try {
|
|
const content = readFileSync(filePath, "utf8").trim();
|
|
const pid = parseInt(content, 10);
|
|
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function deletePidFile(filePath: string): void {
|
|
try {
|
|
unlinkSync(filePath);
|
|
} catch {
|
|
// Non-fatal — file may already be gone
|
|
}
|
|
}
|
|
|
|
export interface WebModeStopOptions {
|
|
/** Stop instance for a specific project path */
|
|
projectCwd?: string;
|
|
/** Stop all running instances */
|
|
all?: boolean;
|
|
}
|
|
|
|
export function stopWebMode(
|
|
deps: Pick<
|
|
WebModeDeps,
|
|
"pidFilePath" | "readPidFile" | "deletePidFile" | "stderr"
|
|
> = {},
|
|
options: WebModeStopOptions = {},
|
|
): WebModeStopResult {
|
|
const stderr = deps.stderr ?? process.stderr;
|
|
|
|
// ── Stop all instances ──────────────────────────────────────────────
|
|
if (options.all) {
|
|
const registry = readInstanceRegistry();
|
|
const entries = Object.entries(registry);
|
|
if (entries.length === 0) {
|
|
// Fall back to legacy PID file
|
|
return stopLegacyPidFile(deps);
|
|
}
|
|
let stopped = 0;
|
|
for (const [cwd, entry] of entries) {
|
|
const result = killPid(entry.pid);
|
|
if (result === "killed") {
|
|
stderr.write(
|
|
`[forge] Stopped web server for ${cwd} (pid=${entry.pid})\n`,
|
|
);
|
|
stopped++;
|
|
} else if (result === "already-dead") {
|
|
stderr.write(
|
|
`[forge] Web server for ${cwd} was already stopped (pid=${entry.pid})\n`,
|
|
);
|
|
stopped++;
|
|
} else {
|
|
stderr.write(
|
|
`[forge] Failed to stop web server for ${cwd}: ${result.error}\n`,
|
|
);
|
|
}
|
|
unregisterInstance(cwd);
|
|
}
|
|
// Also clean up legacy PID file
|
|
const deletePid = deps.deletePidFile ?? deletePidFile;
|
|
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath;
|
|
deletePid(pidFilePath);
|
|
stderr.write(
|
|
`[forge] Stopped ${stopped} instance${stopped === 1 ? "" : "s"}.\n`,
|
|
);
|
|
// Also reap orphaned next-server processes (sf-mooe4m5k-6fm7z9)
|
|
const orphanResult = reapOrphanedNextServerProcesses(stderr);
|
|
if (orphanResult.reaped > 0) {
|
|
stderr.write(
|
|
`[forge] Reaped ${orphanResult.reaped} orphaned next-server process${orphanResult.reaped === 1 ? "" : "es"}.\n`,
|
|
);
|
|
}
|
|
return { ok: true, stoppedCount: stopped };
|
|
}
|
|
|
|
// ── Stop specific project ──────────────────────────────────────────
|
|
if (options.projectCwd) {
|
|
const resolvedCwd = resolve(options.projectCwd);
|
|
const registry = readInstanceRegistry();
|
|
const entry = registry[resolvedCwd];
|
|
if (!entry) {
|
|
stderr.write(`[forge] No web server running for ${resolvedCwd}\n`);
|
|
return { ok: false, reason: "not-found" };
|
|
}
|
|
const result = killPid(entry.pid);
|
|
unregisterInstance(resolvedCwd);
|
|
if (result === "killed") {
|
|
stderr.write(
|
|
`[forge] Stopped web server for ${resolvedCwd} (pid=${entry.pid})\n`,
|
|
);
|
|
return { ok: true, stoppedCount: 1 };
|
|
} else if (result === "already-dead") {
|
|
stderr.write(
|
|
`[forge] Web server for ${resolvedCwd} was already stopped — cleared stale entry.\n`,
|
|
);
|
|
return { ok: true, stoppedCount: 1 };
|
|
} else {
|
|
stderr.write(
|
|
`[forge] Failed to stop web server for ${resolvedCwd}: ${result.error}\n`,
|
|
);
|
|
return { ok: false, reason: result.error };
|
|
}
|
|
}
|
|
|
|
// ── Default: stop via legacy PID file (backward compat) ─────────────
|
|
return stopLegacyPidFile(deps);
|
|
}
|
|
|
|
function stopLegacyPidFile(
|
|
deps: Pick<
|
|
WebModeDeps,
|
|
"pidFilePath" | "readPidFile" | "deletePidFile" | "stderr"
|
|
>,
|
|
): WebModeStopResult {
|
|
const stderr = deps.stderr ?? process.stderr;
|
|
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath;
|
|
const readPid = deps.readPidFile ?? readPidFile;
|
|
const deletePid = deps.deletePidFile ?? deletePidFile;
|
|
|
|
const pid = readPid(pidFilePath);
|
|
if (pid === null) {
|
|
stderr.write(`[forge] Web server is not running (no PID file found)\n`);
|
|
return { ok: false, reason: "no-pid-file" };
|
|
}
|
|
|
|
stderr.write(`[forge] Stopping web server (pid=${pid})…\n`);
|
|
|
|
const result = killPid(pid);
|
|
deletePid(pidFilePath);
|
|
if (result === "killed") {
|
|
stderr.write(`[forge] Web server stopped.\n`);
|
|
return { ok: true };
|
|
} else if (result === "already-dead") {
|
|
stderr.write(
|
|
`[forge] Web server was already stopped — cleared stale PID file.\n`,
|
|
);
|
|
return { ok: true };
|
|
} else {
|
|
stderr.write(`[forge] Failed to stop web server: ${result.error}\n`);
|
|
return { ok: false, reason: result.error };
|
|
}
|
|
}
|
|
|
|
async function loadResourceBootstrap(): Promise<ResourceBootstrapLike> {
|
|
const mod = await import("./resource-loader.js");
|
|
return {
|
|
initResources: mod.initResources,
|
|
};
|
|
}
|
|
|
|
export function resolveWebHostBootstrap(
|
|
options: {
|
|
packageRoot?: string;
|
|
existsSync?: (path: string) => boolean;
|
|
} = {},
|
|
): WebHostBootstrap {
|
|
const packageRoot = options.packageRoot ?? DEFAULT_PACKAGE_ROOT;
|
|
const checkExists = options.existsSync ?? existsSync;
|
|
const packagedStandaloneServer = join(
|
|
packageRoot,
|
|
"dist",
|
|
"web",
|
|
"standalone",
|
|
"server.js",
|
|
);
|
|
if (checkExists(packagedStandaloneServer)) {
|
|
return {
|
|
ok: true,
|
|
kind: "packaged-standalone",
|
|
packageRoot,
|
|
hostRoot: join(packageRoot, "dist", "web", "standalone"),
|
|
entryPath: packagedStandaloneServer,
|
|
};
|
|
}
|
|
|
|
const sourceWebRoot = join(packageRoot, "web");
|
|
const sourceManifest = join(sourceWebRoot, "package.json");
|
|
if (checkExists(sourceManifest)) {
|
|
return {
|
|
ok: true,
|
|
kind: "source-dev",
|
|
packageRoot,
|
|
hostRoot: sourceWebRoot,
|
|
entryPath: sourceManifest,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
packageRoot,
|
|
reason: "host bootstrap not found",
|
|
candidates: [packagedStandaloneServer, sourceManifest],
|
|
};
|
|
}
|
|
|
|
export async function reserveWebPort(host = DEFAULT_HOST): Promise<number> {
|
|
return await new Promise<number>((resolvePort, reject) => {
|
|
const server = createServer();
|
|
server.unref();
|
|
server.once("error", reject);
|
|
server.listen(0, host, () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close(() =>
|
|
reject(new Error("failed to determine reserved web port")),
|
|
);
|
|
return;
|
|
}
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolvePort(address.port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function getSpawnCommandForSourceHost(platform: NodeJS.Platform): string {
|
|
return platform === "win32" ? "npm.cmd" : "npm";
|
|
}
|
|
|
|
function needsWindowsShell(
|
|
command: string,
|
|
platform: NodeJS.Platform,
|
|
): boolean {
|
|
return platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
|
}
|
|
|
|
function formatLaunchStatus(status: WebModeLaunchStatus): string {
|
|
if (status.ok) {
|
|
return `[forge] Web mode startup: status=started cwd=${status.cwd} port=${status.port} host=${status.hostPath} kind=${status.hostKind} url=${status.url}\n`;
|
|
}
|
|
|
|
return `[forge] Web mode startup: status=failed cwd=${status.cwd} port=${status.port ?? "n/a"} host=${status.hostPath ?? "unresolved"} kind=${status.hostKind} reason=${(status as any).failureReason}\n`;
|
|
}
|
|
|
|
function emitLaunchStatus(
|
|
stderr: WritableLike,
|
|
status: WebModeLaunchStatus,
|
|
): void {
|
|
stderr.write(formatLaunchStatus(status));
|
|
}
|
|
|
|
function buildSpawnSpec(
|
|
resolution: ResolvedWebHostBootstrap,
|
|
host: string,
|
|
port: number,
|
|
platform: NodeJS.Platform,
|
|
execPath: string,
|
|
): { command: string; args: string[]; cwd: string } {
|
|
if (resolution.kind === "packaged-standalone") {
|
|
return {
|
|
command: execPath,
|
|
args: [resolution.entryPath],
|
|
cwd: resolution.hostRoot,
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: getSpawnCommandForSourceHost(platform),
|
|
args: ["run", "dev", "--", "--hostname", host, "--port", String(port)],
|
|
cwd: resolution.hostRoot,
|
|
};
|
|
}
|
|
|
|
async function spawnDetachedProcess(
|
|
spawnCommand: (
|
|
command: string,
|
|
args: readonly string[],
|
|
options: SpawnOptions,
|
|
) => SpawnedChildLike,
|
|
command: string,
|
|
args: string[],
|
|
options: SpawnOptions,
|
|
): Promise<
|
|
{ ok: true; child: SpawnedChildLike } | { ok: false; error: unknown }
|
|
> {
|
|
return await new Promise((resolve) => {
|
|
try {
|
|
const child = spawnCommand(command, args, options);
|
|
let settled = false;
|
|
const finish = (
|
|
result:
|
|
| { ok: true; child: SpawnedChildLike }
|
|
| { ok: false; error: unknown },
|
|
) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(result);
|
|
};
|
|
|
|
child.once?.("error", (error) => finish({ ok: false, error }));
|
|
setImmediate(() => finish({ ok: true, child }));
|
|
} catch (error) {
|
|
resolve({ ok: false, error });
|
|
}
|
|
});
|
|
}
|
|
|
|
async function requestLocalJson(
|
|
url: string,
|
|
timeoutMs: number,
|
|
authToken?: string,
|
|
): Promise<{ statusCode: number; body: string }> {
|
|
return await new Promise((resolve, reject) => {
|
|
const headers: Record<string, string> = {
|
|
Accept: "application/json",
|
|
// Keep launch readiness on the cheapest uncompressed path. The
|
|
// packaged host can spend noticeable time compressing the large boot
|
|
// snapshot, which adds avoidable startup jitter for a local health
|
|
// check that only needs the JSON payload itself.
|
|
"Accept-Encoding": "identity",
|
|
};
|
|
if (authToken) {
|
|
headers["Authorization"] = `Bearer ${authToken}`;
|
|
}
|
|
const request = httpRequest(
|
|
url,
|
|
{
|
|
method: "GET",
|
|
headers,
|
|
},
|
|
(response) => {
|
|
const statusCode = response.statusCode ?? 0;
|
|
let body = "";
|
|
response.setEncoding("utf8");
|
|
response.on("data", (chunk) => {
|
|
body += chunk;
|
|
});
|
|
response.on("end", () => resolve({ statusCode, body }));
|
|
},
|
|
);
|
|
|
|
request.setTimeout(timeoutMs, () => {
|
|
request.destroy(new Error(`request timed out after ${timeoutMs}ms`));
|
|
});
|
|
request.once("error", reject);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
async function waitForBootReady(
|
|
url: string,
|
|
timeoutMs = 180_000,
|
|
stderr?: WritableLike,
|
|
authToken?: string,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
const startedAt = Date.now();
|
|
let lastError: string | null = null;
|
|
let lastBody: string | null = null;
|
|
let hostUp = false;
|
|
let consecutive5xx = 0;
|
|
const MAX_CONSECUTIVE_5XX = 3;
|
|
// Print a progress dot every N ms while waiting so the terminal isn't silent
|
|
const TICKER_INTERVAL_MS = 5_000;
|
|
let lastTickAt = startedAt;
|
|
|
|
const elapsed = () => `${Math.round((Date.now() - startedAt) / 1000)}s`;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
// Give the packaged host enough time to finish a cold /api/boot render.
|
|
const response = await requestLocalJson(
|
|
`${url}/api/boot`,
|
|
45_000,
|
|
authToken,
|
|
);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
if (!hostUp) {
|
|
hostUp = true;
|
|
stderr?.write(`[forge] Web host ready.\n`);
|
|
}
|
|
consecutive5xx = 0;
|
|
// Host responded successfully — it's ready for the browser
|
|
return;
|
|
} else if (response.statusCode >= 500) {
|
|
consecutive5xx++;
|
|
lastError = `http ${response.statusCode}`;
|
|
lastBody = response.body || null;
|
|
if (consecutive5xx >= MAX_CONSECUTIVE_5XX) {
|
|
const detail = lastBody ? `: ${lastBody.slice(0, 500)}` : "";
|
|
throw new Error(
|
|
`boot route returned ${MAX_CONSECUTIVE_5XX} consecutive 5xx responses (last: ${response.statusCode})${detail}`,
|
|
);
|
|
}
|
|
} else {
|
|
consecutive5xx = 0;
|
|
lastError = `http ${response.statusCode}`;
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
error.message.startsWith("boot route returned")
|
|
) {
|
|
throw error;
|
|
}
|
|
// Connection refused, timeout, etc. — transient during cold start
|
|
consecutive5xx = 0;
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
// Emit a heartbeat line every TICKER_INTERVAL_MS to show we're alive
|
|
const now = Date.now();
|
|
if (now - lastTickAt >= TICKER_INTERVAL_MS) {
|
|
lastTickAt = now;
|
|
if (hostUp) {
|
|
stderr?.write(`[forge] Still waiting… (${elapsed()})\n`);
|
|
} else {
|
|
stderr?.write(`[forge] Waiting for web host… (${elapsed()})\n`);
|
|
}
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
|
|
throw new Error(lastError ?? "timed out waiting for boot readiness");
|
|
}
|
|
|
|
/**
|
|
* If a previous web server instance is registered for the same `cwd`, attempt
|
|
* to kill it and remove its registry entry so the new launch can bind the port
|
|
* cleanly. This handles the "orphan process" scenario where a prior `sf --web`
|
|
* was terminated without clean shutdown (e.g. terminal closed).
|
|
*/
|
|
function cleanupStaleInstance(
|
|
cwd: string,
|
|
stderr: WritableLike,
|
|
registryPath?: string,
|
|
): void {
|
|
const registry = readInstanceRegistry(registryPath);
|
|
const key = resolve(cwd);
|
|
const stale = registry[key];
|
|
if (!stale) return;
|
|
|
|
stderr.write(
|
|
`[forge] Cleaning up stale web server for ${key} (pid=${stale.pid}, port=${stale.port})…\n`,
|
|
);
|
|
const result = killPid(stale.pid);
|
|
if (result === "killed") {
|
|
stderr.write(`[forge] Killed stale web server (pid=${stale.pid}).\n`);
|
|
} else if (result === "already-dead") {
|
|
stderr.write(
|
|
`[forge] Stale web server was already stopped (pid=${stale.pid}) — clearing entry.\n`,
|
|
);
|
|
} else {
|
|
stderr.write(
|
|
`[forge] Could not kill stale web server (pid=${stale.pid}): ${result.error}\n`,
|
|
);
|
|
}
|
|
unregisterInstance(cwd, registryPath);
|
|
}
|
|
|
|
/**
|
|
* Detect and reap orphaned next-server processes that outlived their parent
|
|
* web host. These orphans have cwd under dist/web/standalone (or a deleted
|
|
* variant) and parent PID 1 (init). They are created when the web host process
|
|
* exits without cleanly terminating its next-server child.
|
|
*
|
|
* Purpose: prevent stale next-server processes from accumulating and holding
|
|
* ports or consuming resources after sf web stop or host replacement.
|
|
* Consumer: launchWebMode before binding a new port, and stopWebMode --all.
|
|
*
|
|
* AC2 from sf-mooe4m5k-6fm7z9: Orphaned next-server processes with cwd under
|
|
* dist/web/standalone are detected and reaped on next web launch for the same repo.
|
|
*/
|
|
export function reapOrphanedNextServerProcesses(
|
|
stderr: WritableLike,
|
|
packageRoot = DEFAULT_PACKAGE_ROOT,
|
|
deps: {
|
|
execSync?: typeof execSync;
|
|
readlinkSync?: typeof readlinkSync;
|
|
kill?: typeof process.kill;
|
|
platform?: NodeJS.Platform;
|
|
} = {},
|
|
): { reaped: number; errors: string[] } {
|
|
const errors: string[] = [];
|
|
let reaped = 0;
|
|
const platform = deps.platform ?? process.platform;
|
|
if (platform === "win32") {
|
|
// Windows orphan detection not implemented; rely on port-kill fallback
|
|
return { reaped: 0, errors: [] };
|
|
}
|
|
try {
|
|
// Find next-server processes with cwd matching our standalone host path
|
|
const standalonePath = resolve(packageRoot, "dist", "web", "standalone");
|
|
// Use ps to find node processes with next-server in their command line
|
|
const psOutput = (deps.execSync ?? execSync)(
|
|
"ps -eo pid,ppid,cmd,comm --no-headers",
|
|
{ encoding: "utf8", timeout: 5000 },
|
|
) as string;
|
|
const lines = psOutput.split("\n").filter((line) => line.trim());
|
|
for (const line of lines) {
|
|
const parts = line.trim().split(/\s+/);
|
|
if (parts.length < 4) continue;
|
|
const pidStr = parts[0];
|
|
const ppidStr = parts[1];
|
|
const cmd = parts.slice(2).join(" ");
|
|
const pid = Number.parseInt(pidStr, 10);
|
|
const ppid = Number.parseInt(ppidStr, 10);
|
|
if (!Number.isFinite(pid) || pid <= 1) continue;
|
|
// Look for next-server in command line
|
|
if (!cmd.includes("next-server") && !cmd.includes("server.js")) continue;
|
|
// Check if the process cwd matches our standalone path (or deleted variant)
|
|
let cwd: string | null = null;
|
|
try {
|
|
cwd = (deps.readlinkSync ?? readlinkSync)(`/proc/${pid}/cwd`);
|
|
} catch {
|
|
// Process may have exited between ps and readlink
|
|
continue;
|
|
}
|
|
if (
|
|
cwd &&
|
|
(cwd.startsWith(standalonePath) || cwd.includes("standalone (deleted)"))
|
|
) {
|
|
// Orphan: parent is init (ppid=1) or the parent is dead
|
|
const isOrphan = ppid === 1;
|
|
if (isOrphan) {
|
|
try {
|
|
(deps.kill ?? process.kill)(pid, "SIGTERM");
|
|
reaped++;
|
|
stderr.write(
|
|
`[forge] Reaped orphaned next-server (pid=${pid}, cwd=${cwd})\n`,
|
|
);
|
|
} catch (killErr) {
|
|
const msg =
|
|
killErr instanceof Error ? killErr.message : String(killErr);
|
|
errors.push(`pid=${pid}: ${msg}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (execErr) {
|
|
const msg = execErr instanceof Error ? execErr.message : String(execErr);
|
|
errors.push(`ps exec failed: ${msg}`);
|
|
}
|
|
return { reaped, errors };
|
|
}
|
|
|
|
export async function launchWebMode(
|
|
options: WebModeLaunchOptions,
|
|
deps: WebModeDeps = {},
|
|
): Promise<WebModeLaunchStatus> {
|
|
const stderr = deps.stderr ?? process.stderr;
|
|
const host = options.host ?? DEFAULT_HOST;
|
|
const resolution = resolveWebHostBootstrap({
|
|
packageRoot: options.packageRoot,
|
|
existsSync: deps.existsSync,
|
|
});
|
|
|
|
if (!resolution.ok) {
|
|
const failure: WebModeLaunchFailure = {
|
|
mode: "web",
|
|
ok: false,
|
|
cwd: options.cwd,
|
|
projectSessionsDir: options.projectSessionsDir,
|
|
host,
|
|
port: null,
|
|
url: null,
|
|
hostKind: "unresolved",
|
|
hostPath: null,
|
|
hostRoot: null,
|
|
failureReason: `${resolution.reason}; checked=${resolution.candidates.join(",")}`,
|
|
candidates: resolution.candidates,
|
|
};
|
|
emitLaunchStatus(stderr, failure);
|
|
return failure;
|
|
}
|
|
|
|
stderr.write(`[forge] Starting web mode…\n`);
|
|
|
|
// Kill any stale server instance for this project before reserving a port.
|
|
// This prevents EADDRINUSE when the previous `sf --web` was terminated
|
|
// without a clean shutdown (e.g. terminal closed, crash).
|
|
cleanupStaleInstance(options.cwd, stderr, deps.registryPath);
|
|
|
|
// Also reap orphaned next-server processes from prior unclean shutdowns
|
|
// (sf-mooe4m5k-6fm7z9): orphaned next-server processes with cwd under
|
|
// dist/web/standalone are detected and reaped on next web launch.
|
|
const orphanResult = reapOrphanedNextServerProcesses(
|
|
stderr,
|
|
options.packageRoot,
|
|
);
|
|
if (orphanResult.reaped > 0) {
|
|
stderr.write(
|
|
`[forge] Reaped ${orphanResult.reaped} orphaned next-server process${orphanResult.reaped === 1 ? "" : "es"} before launch.\n`,
|
|
);
|
|
}
|
|
|
|
const port =
|
|
options.port ?? (await (deps.resolvePort ?? reserveWebPort)(host));
|
|
const authToken = randomBytes(32).toString("hex");
|
|
const url = `http://${host}:${port}`;
|
|
const env = {
|
|
...(deps.env ?? process.env),
|
|
HOSTNAME: host,
|
|
PORT: String(port),
|
|
SF_WEB_HOST: host,
|
|
SF_WEB_PORT: String(port),
|
|
SF_WEB_AUTH_TOKEN: authToken,
|
|
SF_WEB_PROJECT_CWD: options.cwd,
|
|
SF_WEB_PROJECT_SESSIONS_DIR: options.projectSessionsDir,
|
|
SF_WEB_PACKAGE_ROOT: resolution.packageRoot,
|
|
SF_WEB_HOST_KIND: resolution.kind,
|
|
...(resolution.kind === "source-dev" ? { NEXT_PUBLIC_SF_DEV: "1" } : {}),
|
|
...(options.allowedOrigins?.length
|
|
? { SF_WEB_ALLOWED_ORIGINS: options.allowedOrigins.join(",") }
|
|
: {}),
|
|
};
|
|
|
|
try {
|
|
stderr.write(`[forge] Initialising resources…\n`);
|
|
const bootstrap = deps.initResources
|
|
? { initResources: deps.initResources }
|
|
: await loadResourceBootstrap();
|
|
bootstrap.initResources(options.agentDir);
|
|
} catch (error) {
|
|
const failure: WebModeLaunchFailure = {
|
|
mode: "web",
|
|
ok: false,
|
|
cwd: options.cwd,
|
|
projectSessionsDir: options.projectSessionsDir,
|
|
host,
|
|
port,
|
|
url,
|
|
hostKind: resolution.kind,
|
|
hostPath: resolution.entryPath,
|
|
hostRoot: resolution.hostRoot,
|
|
failureReason: `bootstrap:${error instanceof Error ? error.message : String(error)}`,
|
|
};
|
|
emitLaunchStatus(stderr, failure);
|
|
return failure;
|
|
}
|
|
|
|
const spawnSpec = buildSpawnSpec(
|
|
resolution,
|
|
host,
|
|
port,
|
|
deps.platform ?? process.platform,
|
|
deps.execPath ?? process.execPath,
|
|
);
|
|
|
|
stderr.write(`[forge] Launching web host on port ${port}…\n`);
|
|
|
|
const spawnResult = await spawnDetachedProcess(
|
|
deps.spawn ??
|
|
((command, args, spawnOptions) => spawn(command, args, spawnOptions)),
|
|
spawnSpec.command,
|
|
spawnSpec.args,
|
|
{
|
|
cwd: spawnSpec.cwd,
|
|
detached: true,
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
shell: needsWindowsShell(
|
|
spawnSpec.command,
|
|
deps.platform ?? process.platform,
|
|
),
|
|
env,
|
|
},
|
|
);
|
|
|
|
if (!spawnResult.ok) {
|
|
const failure: WebModeLaunchFailure = {
|
|
mode: "web",
|
|
ok: false,
|
|
cwd: options.cwd,
|
|
projectSessionsDir: options.projectSessionsDir,
|
|
host,
|
|
port,
|
|
url,
|
|
hostKind: resolution.kind,
|
|
hostPath: resolution.entryPath,
|
|
hostRoot: resolution.hostRoot,
|
|
failureReason: `launch:${spawnResult.error instanceof Error ? spawnResult.error.message : String(spawnResult.error)}`,
|
|
};
|
|
emitLaunchStatus(stderr, failure);
|
|
return failure;
|
|
}
|
|
|
|
try {
|
|
const bootReadyFn =
|
|
deps.waitForBootReady ??
|
|
((u: string) => waitForBootReady(u, 180_000, stderr, authToken));
|
|
await bootReadyFn(url);
|
|
} catch (error) {
|
|
const failure: WebModeLaunchFailure = {
|
|
mode: "web",
|
|
ok: false,
|
|
cwd: options.cwd,
|
|
projectSessionsDir: options.projectSessionsDir,
|
|
host,
|
|
port,
|
|
url,
|
|
hostKind: resolution.kind,
|
|
hostPath: resolution.entryPath,
|
|
hostRoot: resolution.hostRoot,
|
|
failureReason: `boot-ready:${error instanceof Error ? error.message : String(error)}`,
|
|
};
|
|
emitLaunchStatus(stderr, failure);
|
|
return failure;
|
|
}
|
|
|
|
try {
|
|
spawnResult.child.unref?.();
|
|
const pid = spawnResult.child.pid;
|
|
if (pid !== undefined) {
|
|
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath;
|
|
(deps.writePidFile ?? writePidFile)(pidFilePath, pid);
|
|
// Register in multi-instance registry
|
|
registerInstance(options.cwd, { pid, port, url }, deps.registryPath);
|
|
}
|
|
const authenticatedUrl = `${url}/#token=${authToken}`;
|
|
try {
|
|
(deps.openBrowser ?? openBrowser)(authenticatedUrl);
|
|
} catch (browserError) {
|
|
stderr.write(
|
|
`[forge] Could not open browser: ${browserError instanceof Error ? browserError.message : String(browserError)}\n`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const failure: WebModeLaunchFailure = {
|
|
mode: "web",
|
|
ok: false,
|
|
cwd: options.cwd,
|
|
projectSessionsDir: options.projectSessionsDir,
|
|
host,
|
|
port,
|
|
url,
|
|
hostKind: resolution.kind,
|
|
hostPath: resolution.entryPath,
|
|
hostRoot: resolution.hostRoot,
|
|
failureReason: `browser-open:${error instanceof Error ? error.message : String(error)}`,
|
|
};
|
|
emitLaunchStatus(stderr, failure);
|
|
return failure;
|
|
}
|
|
|
|
const authenticatedUrl = `${url}/#token=${authToken}`;
|
|
const success: WebModeLaunchSuccess = {
|
|
mode: "web",
|
|
ok: true,
|
|
cwd: options.cwd,
|
|
projectSessionsDir: options.projectSessionsDir,
|
|
host,
|
|
port,
|
|
url,
|
|
hostKind: resolution.kind,
|
|
hostPath: resolution.entryPath,
|
|
hostRoot: resolution.hostRoot,
|
|
};
|
|
stderr.write(`[forge] Ready → ${authenticatedUrl}\n`);
|
|
emitLaunchStatus(stderr, success);
|
|
return success;
|
|
}
|