import { Readable, Writable } from 'node:stream'; import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'; import type { HostApiContext } from './context'; import { hostApiRouteHandlers } from './route-handlers'; import { requireJsonContentType, sendJson } from './route-utils'; export type HostApiDispatchInput = { path: string; method?: string; headers?: Record; body?: string; }; export type HostApiDispatchData = { status: number; ok: boolean; json?: unknown; text?: string; }; class DispatchResponse extends Writable { statusCode = 200; statusMessage?: string; headersSent = false; socket = null; private readonly headers = new Map(); private readonly chunks: Buffer[] = []; constructor() { super({ write: (chunk: Buffer | string, _encoding, callback) => { this.chunks.push(Buffer.isBuffer(chunk) ? Buffer.from(chunk) : Buffer.from(chunk)); callback(); }, }); } setHeader(name: string, value: string | number | readonly string[]): this { this.headers.set(name.toLowerCase(), Array.isArray(value) ? [...value] : String(value)); return this; } getHeader(name: string): string | string[] | undefined { return this.headers.get(name.toLowerCase()); } hasHeader(name: string): boolean { return this.headers.has(name.toLowerCase()); } removeHeader(name: string): void { this.headers.delete(name.toLowerCase()); } writeHead( statusCode: number, statusMessageOrHeaders?: string | Record, maybeHeaders?: Record, ): this { this.statusCode = statusCode; if (typeof statusMessageOrHeaders === 'string') { this.statusMessage = statusMessageOrHeaders; for (const [name, value] of Object.entries(maybeHeaders ?? {})) this.setHeader(name, value); } else { for (const [name, value] of Object.entries(statusMessageOrHeaders ?? {})) this.setHeader(name, value); } this.headersSent = true; return this; } flushHeaders(): void { this.headersSent = true; } body(): Buffer { return Buffer.concat(this.chunks); } contentType(): string { const value = this.getHeader('content-type'); return Array.isArray(value) ? value[0] ?? '' : value ?? ''; } } function createRequest(input: HostApiDispatchInput): IncomingMessage { const body = input.body ?? ''; const request = Readable.from(body ? [Buffer.from(body, 'utf8')] : []) as Readable & Partial; const headers: IncomingHttpHeaders = Object.create(null) as IncomingHttpHeaders; for (const [name, value] of Object.entries(input.headers ?? {})) { headers[name.toLowerCase()] = value; } if (body && headers['content-length'] === undefined) { headers['content-length'] = String(Buffer.byteLength(body, 'utf8')); } request.method = (input.method ?? 'GET').toUpperCase(); request.url = input.path; request.headers = headers; return request as IncomingMessage; } function readResponseData(response: DispatchResponse): HostApiDispatchData { const body = response.body(); if (response.statusCode === 204 || body.length === 0) { return { status: response.statusCode, ok: response.statusCode >= 200 && response.statusCode < 300 }; } const text = body.toString('utf8'); if (response.contentType().toLowerCase().includes('json')) { try { return { status: response.statusCode, ok: response.statusCode >= 200 && response.statusCode < 300, json: JSON.parse(text) as unknown, }; } catch { // Keep malformed route output observable to the Renderer instead of // silently treating it as an empty response. } } return { status: response.statusCode, ok: response.statusCode >= 200 && response.statusCode < 300, text, }; } /** * Dispatch a Renderer Host API request without a second IPC -> loopback HTTP * hop. This adapter deliberately reuses the existing Node route handlers so * authentication, validation and response contracts remain unchanged. */ export async function dispatchHostApiRequest( ctx: HostApiContext, input: HostApiDispatchInput, ): Promise { const request = createRequest(input); const response = new DispatchResponse(); const url = new URL(input.path, 'http://127.0.0.1'); try { if (!requireJsonContentType(request)) { sendJson(response as unknown as ServerResponse, 415, { success: false, error: 'Content-Type must be application/json', }); return readResponseData(response); } for (const handler of hostApiRouteHandlers) { if (await handler(request, response as unknown as ServerResponse, url, ctx)) { return readResponseData(response); } } sendJson(response as unknown as ServerResponse, 404, { success: false, error: `No route for ${request.method} ${url.pathname}`, }); return readResponseData(response); } catch (error) { if (!response.headersSent && !response.writableEnded) { sendJson(response as unknown as ServerResponse, 500, { success: false, error: error instanceof Error ? error.message : String(error), }); return readResponseData(response); } throw error; } }