Files
terminal-lab/terminal/apps/web/src/services/transport/gatewayTransport.ts
douboer@gmail.com 3b7c1d558a first commit
2026-03-03 13:23:14 +08:00

556 lines
18 KiB
TypeScript
Raw 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.

import type { SessionState } from "@remoteconn/terminal-core";
import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport";
type GatewayFrame = {
type: string;
payload?: Record<string, unknown>;
data?: unknown;
action?: unknown;
code?: unknown;
message?: unknown;
};
export class GatewayTransport implements TerminalTransport {
private static readonly PREFERRED_ENDPOINT_KEY = "terminal.gateway.preferredEndpoint";
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 connectAttemptId = 0;
private state: SessionState = "idle";
public constructor(
private readonly gatewayUrl: string,
private readonly token: string
) {}
public async connect(params: ConnectParams): Promise<void> {
const attemptId = ++this.connectAttemptId;
this.log("connect:start", {
attemptId,
host: params.host,
port: params.port,
username: params.username,
cols: params.cols,
rows: params.rows,
hasClientSessionKey: Boolean(params.clientSessionKey),
hasKnownHostFingerprint: Boolean(params.knownHostFingerprint)
});
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.log("connect:already_open");
throw new Error("会话已连接");
}
this.state = "connecting";
this.socket = await new Promise<WebSocket>((resolve, reject) => {
const endpoints = this.buildEndpoints();
this.log("connect:endpoints", { endpoints });
const reasons: string[] = [];
let index = 0;
const candidateHint = `候选地址: ${endpoints.join(", ")}`;
const tryConnect = (): void => {
if (attemptId !== this.connectAttemptId) {
this.log("connect:attempt_cancelled", { attemptId });
reject(new Error("连接已取消"));
return;
}
const endpoint = endpoints[index];
if (!endpoint) {
this.log("connect:all_failed", { reasons, candidateHint });
reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`));
return;
}
let settled = false;
let socket: WebSocket;
let timeoutTimer: number | null = null;
this.log("ws:connecting", { endpoint, attempt: index + 1, total: endpoints.length });
try {
socket = new WebSocket(endpoint);
this.socket = socket;
} catch {
this.log("ws:invalid_endpoint", { endpoint });
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;
if (attemptId !== this.connectAttemptId) {
settled = true;
clearTimer();
reject(new Error("连接已取消"));
return;
}
settled = true;
clearTimer();
reasons.push(`${reason}: ${endpoint}`);
this.log("ws:connect_failed", { endpoint, reason });
if (this.getPreferredEndpoint() === endpoint) {
this.clearPreferredEndpoint();
this.log("connect:preferred_endpoint_cleared", { endpoint, reason });
}
try {
socket.close();
} catch {
// ignore
}
if (index < endpoints.length - 1) {
index += 1;
tryConnect();
return;
}
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
};
socket.onopen = () => {
if (settled) return;
if (attemptId !== this.connectAttemptId) {
settled = true;
clearTimer();
try {
socket.close();
} catch {
// ignore
}
reject(new Error("连接已取消"));
return;
}
settled = true;
clearTimer();
this.log("ws:open", { endpoint });
this.setPreferredEndpoint(endpoint);
resolve(socket);
};
socket.onerror = () => fail("网络或协议错误");
socket.onclose = (event) => {
if (!settled) {
fail(`连接关闭 code=${event.code} reason=${event.reason || "none"}`);
}
};
};
tryConnect();
});
if (attemptId !== this.connectAttemptId) {
this.log("connect:socket_ready_but_cancelled", { attemptId });
try {
this.socket?.close();
} catch {
// ignore
}
throw new Error("连接已取消");
}
this.log("connect:socket_ready", { readyState: this.socket.readyState });
this.socket.onmessage = (event) => {
const text = typeof event.data === "string" ? event.data : "";
if (!text) return;
let frame: GatewayFrame;
try {
frame = JSON.parse(text) as GatewayFrame;
} catch {
this.log("ws:message_parse_failed", { sample: text.slice(0, 120) });
return;
}
this.handleFrame(frame);
};
this.socket.onclose = (event) => {
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, {
code: event.code,
reason: event.reason || "none",
wasClean: event.wasClean
});
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, {
code: event.code,
reason: event.reason || "none",
wasClean: event.wasClean
});
this.log("ws:onclose", { code: event.code, reason: event.reason || "none", wasClean: event.wasClean });
this.stopHeartbeat();
this.state = "disconnected";
this.emit({
type: "disconnect",
reason: `ws_closed(code=${event.code},reason=${event.reason || "none"},clean=${event.wasClean})`
});
};
this.socket.onerror = () => {
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`);
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`);
this.log("ws:onerror");
this.stopHeartbeat();
this.state = "error";
this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" });
};
this.log("connect:send_init", {
host: params.host,
port: params.port,
username: params.username,
cols: params.cols,
rows: params.rows,
hasKnownHostFingerprint: Boolean(params.knownHostFingerprint)
});
this.sendRaw({
type: "init",
payload: {
host: params.host,
port: params.port,
username: params.username,
...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}),
credential: params.credential,
...(params.knownHostFingerprint ? { knownHostFingerprint: params.knownHostFingerprint } : {}),
pty: { cols: params.cols, rows: params.rows }
}
});
this.startHeartbeat();
this.state = "auth_pending";
this.log("connect:state_auth_pending");
}
public async send(data: string, meta?: StdinMeta): Promise<void> {
this.log("stdin:send", { length: data.length, source: meta?.source ?? "keyboard" });
this.sendRaw({
type: "stdin",
payload: {
data,
...(meta ? { meta } : {})
}
});
}
public async resize(cols: number, rows: number): Promise<void> {
this.log("pty:resize", { cols, rows });
this.sendRaw({ type: "resize", payload: { cols, rows } });
}
public async disconnect(reason = "manual"): Promise<void> {
this.connectAttemptId += 1;
this.log("disconnect:requested", { reason, readyState: this.socket?.readyState ?? null });
this.stopHeartbeat();
if (this.socket) {
if (this.socket.readyState === WebSocket.OPEN) {
this.log("disconnect:send_control", { reason });
this.sendRaw({ type: "control", payload: { action: "disconnect", reason } });
}
// Force close regardless of state (e.g. CONNECTING)
try {
this.socket.close();
} catch {
// ignore
}
}
this.socket = null;
this.state = "disconnected";
this.log("disconnect:done");
}
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: Record<string, unknown>): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
this.log("ws:send_blocked", {
frameType: String(frame.type ?? "unknown"),
readyState: this.socket?.readyState ?? null,
state: this.state
});
throw new Error("网关连接未建立");
}
this.socket.send(JSON.stringify(frame));
}
private handleFrame(frame: GatewayFrame): void {
const payload = frame.payload ?? {};
const type = String(frame.type ?? "");
const action = String((payload.action ?? frame.action ?? "") as string);
if (type !== "stdout" && type !== "stderr") {
this.log("ws:frame", { type, action: action || undefined });
}
if (type === "stdout") {
const data = String(payload.data ?? frame.data ?? "");
if (!data) return;
if (this.state !== "connected") {
this.log("ws:stdout_promote_connected");
}
this.state = "connected";
this.emit({ type: "stdout", data });
return;
}
if (type === "stderr") {
const data = String(payload.data ?? frame.data ?? "");
if (!data) return;
this.emit({ type: "stderr", data });
return;
}
if (type === "error") {
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, {
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
message: String(payload.message ?? frame.message ?? "未知错误")
});
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, {
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
message: String(payload.message ?? frame.message ?? "未知错误")
});
this.log("ws:error_frame", {
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
message: String(payload.message ?? frame.message ?? "未知错误")
});
this.state = "error";
this.emit({
type: "error",
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
message: String(payload.message ?? frame.message ?? "未知错误")
});
return;
}
if (type === "connected") {
this.log("ws:connected_frame", { fingerprint: String(payload.fingerprint ?? "") || undefined });
this.state = "connected";
this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined });
return;
}
if (type === "disconnect") {
this.log("ws:disconnect_frame", { reason: String(payload.reason ?? "unknown") });
this.state = "disconnected";
this.stopHeartbeat();
this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") });
return;
}
if (type === "control") {
if (action === "ping") {
this.log("heartbeat:ping_recv");
this.sendRaw({ type: "control", payload: { action: "pong" } });
return;
}
if (action === "pong") {
if (this.pingAt > 0) {
this.log("heartbeat:pong_recv", { latencyMs: Date.now() - this.pingAt });
this.emit({ type: "latency", data: Date.now() - this.pingAt });
}
return;
}
if (action === "connected") {
this.log("ws:control_connected", { fingerprint: String(payload.fingerprint ?? "") || undefined });
this.state = "connected";
this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined });
return;
}
if (action === "disconnect") {
this.log("ws:control_disconnect", { reason: String(payload.reason ?? "unknown") });
this.state = "disconnected";
this.stopHeartbeat();
this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") });
}
}
}
private emit(event: TransportEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.log("heartbeat:start");
this.heartbeatTimer = window.setInterval(() => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return;
}
this.pingAt = Date.now();
this.log("heartbeat:ping_send");
this.sendRaw({ type: "control", payload: { action: "ping" } });
}, 10000);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
this.log("heartbeat:stop");
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private log(_message: string, _detail?: unknown): void {
if (typeof window === "undefined") return;
try {
const enabled =
window.localStorage.getItem("terminal.debugTransport") === "1" ||
window.localStorage.getItem("terminal.debugPaste") === "1";
if (!enabled) return;
} catch {
return;
}
const prefix = `[GatewayTransport][${new Date().toISOString()}] ${_message}`;
if (typeof _detail === "undefined") {
console.log(prefix);
return;
}
console.log(prefix, _detail);
}
private buildEndpoints(): string[] {
const pageIsHttps = window.location.protocol === "https:";
const pageHost = window.location.hostname;
const pageHostWithPort = window.location.host;
const pageProtocol = pageIsHttps ? "wss:" : "ws:";
const pageOrigin = `${pageProtocol}//${pageHostWithPort}`;
const rawInput = this.gatewayUrl.trim();
const candidates: string[] = [];
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();
};
const pushCandidate = (next: URL): void => {
if (pageIsHttps && next.protocol === "ws:") {
return;
}
candidates.push(finalizeEndpoint(next));
};
// 兼容相对路径配置(如 /ws/terminal并以当前页面源为基准解析。
const fallbackUrl = new URL(pageOrigin);
let url: URL;
try {
if (!rawInput) {
url = new URL(fallbackUrl.toString());
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawInput)) {
url = new URL(rawInput);
} else {
url = new URL(rawInput, fallbackUrl.toString());
}
} catch {
url = new URL(fallbackUrl.toString());
}
if (url.protocol === "http:") url.protocol = "ws:";
if (url.protocol === "https:") url.protocol = "wss:";
if (pageIsHttps && url.protocol === "ws:") {
url.protocol = "wss:";
}
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
const pageIsLocal = localHosts.has(pageHost);
let targetIsLocal = localHosts.has(url.hostname);
if (!pageIsLocal && targetIsLocal) {
// 页面从远程域名访问时,若配置仍是 localhost/127.0.0.1
// 优先尝试同源地址(通常由 Vite/Nginx 代理到本地网关)。
const sameOrigin = new URL(url.toString());
sameOrigin.protocol = pageProtocol;
sameOrigin.host = pageHostWithPort;
pushCandidate(sameOrigin);
const sameOriginNoPort = new URL(sameOrigin.toString());
sameOriginNoPort.port = "";
pushCandidate(sameOriginNoPort);
url.hostname = pageHost;
targetIsLocal = localHosts.has(url.hostname);
}
pushCandidate(url);
if (!pageIsHttps && url.protocol === "ws:") {
const tlsUrl = new URL(url.toString());
tlsUrl.protocol = "wss:";
pushCandidate(tlsUrl);
} else if (url.protocol === "wss:" && !pageIsHttps) {
const plainUrl = new URL(url.toString());
plainUrl.protocol = "ws:";
pushCandidate(plainUrl);
}
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:" && !pageIsHttps) {
const noPortPlain = new URL(noPort.toString());
noPortPlain.protocol = "ws:";
pushCandidate(noPortPlain);
}
}
const ordered = [...new Set(candidates)];
if (ordered.length === 0) {
return [finalizeEndpoint(fallbackUrl)];
}
const preferred = this.getPreferredEndpoint();
if (preferred && ordered.includes(preferred)) {
return [preferred, ...ordered.filter((endpoint) => endpoint !== preferred)];
}
return ordered;
}
private getPreferredEndpoint(): string | null {
try {
return window.localStorage.getItem(GatewayTransport.PREFERRED_ENDPOINT_KEY);
} catch {
return null;
}
}
private setPreferredEndpoint(endpoint: string): void {
try {
window.localStorage.setItem(GatewayTransport.PREFERRED_ENDPOINT_KEY, endpoint);
} catch {
// ignore
}
}
private clearPreferredEndpoint(): void {
try {
window.localStorage.removeItem(GatewayTransport.PREFERRED_ENDPOINT_KEY);
} catch {
// ignore
}
}
}