first commit

This commit is contained in:
douboer@gmail.com
2026-03-03 13:23:14 +08:00
commit 3b7c1d558a
161 changed files with 28120 additions and 0 deletions

View File

@@ -0,0 +1,555 @@
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
}
}
}