import type { ShellRequest, ShellResponse } from "../type/rpc.ts"; import type { ServerOptions } from "./http_server.ts"; import { timeout } from "../lib/async.ts"; /** * Configuration via environment variables: * - SB_SHELL_BACKEND: "local" or "off" */ export function determineShellBackend( serverOptions: ServerOptions, ): ShellBackend { const backendConfig = Deno.env.get("SB_SHELL_BACKEND") || "local"; switch (backendConfig) { case "local": return new LocalShell(serverOptions.pagesPath); default: console.info( "Running in shellless mode, meaning shell commands are disabled", ); return new NotSupportedShell(); } } export interface ShellBackend { handle(shellRequest: ShellRequest): Promise; } export class NotSupportedShell implements ShellBackend { handle(): Promise { return Promise.resolve({ stdout: "", stderr: "Not supported", code: 1, }); } } export class StreamingShell { private process: Deno.ChildProcess | null = null; private outputHandlers: Array< (type: string, data: string) => void > = []; private textDecoder = new TextDecoder(); private textEncoder = new TextEncoder(); constructor(private cwd: string) {} start(cmd: string, args: string[]): void { console.log("Starting streaming shell:", cmd, args); this.process = new Deno.Command(cmd, { cwd: this.cwd, args: args, stdin: "piped", stdout: "piped", stderr: "piped", }).spawn(); // Handle stdout this.readStream(this.process.stdout, "stdout"); // Handle stderr this.readStream(this.process.stderr, "stderr"); // Handle process exit this.process.status.then((status) => { this.notifyOutput( "exit", "json", JSON.stringify({ code: status.code || 0 }), ); // Process is already exited, just set to null this.process = null; }); } private async readStream( stream: ReadableStream, type: string, ): Promise { const reader = stream.getReader(); try { while (true) { const { value, done } = await reader.read(); if (done) break; // Handle as text data (UTF-8) const text = this.textDecoder.decode(value, { stream: true }); if (text.trim()) { console.log(`${type.toUpperCase()}: ${text}`); this.notifyOutput(type, "text", text); } } } catch (e) { const error = e as Error; console.error(`Error reading ${type}:`, error); this.notifyOutput("error", "text", `Stream error: ${error.message}`); } finally { reader.releaseLock(); } } async writeToStdin(data: string): Promise { if (!this.process || !this.process.stdin) { throw new Error("Process not started or stdin not available"); } const writer = this.process.stdin.getWriter(); try { // Add newline for text data await writer.write(this.textEncoder.encode(data + "\n")); } finally { writer.releaseLock(); } } onOutput( handler: (type: string, data: string) => void, ): void { this.outputHandlers.push(handler); } private notifyOutput(type: string, _format: string, data: string): void { for (const handler of this.outputHandlers) { handler(type, data); } } /** * Send a signal to the process * @param signal The signal to send (e.g., "SIGTERM", "SIGINT", "SIGKILL") * @returns True if the signal was sent successfully, false otherwise */ sendSignal(signal: string): boolean { if (!this.process) { console.error("Cannot send signal: Process not started"); return false; } try { // Convert signal string to Deno.Signal enum let denoSignal: Deno.Signal; switch (signal.toUpperCase()) { case "SIGINT": denoSignal = "SIGINT"; break; case "SIGTERM": denoSignal = "SIGTERM"; break; case "SIGKILL": denoSignal = "SIGKILL"; break; case "SIGHUP": denoSignal = "SIGHUP"; break; default: console.error(`Unsupported signal: ${signal}`); return false; } this.process.kill(denoSignal); console.log(`Sent ${signal} to process`); return true; } catch (e) { console.error(`Error sending ${signal} to process:`, e); return false; } } async close(): Promise { if (this.process) { try { // First try to send SIGTERM to allow graceful shutdown try { console.log("Sending SIGTERM to process"); this.process.kill("SIGTERM"); } catch (e) { console.error("Error sending SIGTERM to process:", e); } // Close stdin if it's available if (this.process.stdin) { try { // Create a writer to check if the stream is writable const writer = this.process.stdin.getWriter(); writer.releaseLock(); // Release immediately to avoid locking // If we got here, the stream is still open, so close it this.process.stdin.close(); } catch (e) { // If we get an error, the stream might already be closed or errored // Just log it and continue console.log("Note: stdin already closed or errored:", e); } } // Wait for the process to exit with a short timeout try { const status = await Promise.race([ this.process.status, timeout(500).then(() => { throw new Error("Process exit timeout after SIGTERM"); }), ]); console.log("Process exited with code:", status.code); this.process = null; return; } catch (e) { console.log("Process did not exit after SIGTERM:", e); } // If we're here, the process didn't exit after SIGTERM // Try SIGKILL as a last resort if (this.process) { // Check if process is still not null try { console.log("Sending SIGKILL to process"); this.process.kill("SIGKILL"); // Wait again with a longer timeout const status = await Promise.race([ this.process.status, timeout(500).then(() => { throw new Error("Process exit timeout after SIGKILL"); }), ]); console.log("Process exited with code after SIGKILL:", status.code); } catch (e) { console.error( "Error waiting for process to exit after SIGKILL:", e, ); } } } catch (e) { console.error("Error closing process:", e); } // Always set process to null to avoid memory leaks this.process = null; } } } export class LocalShell implements ShellBackend { constructor(private cwd: string) { } async handle(shellRequest: ShellRequest): Promise { console.log( "Running shell command:", shellRequest.cmd, shellRequest.args, ); const p = new Deno.Command(shellRequest.cmd, { cwd: this.cwd, args: shellRequest.args, stdout: "piped", stderr: "piped", }); const output = await p.output(); const stdout = new TextDecoder().decode(output.stdout); const stderr = new TextDecoder().decode(output.stderr); if (output.code !== 0) { console.error("Error running shell command", stdout, stderr); } return { stderr, stdout, code: output.code, }; } }