336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
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)];
|
||
}
|
||
}
|