Files
makelore/electron/api/host-api-dispatcher.ts

170 lines
5.3 KiB
TypeScript

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<string, string>;
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<string, string | string[]>();
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<string, string | number | readonly string[]>,
maybeHeaders?: Record<string, string | number | readonly string[]>,
): 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<IncomingMessage>;
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<HostApiDispatchData> {
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;
}
}