Files
zn-ai/electron/api/server.ts

158 lines
4.4 KiB
TypeScript

import { randomBytes } from 'node:crypto';
import { createServer, type Server } from 'node:http';
import log from 'electron-log';
import type { HostApiContext } from './context';
import type { HostApiRequest } from './route-utils';
import {
parseRawJsonBody,
requireJsonContentType,
sendJsonResponse,
setCorsHeaders,
} from './route-utils';
const DEFAULT_HOST_API_PORT = 13210;
const HOST_API_UNAUTHORIZED_CODE = 'HOST_API_UNAUTHORIZED';
type StartHostApiServerOptions = {
ctx: HostApiContext;
dispatchRequest: (request: HostApiRequest) => Promise<unknown | null>;
fallbackRequest?: (request: HostApiRequest) => Promise<unknown>;
port?: number;
};
let hostApiToken = '';
export function getHostApiPort(): number {
const raw = process.env['ZN_AI_HOST_API_PORT'];
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HOST_API_PORT;
}
export function getHostApiToken(): string {
return hostApiToken;
}
export function getHostApiBase(): string {
return `http://127.0.0.1:${getHostApiPort()}`;
}
export function startHostApiServer(options: StartHostApiServerOptions): Server {
const port = options.port ?? getHostApiPort();
hostApiToken = randomBytes(32).toString('hex');
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
setCorsHeaders(res, req.headers.origin);
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
const headerToken = req.headers['x-host-api-token'];
const token = typeof headerToken === 'string'
? headerToken
: Array.isArray(headerToken)
? headerToken[0]
: (requestUrl.searchParams.get('token') || '');
if (token !== hostApiToken) {
sendJsonResponse(res, 401, {
success: false,
ok: false,
code: HOST_API_UNAUTHORIZED_CODE,
error: 'Host API authentication failed',
});
return;
}
if (requestUrl.pathname === '/api/events' && req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
options.ctx.eventBus.addSseClient(res);
res.write(`event: gateway:status\ndata: ${JSON.stringify(options.ctx.gatewayManager.getStatus())}\n\n`);
return;
}
if (!requireJsonContentType(req)) {
sendJsonResponse(res, 415, {
success: false,
ok: false,
error: 'Content-Type must be application/json',
});
return;
}
const body = req.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)
? await parseRawJsonBody<unknown>(req)
: null;
const forwardedHeaders: Record<string, string> = {};
Object.entries(req.headers).forEach(([key, value]) => {
if (typeof value === 'string') {
forwardedHeaders[key] = value;
}
});
delete forwardedHeaders['x-host-api-token'];
const request: HostApiRequest = {
path: `${requestUrl.pathname}${requestUrl.search}`,
method: req.method,
headers: forwardedHeaders,
body,
};
const localResult = await options.dispatchRequest(request);
const response = localResult ?? (
options.fallbackRequest
? await options.fallbackRequest(request)
: null
);
if (response == null) {
sendJsonResponse(res, 404, {
success: false,
ok: false,
error: `No route for ${req.method} ${requestUrl.pathname}`,
});
return;
}
const result = response as {
status?: number;
data?: unknown;
json?: unknown;
success?: boolean;
ok?: boolean;
error?: string;
text?: string;
};
sendJsonResponse(res, result.status ?? 200, response);
} catch (error) {
sendJsonResponse(res, 500, {
success: false,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
server.on('error', (error) => {
log.error('Host API server failed:', error);
});
server.on('close', () => {
hostApiToken = '';
});
server.listen(port, '127.0.0.1');
return server;
}