refactor/channel & ipc (#349)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
8b45960662
commit
e28eba01e1
@@ -1,3 +1,5 @@
|
||||
import { trackUiEvent } from './telemetry';
|
||||
|
||||
export type AppErrorCode =
|
||||
| 'TIMEOUT'
|
||||
| 'RATE_LIMIT'
|
||||
@@ -8,12 +10,7 @@ export type AppErrorCode =
|
||||
| 'UNKNOWN';
|
||||
|
||||
export type TransportKind = 'ipc' | 'ws' | 'http';
|
||||
export type GatewayTransportPreference =
|
||||
| 'ws-first'
|
||||
| 'http-first'
|
||||
| 'ws-only'
|
||||
| 'http-only'
|
||||
| 'ipc-only';
|
||||
export type GatewayTransportPreference = 'ws-first';
|
||||
type TransportInvoker = <T>(channel: string, args: unknown[]) => Promise<T>;
|
||||
type TransportRequest = { channel: string; args: unknown[] };
|
||||
|
||||
@@ -90,6 +87,7 @@ const UNIFIED_CHANNELS = new Set<string>([
|
||||
]);
|
||||
|
||||
const customInvokers = new Map<Exclude<TransportKind, 'ipc'>, TransportInvoker>();
|
||||
const GATEWAY_WS_DIAG_FLAG = 'clawx:gateway-ws-diagnostic';
|
||||
|
||||
let transportConfig: ApiClientTransportConfig = {
|
||||
enabled: {
|
||||
@@ -136,8 +134,21 @@ type GatewayWsTransportOptions = {
|
||||
websocketFactory?: (url: string) => WebSocket;
|
||||
};
|
||||
|
||||
type GatewayControlUiResponse = {
|
||||
success?: boolean;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
function normalizeGatewayRpcEnvelope(value: unknown): { success: boolean; result?: unknown; error?: string } {
|
||||
if (value && typeof value === 'object' && 'success' in (value as Record<string, unknown>)) {
|
||||
return value as { success: boolean; result?: unknown; error?: string };
|
||||
}
|
||||
return { success: true, result: value };
|
||||
}
|
||||
|
||||
let cachedGatewayPort: { port: number; expiresAt: number } | null = null;
|
||||
const transportBackoffUntil: Partial<Record<Exclude<TransportKind, 'ipc'>, number>> = {};
|
||||
const SLOW_REQUEST_THRESHOLD_MS = 800;
|
||||
|
||||
async function resolveGatewayPort(): Promise<number> {
|
||||
const now = Date.now();
|
||||
@@ -173,11 +184,13 @@ class TransportUnsupportedError extends Error {
|
||||
export class AppError extends Error {
|
||||
code: AppErrorCode;
|
||||
cause?: unknown;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
constructor(code: AppErrorCode, message: string, cause?: unknown) {
|
||||
constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,30 +211,60 @@ function mapUnifiedErrorCode(code?: string): AppErrorCode {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(err: unknown): AppError {
|
||||
function normalizeError(err: unknown, details?: Record<string, unknown>): AppError {
|
||||
if (err instanceof AppError) {
|
||||
return new AppError(err.code, err.message, err.cause ?? err, { ...(err.details ?? {}), ...(details ?? {}) });
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (lower.includes('timeout')) {
|
||||
return new AppError('TIMEOUT', message, err);
|
||||
return new AppError('TIMEOUT', message, err, details);
|
||||
}
|
||||
if (lower.includes('rate limit')) {
|
||||
return new AppError('RATE_LIMIT', message, err);
|
||||
return new AppError('RATE_LIMIT', message, err, details);
|
||||
}
|
||||
if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('denied')) {
|
||||
return new AppError('PERMISSION', message, err);
|
||||
return new AppError('PERMISSION', message, err, details);
|
||||
}
|
||||
if (lower.includes('network') || lower.includes('fetch')) {
|
||||
return new AppError('NETWORK', message, err);
|
||||
return new AppError('NETWORK', message, err, details);
|
||||
}
|
||||
if (lower.includes('gateway')) {
|
||||
return new AppError('GATEWAY', message, err);
|
||||
return new AppError('GATEWAY', message, err, details);
|
||||
}
|
||||
if (lower.includes('config') || lower.includes('invalid')) {
|
||||
return new AppError('CONFIG', message, err);
|
||||
return new AppError('CONFIG', message, err, details);
|
||||
}
|
||||
|
||||
return new AppError('UNKNOWN', message, err);
|
||||
return new AppError('UNKNOWN', message, err, details);
|
||||
}
|
||||
|
||||
function shouldLogApiRequests(): boolean {
|
||||
try {
|
||||
return import.meta.env.DEV || window.localStorage.getItem('clawx:api-log') === '1';
|
||||
} catch {
|
||||
return !!import.meta.env.DEV;
|
||||
}
|
||||
}
|
||||
|
||||
function logApiAttempt(entry: {
|
||||
requestId: string;
|
||||
channel: string;
|
||||
transport: TransportKind;
|
||||
attempt: number;
|
||||
durationMs: number;
|
||||
ok: boolean;
|
||||
error?: unknown;
|
||||
}): void {
|
||||
if (!shouldLogApiRequests()) return;
|
||||
const base = `[api-client] id=${entry.requestId} channel=${entry.channel} transport=${entry.transport} attempt=${entry.attempt} durationMs=${entry.durationMs}`;
|
||||
if (entry.ok) {
|
||||
console.info(`${base} result=ok`);
|
||||
} else {
|
||||
console.warn(`${base} result=error`, entry.error);
|
||||
}
|
||||
}
|
||||
|
||||
function isRuleMatch(matcher: string | RegExp, channel: string): boolean {
|
||||
@@ -263,53 +306,58 @@ export function clearTransportBackoff(kind?: Exclude<TransportKind, 'ipc'>): voi
|
||||
delete transportBackoffUntil.http;
|
||||
}
|
||||
|
||||
function gatewayRulesForPreference(preference: GatewayTransportPreference): TransportRule[] {
|
||||
switch (preference) {
|
||||
case 'http-first':
|
||||
return [
|
||||
{ matcher: /^gateway:rpc$/, order: ['http', 'ws', 'ipc'] },
|
||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
||||
{ matcher: /.*/, order: ['ipc'] },
|
||||
];
|
||||
case 'ws-only':
|
||||
return [
|
||||
{ matcher: /^gateway:rpc$/, order: ['ws', 'ipc'] },
|
||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
||||
{ matcher: /.*/, order: ['ipc'] },
|
||||
];
|
||||
case 'http-only':
|
||||
return [
|
||||
{ matcher: /^gateway:rpc$/, order: ['http', 'ipc'] },
|
||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
||||
{ matcher: /.*/, order: ['ipc'] },
|
||||
];
|
||||
case 'ipc-only':
|
||||
return [
|
||||
{ matcher: /^gateway:rpc$/, order: ['ipc'] },
|
||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
||||
{ matcher: /.*/, order: ['ipc'] },
|
||||
];
|
||||
case 'ws-first':
|
||||
default:
|
||||
return [
|
||||
export function applyGatewayTransportPreference(): void {
|
||||
const wsDiagnosticEnabled = getGatewayWsDiagnosticEnabled();
|
||||
clearTransportBackoff();
|
||||
if (wsDiagnosticEnabled) {
|
||||
configureApiClient({
|
||||
enabled: {
|
||||
ws: true,
|
||||
http: true,
|
||||
},
|
||||
rules: [
|
||||
{ matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] },
|
||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
||||
{ matcher: /.*/, order: ['ipc'] },
|
||||
];
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Availability-first default:
|
||||
// keep IPC as the authoritative runtime path.
|
||||
configureApiClient({
|
||||
enabled: {
|
||||
ws: false,
|
||||
http: false,
|
||||
},
|
||||
rules: [
|
||||
{ matcher: /^gateway:rpc$/, order: ['ipc'] },
|
||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
||||
{ matcher: /.*/, order: ['ipc'] },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function getGatewayWsDiagnosticEnabled(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(GATEWAY_WS_DIAG_FLAG) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGatewayTransportPreference(preference: GatewayTransportPreference): void {
|
||||
const enableWs = preference === 'ws-first' || preference === 'http-first' || preference === 'ws-only';
|
||||
const enableHttp = preference === 'ws-first' || preference === 'http-first' || preference === 'http-only';
|
||||
clearTransportBackoff();
|
||||
configureApiClient({
|
||||
enabled: {
|
||||
ws: enableWs,
|
||||
http: enableHttp,
|
||||
},
|
||||
rules: gatewayRulesForPreference(preference),
|
||||
});
|
||||
export function setGatewayWsDiagnosticEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (enabled) {
|
||||
window.localStorage.setItem(GATEWAY_WS_DIAG_FLAG, '1');
|
||||
} else {
|
||||
window.localStorage.removeItem(GATEWAY_WS_DIAG_FLAG);
|
||||
}
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
applyGatewayTransportPreference();
|
||||
}
|
||||
|
||||
function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest {
|
||||
@@ -341,7 +389,7 @@ async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
|
||||
if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) {
|
||||
// Fallback to legacy channel handlers.
|
||||
} else {
|
||||
throw normalizeError(err);
|
||||
throw normalizeError(err, { transport: 'ipc', channel, source: 'app:request' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,7 +397,7 @@ async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
|
||||
try {
|
||||
return await window.electron.ipcRenderer.invoke(channel, ...args) as T;
|
||||
} catch (err) {
|
||||
throw normalizeError(err);
|
||||
throw normalizeError(err, { transport: 'ipc', channel, source: 'legacy-ipc' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,12 +616,13 @@ export function createGatewayHttpTransportInvoker(
|
||||
: 15000;
|
||||
|
||||
const response = await invokeViaIpc<{
|
||||
success: boolean;
|
||||
status?: number;
|
||||
ok?: boolean;
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
success?: boolean;
|
||||
status?: number;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
error?: string;
|
||||
}>('gateway:httpProxy', [{
|
||||
path: '/rpc',
|
||||
method: 'POST',
|
||||
@@ -585,8 +634,42 @@ export function createGatewayHttpTransportInvoker(
|
||||
},
|
||||
}]);
|
||||
|
||||
if (response && 'data' in response && typeof response.ok === 'boolean') {
|
||||
if (!response.ok) {
|
||||
const errObj = response.error as { message?: string } | string | undefined;
|
||||
throw new Error(
|
||||
typeof errObj === 'string'
|
||||
? errObj
|
||||
: (errObj?.message || 'Gateway HTTP proxy failed'),
|
||||
);
|
||||
}
|
||||
const proxyData = response.data as { status?: number; ok?: boolean; json?: unknown; text?: string } | undefined;
|
||||
const payload = proxyData?.json as Record<string, unknown> | undefined;
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new Error(proxyData?.text || `Gateway HTTP returned non-JSON (status=${proxyData?.status ?? 'unknown'})`);
|
||||
}
|
||||
if (payload.type === 'res') {
|
||||
if (payload.ok === false || payload.error) {
|
||||
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
||||
}
|
||||
return normalizeGatewayRpcEnvelope(payload.payload ?? payload) as T;
|
||||
}
|
||||
if ('ok' in payload) {
|
||||
if (!payload.ok) {
|
||||
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
||||
}
|
||||
return normalizeGatewayRpcEnvelope(payload.data ?? payload) as T;
|
||||
}
|
||||
return normalizeGatewayRpcEnvelope(payload) as T;
|
||||
}
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Gateway HTTP proxy failed');
|
||||
const errObj = response?.error as { message?: string } | string | undefined;
|
||||
throw new Error(
|
||||
typeof errObj === 'string'
|
||||
? errObj
|
||||
: (errObj?.message || 'Gateway HTTP proxy failed'),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = response?.json as Record<string, unknown> | undefined;
|
||||
@@ -598,16 +681,16 @@ export function createGatewayHttpTransportInvoker(
|
||||
if (payload.ok === false || payload.error) {
|
||||
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
||||
}
|
||||
return (payload.payload ?? payload) as T;
|
||||
return normalizeGatewayRpcEnvelope(payload.payload ?? payload) as T;
|
||||
}
|
||||
if ('ok' in payload) {
|
||||
if (!payload.ok) {
|
||||
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
||||
}
|
||||
return (payload.data ?? payload) as T;
|
||||
return normalizeGatewayRpcEnvelope(payload.data ?? payload) as T;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
return normalizeGatewayRpcEnvelope(payload) as T;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -615,7 +698,13 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
||||
const timeoutMs = options.timeoutMs ?? 15000;
|
||||
const websocketFactory = options.websocketFactory ?? ((url: string) => new WebSocket(url));
|
||||
const resolveUrl = options.urlResolver ?? resolveDefaultGatewayWsUrl;
|
||||
const resolveToken = options.tokenResolver ?? (() => invokeViaIpc<string | null>('settings:get', ['gatewayToken']));
|
||||
const resolveToken = options.tokenResolver ?? (async () => {
|
||||
const controlUi = await invokeViaIpc<GatewayControlUiResponse>('gateway:getControlUiUrl', []);
|
||||
if (controlUi?.success && typeof controlUi.token === 'string' && controlUi.token.trim()) {
|
||||
return controlUi.token;
|
||||
}
|
||||
return await invokeViaIpc<string | null>('settings:get', [{ key: 'gatewayToken' }]);
|
||||
});
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let connectPromise: Promise<WebSocket> | null = null;
|
||||
@@ -636,12 +725,36 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
||||
}
|
||||
};
|
||||
|
||||
const formatGatewayError = (errorValue: unknown): string => {
|
||||
if (errorValue == null) return 'unknown';
|
||||
if (typeof errorValue === 'string') return errorValue;
|
||||
if (typeof errorValue === 'object') {
|
||||
const asRecord = errorValue as Record<string, unknown>;
|
||||
const message = typeof asRecord.message === 'string' ? asRecord.message : null;
|
||||
const code = typeof asRecord.code === 'string' || typeof asRecord.code === 'number'
|
||||
? String(asRecord.code)
|
||||
: null;
|
||||
if (message && code) return `${code}: ${message}`;
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(errorValue);
|
||||
} catch {
|
||||
return String(errorValue);
|
||||
}
|
||||
}
|
||||
return String(errorValue);
|
||||
};
|
||||
|
||||
const sendConnect = async (_challengeNonce: string) => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Gateway WS not open during connect handshake');
|
||||
}
|
||||
const token = await Promise.resolve(resolveToken());
|
||||
connectRequestId = `connect-${Date.now()}`;
|
||||
const auth =
|
||||
typeof token === 'string' && token.trim().length > 0
|
||||
? { token }
|
||||
: undefined;
|
||||
socket.send(JSON.stringify({
|
||||
type: 'req',
|
||||
id: connectRequestId,
|
||||
@@ -650,18 +763,18 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'clawx-ui',
|
||||
id: 'openclaw-control-ui',
|
||||
displayName: 'ClawX UI',
|
||||
version: '0.1.0',
|
||||
version: '1.0.0',
|
||||
platform: window.electron?.platform ?? 'unknown',
|
||||
mode: 'ui',
|
||||
mode: 'webchat',
|
||||
},
|
||||
auth: {
|
||||
token: token ?? null,
|
||||
},
|
||||
caps: [],
|
||||
auth,
|
||||
caps: ['tool-events'],
|
||||
role: 'operator',
|
||||
scopes: ['operator.admin'],
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
|
||||
locale: typeof navigator !== 'undefined' ? navigator.language : 'en',
|
||||
},
|
||||
}));
|
||||
};
|
||||
@@ -738,7 +851,7 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
||||
const ok = msg.ok !== false && !msg.error;
|
||||
if (!ok) {
|
||||
cleanup();
|
||||
reject(new Error(`Gateway WS connect failed: ${String(msg.error ?? 'unknown')}`));
|
||||
reject(new Error(`Gateway WS connect failed: ${formatGatewayError(msg.error)}`));
|
||||
return;
|
||||
}
|
||||
handshakeDone = true;
|
||||
@@ -765,10 +878,10 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
||||
|
||||
const ok = msg.ok !== false && !msg.error;
|
||||
if (!ok) {
|
||||
item.reject(new Error(String(msg.error ?? 'Gateway WS request failed')));
|
||||
item.reject(new Error(formatGatewayError(msg.error ?? 'Gateway WS request failed')));
|
||||
return;
|
||||
}
|
||||
item.resolve(msg.payload ?? msg);
|
||||
item.resolve(normalizeGatewayRpcEnvelope(msg.payload ?? msg));
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
@@ -838,7 +951,7 @@ export function initializeDefaultTransports(): void {
|
||||
if (defaultTransportsInitialized) return;
|
||||
registerTransportInvoker('ws', createGatewayWsTransportInvoker());
|
||||
registerTransportInvoker('http', createGatewayHttpTransportInvoker());
|
||||
applyGatewayTransportPreference('ws-first');
|
||||
applyGatewayTransportPreference();
|
||||
defaultTransportsInitialized = true;
|
||||
}
|
||||
|
||||
@@ -864,15 +977,65 @@ export function toUserMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const order = resolveTransportOrder(channel);
|
||||
let lastError: unknown;
|
||||
|
||||
for (const kind of order) {
|
||||
for (let i = 0; i < order.length; i += 1) {
|
||||
const kind = order[i];
|
||||
const attempt = i + 1;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await invokeViaTransport<T>(kind, channel, args);
|
||||
const value = await invokeViaTransport<T>(kind, channel, args);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
logApiAttempt({
|
||||
requestId,
|
||||
channel,
|
||||
transport: kind,
|
||||
attempt,
|
||||
durationMs,
|
||||
ok: true,
|
||||
});
|
||||
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS || attempt > 1) {
|
||||
trackUiEvent('api.request', {
|
||||
requestId,
|
||||
channel,
|
||||
transport: kind,
|
||||
attempt,
|
||||
durationMs,
|
||||
fallbackUsed: attempt > 1,
|
||||
});
|
||||
}
|
||||
return value;
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
logApiAttempt({
|
||||
requestId,
|
||||
channel,
|
||||
transport: kind,
|
||||
attempt,
|
||||
durationMs,
|
||||
ok: false,
|
||||
error: err,
|
||||
});
|
||||
trackUiEvent('api.request_error', {
|
||||
requestId,
|
||||
channel,
|
||||
transport: kind,
|
||||
attempt,
|
||||
durationMs,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
|
||||
if (err instanceof TransportUnsupportedError) {
|
||||
markTransportFailure(kind);
|
||||
trackUiEvent('api.transport_fallback', {
|
||||
requestId,
|
||||
channel,
|
||||
from: kind,
|
||||
reason: 'unsupported',
|
||||
nextAttempt: attempt + 1,
|
||||
});
|
||||
lastError = err;
|
||||
continue;
|
||||
}
|
||||
@@ -880,13 +1043,38 @@ export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise
|
||||
// For non-IPC transports, fail open to the next transport.
|
||||
if (kind !== 'ipc') {
|
||||
markTransportFailure(kind);
|
||||
trackUiEvent('api.transport_fallback', {
|
||||
requestId,
|
||||
channel,
|
||||
from: kind,
|
||||
reason: 'error',
|
||||
nextAttempt: attempt + 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
throw normalizeError(err, {
|
||||
requestId,
|
||||
channel,
|
||||
transport: kind,
|
||||
attempt,
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw normalizeError(lastError);
|
||||
trackUiEvent('api.request_failed', {
|
||||
requestId,
|
||||
channel,
|
||||
attempts: order.length,
|
||||
message: lastError instanceof Error ? lastError.message : String(lastError),
|
||||
});
|
||||
|
||||
throw normalizeError(lastError, {
|
||||
requestId,
|
||||
channel,
|
||||
transport: 'ipc',
|
||||
attempt: order.length,
|
||||
});
|
||||
}
|
||||
|
||||
export async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { trackUiEvent } from './telemetry';
|
||||
|
||||
const HOST_API_PORT = 3210;
|
||||
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
|
||||
|
||||
type HostApiProxyResponse = {
|
||||
ok?: boolean;
|
||||
data?: {
|
||||
status?: number;
|
||||
ok?: boolean;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
};
|
||||
error?: { message?: string } | string;
|
||||
// backward compatibility fields
|
||||
success: boolean;
|
||||
status?: number;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
type HostApiProxyData = {
|
||||
status?: number;
|
||||
ok?: boolean;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
function headersToRecord(headers?: HeadersInit): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
||||
return { ...headers };
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
@@ -22,7 +55,104 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
function resolveProxyErrorMessage(error: HostApiProxyResponse['error']): string {
|
||||
return typeof error === 'string'
|
||||
? error
|
||||
: (error?.message || 'Host API proxy request failed');
|
||||
}
|
||||
|
||||
function parseUnifiedProxyResponse<T>(
|
||||
response: HostApiProxyResponse,
|
||||
path: string,
|
||||
method: string,
|
||||
startedAt: number,
|
||||
): T {
|
||||
if (!response.ok) {
|
||||
throw new Error(resolveProxyErrorMessage(response.error));
|
||||
}
|
||||
|
||||
const data: HostApiProxyData = response.data ?? {};
|
||||
trackUiEvent('hostapi.fetch', {
|
||||
path,
|
||||
method,
|
||||
source: 'ipc-proxy',
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: data.status ?? 200,
|
||||
});
|
||||
|
||||
if (data.status === 204) return undefined as T;
|
||||
if (data.json !== undefined) return data.json as T;
|
||||
return data.text as T;
|
||||
}
|
||||
|
||||
function parseLegacyProxyResponse<T>(
|
||||
response: HostApiProxyResponse,
|
||||
path: string,
|
||||
method: string,
|
||||
startedAt: number,
|
||||
): T {
|
||||
if (!response.success) {
|
||||
throw new Error(resolveProxyErrorMessage(response.error));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = response.text
|
||||
|| (typeof response.json === 'object' && response.json != null && 'error' in (response.json as Record<string, unknown>)
|
||||
? String((response.json as Record<string, unknown>).error)
|
||||
: `HTTP ${response.status ?? 'unknown'}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
trackUiEvent('hostapi.fetch', {
|
||||
path,
|
||||
method,
|
||||
source: 'ipc-proxy-legacy',
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: response.status ?? 200,
|
||||
});
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
if (response.json !== undefined) return response.json as T;
|
||||
return response.text as T;
|
||||
}
|
||||
|
||||
function shouldFallbackToBrowser(message: string): boolean {
|
||||
return message.includes('Invalid IPC channel: hostapi:fetch')
|
||||
|| message.includes('window is not defined');
|
||||
}
|
||||
|
||||
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
const method = init?.method || 'GET';
|
||||
// In Electron renderer, always proxy through main process to avoid CORS.
|
||||
try {
|
||||
const response = await invokeIpc<HostApiProxyResponse>('hostapi:fetch', {
|
||||
path,
|
||||
method,
|
||||
headers: headersToRecord(init?.headers),
|
||||
body: init?.body ?? null,
|
||||
});
|
||||
|
||||
if (typeof response?.ok === 'boolean' && 'data' in response) {
|
||||
return parseUnifiedProxyResponse<T>(response, path, method, startedAt);
|
||||
}
|
||||
|
||||
return parseLegacyProxyResponse<T>(response, path, method, startedAt);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
trackUiEvent('hostapi.fetch_error', {
|
||||
path,
|
||||
method,
|
||||
source: 'ipc-proxy',
|
||||
durationMs: Date.now() - startedAt,
|
||||
message,
|
||||
});
|
||||
if (!shouldFallbackToBrowser(message)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Browser-only fallback (non-Electron environments).
|
||||
const response = await fetch(`${HOST_API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
@@ -30,6 +160,13 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
trackUiEvent('hostapi.fetch', {
|
||||
path,
|
||||
method,
|
||||
source: 'browser-fallback',
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: response.status,
|
||||
});
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,21 @@ import { createHostEventSource } from './host-api';
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
const HOST_EVENT_TO_IPC_CHANNEL: Record<string, string> = {
|
||||
'gateway:status': 'gateway:status-changed',
|
||||
'gateway:error': 'gateway:error',
|
||||
'gateway:notification': 'gateway:notification',
|
||||
'gateway:chat-message': 'gateway:chat-message',
|
||||
'gateway:channel-status': 'gateway:channel-status',
|
||||
'gateway:exit': 'gateway:exit',
|
||||
'oauth:code': 'oauth:code',
|
||||
'oauth:success': 'oauth:success',
|
||||
'oauth:error': 'oauth:error',
|
||||
'channel:whatsapp-qr': 'channel:whatsapp-qr',
|
||||
'channel:whatsapp-success': 'channel:whatsapp-success',
|
||||
'channel:whatsapp-error': 'channel:whatsapp-error',
|
||||
};
|
||||
|
||||
function getEventSource(): EventSource {
|
||||
if (!eventSource) {
|
||||
eventSource = createHostEventSource();
|
||||
@@ -9,10 +24,35 @@ function getEventSource(): EventSource {
|
||||
return eventSource;
|
||||
}
|
||||
|
||||
function allowSseFallback(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem('clawx:allow-sse-fallback') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeHostEvent<T = unknown>(
|
||||
eventName: string,
|
||||
handler: (payload: T) => void,
|
||||
): () => void {
|
||||
const ipc = window.electron?.ipcRenderer;
|
||||
const ipcChannel = HOST_EVENT_TO_IPC_CHANNEL[eventName];
|
||||
if (ipcChannel && ipc?.on && ipc?.off) {
|
||||
const listener = (payload: unknown) => {
|
||||
handler(payload as T);
|
||||
};
|
||||
ipc.on(ipcChannel, listener);
|
||||
return () => {
|
||||
ipc.off(ipcChannel, listener);
|
||||
};
|
||||
}
|
||||
|
||||
if (!allowSseFallback()) {
|
||||
console.warn(`[host-events] no IPC mapping for event "${eventName}", SSE fallback disabled`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const source = getEventSource();
|
||||
const listener = (event: Event) => {
|
||||
const payload = JSON.parse((event as MessageEvent).data) as T;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
type TelemetryPayload = Record<string, unknown>;
|
||||
export type UiTelemetryEntry = {
|
||||
id: number;
|
||||
event: string;
|
||||
payload: TelemetryPayload;
|
||||
count: number;
|
||||
ts: string;
|
||||
};
|
||||
|
||||
const counters = new Map<string, number>();
|
||||
const history: UiTelemetryEntry[] = [];
|
||||
const listeners = new Set<(entry: UiTelemetryEntry) => void>();
|
||||
let nextEntryId = 1;
|
||||
const MAX_HISTORY = 500;
|
||||
|
||||
function safeStringify(payload: TelemetryPayload): string {
|
||||
try {
|
||||
@@ -14,10 +25,29 @@ export function trackUiEvent(event: string, payload: TelemetryPayload = {}): voi
|
||||
const count = (counters.get(event) ?? 0) + 1;
|
||||
counters.set(event, count);
|
||||
|
||||
const logPayload = {
|
||||
const normalizedPayload = {
|
||||
...payload,
|
||||
};
|
||||
const ts = new Date().toISOString();
|
||||
const entry: UiTelemetryEntry = {
|
||||
id: nextEntryId,
|
||||
event,
|
||||
payload: normalizedPayload,
|
||||
count,
|
||||
ts: new Date().toISOString(),
|
||||
ts,
|
||||
};
|
||||
nextEntryId += 1;
|
||||
|
||||
history.push(entry);
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.splice(0, history.length - MAX_HISTORY);
|
||||
}
|
||||
listeners.forEach((listener) => listener(entry));
|
||||
|
||||
const logPayload = {
|
||||
...normalizedPayload,
|
||||
count,
|
||||
ts,
|
||||
};
|
||||
|
||||
// Local-only telemetry for UX diagnostics.
|
||||
@@ -27,3 +57,54 @@ export function trackUiEvent(event: string, payload: TelemetryPayload = {}): voi
|
||||
export function getUiCounter(event: string): number {
|
||||
return counters.get(event) ?? 0;
|
||||
}
|
||||
|
||||
export function trackUiTiming(
|
||||
event: string,
|
||||
durationMs: number,
|
||||
payload: TelemetryPayload = {},
|
||||
): void {
|
||||
trackUiEvent(event, {
|
||||
...payload,
|
||||
durationMs: Math.round(durationMs),
|
||||
});
|
||||
}
|
||||
|
||||
export function startUiTiming(
|
||||
event: string,
|
||||
payload: TelemetryPayload = {},
|
||||
): (nextPayload?: TelemetryPayload) => number {
|
||||
const start = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
return (nextPayload: TelemetryPayload = {}): number => {
|
||||
const end = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
const durationMs = Math.max(0, end - start);
|
||||
trackUiTiming(event, durationMs, { ...payload, ...nextPayload });
|
||||
return durationMs;
|
||||
};
|
||||
}
|
||||
|
||||
export function getUiTelemetrySnapshot(limit = 200): UiTelemetryEntry[] {
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
if (limit >= history.length) {
|
||||
return [...history];
|
||||
}
|
||||
return history.slice(-limit);
|
||||
}
|
||||
|
||||
export function clearUiTelemetry(): void {
|
||||
counters.clear();
|
||||
history.length = 0;
|
||||
}
|
||||
|
||||
export function subscribeUiTelemetry(listener: (entry: UiTelemetryEntry) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user