import type { SessionState } from "@remoteconn/terminal-core"; import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport"; type GatewayFrame = { type: string; payload?: Record; data?: unknown; action?: unknown; code?: unknown; message?: unknown; }; export class GatewayTransport implements TerminalTransport { private static readonly PREFERRED_ENDPOINT_KEY = "terminal.gateway.preferredEndpoint"; private static readonly CONNECT_TIMEOUT_MS = 12000; private socket: WebSocket | null = null; private listeners = new Set<(event: TransportEvent) => void>(); private pingAt = 0; private heartbeatTimer: number | null = null; private connectAttemptId = 0; private state: SessionState = "idle"; public constructor( private readonly gatewayUrl: string, private readonly token: string ) {} public async connect(params: ConnectParams): Promise { const attemptId = ++this.connectAttemptId; this.log("connect:start", { attemptId, host: params.host, port: params.port, username: params.username, cols: params.cols, rows: params.rows, hasClientSessionKey: Boolean(params.clientSessionKey), hasKnownHostFingerprint: Boolean(params.knownHostFingerprint) }); if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.log("connect:already_open"); throw new Error("会话已连接"); } this.state = "connecting"; this.socket = await new Promise((resolve, reject) => { const endpoints = this.buildEndpoints(); this.log("connect:endpoints", { endpoints }); const reasons: string[] = []; let index = 0; const candidateHint = `候选地址: ${endpoints.join(", ")}`; const tryConnect = (): void => { if (attemptId !== this.connectAttemptId) { this.log("connect:attempt_cancelled", { attemptId }); reject(new Error("连接已取消")); return; } const endpoint = endpoints[index]; if (!endpoint) { this.log("connect:all_failed", { reasons, candidateHint }); reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`)); return; } let settled = false; let socket: WebSocket; let timeoutTimer: number | null = null; this.log("ws:connecting", { endpoint, attempt: index + 1, total: endpoints.length }); try { socket = new WebSocket(endpoint); this.socket = socket; } catch { this.log("ws:invalid_endpoint", { endpoint }); reasons.push(`地址无效: ${endpoint}`); if (index < endpoints.length - 1) { index += 1; tryConnect(); return; } reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`)); return; } timeoutTimer = window.setTimeout(() => { fail(`连接超时>${GatewayTransport.CONNECT_TIMEOUT_MS}ms`); }, GatewayTransport.CONNECT_TIMEOUT_MS); const clearTimer = (): void => { if (timeoutTimer !== null) { window.clearTimeout(timeoutTimer); timeoutTimer = null; } }; const fail = (reason: string): void => { if (settled) return; if (attemptId !== this.connectAttemptId) { settled = true; clearTimer(); reject(new Error("连接已取消")); return; } settled = true; clearTimer(); reasons.push(`${reason}: ${endpoint}`); this.log("ws:connect_failed", { endpoint, reason }); if (this.getPreferredEndpoint() === endpoint) { this.clearPreferredEndpoint(); this.log("connect:preferred_endpoint_cleared", { endpoint, reason }); } try { socket.close(); } catch { // ignore } if (index < endpoints.length - 1) { index += 1; tryConnect(); return; } reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`)); }; socket.onopen = () => { if (settled) return; if (attemptId !== this.connectAttemptId) { settled = true; clearTimer(); try { socket.close(); } catch { // ignore } reject(new Error("连接已取消")); return; } settled = true; clearTimer(); this.log("ws:open", { endpoint }); this.setPreferredEndpoint(endpoint); resolve(socket); }; socket.onerror = () => fail("网络或协议错误"); socket.onclose = (event) => { if (!settled) { fail(`连接关闭 code=${event.code} reason=${event.reason || "none"}`); } }; }; tryConnect(); }); if (attemptId !== this.connectAttemptId) { this.log("connect:socket_ready_but_cancelled", { attemptId }); try { this.socket?.close(); } catch { // ignore } throw new Error("连接已取消"); } this.log("connect:socket_ready", { readyState: this.socket.readyState }); this.socket.onmessage = (event) => { const text = typeof event.data === "string" ? event.data : ""; if (!text) return; let frame: GatewayFrame; try { frame = JSON.parse(text) as GatewayFrame; } catch { this.log("ws:message_parse_failed", { sample: text.slice(0, 120) }); return; } this.handleFrame(frame); }; this.socket.onclose = (event) => { console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, { code: event.code, reason: event.reason || "none", wasClean: event.wasClean }); console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, { code: event.code, reason: event.reason || "none", wasClean: event.wasClean }); this.log("ws:onclose", { code: event.code, reason: event.reason || "none", wasClean: event.wasClean }); this.stopHeartbeat(); this.state = "disconnected"; this.emit({ type: "disconnect", reason: `ws_closed(code=${event.code},reason=${event.reason || "none"},clean=${event.wasClean})` }); }; this.socket.onerror = () => { console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`); console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`); this.log("ws:onerror"); this.stopHeartbeat(); this.state = "error"; this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" }); }; this.log("connect:send_init", { host: params.host, port: params.port, username: params.username, cols: params.cols, rows: params.rows, hasKnownHostFingerprint: Boolean(params.knownHostFingerprint) }); this.sendRaw({ type: "init", payload: { host: params.host, port: params.port, username: params.username, ...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}), credential: params.credential, ...(params.knownHostFingerprint ? { knownHostFingerprint: params.knownHostFingerprint } : {}), pty: { cols: params.cols, rows: params.rows } } }); this.startHeartbeat(); this.state = "auth_pending"; this.log("connect:state_auth_pending"); } public async send(data: string, meta?: StdinMeta): Promise { this.log("stdin:send", { length: data.length, source: meta?.source ?? "keyboard" }); this.sendRaw({ type: "stdin", payload: { data, ...(meta ? { meta } : {}) } }); } public async resize(cols: number, rows: number): Promise { this.log("pty:resize", { cols, rows }); this.sendRaw({ type: "resize", payload: { cols, rows } }); } public async disconnect(reason = "manual"): Promise { this.connectAttemptId += 1; this.log("disconnect:requested", { reason, readyState: this.socket?.readyState ?? null }); this.stopHeartbeat(); if (this.socket) { if (this.socket.readyState === WebSocket.OPEN) { this.log("disconnect:send_control", { reason }); this.sendRaw({ type: "control", payload: { action: "disconnect", reason } }); } // Force close regardless of state (e.g. CONNECTING) try { this.socket.close(); } catch { // ignore } } this.socket = null; this.state = "disconnected"; this.log("disconnect:done"); } public on(listener: (event: TransportEvent) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } public getState(): SessionState { return this.state; } private sendRaw(frame: Record): void { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { this.log("ws:send_blocked", { frameType: String(frame.type ?? "unknown"), readyState: this.socket?.readyState ?? null, state: this.state }); throw new Error("网关连接未建立"); } this.socket.send(JSON.stringify(frame)); } private handleFrame(frame: GatewayFrame): void { const payload = frame.payload ?? {}; const type = String(frame.type ?? ""); const action = String((payload.action ?? frame.action ?? "") as string); if (type !== "stdout" && type !== "stderr") { this.log("ws:frame", { type, action: action || undefined }); } if (type === "stdout") { const data = String(payload.data ?? frame.data ?? ""); if (!data) return; if (this.state !== "connected") { this.log("ws:stdout_promote_connected"); } this.state = "connected"; this.emit({ type: "stdout", data }); return; } if (type === "stderr") { const data = String(payload.data ?? frame.data ?? ""); if (!data) return; this.emit({ type: "stderr", data }); return; } if (type === "error") { console.log(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, { code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"), message: String(payload.message ?? frame.message ?? "未知错误") }); console.error(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, { code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"), message: String(payload.message ?? frame.message ?? "未知错误") }); this.log("ws:error_frame", { code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"), message: String(payload.message ?? frame.message ?? "未知错误") }); this.state = "error"; this.emit({ type: "error", code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"), message: String(payload.message ?? frame.message ?? "未知错误") }); return; } if (type === "connected") { this.log("ws:connected_frame", { fingerprint: String(payload.fingerprint ?? "") || undefined }); this.state = "connected"; this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined }); return; } if (type === "disconnect") { this.log("ws:disconnect_frame", { reason: String(payload.reason ?? "unknown") }); this.state = "disconnected"; this.stopHeartbeat(); this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") }); return; } if (type === "control") { if (action === "ping") { this.log("heartbeat:ping_recv"); this.sendRaw({ type: "control", payload: { action: "pong" } }); return; } if (action === "pong") { if (this.pingAt > 0) { this.log("heartbeat:pong_recv", { latencyMs: Date.now() - this.pingAt }); this.emit({ type: "latency", data: Date.now() - this.pingAt }); } return; } if (action === "connected") { this.log("ws:control_connected", { fingerprint: String(payload.fingerprint ?? "") || undefined }); this.state = "connected"; this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined }); return; } if (action === "disconnect") { this.log("ws:control_disconnect", { reason: String(payload.reason ?? "unknown") }); this.state = "disconnected"; this.stopHeartbeat(); this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") }); } } } private emit(event: TransportEvent): void { for (const listener of this.listeners) { listener(event); } } private startHeartbeat(): void { this.stopHeartbeat(); this.log("heartbeat:start"); this.heartbeatTimer = window.setInterval(() => { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { return; } this.pingAt = Date.now(); this.log("heartbeat:ping_send"); this.sendRaw({ type: "control", payload: { action: "ping" } }); }, 10000); } private stopHeartbeat(): void { if (this.heartbeatTimer) { this.log("heartbeat:stop"); window.clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } private log(_message: string, _detail?: unknown): void { if (typeof window === "undefined") return; try { const enabled = window.localStorage.getItem("terminal.debugTransport") === "1" || window.localStorage.getItem("terminal.debugPaste") === "1"; if (!enabled) return; } catch { return; } const prefix = `[GatewayTransport][${new Date().toISOString()}] ${_message}`; if (typeof _detail === "undefined") { console.log(prefix); return; } console.log(prefix, _detail); } private buildEndpoints(): string[] { const pageIsHttps = window.location.protocol === "https:"; const pageHost = window.location.hostname; const pageHostWithPort = window.location.host; const pageProtocol = pageIsHttps ? "wss:" : "ws:"; const pageOrigin = `${pageProtocol}//${pageHostWithPort}`; const rawInput = this.gatewayUrl.trim(); const candidates: string[] = []; const finalizeEndpoint = (source: URL): string => { const next = new URL(source.toString()); const pathname = next.pathname.replace(/\/+$/, ""); next.pathname = pathname.endsWith("/ws/terminal") ? pathname : `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/"); next.search = `token=${encodeURIComponent(this.token)}`; return next.toString(); }; const pushCandidate = (next: URL): void => { if (pageIsHttps && next.protocol === "ws:") { return; } candidates.push(finalizeEndpoint(next)); }; // 兼容相对路径配置(如 /ws/terminal),并以当前页面源为基准解析。 const fallbackUrl = new URL(pageOrigin); let url: URL; try { if (!rawInput) { url = new URL(fallbackUrl.toString()); } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawInput)) { url = new URL(rawInput); } else { url = new URL(rawInput, fallbackUrl.toString()); } } catch { url = new URL(fallbackUrl.toString()); } if (url.protocol === "http:") url.protocol = "ws:"; if (url.protocol === "https:") url.protocol = "wss:"; if (pageIsHttps && url.protocol === "ws:") { url.protocol = "wss:"; } const localHosts = new Set(["localhost", "127.0.0.1", "::1"]); const pageIsLocal = localHosts.has(pageHost); let targetIsLocal = localHosts.has(url.hostname); if (!pageIsLocal && targetIsLocal) { // 页面从远程域名访问时,若配置仍是 localhost/127.0.0.1, // 优先尝试同源地址(通常由 Vite/Nginx 代理到本地网关)。 const sameOrigin = new URL(url.toString()); sameOrigin.protocol = pageProtocol; sameOrigin.host = pageHostWithPort; pushCandidate(sameOrigin); const sameOriginNoPort = new URL(sameOrigin.toString()); sameOriginNoPort.port = ""; pushCandidate(sameOriginNoPort); url.hostname = pageHost; targetIsLocal = localHosts.has(url.hostname); } pushCandidate(url); if (!pageIsHttps && url.protocol === "ws:") { const tlsUrl = new URL(url.toString()); tlsUrl.protocol = "wss:"; pushCandidate(tlsUrl); } else if (url.protocol === "wss:" && !pageIsHttps) { const plainUrl = new URL(url.toString()); plainUrl.protocol = "ws:"; pushCandidate(plainUrl); } if (!targetIsLocal) { const noPort = new URL(url.toString()); noPort.port = ""; pushCandidate(noPort); if (!pageIsHttps && noPort.protocol === "ws:") { const noPortTls = new URL(noPort.toString()); noPortTls.protocol = "wss:"; pushCandidate(noPortTls); } else if (noPort.protocol === "wss:" && !pageIsHttps) { const noPortPlain = new URL(noPort.toString()); noPortPlain.protocol = "ws:"; pushCandidate(noPortPlain); } } const ordered = [...new Set(candidates)]; if (ordered.length === 0) { return [finalizeEndpoint(fallbackUrl)]; } const preferred = this.getPreferredEndpoint(); if (preferred && ordered.includes(preferred)) { return [preferred, ...ordered.filter((endpoint) => endpoint !== preferred)]; } return ordered; } private getPreferredEndpoint(): string | null { try { return window.localStorage.getItem(GatewayTransport.PREFERRED_ENDPOINT_KEY); } catch { return null; } } private setPreferredEndpoint(endpoint: string): void { try { window.localStorage.setItem(GatewayTransport.PREFERRED_ENDPOINT_KEY, endpoint); } catch { // ignore } } private clearPreferredEndpoint(): void { try { window.localStorage.removeItem(GatewayTransport.PREFERRED_ENDPOINT_KEY); } catch { // ignore } } }