singularity-forge/web/lib/pty-manager.ts
2026-05-05 14:46:18 +02:00

473 lines
12 KiB
TypeScript

/**
* Server-side PTY manager — spawns and manages pseudo-terminal instances.
*
* Each terminal session gets a unique ID. PTY output is buffered and streamed
* to clients via SSE; input arrives via POST.
*/
import { chmodSync, existsSync, statSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import type { IPty } from "node-pty";
import { resolveSfCliEntry } from "../../src/web/cli-entry.ts";
// Webpack escape hatch — this global exists at runtime in webpack bundles and
// forwards to Node's native require(), bypassing webpack's module resolution.
declare const __non_webpack_require__: NodeRequire;
export interface PtySession {
id: string;
pty: IPty;
listeners: Set<(data: string) => void>;
alive: boolean;
buffer: string[];
bufferedBytes: number;
}
interface LoadedNodePty {
nodePtyModule: typeof import("node-pty");
packageRoot: string;
}
// Use globalThis to persist across Turbopack/HMR module re-evaluations in dev
const GLOBAL_KEY = "__sf_pty_sessions__" as const;
const CLEANUP_GUARD_KEY = "__sf_pty_cleanup_installed__" as const;
const MAX_SESSION_BUFFER_BYTES = 1024 * 1024;
function getSessions(): Map<string, PtySession> {
const g = globalThis as Record<string, unknown>;
if (!g[GLOBAL_KEY]) {
g[GLOBAL_KEY] = new Map<string, PtySession>();
}
return g[GLOBAL_KEY] as Map<string, PtySession>;
}
function getChunkByteLength(data: string): number {
return Buffer.byteLength(data, "utf8");
}
function appendToSessionBuffer(session: PtySession, data: string): void {
if (!data) return;
session.buffer.push(data);
session.bufferedBytes += getChunkByteLength(data);
while (
session.bufferedBytes > MAX_SESSION_BUFFER_BYTES &&
session.buffer.length > 1
) {
const removed = session.buffer.shift();
if (!removed) break;
session.bufferedBytes -= getChunkByteLength(removed);
}
}
function destroyAllSessions(): void {
const map = getSessions();
for (const [sessionId, session] of map.entries()) {
session.alive = false;
try {
session.pty.kill();
} catch {
// Already dead.
}
session.listeners.clear();
map.delete(sessionId);
}
}
function ensureProcessCleanupHandlers(): void {
const g = globalThis as Record<string, unknown>;
if (g[CLEANUP_GUARD_KEY]) return;
g[CLEANUP_GUARD_KEY] = true;
const cleanup = () => {
destroyAllSessions();
};
process.once("exit", cleanup);
process.once("SIGINT", () => {
cleanup();
process.exit(130);
});
process.once("SIGTERM", () => {
cleanup();
process.exit(143);
});
process.once("SIGHUP", () => {
cleanup();
process.exit(129);
});
}
function getDefaultShell(): string {
if (process.platform === "win32") return "powershell.exe";
return process.env.SHELL || "/bin/zsh";
}
function getProjectCwd(): string {
return process.env.SF_WEB_PROJECT_CWD || process.cwd();
}
function getShellArgs(): string[] {
// Launch an interactive login shell with the user's normal config.
// Previously we passed -f / --norc to skip rc files, but that removed the
// user's prompt, PATH, aliases, etc. — making the terminal feel broken.
// History pollution is already prevented via HISTFILE=/dev/null in the env.
return [];
}
interface TerminalSpawnSpec {
executable: string;
args: string[];
label: string;
}
const ALLOWED_TERMINAL_COMMANDS = new Set([
"sf",
process.env.SHELL || "/bin/zsh",
"/bin/bash",
"/bin/zsh",
"/bin/sh",
]);
export function isAllowedTerminalCommand(command?: string): boolean {
if (!command) return true;
return ALLOWED_TERMINAL_COMMANDS.has(command);
}
function resolveTerminalSpawnSpec(
cwd: string,
command?: string,
commandArgs: string[] = [],
): TerminalSpawnSpec {
if (!command) {
const shell = getDefaultShell();
return {
executable: shell,
args: getShellArgs(),
label: basename(shell),
};
}
if (command === "sf") {
try {
const cliEntry = resolveSfCliEntry({
packageRoot: process.env.SF_WEB_PACKAGE_ROOT || process.cwd(),
cwd,
execPath: process.execPath,
hostKind: process.env.SF_WEB_HOST_KIND,
mode: "interactive",
messages: commandArgs,
});
return {
executable: cliEntry.command,
args: cliEntry.args,
label: "sf",
};
} catch (error) {
console.warn(
"[pty] Falling back to PATH-resolved sf:",
error instanceof Error ? error.message : String(error),
);
}
}
return {
executable: command,
args: commandArgs,
label: basename(command),
};
}
function getNodePtyCandidateRoots(): string[] {
const roots = new Set<string>();
roots.add(process.cwd());
const packageRoot = process.env.SF_WEB_PACKAGE_ROOT;
if (packageRoot) {
roots.add(packageRoot);
roots.add(join(packageRoot, "dist", "web", "standalone"));
roots.add(join(packageRoot, "web"));
}
return Array.from(roots);
}
function hasNativeAssets(packageRoot: string): boolean {
const prebuildDir = join(
packageRoot,
"prebuilds",
`${process.platform}-${process.arch}`,
);
return (
existsSync(join(prebuildDir, "pty.node")) ||
existsSync(join(packageRoot, "build", "Release", "pty.node")) ||
existsSync(join(packageRoot, "build", "Debug", "pty.node"))
);
}
function loadNodePty(): LoadedNodePty {
const failures: string[] = [];
for (const root of getNodePtyCandidateRoots()) {
// Probe for node-pty's package.json directly in node_modules under this root.
// We avoid createRequire from node:module because webpack mangles it in
// Next.js standalone builds — the import gets swallowed/replaced with
// undefined since webpack treats `module` as its own internal concept.
const candidate = join(root, "node_modules", "node-pty", "package.json");
if (!existsSync(candidate)) {
failures.push(`${root}: node-pty not found`);
continue;
}
try {
const packageRoot = dirname(candidate);
if (!hasNativeAssets(packageRoot)) {
failures.push(`${packageRoot}: missing native assets`);
continue;
}
// node-pty is listed in serverExternalPackages, but webpack still
// processes require() calls with computed paths — it replaces them with
// a "module not found" stub. We use __non_webpack_require__ (webpack's
// escape hatch) so the require passes through to Node's native loader
// at runtime.
//
// The bare `require` fallback is wrapped in Function() to prevent
// webpack from statically analyzing it and emitting a "critical
// dependency" warning. At runtime in non-webpack environments (e.g.
// tests) this produces an identical NodeRequire function.
const nativeRequire: NodeRequire =
typeof __non_webpack_require__ !== "undefined"
? __non_webpack_require__
: (new Function("return require")() as NodeRequire);
const nodePtyModule = nativeRequire(
join(packageRoot, "lib", "index.js"),
) as typeof import("node-pty");
return { nodePtyModule, packageRoot };
} catch (error) {
failures.push(
`${root}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
throw new Error(
`Failed to load node-pty with native assets. Tried: ${failures.join(" | ") || "no candidate roots"}`,
);
}
export function getOrCreateSession(
sessionId: string,
projectCwd?: string,
command?: string,
commandArgs: string[] = [],
): PtySession {
ensureProcessCleanupHandlers();
if (!isAllowedTerminalCommand(command)) {
throw new Error(`Command not allowed: ${command}`);
}
const map = getSessions();
const existing = map.get(sessionId);
if (existing?.alive) return existing;
// Clean up dead session if it exists
if (existing) {
map.delete(sessionId);
}
const { nodePtyModule: pty, packageRoot: nodePtyRoot } = loadNodePty();
// Ensure the spawn-helper binary is executable (npm doesn't always preserve permissions)
try {
const helperPath = join(
nodePtyRoot,
"prebuilds",
`${process.platform}-${process.arch}`,
"spawn-helper",
);
if (existsSync(helperPath)) {
const st = statSync(helperPath);
if ((st.mode & 0o111) === 0) {
chmodSync(helperPath, st.mode | 0o755);
console.log("[pty] Fixed spawn-helper permissions:", helperPath);
}
}
} catch (e) {
console.warn("[pty] Could not check spawn-helper:", e);
}
const cwd = projectCwd || getProjectCwd();
const spawnSpec = resolveTerminalSpawnSpec(cwd, command, commandArgs);
console.log(
"[pty] Spawning command:",
spawnSpec.label,
"cwd:",
cwd,
"node-pty:",
nodePtyRoot,
);
// Build a clean env — remove SF-specific vars that would confuse a shell
const cleanEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !key.startsWith("SF_WEB_")) {
cleanEnv[key] = value;
}
}
cleanEnv.TERM = "xterm-256color";
cleanEnv.COLORTERM = "truecolor";
cleanEnv.HISTFILE = "/dev/null";
cleanEnv.HISTSIZE = "0";
cleanEnv.SAVEHIST = "0";
cleanEnv.LESSHISTFILE = "/dev/null";
cleanEnv.NODE_REPL_HISTORY = "/dev/null";
if (command) {
cleanEnv.SF_WEB_PTY = "1";
}
let ptyProcess: IPty;
try {
ptyProcess = pty.spawn(spawnSpec.executable, spawnSpec.args, {
name: "xterm-256color",
cols: 120,
rows: 30,
cwd,
env: cleanEnv,
});
console.log("[pty] Spawned pid:", ptyProcess.pid);
} catch (spawnError) {
console.error("[pty] Spawn failed:", spawnError);
console.error(
"[pty] Command:",
spawnSpec.executable,
"Args:",
spawnSpec.args,
"CWD:",
cwd,
);
console.error("[pty] CWD exists:", existsSync(cwd));
throw spawnError;
}
const session: PtySession = {
id: sessionId,
pty: ptyProcess,
listeners: new Set(),
alive: true,
buffer: [],
bufferedBytes: 0,
};
ptyProcess.onData((data: string) => {
appendToSessionBuffer(session, data);
for (const listener of session.listeners) {
try {
listener(data);
} catch {
// Listener may have been removed during iteration
}
}
});
ptyProcess.onExit(({ exitCode, signal }) => {
session.alive = false;
// Notify listeners about exit
const exitMessage = `\r\n\x1b[90m[Process exited with code ${exitCode}${signal ? `, signal ${signal}` : ""}]\x1b[0m\r\n`;
appendToSessionBuffer(session, exitMessage);
for (const listener of session.listeners) {
try {
listener(exitMessage);
} catch {
// ignore
}
}
});
map.set(sessionId, session);
return session;
}
export function writeToSession(sessionId: string, data: string): boolean {
const session = getSessions().get(sessionId);
if (!session?.alive) return false;
session.pty.write(data);
return true;
}
export function resizeSession(
sessionId: string,
cols: number,
rows: number,
): boolean {
const session = getSessions().get(sessionId);
if (!session?.alive) return false;
try {
session.pty.resize(cols, rows);
return true;
} catch {
return false;
}
}
export function destroySession(sessionId: string): boolean {
const map = getSessions();
const session = map.get(sessionId);
if (!session) return false;
session.alive = false;
try {
session.pty.kill();
} catch {
// Already dead
}
session.listeners.clear();
map.delete(sessionId);
return true;
}
export function addListener(
sessionId: string,
listener: (data: string) => void,
): (() => void) | null {
const session = getSessions().get(sessionId);
if (!session) return null;
const snapshot = session.buffer.slice();
session.listeners.add(listener);
for (const chunk of snapshot) {
try {
listener(chunk);
} catch {
session.listeners.delete(listener);
return null;
}
}
return () => {
session.listeners.delete(listener);
};
}
export function isSessionAlive(sessionId: string): boolean {
const session = getSessions().get(sessionId);
return session?.alive ?? false;
}
export interface PtySessionInfo {
id: string;
alive: boolean;
pid: number | undefined;
}
export function listSessions(): PtySessionInfo[] {
const map = getSessions();
return Array.from(map.values()).map((s) => ({
id: s.id,
alive: s.alive,
pid: s.pty.pid,
}));
}