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