602 lines · 23.6 KB · TypeScript
RawDownload
/** * HTTP-based implementation of ElectronAPI for browser mode. * * Replaces Electron IPC with fetch() for request/response and * EventSource (SSE) for real-time events. Allows the renderer * to run in a regular browser connected to an HTTP server. */ import type {  AppConfig,  ClaudeMdFileInfo,  ClaudeRootFolderSelection,  ClaudeRootInfo,  ConfigAPI,  ContextInfo,  ConversationGroup,  ElectronAPI,  FileChangeEvent,  HttpServerAPI,  HttpServerStatus,  NotificationsAPI,  NotificationTrigger,  PaginatedSessionsResult,  Project,  RepositoryGroup,  SearchSessionsResult,  Session,  SessionAPI,  SessionDetail,  SessionMetrics,  SessionsByIdsOptions,  SessionsPaginationOptions,  SshAPI,  SshConfigHostEntry,  SshConnectionConfig,  SshConnectionStatus,  SshLastConnection,  SubagentDetail,  TriggerTestResult,  UpdaterAPI,  WaterfallData,  WslClaudeRootCandidate,} from '@shared/types';import type { AgentConfig } from '@shared/types/api'; export class HttpAPIClient implements ElectronAPI {  private baseUrl: string;  private eventSource: EventSource | null = null;  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures  private eventListeners = new Map<string, Set<(...args: any[]) => void>>();   constructor(baseUrl: string) {    this.baseUrl = baseUrl;    this.initEventSource();  }   // ---------------------------------------------------------------------------  // SSE event infrastructure  // ---------------------------------------------------------------------------   private initEventSource(): void {    this.eventSource = new EventSource(`${this.baseUrl}/api/events`);    this.eventSource.onopen = () => console.log('[HttpAPIClient] SSE connected');    this.eventSource.onerror = () => {      // Auto-reconnect is built into EventSource      console.warn('[HttpAPIClient] SSE connection error, will reconnect...');    };  }   // eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures  private addEventListener(channel: string, callback: (...args: any[]) => void): () => void {    if (!this.eventListeners.has(channel)) {      this.eventListeners.set(channel, new Set());      // Register SSE listener for this channel once      this.eventSource?.addEventListener(channel, ((event: MessageEvent) => {        const data: unknown = JSON.parse(event.data as string);        const listeners = this.eventListeners.get(channel);        listeners?.forEach((cb) => cb(data));      }) as EventListener);    }    this.eventListeners.get(channel)!.add(callback);     return () => {      this.eventListeners.get(channel)?.delete(callback);    };  }   // ---------------------------------------------------------------------------  // HTTP helpers  // ---------------------------------------------------------------------------   /**   * JSON reviver that converts ISO 8601 date strings back to Date objects.   * Electron IPC preserves Date instances via structured clone, but HTTP JSON   * serialization turns them into strings. This restores them so that   * `.getTime()` and other Date methods work in the renderer.   */  // eslint-disable-next-line security/detect-unsafe-regex -- anchored pattern with bounded quantifier; no backtracking risk  private static readonly ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z?$/;   private static reviveDates(_key: string, value: unknown): unknown {    if (typeof value === 'string' && HttpAPIClient.ISO_DATE_RE.test(value)) {      const d = new Date(value);      if (!isNaN(d.getTime())) return d;    }    return value;  }   private async parseJson<T>(res: Response): Promise<T> {    const text = await res.text();    if (!res.ok) {      const parsed = JSON.parse(text) as { error?: string };      throw new Error(parsed.error ?? `HTTP ${res.status}`);    }    return JSON.parse(text, (key, value) => HttpAPIClient.reviveDates(key, value)) as T;  }   private async get<T>(path: string): Promise<T> {    const controller = new AbortController();    const timeout = setTimeout(() => controller.abort(), 10_000);    try {      const res = await fetch(`${this.baseUrl}${path}`, { signal: controller.signal });      return this.parseJson<T>(res);    } finally {      clearTimeout(timeout);    }  }   private async post<T>(path: string, body?: unknown): Promise<T> {    const controller = new AbortController();    const timeout = setTimeout(() => controller.abort(), 10_000);    try {      const res = await fetch(`${this.baseUrl}${path}`, {        method: 'POST',        headers: { 'Content-Type': 'application/json' },        body: body ? JSON.stringify(body) : undefined,        signal: controller.signal,      });      return this.parseJson<T>(res);    } finally {      clearTimeout(timeout);    }  }   private async del<T>(path: string, body?: unknown): Promise<T> {    const controller = new AbortController();    const timeout = setTimeout(() => controller.abort(), 10_000);    try {      const res = await fetch(`${this.baseUrl}${path}`, {        method: 'DELETE',        headers: { 'Content-Type': 'application/json' },        body: body ? JSON.stringify(body) : undefined,        signal: controller.signal,      });      return this.parseJson<T>(res);    } finally {      clearTimeout(timeout);    }  }   private async put<T>(path: string, body?: unknown): Promise<T> {    const controller = new AbortController();    const timeout = setTimeout(() => controller.abort(), 10_000);    try {      const res = await fetch(`${this.baseUrl}${path}`, {        method: 'PUT',        headers: { 'Content-Type': 'application/json' },        body: body ? JSON.stringify(body) : undefined,        signal: controller.signal,      });      return this.parseJson<T>(res);    } finally {      clearTimeout(timeout);    }  }   // ---------------------------------------------------------------------------  // Core session/project APIs  // ---------------------------------------------------------------------------   getAppVersion = (): Promise<string> => this.get<string>('/api/version');   getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');   getSessions = (projectId: string): Promise<Session[]> =>    this.get<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions`);   getSessionsPaginated = (    projectId: string,    cursor: string | null,    limit?: number,    options?: SessionsPaginationOptions  ): Promise<PaginatedSessionsResult> => {    const params = new URLSearchParams();    if (cursor) params.set('cursor', cursor);    if (limit) params.set('limit', String(limit));    if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false');    if (options?.prefilterAll === false) params.set('prefilterAll', 'false');    if (options?.metadataLevel) params.set('metadataLevel', options.metadataLevel);    const qs = params.toString();    const encodedId = encodeURIComponent(projectId);    const path = `/api/projects/${encodedId}/sessions-paginated`;    return this.get<PaginatedSessionsResult>(qs ? `${path}?${qs}` : path);  };   searchSessions = (    projectId: string,    query: string,    maxResults?: number  ): Promise<SearchSessionsResult> => {    const params = new URLSearchParams({ q: query });    if (maxResults) params.set('maxResults', String(maxResults));    return this.get<SearchSessionsResult>(      `/api/projects/${encodeURIComponent(projectId)}/search?${params}`    );  };   searchAllProjects = (query: string, maxResults?: number): Promise<SearchSessionsResult> => {    const params = new URLSearchParams({ q: query });    if (maxResults) params.set('maxResults', String(maxResults));    return this.get<SearchSessionsResult>(`/api/search?${params}`);  };   getSessionDetail = (projectId: string, sessionId: string): Promise<SessionDetail | null> =>    this.get<SessionDetail | null>(      `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`    );   getSessionMetrics = (projectId: string, sessionId: string): Promise<SessionMetrics | null> =>    this.get<SessionMetrics | null>(      `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/metrics`    );   getWaterfallData = (projectId: string, sessionId: string): Promise<WaterfallData | null> =>    this.get<WaterfallData | null>(      `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/waterfall`    );   getSubagentDetail = (    projectId: string,    sessionId: string,    subagentId: string  ): Promise<SubagentDetail | null> =>    this.get<SubagentDetail | null>(      `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`    );   getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>    this.get<ConversationGroup[]>(      `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`    );   getSessionsByIds = (    projectId: string,    sessionIds: string[],    options?: SessionsByIdsOptions  ): Promise<Session[]> =>    this.post<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions-by-ids`, {      sessionIds,      metadataLevel: options?.metadataLevel,    });   // ---------------------------------------------------------------------------  // Repository grouping  // ---------------------------------------------------------------------------   getRepositoryGroups = (): Promise<RepositoryGroup[]> =>    this.get<RepositoryGroup[]>('/api/repository-groups');   getWorktreeSessions = (worktreeId: string): Promise<Session[]> =>    this.get<Session[]>(`/api/worktrees/${encodeURIComponent(worktreeId)}/sessions`);   // ---------------------------------------------------------------------------  // Validation  // ---------------------------------------------------------------------------   validatePath = (    relativePath: string,    projectPath: string  ): Promise<{ exists: boolean; isDirectory?: boolean }> =>    this.post<{ exists: boolean; isDirectory?: boolean }>('/api/validate/path', {      relativePath,      projectPath,    });   validateMentions = (    mentions: { type: 'path'; value: string }[],    projectPath: string  ): Promise<Record<string, boolean>> =>    this.post<Record<string, boolean>>('/api/validate/mentions', { mentions, projectPath });   // ---------------------------------------------------------------------------  // CLAUDE.md reading  // ---------------------------------------------------------------------------   readClaudeMdFiles = (projectRoot: string): Promise<Record<string, ClaudeMdFileInfo>> =>    this.post<Record<string, ClaudeMdFileInfo>>('/api/read-claude-md', { projectRoot });   readDirectoryClaudeMd = (dirPath: string): Promise<ClaudeMdFileInfo> =>    this.post<ClaudeMdFileInfo>('/api/read-directory-claude-md', { dirPath });   readMentionedFile = (    absolutePath: string,    projectRoot: string,    maxTokens?: number  ): Promise<ClaudeMdFileInfo | null> =>    this.post<ClaudeMdFileInfo | null>('/api/read-mentioned-file', {      absolutePath,      projectRoot,      maxTokens,    });   // ---------------------------------------------------------------------------  // Agent config reading  // ---------------------------------------------------------------------------   readAgentConfigs = (projectRoot: string): Promise<Record<string, AgentConfig>> =>    this.post<Record<string, AgentConfig>>('/api/read-agent-configs', { projectRoot });   // ---------------------------------------------------------------------------  // Notifications (nested API)  // ---------------------------------------------------------------------------   notifications: NotificationsAPI = {    get: (options) =>      this.get(        `/api/notifications?${new URLSearchParams(          options            ? {                limit: String(options.limit ?? 20),                offset: String(options.offset ?? 0),              }            : {}        )}`      ),    markRead: (id) => this.post(`/api/notifications/${encodeURIComponent(id)}/read`),    markAllRead: () => this.post('/api/notifications/read-all'),    delete: (id) => this.del(`/api/notifications/${encodeURIComponent(id)}`),    clear: () => this.del('/api/notifications'),    getUnreadCount: () => this.get('/api/notifications/unread-count'),    // IPC signature: (event: unknown, error: unknown) => void    onNew: (callback) =>      this.addEventListener('notification:new', (data: unknown) => callback(null, data)),    // IPC signature: (event: unknown, payload: { total; unreadCount }) => void    onUpdated: (callback) =>      this.addEventListener('notification:updated', (data: unknown) =>        callback(null, data as { total: number; unreadCount: number })      ),    // IPC signature: (event: unknown, data: unknown) => void    onClicked: (callback) =>      this.addEventListener('notification:clicked', (data: unknown) => callback(null, data)),  };   // ---------------------------------------------------------------------------  // Config (nested API)  // ---------------------------------------------------------------------------   config: ConfigAPI = {    get: async (): Promise<AppConfig> => {      const result = await this.get<{ success: boolean; data?: AppConfig; error?: string }>(        '/api/config'      );      if (!result.success) throw new Error(result.error ?? 'Failed to get config');      return result.data!;    },    update: async (section: string, data: object): Promise<AppConfig> => {      const result = await this.post<{ success: boolean; data?: AppConfig; error?: string }>(        '/api/config/update',        { section, data }      );      if (!result.success) throw new Error(result.error ?? 'Failed to update config');      return result.data!;    },    addIgnoreRegex: async (pattern: string): Promise<AppConfig> => {      await this.post('/api/config/ignore-regex', { pattern });      return this.config.get();    },    removeIgnoreRegex: async (pattern: string): Promise<AppConfig> => {      await this.del('/api/config/ignore-regex', { pattern });      return this.config.get();    },    addIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {      await this.post('/api/config/ignore-repository', { repositoryId });      return this.config.get();    },    removeIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {      await this.del('/api/config/ignore-repository', { repositoryId });      return this.config.get();    },    snooze: async (minutes: number): Promise<AppConfig> => {      await this.post('/api/config/snooze', { minutes });      return this.config.get();    },    clearSnooze: async (): Promise<AppConfig> => {      await this.post('/api/config/clear-snooze');      return this.config.get();    },    addTrigger: async (trigger): Promise<AppConfig> => {      await this.post('/api/config/triggers', trigger);      return this.config.get();    },    updateTrigger: async (triggerId: string, updates): Promise<AppConfig> => {      await this.put(`/api/config/triggers/${encodeURIComponent(triggerId)}`, updates);      return this.config.get();    },    removeTrigger: async (triggerId: string): Promise<AppConfig> => {      await this.del(`/api/config/triggers/${encodeURIComponent(triggerId)}`);      return this.config.get();    },    getTriggers: async (): Promise<NotificationTrigger[]> => {      const result = await this.get<{ success: boolean; data?: NotificationTrigger[] }>(        '/api/config/triggers'      );      return result.data ?? [];    },    testTrigger: async (trigger: NotificationTrigger): Promise<TriggerTestResult> => {      const result = await this.post<{        success: boolean;        data?: TriggerTestResult;        error?: string;      }>(`/api/config/triggers/${encodeURIComponent(trigger.id)}/test`, trigger);      if (!result.success) throw new Error(result.error ?? 'Failed to test trigger');      return result.data!;    },    selectFolders: async (): Promise<string[]> => {      console.warn('[HttpAPIClient] selectFolders is not available in browser mode');      return [];    },    selectClaudeRootFolder: async (): Promise<ClaudeRootFolderSelection | null> => {      console.warn('[HttpAPIClient] selectClaudeRootFolder is not available in browser mode');      return null;    },    getClaudeRootInfo: async (): Promise<ClaudeRootInfo> => {      const config = await this.config.get();      const fallbackPath = config.general.claudeRootPath ?? '~/.claude';      return {        defaultPath: fallbackPath,        resolvedPath: fallbackPath,        customPath: config.general.claudeRootPath,      };    },    findWslClaudeRoots: async (): Promise<WslClaudeRootCandidate[]> => {      console.warn('[HttpAPIClient] findWslClaudeRoots is not available in browser mode');      return [];    },    openInEditor: async (): Promise<void> => {      console.warn('[HttpAPIClient] openInEditor is not available in browser mode');    },    pinSession: (projectId: string, sessionId: string): Promise<void> =>      this.post('/api/config/pin-session', { projectId, sessionId }),    unpinSession: (projectId: string, sessionId: string): Promise<void> =>      this.post('/api/config/unpin-session', { projectId, sessionId }),    hideSession: (projectId: string, sessionId: string): Promise<void> =>      this.post('/api/config/hide-session', { projectId, sessionId }),    unhideSession: (projectId: string, sessionId: string): Promise<void> =>      this.post('/api/config/unhide-session', { projectId, sessionId }),    hideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>      this.post('/api/config/hide-sessions', { projectId, sessionIds }),    unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>      this.post('/api/config/unhide-sessions', { projectId, sessionIds }),  };   // ---------------------------------------------------------------------------  // Session navigation  // ---------------------------------------------------------------------------   session: SessionAPI = {    scrollToLine: (sessionId: string, lineNumber: number): Promise<void> =>      this.post('/api/session/scroll-to-line', { sessionId, lineNumber }),  };   // ---------------------------------------------------------------------------  // Zoom (browser fallbacks)  // ---------------------------------------------------------------------------   getZoomFactor = async (): Promise<number> => 1.0;   onZoomFactorChanged = (_callback: (zoomFactor: number) => void): (() => void) => {    // No-op in browser mode — zoom is managed by the browser itself    return () => {};  };   // ---------------------------------------------------------------------------  // File change events (via SSE)  // ---------------------------------------------------------------------------   onFileChange = (callback: (event: FileChangeEvent) => void): (() => void) =>    this.addEventListener('file-change', callback);   onTodoChange = (callback: (event: FileChangeEvent) => void): (() => void) =>    this.addEventListener('todo-change', callback);   // ---------------------------------------------------------------------------  // Shell operations (browser fallbacks)  // ---------------------------------------------------------------------------   openPath = async (    _targetPath: string,    _projectRoot?: string  ): Promise<{ success: boolean; error?: string }> => {

Lines 1–500 of 602.