170 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|