feat: implement telemetry system for application usage tracking
- Added telemetry utility to capture application events and metrics. - Integrated PostHog for event tracking with distinct user identification. - Implemented telemetry initialization, event capturing, and shutdown procedures. feat: add UV environment setup for Python management - Created utilities to manage Python installation and configuration. - Implemented network optimization checks for Python installation mirrors. - Added functions to set up managed Python environments with error handling. feat: enhance host API communication with token management - Introduced host API token retrieval and management for secure requests. - Updated host API fetch functions to include token in headers. - Added support for creating event sources with authentication. test: add comprehensive tests for gateway protocol and startup helpers - Implemented unit tests for gateway protocol helpers, event dispatching, and state management. - Added tests for startup recovery strategies and process policies. - Ensured coverage for connection monitoring and restart governance logic.
This commit is contained in:
@@ -2,10 +2,12 @@ import type { BrowserWindow } from 'electron';
|
||||
import type { gatewayManager } from '@electron/gateway/manager';
|
||||
import type { providerApiService } from '@electron/service/provider-api-service';
|
||||
import type { ClawHubService } from '@electron/gateway/clawhub';
|
||||
import type { hostEventBus } from './event-bus';
|
||||
|
||||
export interface HostApiContext {
|
||||
gatewayManager: typeof gatewayManager;
|
||||
providerApiService: typeof providerApiService;
|
||||
mainWindow: BrowserWindow | null;
|
||||
clawHubService: ClawHubService;
|
||||
eventBus: typeof hostEventBus;
|
||||
}
|
||||
|
||||
64
electron/api/event-bus.ts
Normal file
64
electron/api/event-bus.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
|
||||
type EventPayload = unknown;
|
||||
|
||||
type EventListener = (payload: EventPayload) => void;
|
||||
|
||||
export class HostEventBus {
|
||||
private readonly listeners = new Map<string, Set<EventListener>>();
|
||||
private readonly sseClients = new Set<ServerResponse>();
|
||||
|
||||
on(eventName: string, listener: EventListener): () => void {
|
||||
const bucket = this.listeners.get(eventName) ?? new Set<EventListener>();
|
||||
bucket.add(listener);
|
||||
this.listeners.set(eventName, bucket);
|
||||
|
||||
return () => {
|
||||
bucket.delete(listener);
|
||||
if (bucket.size === 0) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
addSseClient(res: ServerResponse): void {
|
||||
this.sseClients.add(res);
|
||||
res.on('close', () => {
|
||||
this.sseClients.delete(res);
|
||||
});
|
||||
}
|
||||
|
||||
emit(eventName: string, payload: EventPayload): void {
|
||||
const bucket = this.listeners.get(eventName);
|
||||
if (bucket) {
|
||||
for (const listener of bucket) {
|
||||
listener(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sseClients.size > 0) {
|
||||
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
for (const client of this.sseClients) {
|
||||
try {
|
||||
client.write(message);
|
||||
} catch {
|
||||
this.sseClients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
this.listeners.clear();
|
||||
for (const client of this.sseClients) {
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// Ignore individual client close failures.
|
||||
}
|
||||
}
|
||||
this.sseClients.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const hostEventBus = new HostEventBus();
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HostApiResult } from '@src/types/runtime';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
export interface HostApiRequest {
|
||||
path: string;
|
||||
@@ -16,6 +17,20 @@ export interface NormalizedHostApiRequest {
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export async function parseRawJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
export function normalizeRequest(request: HostApiRequest): NormalizedHostApiRequest {
|
||||
const path = String(request.path || '/').trim() || '/';
|
||||
|
||||
@@ -61,3 +76,34 @@ export function fail<T = unknown>(status: number, error: string, data?: T): Host
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function setCorsHeaders(res: ServerResponse, origin?: string): void {
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
} else {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Host-Api-Token');
|
||||
}
|
||||
|
||||
export function requireJsonContentType(req: IncomingMessage): boolean {
|
||||
if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const contentLength = req.headers['content-length'];
|
||||
if (contentLength === '0' || contentLength === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
return contentType.includes('application/json');
|
||||
}
|
||||
|
||||
export function sendJsonResponse(res: ServerResponse, statusCode: number, payload: unknown): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron';
|
||||
import { gatewayManager } from '@electron/gateway/manager';
|
||||
import { providerApiService } from '@electron/service/provider-api-service';
|
||||
import { ClawHubService } from '@electron/gateway/clawhub';
|
||||
import { hostEventBus } from './event-bus';
|
||||
import type { HostApiContext } from './context';
|
||||
import type { HostApiRequest } from './route-utils';
|
||||
import { normalizeRequest } from './route-utils';
|
||||
@@ -36,18 +37,19 @@ const routeHandlers: RouteHandler[] = [
|
||||
handleSkillRoutes,
|
||||
];
|
||||
|
||||
function createContext(): HostApiContext {
|
||||
export function createHostApiContext(): HostApiContext {
|
||||
return {
|
||||
gatewayManager,
|
||||
providerApiService,
|
||||
mainWindow: BrowserWindow.getAllWindows()[0] ?? null,
|
||||
clawHubService: new ClawHubService(),
|
||||
eventBus: hostEventBus,
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchLocalHostApi(request: HostApiRequest) {
|
||||
const normalized = normalizeRequest(request);
|
||||
const ctx = createContext();
|
||||
const ctx = createHostApiContext();
|
||||
|
||||
for (const handler of routeHandlers) {
|
||||
const result = await handler(normalized, ctx);
|
||||
|
||||
158
electron/api/server.ts
Normal file
158
electron/api/server.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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;
|
||||
|
||||
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 bearerHeader = req.headers.authorization || '';
|
||||
const bearerToken = bearerHeader.startsWith('Bearer ')
|
||||
? bearerHeader.slice('Bearer '.length)
|
||||
: '';
|
||||
const token = (
|
||||
req.headers['x-host-api-token']
|
||||
|| requestUrl.searchParams.get('token')
|
||||
|| bearerToken
|
||||
);
|
||||
|
||||
if (token !== hostApiToken) {
|
||||
sendJsonResponse(res, 401, {
|
||||
success: false,
|
||||
ok: false,
|
||||
error: 'Unauthorized',
|
||||
});
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user