Files
2026-03-21 18:57:10 +08:00

462 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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
};