first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View File

@@ -0,0 +1,461 @@
/* 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
};