import { invokeIpc } from '@/lib/api-client'; import { trackUiEvent } from './telemetry'; import { normalizeAppError } from './error-model'; const DEFAULT_HOST_API_PORT = 13210; const DEFAULT_HOST_API_BASE = `http://127.0.0.1:${DEFAULT_HOST_API_PORT}`; /** Cached Host API auth token, fetched once from the main process via IPC. */ let cachedHostApiToken: string | null = null; let cachedHostApiBase = DEFAULT_HOST_API_BASE; let hostApiBaseResolved = false; async function getHostApiToken(): Promise { if (cachedHostApiToken) return cachedHostApiToken; try { cachedHostApiToken = await invokeIpc('hostapi:token'); } catch { cachedHostApiToken = ''; } return cachedHostApiToken ?? ''; } export async function ensureHostApiToken(): Promise { const [token] = await Promise.all([ getHostApiToken(), getHostApiBaseUrl(), ]); return token; } async function getHostApiBaseUrl(): Promise { if (hostApiBaseResolved) return cachedHostApiBase; try { const value = await invokeIpc('hostapi:base-url'); if (typeof value === 'string' && value.trim()) { cachedHostApiBase = value.replace(/\/+$/, ''); } } catch { cachedHostApiBase = DEFAULT_HOST_API_BASE; } hostApiBaseResolved = true; return cachedHostApiBase; } type HostApiProxyResponse = { ok?: boolean; data?: { status?: number; ok?: boolean; json?: unknown; text?: string; }; error?: { message?: string } | string; // backward compatibility fields success: boolean; status?: number; json?: unknown; text?: string; }; type HostApiProxyData = { status?: number; ok?: boolean; json?: unknown; text?: string; }; function headersToRecord(headers?: HeadersInit): Record { if (!headers) return {}; if (headers instanceof Headers) return Object.fromEntries(headers.entries()); if (Array.isArray(headers)) return Object.fromEntries(headers); return { ...headers }; } async function parseResponse(response: Response): Promise { if (!response.ok) { let message = `${response.status} ${response.statusText}`; try { const payload = await response.json() as { error?: string }; if (payload?.error) { message = payload.error; } } catch { // ignore body parse failure } throw normalizeAppError(new Error(message), { source: 'browser-fallback', status: response.status, }); } if (response.status === 204) { return undefined as T; } return await response.json() as T; } function resolveProxyErrorMessage(error: HostApiProxyResponse['error']): string { return typeof error === 'string' ? error : (error?.message || 'Host API proxy request failed'); } function createProxyHttpError(data: HostApiProxyData): Error { const payload = typeof data.json === 'object' && data.json != null ? data.json as Record : null; const message = data.text || (payload && 'error' in payload ? String(payload.error) : `HTTP ${data.status ?? 'unknown'}`); return normalizeAppError(new Error(message), { status: data.status, ...(payload && typeof payload.code === 'string' ? { backendCode: payload.code } : {}), }); } function parseUnifiedProxyResponse( response: HostApiProxyResponse, path: string, method: string, startedAt: number, ): T { if (!response.ok) { throw new Error(resolveProxyErrorMessage(response.error)); } const data: HostApiProxyData = response.data ?? {}; if (data.ok === false || (typeof data.status === 'number' && data.status >= 400)) { throw createProxyHttpError(data); } trackUiEvent('hostapi.fetch', { path, method, source: 'ipc-proxy', durationMs: Date.now() - startedAt, status: data.status ?? 200, }); if (data.status === 204) return undefined as T; if (data.json !== undefined) return data.json as T; return data.text as T; } function parseLegacyProxyResponse( response: HostApiProxyResponse, path: string, method: string, startedAt: number, ): T { if (!response.success) { throw new Error(resolveProxyErrorMessage(response.error)); } if (!response.ok) { throw createProxyHttpError({ status: response.status, ok: response.ok, json: response.json, text: response.text, }); } trackUiEvent('hostapi.fetch', { path, method, source: 'ipc-proxy-legacy', durationMs: Date.now() - startedAt, status: response.status ?? 200, }); if (response.status === 204) return undefined as T; if (response.json !== undefined) return response.json as T; return response.text as T; } function shouldFallbackToBrowser(message: string): boolean { const normalized = message.toLowerCase(); return normalized.includes('invalid ipc channel: hostapi:fetch') || normalized.includes("no handler registered for 'hostapi:fetch'") || normalized.includes('no handler registered for "hostapi:fetch"') || normalized.includes('no handler registered for hostapi:fetch') || normalized.includes('window is not defined'); } function allowLocalhostFallback(): boolean { try { return window.localStorage.getItem('niancode:allow-localhost-fallback') === '1'; } catch { return false; } } export async function hostApiFetch(path: string, init?: RequestInit): Promise { const startedAt = Date.now(); const method = init?.method || 'GET'; // In Electron renderer, always proxy through main process to avoid CORS. try { const response = await invokeIpc('hostapi:fetch', { path, method, headers: headersToRecord(init?.headers), body: init?.body ?? null, }); if (typeof response?.ok === 'boolean' && 'data' in response) { return parseUnifiedProxyResponse(response, path, method, startedAt); } return parseLegacyProxyResponse(response, path, method, startedAt); } catch (error) { const normalized = normalizeAppError(error, { source: 'ipc-proxy', path, method }); const message = normalized.message; trackUiEvent('hostapi.fetch_error', { path, method, source: 'ipc-proxy', durationMs: Date.now() - startedAt, message, code: normalized.code, }); if (!shouldFallbackToBrowser(message)) { throw normalized; } if (!allowLocalhostFallback()) { trackUiEvent('hostapi.fetch_error', { path, method, source: 'ipc-proxy', durationMs: Date.now() - startedAt, message: 'localhost fallback blocked by policy', code: 'CHANNEL_UNAVAILABLE', }); throw normalized; } } // Browser-only fallback (non-Electron environments). const [token, hostApiBase] = await Promise.all([ getHostApiToken(), getHostApiBaseUrl(), ]); const response = await fetch(`${hostApiBase}${path}`, { ...init, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, ...(init?.headers || {}), }, }); trackUiEvent('hostapi.fetch', { path, method, source: 'browser-fallback', durationMs: Date.now() - startedAt, status: response.status, }); try { return await parseResponse(response); } catch (error) { throw normalizeAppError(error, { source: 'browser-fallback', path, method }); } } export function createHostEventSource(path = '/api/events'): EventSource { // EventSource does not support custom headers, so pass the auth token // as a query parameter. The server accepts both mechanisms. const separator = path.includes('?') ? '&' : '?'; const tokenParam = `token=${encodeURIComponent(cachedHostApiToken ?? '')}`; return new EventSource(`${cachedHostApiBase}${path}${separator}${tokenParam}`); } export function getHostApiBase(): string { return cachedHostApiBase; }