update at 2026-03-03 21:19:52

This commit is contained in:
douboer@gmail.com
2026-03-03 21:19:52 +08:00
parent 3dc4144007
commit e4987a2d77
139 changed files with 21522 additions and 43 deletions

View File

@@ -0,0 +1,335 @@
import type { GatewayFrame, SessionState, StdinMeta } from "@remoteconn/shared";
import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport";
/**
* 网关传输实现Web/小程序共用。
*/
export class GatewayTransport implements TerminalTransport {
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 state: SessionState = "idle";
public constructor(
private readonly gatewayUrl: string,
private readonly token: string
) {}
public async connect(params: ConnectParams): Promise<void> {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
throw new Error("会话已连接");
}
this.state = "connecting";
this.socket = await new Promise<WebSocket>((resolve, reject) => {
const endpoints = this.buildEndpoints();
const reasons: string[] = [];
let index = 0;
const candidateHint = `候选地址: ${endpoints.join(", ")}`;
const tryConnect = (): void => {
const endpoint = endpoints[index];
if (!endpoint) {
reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`));
return;
}
let settled = false;
let socket: WebSocket;
let timeoutTimer: number | null = null;
try {
socket = new WebSocket(endpoint);
} catch {
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;
settled = true;
clearTimer();
reasons.push(`${reason}: ${endpoint}`);
try {
socket.close();
} catch {
// 忽略关闭阶段异常,继续下一个候选地址。
}
if (index < endpoints.length - 1) {
index += 1;
tryConnect();
return;
}
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
};
socket.onopen = () => {
if (settled) return;
settled = true;
clearTimer();
resolve(socket);
};
socket.onerror = () => fail("网络或协议错误");
socket.onclose = (event) => {
if (!settled) {
fail(`连接关闭 code=${event.code}`);
}
};
};
tryConnect();
});
this.socket.onmessage = (event) => {
const frame = JSON.parse(event.data as string) as GatewayFrame;
this.handleFrame(frame);
};
this.socket.onclose = () => {
this.stopHeartbeat();
this.state = "disconnected";
this.emit({ type: "disconnect", reason: "ws_closed" });
};
this.socket.onerror = () => {
this.stopHeartbeat();
this.state = "error";
this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" });
};
const initFrame: GatewayFrame = {
type: "init",
payload: {
host: params.host,
port: params.port,
username: params.username,
...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}),
credential: params.credential,
knownHostFingerprint: params.knownHostFingerprint,
pty: { cols: params.cols, rows: params.rows }
}
};
this.sendRaw(initFrame);
this.startHeartbeat();
this.state = "auth_pending";
}
public async send(data: string, meta?: StdinMeta): Promise<void> {
this.sendRaw({
type: "stdin",
payload: {
data,
...(meta ? { meta } : {})
}
});
}
public async resize(cols: number, rows: number): Promise<void> {
this.sendRaw({ type: "resize", payload: { cols, rows } });
}
public async disconnect(reason = "manual"): Promise<void> {
this.stopHeartbeat();
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendRaw({ type: "control", payload: { action: "disconnect", reason } });
this.socket.close();
}
this.socket = null;
this.state = "disconnected";
}
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: GatewayFrame): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error("网关连接未建立");
}
this.socket.send(JSON.stringify(frame));
}
private handleFrame(frame: GatewayFrame): void {
if (frame.type === "stdout") {
this.state = "connected";
this.emit({ type: "stdout", data: frame.payload.data });
return;
}
if (frame.type === "stderr") {
this.emit({ type: "stderr", data: frame.payload.data });
return;
}
if (frame.type === "error") {
this.state = "error";
this.emit({ type: "error", code: frame.payload.code, message: frame.payload.message });
return;
}
if (frame.type === "control") {
if (frame.payload.action === "ping") {
this.sendRaw({ type: "control", payload: { action: "pong" } });
return;
}
if (frame.payload.action === "pong") {
if (this.pingAt > 0) {
this.emit({ type: "latency", data: Date.now() - this.pingAt });
}
return;
}
if (frame.payload.action === "connected") {
this.state = "connected";
this.emit({ type: "connected", fingerprint: frame.payload.fingerprint });
return;
}
if (frame.payload.action === "disconnect") {
this.state = "disconnected";
this.stopHeartbeat();
this.emit({ type: "disconnect", reason: frame.payload.reason ?? "unknown" });
}
}
}
private emit(event: TransportEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return;
}
this.pingAt = Date.now();
this.sendRaw({ type: "control", payload: { action: "ping" } });
}, 10000);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* 统一网关地址构造(含容错候选):
* 1) 自动将 http/https 转换为 ws/wss
* 2) 页面非本机访问时,避免把 localhost 误连到客户端本机;
* 3) https 页面下,补充 wss 与去端口候选,适配反向代理场景;
* 4) 统一补全 /ws/terminal?token=...
*/
private buildEndpoints(): string[] {
const pageIsHttps = window.location.protocol === "https:";
const pageHost = window.location.hostname;
const pageProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const rawInput = this.gatewayUrl.trim();
const fallback = `${pageProtocol}//${pageHost}`;
const input = rawInput.length > 0 ? rawInput : fallback;
const candidates: string[] = [];
const pushCandidate = (next: URL): void => {
if (pageIsHttps && next.protocol === "ws:") {
return;
}
candidates.push(finalizeEndpoint(next));
};
let url: URL;
try {
const maybeUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(input) ? input : `${pageProtocol}//${input}`;
url = new URL(maybeUrl);
} catch {
url = new URL(fallback);
}
if (url.protocol === "http:") url.protocol = "ws:";
if (url.protocol === "https:") url.protocol = "wss:";
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
const pageIsLocal = localHosts.has(pageHost);
const targetIsLocal = localHosts.has(url.hostname);
if (!pageIsLocal && targetIsLocal) {
url.hostname = pageHost;
}
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();
};
// 1) 优先使用用户配置原始地址。
pushCandidate(url);
// 2) 补充同主机不同协议候选ws <-> wss
// HTTPS 页面禁止 ws://(混合内容会被浏览器直接拦截)。
if (!pageIsHttps && url.protocol === "ws:") {
const tlsUrl = new URL(url.toString());
tlsUrl.protocol = "wss:";
pushCandidate(tlsUrl);
} else if (url.protocol === "wss:") {
const plainUrl = new URL(url.toString());
if (!pageIsHttps) {
plainUrl.protocol = "ws:";
pushCandidate(plainUrl);
}
}
// 3) 远端主机时,始终补充“去端口走反向代理(80/443)”候选。
// 适配公网仅开放 443、Nginx 反代到内网端口的部署。
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:") {
if (!pageIsHttps) {
const noPortPlain = new URL(noPort.toString());
noPortPlain.protocol = "ws:";
pushCandidate(noPortPlain);
}
}
}
return [...new Set(candidates)];
}
}