Files
hyperframes/packages/cli/src/commands/previewLifecycle.ts
T
Miguel Ángel 2a7a5e6236 feat(preview): keep review sessions alive (#2384)
* feat(preview): keep review sessions alive

* fix(preview): harden background lifecycle state

* refactor(preview): satisfy lifecycle quality gate
2026-07-13 22:55:19 -04:00

268 lines
8.1 KiB
TypeScript

import { spawn as nodeSpawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import { killProcessTree } from "../utils/orphanCleanup.js";
export interface PreviewSession {
pid: number;
port: number;
projectDir: string;
logPath: string;
}
type SpawnResult = { pid?: number; unref(): void };
type SpawnPreview = (
command: string,
args: string[],
options: {
detached: boolean;
stdio: ["ignore", number, number];
env: NodeJS.ProcessEnv;
},
) => SpawnResult;
interface LifecycleDependencies {
argv?: string[];
execPath?: string;
scan?: (startPort?: number) => Promise<ActiveServer[]>;
spawn?: SpawnPreview;
sleep?: (ms: number) => Promise<void>;
kill?: (pid: number) => void;
stateHome?: string;
forceNew?: boolean;
}
function defaultStateHome(): string {
return process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
}
function normalized(path: string): string {
const resolved = resolve(path).replace(/\\/g, "/");
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function sessionDirectory(stateHome = defaultStateHome()): string {
return join(stateHome, "hyperframes", "previews");
}
export function previewSessionPath(projectDir: string, stateHome = defaultStateHome()): string {
const key = createHash("sha256").update(normalized(projectDir)).digest("hex").slice(0, 16);
return join(sessionDirectory(stateHome), `${key}.json`);
}
function previewLogPath(projectDir: string, stateHome = defaultStateHome()): string {
return previewSessionPath(projectDir, stateHome).replace(/\.json$/, ".log");
}
export function writePreviewSession(session: PreviewSession, stateHome = defaultStateHome()): void {
const path = previewSessionPath(session.projectDir, stateHome);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
}
function readPreviewSession(
projectDir: string,
stateHome = defaultStateHome(),
): PreviewSession | null {
const path = previewSessionPath(projectDir, stateHome);
if (!existsSync(path)) return null;
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as PreviewSession;
if (
!Number.isInteger(parsed.pid) ||
parsed.pid <= 0 ||
normalized(parsed.projectDir) !== normalized(projectDir)
) {
throw new Error("invalid preview session");
}
return parsed;
} catch {
rmSync(path, { force: true });
return null;
}
}
function removePreviewSession(projectDir: string, stateHome = defaultStateHome()): void {
rmSync(previewSessionPath(projectDir, stateHome), { force: true });
}
function matchingServer(servers: ActiveServer[], projectDir: string): ActiveServer | null {
return servers.find((server) => normalized(server.projectDir) === normalized(projectDir)) ?? null;
}
function stopProcess(pid: number): void {
killProcessTree(pid);
if (process.platform === "win32") {
try {
process.kill(pid, "SIGTERM");
} catch {
// Process already exited.
}
}
}
const delay = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
function spawnDetachedPreview(
projectDir: string,
stateHome: string,
dependencies: LifecycleDependencies,
): { pid: number; logPath: string } {
const logPath = previewLogPath(projectDir, stateHome);
mkdirSync(dirname(logPath), { recursive: true });
const logFd = openSync(logPath, "a", 0o600);
const spawn = dependencies.spawn ?? (nodeSpawn as unknown as SpawnPreview);
let child: SpawnResult;
try {
child = spawn(
dependencies.execPath ?? process.execPath,
buildBackgroundPreviewArgs(dependencies.argv ?? process.argv.slice(1)),
{
detached: true,
stdio: ["ignore", logFd, logFd],
env: process.env,
},
);
} finally {
closeSync(logFd);
}
if (!child.pid) throw new Error("background preview child did not report a PID");
child.unref();
return { pid: child.pid, logPath };
}
function startedServer(
servers: ActiveServer[],
projectDir: string,
existing: ActiveServer | null,
forceNew: boolean,
): ActiveServer | null {
const candidates =
forceNew && existing ? servers.filter((server) => server.port !== existing.port) : servers;
return matchingServer(candidates, projectDir);
}
export function buildBackgroundPreviewArgs(argv: string[]): string[] {
const filtered = argv.filter(
(arg) =>
arg !== "--background" &&
!arg.startsWith("--background=") &&
arg !== "--open" &&
arg !== "--no-open",
);
return [...filtered, "--no-open"];
}
export async function readBackgroundPreviewStatus(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<PreviewSession | null> {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const server = matchingServer(await scan(saved?.port ?? startPort), projectDir);
if (server) {
const pid = Number(server.pid ?? saved?.pid);
if (Number.isInteger(pid) && pid > 0) {
return {
pid,
port: server.port,
projectDir: resolve(projectDir),
logPath: saved?.logPath ?? previewLogPath(projectDir, stateHome),
};
}
}
removePreviewSession(projectDir, stateHome);
return null;
}
export async function startBackgroundPreview(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<
| { type: "reused"; port: number; pid: number | null; logPath: string | null }
| { type: "started"; port: number; pid: number; logPath: string }
> {
const scan = dependencies.scan ?? scanActiveServers;
const existing = matchingServer(await scan(startPort), projectDir);
if (existing && !dependencies.forceNew) {
return {
type: "reused",
port: existing.port,
pid: existing.pid ? Number(existing.pid) : null,
logPath: null,
};
}
const stateHome = dependencies.stateHome ?? defaultStateHome();
const { pid, logPath } = spawnDetachedPreview(projectDir, stateHome, dependencies);
const sleep = dependencies.sleep ?? delay;
for (let attempt = 0; attempt < 50; attempt++) {
const server = startedServer(
await scan(startPort),
projectDir,
existing,
dependencies.forceNew === true,
);
if (server) {
const session = {
pid,
port: server.port,
projectDir: resolve(projectDir),
logPath,
};
writePreviewSession(session, stateHome);
return { type: "started", ...session };
}
await sleep(200);
}
(dependencies.kill ?? stopProcess)(pid);
throw new Error(`background preview did not become ready; see ${logPath}`);
}
export async function stopBackgroundPreview(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<boolean> {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const scanStart = saved?.port ?? startPort;
const server = matchingServer(await scan(scanStart), projectDir);
// A saved PID can be reused after a crashed preview, so only trust it while
// a currently reachable server proves this exact project is still running.
const pid = Number(server ? (server.pid ?? saved?.pid) : undefined);
if (!Number.isInteger(pid) || pid <= 0) {
removePreviewSession(projectDir, stateHome);
return false;
}
(dependencies.kill ?? stopProcess)(pid);
const sleep = dependencies.sleep ?? delay;
for (let attempt = 0; attempt < 25; attempt++) {
if (!matchingServer(await scan(scanStart), projectDir)) {
removePreviewSession(projectDir, stateHome);
return true;
}
await sleep(100);
}
throw new Error(`background preview did not stop for ${resolve(projectDir)}`);
}