cc工作前备份
This commit is contained in:
13
src/gateway/index.ts
Normal file
13
src/gateway/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { GatewayManager } from './manager';
|
||||
export type { GatewayManagerOptions, GatewayInfo, GatewayStatus } from './manager';
|
||||
export { GatewayWsClient } from './ws-client';
|
||||
export type {
|
||||
ConnectParams,
|
||||
GatewayRequest,
|
||||
GatewayResponse,
|
||||
GatewayEvent,
|
||||
GatewayFrame,
|
||||
AgentStreamEvent,
|
||||
WsClientOptions,
|
||||
ConnectionState,
|
||||
} from './ws-client';
|
||||
328
src/gateway/manager.ts
Normal file
328
src/gateway/manager.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* ZCLAW Gateway Manager
|
||||
*
|
||||
* Manages the OpenClaw Gateway subprocess lifecycle:
|
||||
* - Start/stop Gateway daemon
|
||||
* - Health checking
|
||||
* - Auto-restart on crash
|
||||
* - Configuration management
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess, execSync } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export type GatewayStatus = 'stopped' | 'starting' | 'running' | 'error' | 'stopping';
|
||||
|
||||
export interface GatewayManagerOptions {
|
||||
/** OpenClaw home directory (default: ~/.openclaw) */
|
||||
home?: string;
|
||||
/** Gateway port (default: 18789) */
|
||||
port?: number;
|
||||
/** Gateway host (default: 127.0.0.1) */
|
||||
host?: string;
|
||||
/** Auth token for Gateway connections */
|
||||
token?: string;
|
||||
/** Auto-restart on crash */
|
||||
autoRestart?: boolean;
|
||||
/** Max restart attempts */
|
||||
maxRestarts?: number;
|
||||
/** Health check interval in ms (default: 15000) */
|
||||
healthCheckInterval?: number;
|
||||
}
|
||||
|
||||
export interface GatewayInfo {
|
||||
status: GatewayStatus;
|
||||
pid?: number;
|
||||
port: number;
|
||||
host: string;
|
||||
wsUrl: string;
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class GatewayManager extends EventEmitter {
|
||||
private process: ChildProcess | null = null;
|
||||
private status: GatewayStatus = 'stopped';
|
||||
private startTime: number = 0;
|
||||
private restartCount: number = 0;
|
||||
private healthTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private options: Required<GatewayManagerOptions>;
|
||||
private lastError: string | null = null;
|
||||
|
||||
constructor(opts: GatewayManagerOptions = {}) {
|
||||
super();
|
||||
this.options = {
|
||||
home: opts.home || path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw'),
|
||||
port: opts.port || 18789,
|
||||
host: opts.host || '127.0.0.1',
|
||||
token: opts.token || '',
|
||||
autoRestart: opts.autoRestart ?? true,
|
||||
maxRestarts: opts.maxRestarts || 5,
|
||||
healthCheckInterval: opts.healthCheckInterval || 15000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get current gateway info */
|
||||
getInfo(): GatewayInfo {
|
||||
return {
|
||||
status: this.status,
|
||||
pid: this.process?.pid,
|
||||
port: this.options.port,
|
||||
host: this.options.host,
|
||||
wsUrl: `ws://${this.options.host}:${this.options.port}`,
|
||||
version: this.getVersion(),
|
||||
uptime: this.status === 'running' ? Date.now() - this.startTime : undefined,
|
||||
error: this.lastError || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if OpenClaw is installed */
|
||||
isInstalled(): boolean {
|
||||
try {
|
||||
execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get OpenClaw version */
|
||||
getVersion(): string | undefined {
|
||||
try {
|
||||
return execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the OpenClaw Gateway */
|
||||
async start(): Promise<void> {
|
||||
if (this.status === 'running' || this.status === 'starting') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isInstalled()) {
|
||||
this.setStatus('error');
|
||||
this.lastError = 'OpenClaw is not installed';
|
||||
throw new Error('OpenClaw is not installed. Run: npm install -g openclaw@latest');
|
||||
}
|
||||
|
||||
this.setStatus('starting');
|
||||
this.lastError = null;
|
||||
|
||||
try {
|
||||
// Check if Gateway is already running (external instance)
|
||||
const alreadyRunning = await this.checkHealth();
|
||||
if (alreadyRunning) {
|
||||
this.setStatus('running');
|
||||
this.startTime = Date.now();
|
||||
this.startHealthCheck();
|
||||
this.emit('connected', { external: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start Gateway as subprocess
|
||||
const env: Record<string, string> = {
|
||||
...process.env as Record<string, string>,
|
||||
OPENCLAW_HOME: this.options.home,
|
||||
};
|
||||
|
||||
if (this.options.token) {
|
||||
env.OPENCLAW_GATEWAY_TOKEN = this.options.token;
|
||||
}
|
||||
|
||||
this.process = spawn('openclaw', ['gateway'], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
// Capture stdout
|
||||
this.process.stdout?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
this.emit('log', { level: 'info', message: output.trim() });
|
||||
|
||||
// Detect when Gateway is ready
|
||||
if (output.includes('Gateway listening') || output.includes('ready')) {
|
||||
this.setStatus('running');
|
||||
this.startTime = Date.now();
|
||||
this.restartCount = 0;
|
||||
this.startHealthCheck();
|
||||
}
|
||||
});
|
||||
|
||||
// Capture stderr
|
||||
this.process.stderr?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
this.emit('log', { level: 'error', message: output.trim() });
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
this.process.on('exit', (code, signal) => {
|
||||
const wasRunning = this.status === 'running';
|
||||
this.process = null;
|
||||
this.stopHealthCheck();
|
||||
|
||||
if (this.status === 'stopping') {
|
||||
this.setStatus('stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0 && wasRunning && this.options.autoRestart) {
|
||||
this.restartCount++;
|
||||
if (this.restartCount <= this.options.maxRestarts) {
|
||||
this.lastError = `Gateway crashed (exit code: ${code}), restarting (${this.restartCount}/${this.options.maxRestarts})`;
|
||||
this.emit('log', { level: 'warn', message: this.lastError });
|
||||
setTimeout(() => this.start(), 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastError = `Gateway exited with code ${code}, signal ${signal}`;
|
||||
this.setStatus(code === 0 ? 'stopped' : 'error');
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
this.process.on('error', (err) => {
|
||||
this.lastError = err.message;
|
||||
this.setStatus('error');
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
// Wait for Gateway to be ready (timeout 30s)
|
||||
await this.waitForReady(30000);
|
||||
} catch (err: any) {
|
||||
this.lastError = err.message;
|
||||
this.setStatus('error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the Gateway */
|
||||
async stop(): Promise<void> {
|
||||
if (this.status === 'stopped' || this.status === 'stopping') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStatus('stopping');
|
||||
this.stopHealthCheck();
|
||||
|
||||
if (this.process) {
|
||||
// Send SIGTERM for graceful shutdown
|
||||
this.process.kill('SIGTERM');
|
||||
|
||||
// Wait up to 10s for graceful exit
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.process) {
|
||||
this.process.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 10000);
|
||||
|
||||
if (this.process) {
|
||||
this.process.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.process = null;
|
||||
this.setStatus('stopped');
|
||||
}
|
||||
|
||||
/** Restart the Gateway */
|
||||
async restart(): Promise<void> {
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
/** Check Gateway health via HTTP */
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const url = `http://${this.options.host}:${this.options.port}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
return response.ok || response.status === 200 || response.status === 101;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for Gateway to become ready */
|
||||
private waitForReady(timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
|
||||
const check = async () => {
|
||||
if (this.status === 'running') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.status === 'error') {
|
||||
reject(new Error(this.lastError || 'Gateway failed to start'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
// Try health check as last resort
|
||||
const healthy = await this.checkHealth();
|
||||
if (healthy) {
|
||||
this.setStatus('running');
|
||||
this.startTime = Date.now();
|
||||
this.startHealthCheck();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error('Gateway startup timed out'));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(check, 1000);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/** Start periodic health checks */
|
||||
private startHealthCheck() {
|
||||
this.stopHealthCheck();
|
||||
this.healthTimer = setInterval(async () => {
|
||||
const healthy = await this.checkHealth();
|
||||
if (!healthy && this.status === 'running') {
|
||||
this.emit('log', { level: 'warn', message: 'Health check failed' });
|
||||
// Don't immediately mark as error — may be transient
|
||||
}
|
||||
}, this.options.healthCheckInterval);
|
||||
}
|
||||
|
||||
/** Stop health checks */
|
||||
private stopHealthCheck() {
|
||||
if (this.healthTimer) {
|
||||
clearInterval(this.healthTimer);
|
||||
this.healthTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Update status and emit event */
|
||||
private setStatus(status: GatewayStatus) {
|
||||
const prev = this.status;
|
||||
this.status = status;
|
||||
if (prev !== status) {
|
||||
this.emit('status', { status, previous: prev });
|
||||
}
|
||||
}
|
||||
}
|
||||
443
src/gateway/ws-client.ts
Normal file
443
src/gateway/ws-client.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* ZCLAW WebSocket Client
|
||||
*
|
||||
* Typed WebSocket client for OpenClaw Gateway protocol.
|
||||
* Handles:
|
||||
* - Connection + handshake (connect challenge/response)
|
||||
* - Request/response pattern (with timeout)
|
||||
* - Event subscription
|
||||
* - Auto-reconnect
|
||||
* - Agent streaming
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// === Protocol Types ===
|
||||
|
||||
export interface ConnectParams {
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
client: {
|
||||
id: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
mode: 'operator' | 'node';
|
||||
};
|
||||
role: 'operator' | 'node';
|
||||
scopes: string[];
|
||||
auth?: { token?: string };
|
||||
locale?: string;
|
||||
userAgent?: string;
|
||||
device?: {
|
||||
id: string;
|
||||
publicKey?: string;
|
||||
signature?: string;
|
||||
signedAt?: number;
|
||||
nonce?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GatewayRequest {
|
||||
type: 'req';
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GatewayResponse {
|
||||
type: 'res';
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: any;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export interface GatewayEvent {
|
||||
type: 'event';
|
||||
event: string;
|
||||
payload?: any;
|
||||
seq?: number;
|
||||
stateVersion?: number;
|
||||
}
|
||||
|
||||
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent;
|
||||
|
||||
// Agent stream events
|
||||
export interface AgentStreamEvent {
|
||||
stream: 'assistant' | 'tool' | 'lifecycle';
|
||||
delta?: string;
|
||||
content?: string;
|
||||
tool?: string;
|
||||
phase?: 'start' | 'end' | 'error';
|
||||
runId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WsClientOptions {
|
||||
/** Gateway WebSocket URL (default: ws://127.0.0.1:18789) */
|
||||
url?: string;
|
||||
/** Auth token */
|
||||
token?: string;
|
||||
/** Client identifier */
|
||||
clientId?: string;
|
||||
/** Client version */
|
||||
clientVersion?: string;
|
||||
/** Auto-reconnect on disconnect */
|
||||
autoReconnect?: boolean;
|
||||
/** Reconnect interval ms */
|
||||
reconnectInterval?: number;
|
||||
/** Max reconnect attempts (0 = infinite) */
|
||||
maxReconnectAttempts?: number;
|
||||
/** Request timeout ms */
|
||||
requestTimeout?: number;
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
|
||||
|
||||
export class GatewayWsClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: ConnectionState = 'disconnected';
|
||||
private requestId: number = 0;
|
||||
private pendingRequests = new Map<string, {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason: any) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>();
|
||||
private reconnectAttempts: number = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private deviceId: string;
|
||||
private options: Required<WsClientOptions>;
|
||||
|
||||
constructor(opts: WsClientOptions = {}) {
|
||||
super();
|
||||
this.options = {
|
||||
url: opts.url || 'ws://127.0.0.1:18789',
|
||||
token: opts.token || '',
|
||||
clientId: opts.clientId || 'zclaw-tauri',
|
||||
clientVersion: opts.clientVersion || '0.1.0',
|
||||
autoReconnect: opts.autoReconnect ?? true,
|
||||
reconnectInterval: opts.reconnectInterval || 3000,
|
||||
maxReconnectAttempts: opts.maxReconnectAttempts || 0,
|
||||
requestTimeout: opts.requestTimeout || 30000,
|
||||
};
|
||||
this.deviceId = crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
/** Current connection state */
|
||||
getState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Connect to Gateway */
|
||||
async connect(): Promise<void> {
|
||||
if (this.state === 'connected' || this.state === 'connecting') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState('connecting');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(this.options.url);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
this.setState('handshaking');
|
||||
// Wait for connect.challenge event
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const frame: GatewayFrame = JSON.parse(data.toString());
|
||||
this.handleFrame(frame, resolve);
|
||||
} catch (err: any) {
|
||||
this.emit('error', new Error(`Failed to parse frame: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', (code: number, reason: Buffer) => {
|
||||
const wasConnected = this.state === 'connected';
|
||||
this.cleanup();
|
||||
|
||||
if (wasConnected && this.options.autoReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
this.emit('close', { code, reason: reason.toString() });
|
||||
});
|
||||
|
||||
this.ws.on('error', (err: Error) => {
|
||||
if (this.state === 'connecting') {
|
||||
this.cleanup();
|
||||
reject(err);
|
||||
}
|
||||
this.emit('error', err);
|
||||
});
|
||||
} catch (err) {
|
||||
this.cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Disconnect from Gateway */
|
||||
disconnect() {
|
||||
this.cancelReconnect();
|
||||
this.options.autoReconnect = false;
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect');
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
/** Send a request and wait for response */
|
||||
async request(method: string, params?: Record<string, any>): Promise<any> {
|
||||
if (this.state !== 'connected') {
|
||||
throw new Error(`Cannot send request in state: ${this.state}`);
|
||||
}
|
||||
|
||||
const id = `req_${++this.requestId}`;
|
||||
const frame: GatewayRequest = { type: 'req', id, method, params: params || {} };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${method} timed out after ${this.options.requestTimeout}ms`));
|
||||
}, this.options.requestTimeout);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
this.send(frame);
|
||||
});
|
||||
}
|
||||
|
||||
/** Send agent message (trigger agent loop) */
|
||||
async sendAgentMessage(message: string, opts?: {
|
||||
sessionKey?: string;
|
||||
model?: string;
|
||||
agentId?: string;
|
||||
}): Promise<{ runId: string; acceptedAt: string }> {
|
||||
return this.request('agent', {
|
||||
message,
|
||||
sessionKey: opts?.sessionKey,
|
||||
model: opts?.model,
|
||||
agentId: opts?.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for agent run to complete */
|
||||
async waitForAgent(runId: string): Promise<{ status: string; startedAt: string; endedAt: string; error?: string }> {
|
||||
return this.request('agent.wait', { runId });
|
||||
}
|
||||
|
||||
/** Send message through IM channel */
|
||||
async sendChannelMessage(channel: string, chatId: string, text: string): Promise<any> {
|
||||
return this.request('send', { channel, chatId, text });
|
||||
}
|
||||
|
||||
/** Get Gateway health */
|
||||
async getHealth(): Promise<any> {
|
||||
return this.request('health');
|
||||
}
|
||||
|
||||
/** Get Gateway status */
|
||||
async getStatus(): Promise<any> {
|
||||
return this.request('status');
|
||||
}
|
||||
|
||||
// === ZCLAW Custom RPC Methods ===
|
||||
|
||||
async listClones(): Promise<any> {
|
||||
return this.request('zclaw.clones.list');
|
||||
}
|
||||
|
||||
async createClone(opts: { name: string; role?: string; nickname?: string; scenarios?: string[]; model?: string }): Promise<any> {
|
||||
return this.request('zclaw.clones.create', opts);
|
||||
}
|
||||
|
||||
async getUsageStats(): Promise<any> {
|
||||
return this.request('zclaw.stats.usage');
|
||||
}
|
||||
|
||||
async getSessionStats(): Promise<any> {
|
||||
return this.request('zclaw.stats.sessions');
|
||||
}
|
||||
|
||||
async getWorkspaceInfo(): Promise<any> {
|
||||
return this.request('zclaw.workspace.info');
|
||||
}
|
||||
|
||||
async getPluginStatus(): Promise<any> {
|
||||
return this.request('zclaw.plugins.status');
|
||||
}
|
||||
|
||||
async getQuickConfig(): Promise<any> {
|
||||
return this.request('zclaw.config.quick', { get: true });
|
||||
}
|
||||
|
||||
async saveQuickConfig(config: Record<string, any>): Promise<any> {
|
||||
return this.request('zclaw.config.quick', config);
|
||||
}
|
||||
|
||||
// === Internal ===
|
||||
|
||||
private handleFrame(frame: GatewayFrame, connectResolve?: (value: void) => void) {
|
||||
switch (frame.type) {
|
||||
case 'event':
|
||||
this.handleEvent(frame as GatewayEvent, connectResolve);
|
||||
break;
|
||||
case 'res':
|
||||
this.handleResponse(frame as GatewayResponse);
|
||||
break;
|
||||
default:
|
||||
// Ignore unexpected frame types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: GatewayEvent, connectResolve?: (value: void) => void) {
|
||||
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
|
||||
// Respond to challenge with connect request
|
||||
this.performHandshake(event.payload?.nonce, connectResolve);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit typed events
|
||||
this.emit('event', event);
|
||||
this.emit(`event:${event.event}`, event.payload);
|
||||
|
||||
// Specific agent stream events
|
||||
if (event.event === 'agent') {
|
||||
this.emit('agent:stream', event.payload as AgentStreamEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private performHandshake(nonce: string, connectResolve?: (value: void) => void) {
|
||||
const platform = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux';
|
||||
|
||||
const connectReq: GatewayRequest = {
|
||||
type: 'req',
|
||||
id: `connect_${Date.now()}`,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: this.options.clientId,
|
||||
version: this.options.clientVersion,
|
||||
platform,
|
||||
mode: 'operator',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read', 'operator.write'],
|
||||
auth: this.options.token ? { token: this.options.token } : {},
|
||||
locale: 'zh-CN',
|
||||
userAgent: `zclaw-tauri/${this.options.clientVersion}`,
|
||||
device: {
|
||||
id: this.deviceId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Listen for the connect response
|
||||
const handler = (data: Buffer) => {
|
||||
try {
|
||||
const frame = JSON.parse(data.toString());
|
||||
if (frame.type === 'res' && frame.id === connectReq.id) {
|
||||
this.ws?.removeListener('message', handler);
|
||||
if (frame.ok) {
|
||||
this.setState('connected');
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected', frame.payload);
|
||||
connectResolve?.();
|
||||
} else {
|
||||
const err = new Error(`Handshake failed: ${JSON.stringify(frame.error)}`);
|
||||
this.emit('error', err);
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
} catch { /* ignore parse errors during handshake */ }
|
||||
};
|
||||
|
||||
this.ws?.on('message', handler);
|
||||
this.send(connectReq);
|
||||
}
|
||||
|
||||
private handleResponse(res: GatewayResponse) {
|
||||
const pending = this.pendingRequests.get(res.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(res.id);
|
||||
if (res.ok) {
|
||||
pending.resolve(res.payload);
|
||||
} else {
|
||||
pending.reject(new Error(JSON.stringify(res.error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private send(frame: GatewayFrame) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState) {
|
||||
const prev = this.state;
|
||||
this.state = state;
|
||||
if (prev !== state) {
|
||||
this.emit('state', { state, previous: prev });
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
// Clear all pending requests
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('Connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners();
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
try { this.ws.close(); } catch { /* ignore */ }
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.options.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
||||
this.emit('reconnect:failed', { attempts: this.reconnectAttempts });
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
this.setState('reconnecting');
|
||||
|
||||
const delay = Math.min(
|
||||
this.options.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||
30000
|
||||
);
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
try {
|
||||
await this.connect();
|
||||
this.emit('reconnect:success', { attempts: this.reconnectAttempts });
|
||||
} catch {
|
||||
// Will trigger another reconnect via the close handler
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private cancelReconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user