/* global wx, console, module, clearInterval, setInterval, setTimeout */ const DEFAULT_LATENCY_SAMPLE_INTERVAL_MS = 10000; const DEFAULT_CONNECT_RETRY_COUNT = 1; const DEFAULT_CONNECT_RETRY_DELAY_MS = 400; function normalizeLatencySampleIntervalMs(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric < 1000) { return DEFAULT_LATENCY_SAMPLE_INTERVAL_MS; } return Math.round(numeric); } function normalizeConnectRetryCount(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric < 0) { return DEFAULT_CONNECT_RETRY_COUNT; } return Math.min(2, Math.round(numeric)); } function normalizeConnectRetryDelayMs(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric < 100) { return DEFAULT_CONNECT_RETRY_DELAY_MS; } return Math.min(2000, Math.round(numeric)); } function wait(ms) { return new Promise((resolve) => { setTimeout(resolve, Math.max(0, Math.round(Number(ms) || 0))); }); } /** * 仅对“明显属于临时网络态”的首连失败做一次短退避重试: * 1. `connection refused / timeout / reset` 一类通常是客户端网络栈或链路瞬时抖动; * 2. 域名白名单、证书、URL 非法等配置问题不会被重试,避免掩盖真实错误; * 3. 这里只处理 `onOpen` 之前的失败,已建立的会话仍按原有断线逻辑处理。 */ function isRetryableConnectFailure(detail) { const text = String(detail || "") .trim() .toLowerCase(); if (!text) return false; const blockedHints = [ "url not in domain list", "socket 域名", "invalid url", "ssl handshake failed", "certificate", "cert", "tls", "token 无效", "auth_failed" ]; if (blockedHints.some((hint) => text.includes(hint))) { return false; } const retryableHints = [ "connection refused", "econnrefused", "software caused connection abort", "connection abort", "connection reset", "econnreset", "network is unreachable", "host is unreachable", "timed out", "timeout", "temporarily unavailable", "socket hang up", "failed to connect" ]; return retryableHints.some((hint) => text.includes(hint)); } function isRetryablePreOpenClose(event) { const code = event && typeof event.code === "number" ? event.code : 0; if (!code) return true; return code === 1001 || code === 1006 || code === 1011; } /** * 微信小程序网关传输层(最小可用版)。 * 协议与 Web 端保持一致: * - init / stdin / resize / control(ping-pong-disconnect) */ function buildWsUrl(rawGatewayUrl, token) { const input = String(rawGatewayUrl || "").trim(); if (!input) { throw new Error("网关地址为空"); } let base = input; if (base.startsWith("http://")) base = `ws://${base.slice(7)}`; if (base.startsWith("https://")) base = `wss://${base.slice(8)}`; if (!base.startsWith("ws://") && !base.startsWith("wss://")) { base = `wss://${base}`; } base = base.replace(/\/+$/, ""); const safeToken = encodeURIComponent(String(token || "")); return `${base}/ws/terminal?token=${safeToken}`; } function createGatewayClient(options) { const config = options || {}; let socketTask = null; let socketReady = false; let heartbeatTimer = null; let heartbeatIntervalMs = normalizeLatencySampleIntervalMs(config.heartbeatIntervalMs); const connectRetryCount = normalizeConnectRetryCount(config.connectRetryCount); const connectRetryDelayMs = normalizeConnectRetryDelayMs(config.connectRetryDelayMs); let pingAt = 0; let connectPromise = null; let activeSeq = 0; let activeConnectRunSeq = 0; const debugLog = typeof config.debugLog === "function" ? config.debugLog : null; function log(event, payload) { if (!debugLog) return; try { debugLog(event, payload || {}); } catch (error) { console.warn("[gateway.debug]", error); } } function clearHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } } function releaseSocketTask(task) { if (task && task === socketTask) { socketTask = null; } socketReady = false; clearHeartbeat(); } function safeCloseSocketTask(task, reason) { if (!task || typeof task.close !== "function") return; try { task.close({ code: 1000, reason: reason || "cleanup" }); } catch (error) { console.warn("[gateway.cleanup]", error); } } function sendFrame(frame) { if (!socketTask) return; socketTask.send({ data: JSON.stringify(frame) }); } function sendLatencyPing() { if (!socketTask) return; pingAt = Date.now(); sendFrame({ type: "control", payload: { action: "ping" } }); } function startHeartbeat() { clearHeartbeat(); if (!socketReady) return; heartbeatTimer = setInterval(() => { sendLatencyPing(); }, heartbeatIntervalMs); } /** * 连接真正进入可用态后,允许上层主动补一拍时延采样, * 避免首个 ping 早于 shell ready,导致 UI 要等下一轮 10 秒心跳。 */ function sampleLatency() { sendLatencyPing(); } /** * 诊断面板展开时,小程序会把采样频率提升到 3 秒; * 收起后再恢复默认节奏,这里只负责切换 WebSocket ping 心跳本身。 */ function setLatencySampleInterval(intervalMs) { const nextIntervalMs = normalizeLatencySampleIntervalMs(intervalMs); if (nextIntervalMs === heartbeatIntervalMs) { return; } heartbeatIntervalMs = nextIntervalMs; if (socketReady) { startHeartbeat(); } } function connect(params) { const payload = params || {}; const url = buildWsUrl(config.gatewayUrl, config.gatewayToken); const connectTimeoutMs = Number(config.connectTimeoutMs); const timeout = Number.isFinite(connectTimeoutMs) && connectTimeoutMs >= 1000 ? connectTimeoutMs : 12000; const maxAttempts = 1 + connectRetryCount; if (connectPromise) return connectPromise; const connectRunSeq = ++activeConnectRunSeq; function connectOnce(attempt) { return new Promise((resolve, reject) => { let settled = false; let opened = false; const seq = ++activeSeq; log("gateway.socket.connecting", { attempt, maxAttempts, timeoutMs: timeout, host: payload.host, port: Number(payload.port) || 22, cols: Number(payload.cols) || 80, rows: Number(payload.rows) || 24, hasJumpHost: !!payload.jumpHost }); const previousSocket = socketTask; if (previousSocket) { releaseSocketTask(previousSocket); safeCloseSocketTask(previousSocket, "replace_before_connect"); } const rejectOnce = (message, retryable) => { if (settled) return; settled = true; reject({ message, retryable: !!retryable }); }; const resolveOnce = () => { if (settled) return; settled = true; resolve(); }; const task = wx.connectSocket({ url, timeout }); socketTask = task; task.onOpen(() => { if (seq !== activeSeq || task !== socketTask) return; opened = true; socketReady = true; log("gateway.socket.open", { seq, attempt, maxAttempts }); sendFrame({ type: "init", payload: { host: payload.host, port: Number(payload.port) || 22, username: payload.username, ...(payload.clientSessionKey ? { clientSessionKey: String(payload.clientSessionKey) } : {}), ...(Number.isFinite(Number(payload.resumeGraceMs)) ? { resumeGraceMs: Math.round(Number(payload.resumeGraceMs)) } : {}), credential: payload.credential || { type: "password", password: "" }, ...(payload.jumpHost ? { jumpHost: payload.jumpHost } : {}), pty: { cols: Number(payload.cols) || 80, rows: Number(payload.rows) || 24 } } }); log("gateway.init.sent", { seq, attempt, cols: Number(payload.cols) || 80, rows: Number(payload.rows) || 24, hasJumpHost: !!payload.jumpHost, hasClientSessionKey: !!payload.clientSessionKey }); startHeartbeat(); resolveOnce(); }); task.onMessage((event) => { if (seq !== activeSeq || task !== socketTask) return; try { const frame = JSON.parse(event.data || "{}"); if (frame.type === "control" && frame.payload?.action === "ping") { sendFrame({ type: "control", payload: { action: "pong" } }); return; } if (frame.type === "control" && frame.payload?.action === "pong") { if (typeof config.onLatency === "function" && pingAt > 0) { config.onLatency(Date.now() - pingAt); } return; } if (typeof config.onFrame === "function") { config.onFrame(frame); } } catch (error) { if (typeof config.onError === "function") { config.onError(error); } } }); task.onClose((event) => { if (seq !== activeSeq || task !== socketTask) return; const code = event && typeof event.code === "number" ? event.code : ""; const reason = event && event.reason ? String(event.reason) : ""; releaseSocketTask(task); log("gateway.socket.close", { seq, attempt, opened, code, reason }); if (!opened) { rejectOnce( `网关连接失败${code ? `(${code})` : ""}${reason ? `: ${reason}` : ""}`, isRetryablePreOpenClose(event) ); return; } if (typeof config.onClose === "function") { config.onClose(event); } }); task.onError((error) => { if (seq !== activeSeq || task !== socketTask) return; const detail = error && error.errMsg ? String(error.errMsg) : ""; const message = detail ? `网关连接失败: ${detail}` : "网关连接失败"; log("gateway.socket.error", { seq, attempt, errMsg: detail }); if (!opened) { const retryable = isRetryableConnectFailure(detail); releaseSocketTask(task); safeCloseSocketTask(task, "connect_error"); rejectOnce(message, retryable); return; } if (typeof config.onError === "function") { config.onError(error); } }); }); } connectPromise = (async () => { let lastMessage = "网关连接失败"; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (connectRunSeq !== activeConnectRunSeq) { return; } try { await connectOnce(attempt); return; } catch (error) { if (connectRunSeq !== activeConnectRunSeq) { return; } const detail = error && typeof error === "object" ? error : {}; const message = detail.message ? String(detail.message) : "网关连接失败"; lastMessage = message; const canRetry = attempt < maxAttempts && !!detail.retryable; log("gateway.socket.attempt_failed", { attempt, maxAttempts, retryable: !!detail.retryable, message }); if (!canRetry) { throw new Error(message); } log("gateway.socket.retry_scheduled", { attempt, nextAttempt: attempt + 1, delayMs: connectRetryDelayMs, message }); await wait(connectRetryDelayMs); } } throw new Error(lastMessage); })().finally(() => { connectPromise = null; }); return connectPromise; } function sendStdin(text, meta) { const inputMeta = meta && typeof meta === "object" ? meta : {}; sendFrame({ type: "stdin", payload: { data: String(text || ""), meta: { source: inputMeta.source === "assist" ? "assist" : "keyboard", ...(inputMeta.txnId ? { txnId: String(inputMeta.txnId) } : {}) } } }); } function resize(cols, rows) { sendFrame({ type: "resize", payload: { cols: Number(cols) || 80, rows: Number(rows) || 24 } }); } function disconnect(reason) { activeConnectRunSeq += 1; activeSeq += 1; sendFrame({ type: "control", payload: { action: "disconnect", reason: reason || "manual" } }); socketReady = false; clearHeartbeat(); if (socketTask) { socketTask.close({ code: 1000, reason: "manual" }); socketTask = null; } connectPromise = null; } /** * 仅关闭 WebSocket,不向服务端发送 disconnect 控制帧: * - 供终端页切后台/离开页面时使用; * - 网关会把 SSH 会话转入“驻留等待续接”窗口。 */ function suspend(reason) { activeConnectRunSeq += 1; activeSeq += 1; socketReady = false; clearHeartbeat(); if (socketTask) { try { socketTask.close({ code: 1000, reason: reason || "suspend" }); } catch (error) { console.warn("[gateway.suspend]", error); } socketTask = null; } connectPromise = null; } return { connect, sendStdin, resize, sampleLatency, setLatencySampleInterval, disconnect, suspend }; } module.exports = { createGatewayClient, buildWsUrl };