import { ipcMain } from 'electron'; import { getPort } from '../../utils/config'; import { getHostApiToken } from '../../api/server'; import type { HostApiContext } from '../../api/context'; import { shouldUseLoopbackHostApi } from '../../api/host-api-transport'; import { getRendererCapability, RENDERER_CAPABILITY_HEADER, } from '../../api/renderer-capability'; type HostApiFetchRequest = { path: string; method?: string; headers?: Record; body?: unknown; }; export function registerHostApiProxyHandlers(hostApiContext?: HostApiContext): void { const hostApiPort = getPort('NIANCODE_HOST_API'); // Expose the per-session auth token to the renderer so the browser-fallback // path in host-api.ts can authenticate against the Host API server. ipcMain.handle('hostapi:token', () => getHostApiToken()); ipcMain.handle('hostapi:base-url', () => `http://127.0.0.1:${hostApiPort}`); ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => { try { const path = typeof request?.path === 'string' ? request.path : ''; if (!path || !path.startsWith('/')) { throw new Error(`Invalid host API path: ${String(request?.path)}`); } const method = (request.method || 'GET').toUpperCase(); const protectedHeaders = new Set([ 'authorization', RENDERER_CAPABILITY_HEADER.toLowerCase(), ]); const headers = Object.fromEntries( Object.entries(request.headers || {}).filter( ([name]) => !protectedHeaders.has(name.toLowerCase()), ), ); // Inject the per-session auth token so the Host API server accepts this request. headers['Authorization'] = `Bearer ${getHostApiToken()}`; headers[RENDERER_CAPABILITY_HEADER] = getRendererCapability(); let body: string | Uint8Array | undefined; if (request.body !== undefined && request.body !== null) { if (typeof request.body === 'string') { body = request.body; } else if (request.body instanceof ArrayBuffer) { body = new Uint8Array(request.body); } else if (ArrayBuffer.isView(request.body)) { body = new Uint8Array( request.body.buffer, request.body.byteOffset, request.body.byteLength, ); } else { body = JSON.stringify(request.body); } // Ensure Content-Type is set for requests with a body so the // server's anti-CSRF Content-Type gate does not reject them. if (!headers['Content-Type'] && !headers['content-type']) { headers['Content-Type'] = 'application/json'; } } if (hostApiContext && !shouldUseLoopbackHostApi(path, method)) { const { dispatchHostApiRequest } = await import('../../api/host-api-dispatcher'); const data = await dispatchHostApiRequest(hostApiContext, { path, method, headers, ...(body === undefined ? {} : { body }), }); return { ok: true, data: { ...data, transport: 'dispatcher' as const } }; } // The Host API is an in-process loopback server. Using Electron's // proxy-aware network stack here can incorrectly send 127.0.0.1 through // a configured system proxy and surface a generic `fetch failed` before // the request ever reaches the local route. const response = await fetch(`http://127.0.0.1:${hostApiPort}${path}`, { method, headers, body, }); const data: { status: number; ok: boolean; json?: unknown; text?: string; bytes?: Uint8Array; contentType?: string; } = { status: response.status, ok: response.ok, }; if (response.status !== 204) { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { data.json = await response.json().catch(() => undefined); } else if (contentType.startsWith('image/') || contentType === 'application/octet-stream') { data.bytes = new Uint8Array(await response.arrayBuffer()); data.contentType = contentType.split(';', 1)[0]?.trim() || 'application/octet-stream'; } else { data.text = await response.text().catch(() => ''); } } return { ok: true, data }; } catch (error) { return { ok: false, error: { message: error instanceof Error ? error.message : String(error), }, }; } }); }