1104 lines · 35.1 KB · TypeScript
RawDownload
/** * Server - HTTP/WebSocket server service interface. * * Owns startup and shutdown lifecycle of the HTTP server, static asset serving, * and WebSocket request routing. * * @module Server */import http from "node:http";import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime";import {  CommandId,  DEFAULT_PROVIDER_INTERACTION_MODE,  type ClientOrchestrationCommand,  type OrchestrationCommand,  ORCHESTRATION_WS_CHANNELS,  ORCHESTRATION_WS_METHODS,  PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,  ProjectId,  ThreadId,  WS_CHANNELS,  WS_METHODS,  WebSocketRequest,  type WsResponse as WsResponseMessage,  WsResponse,  type WsPushEnvelopeBase,} from "@t3tools/contracts";import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";import {  Cause,  Effect,  Exit,  FileSystem,  Layer,  Path,  Ref,  Result,  Schema,  Scope,  ServiceMap,  Stream,  Struct,} from "effect";import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger";import { GitManager } from "./git/Services/GitManager.ts";import { TerminalManager } from "./terminal/Services/Manager.ts";import { Keybindings } from "./keybindings";import { searchWorkspaceEntries } from "./workspaceEntries";import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";import { ProviderService } from "./provider/Services/ProviderService";import { ProviderHealth } from "./provider/Services/ProviderHealth";import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";import { clamp } from "effect/Number";import { Open, resolveAvailableEditors } from "./open";import { ServerConfig } from "./config";import { GitCore } from "./git/Services/GitCore.ts";import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";import {  ATTACHMENTS_ROUTE_PREFIX,  normalizeAttachmentRelativePath,  resolveAttachmentRelativePath,} from "./attachmentPaths"; import {  createAttachmentId,  resolveAttachmentPath,  resolveAttachmentPathById,} from "./attachmentStore.ts";import { parseBase64DataUrl } from "./imageMime.ts";import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";import { expandHomePath } from "./os-jank.ts";import { makeServerPushBus } from "./wsServer/pushBus.ts";import { makeServerReadiness } from "./wsServer/readiness.ts";import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; /** * ServerShape - Service API for server lifecycle control. */export interface ServerShape {  /**   * Start HTTP and WebSocket listeners.   */  readonly start: Effect.Effect<    http.Server,    ServerLifecycleError,    Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path  >;   /**   * Wait for process shutdown signals.   */  readonly stopSignal: Effect.Effect<void, never>;} /** * Server - Service tag for HTTP/WebSocket lifecycle management. */export class Server extends ServiceMap.Service<Server, ServerShape>()("t3/wsServer/Server") {} const isServerNotRunningError = (error: Error): boolean => {  const maybeCode = (error as NodeJS.ErrnoException).code;  return (    maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running")  );}; function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void {  const statusText =    statusCode === 401 ? "Unauthorized" : statusCode === 403 ? "Forbidden" : "Bad Request";  socket.end(    `HTTP/1.1 ${statusCode} ${statusText}\r\n` +      "Connection: close\r\n" +      "Content-Type: text/plain\r\n" +      `Content-Length: ${Buffer.byteLength(message)}\r\n` +      "\r\n" +      message,  );} function getFirstHeaderValue(header: string | readonly string[] | undefined): string | undefined {  if (typeof header === "string") {    return header;  }  return header?.[0];} function getForwardedHeaderValue(  header: string | readonly string[] | undefined,): string | undefined {  const value = getFirstHeaderValue(header)?.trim();  if (!value) {    return undefined;  }  const [firstValue] = value.split(",");  return firstValue?.trim() || undefined;} function normalizeRequestHost(header: string | readonly string[] | undefined): string | null {  const host = getForwardedHeaderValue(header);  if (!host) {    return null;  }   try {    const parsed = new URL(`http://${host}`);    return parsed.host.toLowerCase();  } catch {    return null;  }} function hasAllowedRequestHost(  request: http.IncomingMessage,  allowedHosts: ReadonlySet<string>,): boolean {  if (allowedHosts.size === 0) {    return true;  }   const requestHost =    normalizeRequestHost(request.headers["x-forwarded-host"]) ??    normalizeRequestHost(request.headers.host);  return requestHost !== null && allowedHosts.has(requestHost);} function getUpgradeRequestOrigin(request: http.IncomingMessage): URL | null {  const host =    normalizeRequestHost(request.headers["x-forwarded-host"]) ??    normalizeRequestHost(request.headers.host);  if (!host) {    return null;  }   const protocol =    getForwardedHeaderValue(request.headers["x-forwarded-proto"]) ??    ((request.socket as { encrypted?: boolean }).encrypted ? "https" : "http");   try {    return new URL(`${protocol}://${host}`);  } catch {    return null;  }} function hasVerifiedWebSocketOrigin(request: http.IncomingMessage): boolean {  const requestOrigin = getUpgradeRequestOrigin(request);  const originHeader = getForwardedHeaderValue(request.headers.origin);  if (!requestOrigin || !originHeader) {    return false;  }   try {    return new URL(originHeader).origin === requestOrigin.origin;  } catch {    return false;  }} function websocketRawToString(raw: unknown): string | null {  if (typeof raw === "string") {    return raw;  }  if (raw instanceof Uint8Array) {    return Buffer.from(raw).toString("utf8");  }  if (raw instanceof ArrayBuffer) {    return Buffer.from(new Uint8Array(raw)).toString("utf8");  }  if (Array.isArray(raw)) {    const chunks: string[] = [];    for (const chunk of raw) {      if (typeof chunk === "string") {        chunks.push(chunk);        continue;      }      if (chunk instanceof Uint8Array) {        chunks.push(Buffer.from(chunk).toString("utf8"));        continue;      }      if (chunk instanceof ArrayBuffer) {        chunks.push(Buffer.from(new Uint8Array(chunk)).toString("utf8"));        continue;      }      return null;    }    return chunks.join("");  }  return null;} function toPosixRelativePath(input: string): string {  return input.replaceAll("\\", "/");} function resolveWorkspaceWritePath(params: {  workspaceRoot: string;  relativePath: string;  path: Path.Path;}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> {  const normalizedInputPath = params.relativePath.trim();  if (params.path.isAbsolute(normalizedInputPath)) {    return Effect.fail(      new RouteRequestError({        message: "Workspace file path must be relative to the project root.",      }),    );  }   const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath);  const relativeToRoot = toPosixRelativePath(    params.path.relative(params.workspaceRoot, absolutePath),  );  if (    relativeToRoot.length === 0 ||    relativeToRoot === "." ||    relativeToRoot.startsWith("../") ||    relativeToRoot === ".." ||    params.path.isAbsolute(relativeToRoot)  ) {    return Effect.fail(      new RouteRequestError({        message: "Workspace file path must stay within the project root.",      }),    );  }   return Effect.succeed({    absolutePath,    relativePath: relativeToRoot,  });} function stripRequestTag<T extends { _tag: string }>(body: T) {  return Struct.omit(body, ["_tag"]);} const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); export type ServerCoreRuntimeServices =  | OrchestrationEngineService  | ProjectionSnapshotQuery  | CheckpointDiffQuery  | OrchestrationReactor  | ProviderService  | ProviderHealth; export type ServerRuntimeServices =  | ServerCoreRuntimeServices  | GitManager  | GitCore  | TerminalManager  | Keybindings  | Open  | AnalyticsService; export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(  "ServerLifecycleError",  {    operation: Schema.String,    cause: Schema.optional(Schema.Defect),  },) {} class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("RouteRequestError", {  message: Schema.String,}) {} export const createServer = Effect.fn(function* (): Effect.fn.Return<  http.Server,  ServerLifecycleError,  Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path> {  const serverConfig = yield* ServerConfig;  const {    port,    cwd,    keybindingsConfigPath,    staticDir,    devUrl,    authToken,    allowedHosts,    host,    logWebSocketEvents,    autoBootstrapProjectFromCwd,  } = serverConfig;  const allowedHostSet = new Set(allowedHosts);  const availableEditors = resolveAvailableEditors();   const gitManager = yield* GitManager;  const terminalManager = yield* TerminalManager;  const keybindingsManager = yield* Keybindings;  const providerHealth = yield* ProviderHealth;  const git = yield* GitCore;  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;   yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe(    Effect.catch((error) =>      Effect.logWarning("failed to sync keybindings defaults on startup", {        path: error.configPath,        detail: error.detail,        cause: error.cause,      }),    ),  );   const providerStatuses = yield* providerHealth.getStatuses;   const clients = yield* Ref.make(new Set<WebSocket>());  const logger = createLogger("ws");  const readiness = yield* makeServerReadiness;   function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) {    if (!logWebSocketEvents) return;    logger.event("outgoing push", {      channel: push.channel,      sequence: push.sequence,      recipients,      payload: push.data,    });  }   const pushBus = yield* makeServerPushBus({    clients,    logOutgoingPush,  });  yield* readiness.markPushBusReady;  yield* keybindingsManager.start.pipe(    Effect.mapError(      (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }),    ),  );  yield* readiness.markKeybindingsReady;   const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {    readonly command: ClientOrchestrationCommand;  }) {    const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) {      const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim()));      const workspaceStat = yield* fileSystem        .stat(normalizedWorkspaceRoot)        .pipe(Effect.catch(() => Effect.succeed(null)));      if (!workspaceStat) {        return yield* new RouteRequestError({          message: `Project directory does not exist: ${normalizedWorkspaceRoot}`,        });      }      if (workspaceStat.type !== "Directory") {        return yield* new RouteRequestError({          message: `Project path is not a directory: ${normalizedWorkspaceRoot}`,        });      }      return normalizedWorkspaceRoot;    });     if (input.command.type === "project.create") {      return {        ...input.command,        workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),      } satisfies OrchestrationCommand;    }     if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) {      return {        ...input.command,        workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),      } satisfies OrchestrationCommand;    }     if (input.command.type !== "thread.turn.start") {      return input.command as OrchestrationCommand;    }    const turnStartCommand = input.command;     const normalizedAttachments = yield* Effect.forEach(      turnStartCommand.message.attachments,      (attachment) =>        Effect.gen(function* () {          const parsed = parseBase64DataUrl(attachment.dataUrl);          if (!parsed || !parsed.mimeType.startsWith("image/")) {            return yield* new RouteRequestError({              message: `Invalid image attachment payload for '${attachment.name}'.`,            });          }           const bytes = Buffer.from(parsed.base64, "base64");          if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {            return yield* new RouteRequestError({              message: `Image attachment '${attachment.name}' is empty or too large.`,            });          }           const attachmentId = createAttachmentId(turnStartCommand.threadId);          if (!attachmentId) {            return yield* new RouteRequestError({              message: "Failed to create a safe attachment id.",            });          }           const persistedAttachment = {            type: "image" as const,            id: attachmentId,            name: attachment.name,            mimeType: parsed.mimeType.toLowerCase(),            sizeBytes: bytes.byteLength,          };           const attachmentPath = resolveAttachmentPath({            stateDir: serverConfig.stateDir,            attachment: persistedAttachment,          });          if (!attachmentPath) {            return yield* new RouteRequestError({              message: `Failed to resolve persisted path for '${attachment.name}'.`,            });          }           yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(            Effect.mapError(              () =>                new RouteRequestError({                  message: `Failed to create attachment directory for '${attachment.name}'.`,                }),            ),          );          yield* fileSystem.writeFile(attachmentPath, bytes).pipe(            Effect.mapError(              () =>                new RouteRequestError({                  message: `Failed to persist attachment '${attachment.name}'.`,                }),            ),          );           return persistedAttachment;        }),      { concurrency: 1 },    );     return {      ...turnStartCommand,      message: {        ...turnStartCommand.message,        attachments: normalizedAttachments,      },    } satisfies OrchestrationCommand;  });   // HTTP server — serves static files or redirects to Vite dev server  const httpServer = http.createServer((req, res) => {    const respond = (      statusCode: number,      headers: Record<string, string>,

Lines 1–500 of 1104.