refactor(extension): 모듈 분리 + Hub 통합 테스트 #task-395
- extension.ts 3,446→1,289줄 (-63%) - step-probe.ts (1,435줄): setupMonitor, processResponseFile, tryApprovalStrategies - observer-script.ts (687줄): DOM observer script - ws-client.ts (390줄): WSBridgeClient - step-utils.ts (114줄): step 파싱 유틸 - auth.py (115줄): JWT + registration code - hub.py (581줄): WSHub + per-client queue - Hub WS 연동 테스트 통과 (auth, chat, register) - VSIX v0.4.0 빌드
This commit is contained in:
505
extension/src/ws-client.ts
Normal file
505
extension/src/ws-client.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* WebSocket Bridge Client — connects Extension to the Hub server.
|
||||
*
|
||||
* Replaces file-based IPC for:
|
||||
* - Pending approvals (Extension → Hub → Bot → Discord)
|
||||
* - User responses (Discord → Bot → Hub → Extension)
|
||||
* - Chat snapshots (Extension → Hub → Bot → Discord)
|
||||
* - Commands (Discord → Bot → Hub → Extension)
|
||||
* - Session registration
|
||||
* - Auto-resolve notifications
|
||||
*
|
||||
* Features:
|
||||
* - Exponential backoff + jitter reconnection
|
||||
* - Message queue (survives reconnection)
|
||||
* - Heartbeat ping/pong
|
||||
* - First-message JWT authentication
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export interface WSMessage {
|
||||
type: string;
|
||||
data?: any;
|
||||
msg_id?: string;
|
||||
}
|
||||
|
||||
export interface WSAuthMessage {
|
||||
type: 'auth';
|
||||
token?: string;
|
||||
registration_code?: string;
|
||||
project: string;
|
||||
pc: string;
|
||||
}
|
||||
|
||||
export interface WSAuthOkResponse {
|
||||
type: 'auth_ok';
|
||||
conn_id: string;
|
||||
instance_number: number;
|
||||
session_token: string;
|
||||
active_count: number;
|
||||
}
|
||||
|
||||
export interface WSPendingData {
|
||||
request_id: string;
|
||||
command: string;
|
||||
description?: string;
|
||||
step_type?: string;
|
||||
status?: string;
|
||||
buttons?: Array<{ text: string; index: number }>;
|
||||
project_name?: string;
|
||||
// diff_review metadata
|
||||
edit_step_indices?: number[];
|
||||
modified_files?: string[];
|
||||
}
|
||||
|
||||
export interface WSResponseData {
|
||||
request_id: string;
|
||||
approved: boolean;
|
||||
button_index?: number;
|
||||
step_type?: string;
|
||||
project_name?: string;
|
||||
}
|
||||
|
||||
export interface WSCommandData {
|
||||
text: string;
|
||||
project_name?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface WSChatData {
|
||||
content: string;
|
||||
attached_files?: Array<{ name: string; content: string }>;
|
||||
conversation_id?: string;
|
||||
project_name?: string;
|
||||
}
|
||||
|
||||
export interface WSRegisterData {
|
||||
conversation_id: string;
|
||||
project_name: string;
|
||||
}
|
||||
|
||||
// ─── Event Handlers ───
|
||||
|
||||
export interface WSBridgeHandlers {
|
||||
onResponse?: (data: WSResponseData) => void;
|
||||
onCommand?: (data: WSCommandData) => void;
|
||||
onInstanceUpdate?: (activeCount: number, instances: Array<{ instance_number: number; pc: string }>) => void;
|
||||
onConnected?: (connId: string, instanceNumber: number, sessionToken: string) => void;
|
||||
onDisconnected?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
// ─── Constants ───
|
||||
|
||||
const INITIAL_RECONNECT_DELAY = 1000; // 1s
|
||||
const MAX_RECONNECT_DELAY = 60000; // 60s
|
||||
const RECONNECT_JITTER = 0.3; // ±30%
|
||||
const HEARTBEAT_INTERVAL = 25000; // 25s (server expects 30s)
|
||||
const MAX_QUEUE_SIZE = 200;
|
||||
const AUTH_TIMEOUT = 10000; // 10s
|
||||
|
||||
// ─── WSBridgeClient ───
|
||||
|
||||
export class WSBridgeClient {
|
||||
private ws: any = null; // WebSocket instance (Node.js ws module)
|
||||
private hubUrl: string;
|
||||
private registrationCode: string;
|
||||
private project: string;
|
||||
private pcName: string;
|
||||
private handlers: WSBridgeHandlers;
|
||||
private logFn: (msg: string) => void;
|
||||
|
||||
// Connection state
|
||||
private connected = false;
|
||||
private authenticated = false;
|
||||
private connId = '';
|
||||
private instanceNumber = 0;
|
||||
private sessionToken = '';
|
||||
private shouldReconnect = true;
|
||||
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
private authTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Message queue (survives reconnection)
|
||||
private messageQueue: WSMessage[] = [];
|
||||
private msgIdCounter = 0;
|
||||
|
||||
constructor(
|
||||
hubUrl: string,
|
||||
registrationCode: string,
|
||||
project: string,
|
||||
pcName: string,
|
||||
handlers: WSBridgeHandlers,
|
||||
logFn: (msg: string) => void,
|
||||
) {
|
||||
this.hubUrl = hubUrl;
|
||||
this.registrationCode = registrationCode;
|
||||
this.project = project;
|
||||
this.pcName = pcName;
|
||||
this.handlers = handlers;
|
||||
this.logFn = logFn;
|
||||
}
|
||||
|
||||
// ─── Public API ───
|
||||
|
||||
/** Start the WebSocket connection. */
|
||||
async connect(): Promise<void> {
|
||||
if (!this.hubUrl) {
|
||||
this.logFn('[WS] No hub URL configured — WS disabled');
|
||||
return;
|
||||
}
|
||||
this.shouldReconnect = true;
|
||||
await this._connect();
|
||||
}
|
||||
|
||||
/** Gracefully disconnect. */
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this._cleanup();
|
||||
this.logFn('[WS] Disconnected (intentional)');
|
||||
}
|
||||
|
||||
/** Check if connected and authenticated. */
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.authenticated;
|
||||
}
|
||||
|
||||
/** Get the instance number assigned by the Hub. */
|
||||
getInstanceNumber(): number {
|
||||
return this.instanceNumber;
|
||||
}
|
||||
|
||||
/** Send a pending approval to the Hub. */
|
||||
sendPending(data: WSPendingData): boolean {
|
||||
return this._send({ type: 'pending', data });
|
||||
}
|
||||
|
||||
/** Send a chat snapshot to the Hub. */
|
||||
sendChat(data: WSChatData): boolean {
|
||||
return this._send({ type: 'chat', data });
|
||||
}
|
||||
|
||||
/** Send a session registration. */
|
||||
sendRegister(data: WSRegisterData): boolean {
|
||||
return this._send({ type: 'register', data });
|
||||
}
|
||||
|
||||
/** Send an auto_resolve notification. */
|
||||
sendAutoResolve(requestId: string): boolean {
|
||||
return this._send({ type: 'auto_resolve', data: { request_id: requestId } });
|
||||
}
|
||||
|
||||
/** Send a brain event. */
|
||||
sendBrainEvent(data: any): boolean {
|
||||
return this._send({ type: 'brain_event', data });
|
||||
}
|
||||
|
||||
// ─── Internal Connection ───
|
||||
|
||||
private async _connect(): Promise<void> {
|
||||
try {
|
||||
// Dynamic import of ws module (Node.js built-in or npm package)
|
||||
const WebSocket = await this._getWebSocketClass();
|
||||
if (!WebSocket) {
|
||||
this.logFn('[WS] WebSocket module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logFn(`[WS] Connecting to ${this.hubUrl}...`);
|
||||
const ws = new WebSocket(this.hubUrl);
|
||||
|
||||
ws.on('open', () => {
|
||||
this.logFn('[WS] Connection opened, authenticating...');
|
||||
this.ws = ws;
|
||||
this.connected = true;
|
||||
this._authenticate();
|
||||
});
|
||||
|
||||
ws.on('message', (raw: Buffer | string) => {
|
||||
try {
|
||||
const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
|
||||
this._handleMessage(data);
|
||||
} catch (e: any) {
|
||||
this.logFn(`[WS] Parse error: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code: number, reason: Buffer) => {
|
||||
const reasonStr = reason ? reason.toString('utf-8') : '';
|
||||
this.logFn(`[WS] Connection closed: code=${code} reason=${reasonStr}`);
|
||||
this._onDisconnect();
|
||||
});
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
this.logFn(`[WS] Error: ${err.message}`);
|
||||
// close event will follow
|
||||
});
|
||||
|
||||
ws.on('pong', () => {
|
||||
// Server responded to our ping — connection is alive
|
||||
});
|
||||
|
||||
} catch (e: any) {
|
||||
this.logFn(`[WS] Connect failed: ${e.message}`);
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async _getWebSocketClass(): Promise<any> {
|
||||
try {
|
||||
// Try Node.js built-in WebSocket (v21+)
|
||||
if (typeof globalThis.WebSocket !== 'undefined') {
|
||||
return globalThis.WebSocket;
|
||||
}
|
||||
// Try require('ws') — should be available in VS Code's Node.js
|
||||
const ws = require('ws');
|
||||
return ws;
|
||||
} catch {
|
||||
// ws module not available
|
||||
try {
|
||||
// Fallback: try the built-in undici WebSocket
|
||||
const { WebSocket } = require('undici');
|
||||
return WebSocket;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Authentication ───
|
||||
|
||||
private _authenticate(): void {
|
||||
if (!this.ws) return;
|
||||
|
||||
const authMsg: WSAuthMessage = {
|
||||
type: 'auth',
|
||||
project: this.project,
|
||||
pc: this.pcName,
|
||||
};
|
||||
|
||||
// Use session token if available (from previous connection)
|
||||
if (this.sessionToken) {
|
||||
authMsg.token = this.sessionToken;
|
||||
} else if (this.registrationCode) {
|
||||
authMsg.registration_code = this.registrationCode;
|
||||
}
|
||||
|
||||
this._sendRaw(authMsg);
|
||||
|
||||
// Timeout for auth response
|
||||
this.authTimer = setTimeout(() => {
|
||||
if (!this.authenticated) {
|
||||
this.logFn('[WS] Auth timeout — closing connection');
|
||||
this._cleanup();
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}, AUTH_TIMEOUT);
|
||||
}
|
||||
|
||||
// ─── Message Handling ───
|
||||
|
||||
private _handleMessage(msg: WSMessage): void {
|
||||
switch (msg.type) {
|
||||
case 'auth_ok': {
|
||||
const authOk = msg as unknown as WSAuthOkResponse;
|
||||
this.authenticated = true;
|
||||
this.connId = authOk.conn_id;
|
||||
this.instanceNumber = authOk.instance_number;
|
||||
this.sessionToken = authOk.session_token;
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer);
|
||||
this.authTimer = null;
|
||||
}
|
||||
|
||||
this.logFn(`[WS] Authenticated: conn=${this.connId} instance=#${this.instanceNumber} active=${authOk.active_count}`);
|
||||
this._startHeartbeat();
|
||||
this._flushQueue();
|
||||
this.handlers.onConnected?.(this.connId, this.instanceNumber, this.sessionToken);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'auth_fail': {
|
||||
const reason = (msg as any).reason || 'Unknown';
|
||||
this.logFn(`[WS] Auth failed: ${reason}`);
|
||||
// Clear session token if it was rejected
|
||||
this.sessionToken = '';
|
||||
this._cleanup();
|
||||
// Don't reconnect on auth failure (needs manual fix)
|
||||
this.handlers.onError?.(`Auth failed: ${reason}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'response': {
|
||||
const data = msg.data as WSResponseData;
|
||||
if (data) {
|
||||
this.logFn(`[WS] Response received: ${data.request_id?.substring(0, 12)} approved=${data.approved}`);
|
||||
this.handlers.onResponse?.(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'command': {
|
||||
const data = msg.data as WSCommandData;
|
||||
if (data) {
|
||||
this.logFn(`[WS] Command received: ${data.text?.substring(0, 50)}`);
|
||||
this.handlers.onCommand?.(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'instance_update': {
|
||||
const activeCount = (msg as any).active_count || 0;
|
||||
const instances = (msg as any).instances || [];
|
||||
this.logFn(`[WS] Instance update: ${activeCount} active`);
|
||||
this.handlers.onInstanceUpdate?.(activeCount, instances);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const error = (msg as any).error || 'Unknown error';
|
||||
this.logFn(`[WS] Server error: ${error}`);
|
||||
this.handlers.onError?.(error);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logFn(`[WS] Unknown message type: ${msg.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Send ───
|
||||
|
||||
private _send(msg: WSMessage): boolean {
|
||||
// Add unique message ID for dedup
|
||||
msg.msg_id = `${this.project}-${Date.now()}-${++this.msgIdCounter}`;
|
||||
|
||||
if (this.isConnected()) {
|
||||
return this._sendRaw(msg);
|
||||
}
|
||||
|
||||
// Queue for later
|
||||
if (this.messageQueue.length >= MAX_QUEUE_SIZE) {
|
||||
// Drop oldest
|
||||
this.messageQueue.shift();
|
||||
this.logFn('[WS] Queue full — dropped oldest message');
|
||||
}
|
||||
this.messageQueue.push(msg);
|
||||
this.logFn(`[WS] Queued message (type=${msg.type}, queue=${this.messageQueue.length})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
private _sendRaw(msg: any): boolean {
|
||||
try {
|
||||
if (this.ws && this.connected) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e: any) {
|
||||
this.logFn(`[WS] Send error: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _flushQueue(): void {
|
||||
if (this.messageQueue.length === 0) return;
|
||||
this.logFn(`[WS] Flushing ${this.messageQueue.length} queued messages`);
|
||||
const queue = [...this.messageQueue];
|
||||
this.messageQueue = [];
|
||||
for (const msg of queue) {
|
||||
this._sendRaw(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Heartbeat ───
|
||||
|
||||
private _startHeartbeat(): void {
|
||||
this._stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.ws && this.connected) {
|
||||
try {
|
||||
this.ws.ping();
|
||||
} catch {
|
||||
// ping failure will trigger close event
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
private _stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reconnection ───
|
||||
|
||||
private _onDisconnect(): void {
|
||||
const wasAuthenticated = this.authenticated;
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
this.ws = null;
|
||||
|
||||
this._stopHeartbeat();
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer);
|
||||
this.authTimer = null;
|
||||
}
|
||||
|
||||
if (wasAuthenticated) {
|
||||
this.handlers.onDisconnected?.();
|
||||
}
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return;
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER;
|
||||
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
|
||||
this.logFn(`[WS] Reconnecting in ${Math.round(delay)}ms...`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
this._connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ─── Cleanup ───
|
||||
|
||||
private _cleanup(): void {
|
||||
this._stopHeartbeat();
|
||||
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer);
|
||||
this.authTimer = null;
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch { }
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user