664 lines · 20.4 KB · TypeScript
RawDownload
import { spawn } from "node:child_process";import fs from "node:fs";import http from "node:http";import os from "node:os";import path from "node:path";import crypto from "node:crypto";import type { Duplex } from "node:stream"; import {  EDITORS,  WS_CHANNELS,  WS_METHODS,  type TerminalEvent,  type WsPush,  type WsRequest,  type WsResponse,  wsRequestSchema,} from "@t3tools/contracts";import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger";import { ProjectRegistry } from "./projectRegistry";import { ProviderManager } from "./providerManager";import { GitManager } from "./gitManager";import {  checkoutGitBranch,  createGitBranch,  createGitWorktree,  initGitRepo,  listGitBranches,  pullGitBranch,  removeGitWorktree,} from "./git";import { TerminalManager } from "./terminalManager";import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";import { searchWorkspaceEntries } from "./workspaceEntries";import { CoreRuntime } from "./coreRuntime"; const MIME_TYPES: Record<string, string> = {  ".html": "text/html; charset=utf-8",  ".js": "application/javascript; charset=utf-8",  ".css": "text/css; charset=utf-8",  ".json": "application/json; charset=utf-8",  ".png": "image/png",  ".jpg": "image/jpeg",  ".svg": "image/svg+xml",  ".ico": "image/x-icon",  ".woff": "font/woff",  ".woff2": "font/woff2",  ".ttf": "font/ttf",  ".map": "application/json",}; export interface ServerOptions {  port: number;  host?: string | undefined;  cwd: string;  staticDir?: string | undefined;  devUrl?: string | undefined;  logWebSocketEvents?: boolean | undefined;  projectRegistry?: ProjectRegistry | undefined;  stateDir?: string | undefined;  gitManager?: GitManager | undefined;  terminalManager?: TerminalManager | undefined;  authToken?: string | undefined;} function parseBooleanEnv(value: string | undefined): boolean | undefined {  if (value === undefined) return undefined;  const normalized = value.trim().toLowerCase();  if (["1", "true", "yes", "on"].includes(normalized)) return true;  if (["0", "false", "no", "off"].includes(normalized)) return false;  return undefined;} export function createServer(options: ServerOptions) {  const {    port,    host,    cwd,    staticDir,    devUrl,    logWebSocketEvents: explicitLogWsEvents,    projectRegistry: providedRegistry,    stateDir,    gitManager: providedGitManager,    terminalManager: providedTerminalManager,    authToken,  } = options;  const providerManager = new ProviderManager();  const terminalManager = providedTerminalManager ?? new TerminalManager();  const projectRegistry =    providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));  const gitManager = providedGitManager ?? new GitManager();  const runtimeStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");  const coreRuntime = new CoreRuntime(runtimeStateDir);  const clients = new Set<WebSocket>();  const logger = createLogger("ws");  const segments = cwd.split(/[/\\]/).filter(Boolean);  const projectName = segments[segments.length - 1] ?? "project";  const logWebSocketEvents =    explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);  let keybindingsConfig = loadResolvedKeybindingsConfig(logger);   function logOutgoingPush(push: WsPush, recipients: number) {    if (!logWebSocketEvents) return;    logger.event("outgoing push", {      channel: push.channel,      recipients,      payload: push.data,    });  }   function broadcastPush(push: WsPush): void {    const message = JSON.stringify(push);    let recipients = 0;    for (const client of clients) {      if (client.readyState === client.OPEN) {        client.send(message);        recipients += 1;      }    }    logOutgoingPush(push, recipients);  }   const onTerminalEvent = (event: TerminalEvent) => {    broadcastPush({      type: "push",      channel: WS_CHANNELS.terminalEvent,      data: event,    });    if (event.type === "activity" || event.type === "exited" || event.type === "error") {      void coreRuntime.dispatch({        id: crypto.randomUUID(),        type: "thread.setTerminalActivity",        issuedAt: event.createdAt,        payload: {          threadId: event.threadId,          terminalId: event.terminalId,          running: event.type === "activity" ? event.hasRunningSubprocess : false,        },      });    }  };  terminalManager.on("event", onTerminalEvent);  coreRuntime.bindProviderEvents(providerManager);  let stateStreamStopped = false;  let stateStreamTask: Promise<void> | null = null;   // HTTP server — serves static files or redirects to Vite dev server  const httpServer = http.createServer((req, res) => {    // In dev mode, redirect to Vite dev server    if (devUrl) {      res.writeHead(302, { Location: devUrl });      res.end();      return;    }     // Serve static files from the web app build    if (!staticDir) {      res.writeHead(503, { "Content-Type": "text/plain" });      res.end("No static directory configured and no dev URL set.");      return;    }     const url = new URL(req.url ?? "/", `http://localhost:${port}`);    let filePath = path.join(staticDir, url.pathname);     // SPA fallback: if no file extension and not found, serve index.html    const ext = path.extname(filePath);    if (!ext) {      filePath = path.join(filePath, "index.html");    }     fs.stat(filePath, (err, stats) => {      if (err || !stats?.isFile()) {        // SPA fallback        const indexPath = path.join(staticDir, "index.html");        fs.readFile(indexPath, (readErr, data) => {          if (readErr) {            res.writeHead(404, { "Content-Type": "text/plain" });            res.end("Not Found");            return;          }          res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });          res.end(data);        });        return;      }       const fileExt = path.extname(filePath);      const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream";       fs.readFile(filePath, (readErr, data) => {        if (readErr) {          res.writeHead(500, { "Content-Type": "text/plain" });          res.end("Internal Server Error");          return;        }        res.writeHead(200, { "Content-Type": contentType });        res.end(data);      });    });  });   // WebSocket server — upgrades from the HTTP server  const wss = new WebSocketServer({ noServer: true });   function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void {    socket.write(      `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` +        "Connection: close\r\n" +        "Content-Type: text/plain\r\n" +        `Content-Length: ${Buffer.byteLength(message)}\r\n` +        "\r\n" +        message,    );    socket.destroy();  }   httpServer.on("upgrade", (request, socket, head) => {    if (authToken) {      let providedToken: string | null = null;      try {        const url = new URL(request.url ?? "/", `http://localhost:${port}`);        providedToken = url.searchParams.get("token");      } catch {        rejectUpgrade(socket, 400, "Invalid WebSocket URL");        return;      }       if (providedToken !== authToken) {        rejectUpgrade(socket, 401, "Unauthorized WebSocket connection");        return;      }    }     wss.handleUpgrade(request, socket, head, (ws) => {      wss.emit("connection", ws, request);    });  });   wss.on("connection", (ws) => {    clients.add(ws);     const welcome: WsPush = {      type: "push",      channel: WS_CHANNELS.serverWelcome,      data: { cwd, projectName },    };    logOutgoingPush(welcome, 1);    ws.send(JSON.stringify(welcome));     ws.on("message", (raw) => {      void handleMessage(ws, raw);    });     ws.on("close", () => {      clients.delete(ws);    });     ws.on("error", () => {      clients.delete(ws);    });  });   async function handleMessage(ws: WebSocket, raw: unknown) {    let request: WsRequest;    try {      const parsed = JSON.parse(String(raw));      request = wsRequestSchema.parse(parsed);    } catch {      const errorResponse: WsResponse = {        id: "unknown",        error: { message: "Invalid request format" },      };      ws.send(JSON.stringify(errorResponse));      return;    }     try {      const result = await routeRequest(request);      const response: WsResponse = { id: request.id, result };      ws.send(JSON.stringify(response));    } catch (err) {      const message = err instanceof Error ? err.message : "Unknown server error";      const response: WsResponse = {        id: request.id,        error: { message },      };      ws.send(JSON.stringify(response));    }  }   async function routeRequest(request: WsRequest): Promise<unknown> {    const requestNow = new Date().toISOString();    const paramsObj = (request.params ?? {}) as Record<string, unknown>;    const state = await coreRuntime.state();    const findThreadBySessionId = (sessionId: string | undefined) =>      sessionId        ? state.threads.find((thread) => thread.session?.sessionId === sessionId)        : undefined;     switch (request.method) {      case WS_METHODS.providersStartSession: {        const session = await providerManager.startSession(request.params as never);        const uiThreadId = typeof paramsObj.uiThreadId === "string" ? paramsObj.uiThreadId : undefined;        if (uiThreadId) {          await coreRuntime.bindProviderSession(uiThreadId, session);        }        return session;      }       case WS_METHODS.providersSendTurn: {        const sessionId = typeof paramsObj.sessionId === "string" ? paramsObj.sessionId : undefined;        const uiThreadId = typeof paramsObj.uiThreadId === "string" ? paramsObj.uiThreadId : undefined;        const targetThread = uiThreadId ? state.threads.find((thread) => thread.id === uiThreadId) : findThreadBySessionId(sessionId);        const inputText = typeof paramsObj.input === "string" ? paramsObj.input : undefined;        if (targetThread && inputText && inputText.trim().length > 0) {          await coreRuntime.dispatch({            id: crypto.randomUUID(),            type: "thread.addUserMessage",            issuedAt: requestNow,            payload: {              threadId: targetThread.id,              messageId: crypto.randomUUID(),              text: inputText,              createdAt: requestNow,            },          });        }        return providerManager.sendTurn(request.params as never);      }       case WS_METHODS.providersInterruptTurn:        return providerManager.interruptTurn(request.params as never);       case WS_METHODS.providersRespondToRequest:        return providerManager.respondToRequest(request.params as never);       case WS_METHODS.providersStopSession: {        const sessionId = typeof paramsObj.sessionId === "string" ? paramsObj.sessionId : undefined;        const boundThread = findThreadBySessionId(sessionId);        providerManager.stopSession(request.params as never);        if (boundThread) {          await coreRuntime.clearProviderSession(boundThread.id);        }        return undefined;      }       case WS_METHODS.providersListSessions:        return providerManager.listSessions();       case WS_METHODS.providersListCheckpoints:        return providerManager.listCheckpoints(request.params as never);       case WS_METHODS.providersGetCheckpointDiff:        return providerManager.getCheckpointDiff(request.params as never);       case WS_METHODS.providersRevertToCheckpoint:        return providerManager.revertToCheckpoint(request.params as never);       case WS_METHODS.projectsList:        return projectRegistry.list();       case WS_METHODS.projectsAdd:        {          const result = projectRegistry.add(request.params as never);          await coreRuntime.dispatch({            id: crypto.randomUUID(),            type: "project.add",            issuedAt: requestNow,            payload: {              id: result.project.id,              name: result.project.name,              cwd: result.project.cwd,              model: "gpt-5-codex",              scripts: result.project.scripts,            },          });          return result;        }       case WS_METHODS.projectsRemove:        {          const projectId = typeof paramsObj.id === "string" ? paramsObj.id : undefined;          if (projectId) {            await coreRuntime.dispatch({              id: crypto.randomUUID(),              type: "project.remove",              issuedAt: requestNow,              payload: { id: projectId },            });          }        projectRegistry.remove(request.params as never);        return undefined;        }       case WS_METHODS.projectsSearchEntries:        return searchWorkspaceEntries(request.params as never);      case WS_METHODS.projectsUpdateScripts:        {          const result = projectRegistry.updateScripts(request.params as never);          await coreRuntime.dispatch({            id: crypto.randomUUID(),            type: "project.updateScripts",            issuedAt: requestNow,            payload: { id: result.project.id, scripts: result.project.scripts },          });          return result;        }       case WS_METHODS.shellOpenInEditor: {        const params = request.params as {          cwd: string;          editor: string;        };        if (!params?.cwd) throw new Error("cwd is required");        const editorDef = EDITORS.find((e) => e.id === params.editor);        if (!editorDef) throw new Error(`Unknown editor: ${params.editor}`);         let command: string;        let args: string[];         if (editorDef.command) {          command = editorDef.command;          args = [params.cwd];        } else if (editorDef.id === "file-manager") {          // Use platform-specific file manager command          switch (process.platform) {            case "darwin":              command = "open";              break;            case "win32":              command = "explorer";              break;            default:              command = "xdg-open";              break;          }          args = [params.cwd];        } else {          return undefined;        }         const child = spawn(command, args, {          detached: true,          stdio: "ignore",        });        child.on("error", () => {          /* ignore spawn failures for detached editors */        });        child.unref();        return undefined;      }       case WS_METHODS.gitStatus:        return gitManager.status(request.params as never);       case WS_METHODS.gitPull:        return pullGitBranch(request.params as never);       case WS_METHODS.gitRunStackedAction:        return gitManager.runStackedAction(request.params as never);      case WS_METHODS.gitListBranches:        return listGitBranches(request.params as never);       case WS_METHODS.gitCreateWorktree:        return createGitWorktree(request.params as never);       case WS_METHODS.gitRemoveWorktree:        return removeGitWorktree(request.params as never);       case WS_METHODS.gitCreateBranch:        return createGitBranch(request.params as never);       case WS_METHODS.gitCheckout:        return checkoutGitBranch(request.params as never);       case WS_METHODS.gitInit:        return initGitRepo(request.params as never);       case WS_METHODS.terminalOpen:        return terminalManager.open(request.params as never);       case WS_METHODS.terminalWrite:        await terminalManager.write(request.params as never);        return undefined;       case WS_METHODS.terminalResize:        await terminalManager.resize(request.params as never);        return undefined;       case WS_METHODS.terminalClear:        await terminalManager.clear(request.params as never);        return undefined;       case WS_METHODS.terminalRestart:        return terminalManager.restart(request.params as never); 

Lines 1–500 of 664.