import type { BackgroundProcess, BackgroundProcessListResponse, BackgroundProcessOutputResponse, BinaryValidationResult, FileSystemEntry, FileSystemCreateFolderResponse, FileSystemListResponse, InstanceData, SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse, SideCar, ServerMeta, RemoteServerProbeRequest, RemoteServerProbeResponse, VoiceModeStateResponse, WorkspaceCreateRequest, WorkspaceDescriptor, WorkspaceFileResponse, WorkspaceFileSearchResponse, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, WorktreeListResponse, WorktreeMap, WorktreeCreateRequest, } from "../../../server/src/api-types" import { getClientIdentity } from "./client-identity" import { getLogger } from "./logger" const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events" const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH) export const CODENOMAD_API_BASE = API_BASE export function buildBackgroundProcessStreamUrl(instanceId: string, processId: string): string { const encodedInstanceId = encodeURIComponent(instanceId) const encodedProcessId = encodeURIComponent(processId) return buildAbsoluteUrl(`/workspaces/${encodedInstanceId}/plugin/background-processes/${encodedProcessId}/stream`) } function buildEventsUrl(base: string | undefined, path: string): string { if (path.startsWith("http://") || path.startsWith("https://")) { return path } if (base) { const normalized = path.startsWith("/") ? path : `/${path}` return `${base}${normalized}` } return path } function buildAbsoluteUrl(path: string): string { if (path.startsWith("http://") || path.startsWith("https://")) { return path } if (!API_BASE) { return path } const normalized = path.startsWith("/") ? path : `/${path}` return `${API_BASE}${normalized}` } const httpLogger = getLogger("api") const sseLogger = getLogger("sse") function normalizeHeaders(headers: HeadersInit | undefined): Record { const output: Record = {} if (!headers) return output if (headers instanceof Headers) { headers.forEach((value, key) => { output[key] = value }) return output } if (Array.isArray(headers)) { for (const [key, value] of headers) { output[key] = value } return output } return { ...headers } } function logHttp(message: string, context?: Record) { if (context) { httpLogger.info(message, context) return } httpLogger.info(message) } async function request(path: string, init?: RequestInit): Promise { const url = API_BASE ? new URL(path, API_BASE).toString() : path const headers = normalizeHeaders(init?.headers) if (init?.body !== undefined) { headers["Content-Type"] = "application/json" } const method = (init?.method ?? "GET").toUpperCase() const startedAt = Date.now() logHttp(`${method} ${path}`) try { const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) if (!response.ok) { const message = await response.text() logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) throw new Error(message || `Request failed with ${response.status}`) } const duration = Date.now() - startedAt logHttp(`${method} ${path} -> ${response.status}`, { durationMs: duration }) if (response.status === 204) { return undefined as T } return (await response.json()) as T } catch (error) { logHttp(`${method} ${path} failed`, { durationMs: Date.now() - startedAt, error }) throw error } } async function requestRaw(path: string, init?: RequestInit): Promise { const url = API_BASE ? new URL(path, API_BASE).toString() : path const headers = normalizeHeaders(init?.headers) if (init?.body !== undefined && !headers["Content-Type"]) { headers["Content-Type"] = "application/json" } const method = (init?.method ?? "GET").toUpperCase() const startedAt = Date.now() logHttp(`${method} ${path}`) const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) if (!response.ok) { const message = await response.text() logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) throw new Error(message || `Request failed with ${response.status}`) } logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt }) return response } export const serverApi = { fetchWorkspaces(): Promise { return request("/api/workspaces") }, fetchWorktrees(id: string): Promise { return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees`) }, createWorktree(id: string, payload: WorktreeCreateRequest): Promise<{ slug: string; directory: string; branch?: string }> { return request<{ slug: string; directory: string; branch?: string }>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`, { method: "POST", body: JSON.stringify(payload), }) }, deleteWorktree(id: string, slug: string, options?: { force?: boolean }): Promise { const params = new URLSearchParams() if (options?.force) { params.set("force", "true") } const suffix = params.toString() ? `?${params.toString()}` : "" return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}${suffix}`, { method: "DELETE", }) }, readWorktreeMap(id: string): Promise { return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`) }, writeWorktreeMap(id: string, map: WorktreeMap): Promise { return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, { method: "PUT", body: JSON.stringify(map), }) }, createWorkspace(payload: WorkspaceCreateRequest): Promise { return request("/api/workspaces", { method: "POST", body: JSON.stringify(payload), }) }, fetchSidecars(): Promise<{ sidecars: SideCar[] }> { return request<{ sidecars: SideCar[] }>("/api/sidecars") }, createSidecar(payload: { kind: "port" name: string port: number insecure: boolean prefixMode: "strip" | "preserve" }): Promise { return request("/api/sidecars", { method: "POST", body: JSON.stringify(payload), }) }, updateSidecar( id: string, payload: Partial<{ name: string; port: number; insecure: boolean; prefixMode: "strip" | "preserve" }>, ): Promise { return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(payload), }) }, deleteSidecar(id: string): Promise { return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" }) }, fetchServerMeta(): Promise { return request("/api/meta") }, probeRemoteServer(payload: RemoteServerProbeRequest): Promise { return request("/api/remote-servers/probe", { method: "POST", body: JSON.stringify(payload), }) }, fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> { return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status") }, setServerPassword(password: string): Promise<{ ok: boolean; username: string; passwordUserProvided: boolean }> { return request<{ ok: boolean; username: string; passwordUserProvided: boolean }>("/api/auth/password", { method: "POST", body: JSON.stringify({ password }), }) }, deleteWorkspace(id: string): Promise { return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" }) }, listWorkspaceFiles(id: string, relativePath = "."): Promise { const params = new URLSearchParams({ path: relativePath }) return request(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`) }, searchWorkspaceFiles( id: string, query: string, opts?: { limit?: number; type?: "file" | "directory" | "all" }, ): Promise { const trimmed = query.trim() if (!trimmed) { return Promise.resolve([]) } const params = new URLSearchParams({ q: trimmed }) if (opts?.limit) { params.set("limit", String(opts.limit)) } if (opts?.type) { params.set("type", opts.type) } return request( `/api/workspaces/${encodeURIComponent(id)}/files/search?${params.toString()}`, ) }, readWorkspaceFile(id: string, relativePath: string): Promise { const params = new URLSearchParams({ path: relativePath }) return request( `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, ) }, writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise { const params = new URLSearchParams({ path: relativePath }) return request( `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, { method: "PUT", body: JSON.stringify({ contents }), }, ) }, fetchConfigOwner = Record>(owner: string): Promise { return request(`/api/storage/config/${encodeURIComponent(owner)}`) }, patchConfigOwner = Record>(owner: string, patch: unknown): Promise { return request(`/api/storage/config/${encodeURIComponent(owner)}`, { method: "PATCH", body: JSON.stringify(patch ?? {}), }) }, fetchStateOwner = Record>(owner: string): Promise { return request(`/api/storage/state/${encodeURIComponent(owner)}`) }, patchStateOwner = Record>(owner: string, patch: unknown): Promise { return request(`/api/storage/state/${encodeURIComponent(owner)}`, { method: "PATCH", body: JSON.stringify(patch ?? {}), }) }, validateBinary(path: string): Promise { return request("/api/storage/binaries/validate", { method: "POST", body: JSON.stringify({ path }), }) }, fetchSpeechCapabilities(): Promise { return request("/api/speech/capabilities") }, transcribeAudio(payload: { audioBase64: string mimeType: string filename?: string language?: string prompt?: string }): Promise { return request("/api/speech/transcribe", { method: "POST", body: JSON.stringify(payload), }) }, synthesizeSpeech(payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" }): Promise { return request("/api/speech/synthesize", { method: "POST", body: JSON.stringify(payload), }) }, synthesizeSpeechStream( payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" }, signal?: AbortSignal, ): Promise { return requestRaw("/api/speech/synthesize/stream", { method: "POST", body: JSON.stringify(payload), signal, }) }, listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise { const params = new URLSearchParams() if (path && path !== ".") { params.set("path", path) } if (options?.includeFiles !== undefined) { params.set("includeFiles", String(options.includeFiles)) } const query = params.toString() return request(query ? `/api/filesystem?${query}` : "/api/filesystem") }, createFileSystemFolder(parentPath: string | undefined, name: string): Promise { return request("/api/filesystem/folders", { method: "POST", body: JSON.stringify({ parentPath, name }), }) }, readInstanceData(id: string): Promise { return request(`/api/storage/instances/${encodeURIComponent(id)}`) }, writeInstanceData(id: string, data: InstanceData): Promise { return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(data), }) }, deleteInstanceData(id: string): Promise { return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" }) }, listBackgroundProcesses(instanceId: string): Promise { return request( `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes`, ) }, stopBackgroundProcess(instanceId: string, processId: string): Promise { return request( `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/stop`, { method: "POST" }, ) }, terminateBackgroundProcess(instanceId: string, processId: string): Promise { return request( `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/terminate`, { method: "POST" }, ) }, updateVoiceMode(instanceId: string, enabled: boolean): Promise { const identity = getClientIdentity() return request(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, { method: "POST", body: JSON.stringify({ ...identity, enabled }), }) }, sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }): Promise { return request("/api/client-connections/pong", { method: "POST", body: JSON.stringify(payload), }) }, fetchBackgroundProcessOutput( instanceId: string, processId: string, options?: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number }, ): Promise { const params = new URLSearchParams() if (options?.method) { params.set("method", options.method) } if (options?.pattern) { params.set("pattern", options.pattern) } if (options?.lines) { params.set("lines", String(options.lines)) } if (options?.maxBytes !== undefined) { params.set("maxBytes", String(options.maxBytes)) } const query = params.toString() const suffix = query ? `?${query}` : "" return request( `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`, ) }, connectEvents( onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void, onPing?: (payload: { ts?: number }) => void, ) { const identity = getClientIdentity() const url = buildClientEventsUrl(identity) sseLogger.info(`Connecting to ${url}`) const source = new EventSource(url, { withCredentials: true } as any) source.onmessage = (event) => { try { const payload = JSON.parse(event.data) as WorkspaceEventPayload onEvent(payload) } catch (error) { sseLogger.error("Failed to parse event", error) } } source.onerror = () => { sseLogger.warn("EventSource error, closing stream") onError?.() } source.addEventListener("codenomad.client.ping", (event: MessageEvent) => { try { const payload = event.data ? (JSON.parse(event.data) as { ts?: number }) : {} onPing?.(payload) } catch (error) { sseLogger.error("Failed to parse ping event", error) } }) return source }, } function buildClientEventsUrl(identity: { clientId: string; connectionId: string }): string { const url = new URL(EVENTS_URL, typeof window !== "undefined" ? window.location.origin : "http://localhost") url.searchParams.set("clientId", identity.clientId) url.searchParams.set("connectionId", identity.connectionId) if (EVENTS_URL.startsWith("http://") || EVENTS_URL.startsWith("https://")) { return url.toString() } return `${url.pathname}${url.search}` } export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar }