diff --git a/ui/package-lock.json b/ui/package-lock.json index 437b0c932..bd1d54ef6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -37,6 +37,7 @@ "react-xtermjs": "^1.0.10", "recharts": "^3.3.0", "tailwind-merge": "^3.3.1", + "tslog": "^4.10.2", "usehooks-ts": "^3.1.1", "validator": "^13.15.20", "zustand": "^4.5.2" @@ -7330,6 +7331,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tslog": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/tslog/-/tslog-4.10.2.tgz", + "integrity": "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/fullstack-build/tslog?sponsor=1" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/ui/package.json b/ui/package.json index 0f4d7f64c..70eaa6628 100644 --- a/ui/package.json +++ b/ui/package.json @@ -56,6 +56,7 @@ "react-xtermjs": "^1.0.10", "recharts": "^3.3.0", "tailwind-merge": "^3.3.1", + "tslog": "^4.10.2", "usehooks-ts": "^3.1.1", "validator": "^13.15.20", "zustand": "^4.5.2" diff --git a/ui/src/hooks/useHidRpc.ts b/ui/src/hooks/useHidRpc.ts index 2db8279f0..38014d203 100644 --- a/ui/src/hooks/useHidRpc.ts +++ b/ui/src/hooks/useHidRpc.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; +import { Logger } from "tslog"; import { useRTCStore } from "@hooks/stores"; @@ -25,6 +26,128 @@ interface sendMessageParams { requireOrdered?: boolean; } +const HANDSHAKE_TIMEOUT = 30 * 1000; // 30 seconds +const HANDSHAKE_MAX_ATTEMPTS = 10; +const logger = new Logger({ name: "hidrpc" }); + +export function doRpcHidHandshake(rpcHidChannel: RTCDataChannel, setRpcHidProtocolVersion: (version: number | null) => void) { + let attempts = 0; + let lastConnectedTime: Date | undefined; + let lastSendTime: Date | undefined; + let handshakeCompleted = false; + let handshakeInterval: ReturnType | null = null; + + const shouldGiveUp = () => { + if (attempts > HANDSHAKE_MAX_ATTEMPTS) { + logger.error(`Failed to send handshake message after ${HANDSHAKE_MAX_ATTEMPTS} attempts`); + return true; + } + + const timeSinceConnected = lastConnectedTime ? Date.now() - lastConnectedTime.getTime() : 0; + if (timeSinceConnected > HANDSHAKE_TIMEOUT) { + logger.error(`Handshake timed out after ${timeSinceConnected}ms`); + return true; + } + + return false; + } + + const sendHandshake = (initial: boolean) => { + if (handshakeCompleted) return; + + attempts++; + lastSendTime = new Date(); + + if (!initial && shouldGiveUp()) { + if (handshakeInterval) { + clearInterval(handshakeInterval); + handshakeInterval = null; + } + return; + } + + let data: Uint8Array | undefined; + try { + const message = new HandshakeMessage(HID_RPC_VERSION); + data = message.marshal(); + } catch (e) { + logger.error("Failed to marshal message", e); + return; + } + if (!data) return; + rpcHidChannel.send(data as unknown as ArrayBuffer); + + if (initial) { + handshakeInterval = setInterval(() => { + sendHandshake(false); + }, 1000); + } + }; + + const onMessage = (ev: MessageEvent) => { + const message = unmarshalHidRpcMessage(new Uint8Array(ev.data)); + if (!message || !(message instanceof HandshakeMessage)) return; + + if (!message.version) { + logger.error("Received handshake message without version", message); + return; + } + + if (message.version > HID_RPC_VERSION) { + // we assume that the UI is always using the latest version of the HID RPC protocol + // so we can't support this + // TODO: use capabilities to determine rather than version number + logger.error("Server is using a newer version than the client", message); + return; + } + + setRpcHidProtocolVersion(message.version); + + const timeUsed = lastSendTime ? Date.now() - lastSendTime.getTime() : 0; + logger.info(`Handshake completed in ${timeUsed}ms after ${attempts} attempts (Version: ${message.version} / ${HID_RPC_VERSION})`); + + // clean up + rpcHidChannel.removeEventListener("message", onMessage); + resetHandshake({ completed: true }); + }; + + const resetHandshake = ({ lastConnectedTime: newLastConnectedTime, completed }: { lastConnectedTime?: Date | undefined, completed?: boolean }) => { + if (newLastConnectedTime) lastConnectedTime = newLastConnectedTime; + lastSendTime = undefined; + attempts = 0; + if (completed !== undefined) handshakeCompleted = completed; + if (handshakeInterval) { + clearInterval(handshakeInterval); + handshakeInterval = null; + } + }; + + const onConnected = () => { + resetHandshake({ lastConnectedTime: new Date() }); + logger.info("Channel connected"); + + sendHandshake(true); + rpcHidChannel.addEventListener("message", onMessage); + }; + + const onClose = () => { + resetHandshake({ lastConnectedTime: undefined, completed: false }); + + logger.info("Channel closed"); + setRpcHidProtocolVersion(null); + + rpcHidChannel.removeEventListener("message", onMessage); + }; + + rpcHidChannel.addEventListener("open", onConnected); + rpcHidChannel.addEventListener("close", onClose); + + // handle case where channel is already open when the hook is mounted + if (rpcHidChannel.readyState === "open") { + onConnected(); + } +} + export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { const { rpcHidChannel, @@ -78,7 +201,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { try { data = message.marshal(); } catch (e) { - console.error("Failed to marshal HID RPC message", e); + logger.error("Failed to marshal message", e); } if (!data) return; @@ -151,99 +274,46 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { sendMessage(KEEPALIVE_MESSAGE); }, [sendMessage]); - const sendHandshake = useCallback(() => { - if (hidRpcDisabled) return; - if (rpcHidProtocolVersion) return; - if (!rpcHidChannel) return; - - sendMessage(new HandshakeMessage(HID_RPC_VERSION), { ignoreHandshakeState: true }); - }, [rpcHidChannel, rpcHidProtocolVersion, sendMessage, hidRpcDisabled]); - - const handleHandshake = useCallback( - (message: HandshakeMessage) => { - if (hidRpcDisabled) return; - - if (!message.version) { - console.error("Received handshake message without version", message); - return; - } - - if (message.version > HID_RPC_VERSION) { - // we assume that the UI is always using the latest version of the HID RPC protocol - // so we can't support this - // TODO: use capabilities to determine rather than version number - console.error("Server is using a newer HID RPC version than the client", message); - return; - } - - setRpcHidProtocolVersion(message.version); - }, - [setRpcHidProtocolVersion, hidRpcDisabled], - ); - useEffect(() => { if (!rpcHidChannel) return; if (hidRpcDisabled) return; - // send handshake message - sendHandshake(); - const messageHandler = (e: MessageEvent) => { if (typeof e.data === "string") { - console.warn("Received string data in HID RPC message handler", e.data); + logger.warn("Received string data in message handler", e.data); return; } const message = unmarshalHidRpcMessage(new Uint8Array(e.data)); if (!message) { - console.warn("Received invalid HID RPC message", e.data); + logger.warn("Received invalid message", e.data); return; } - console.debug("Received HID RPC message", message); - switch (message.constructor) { - case HandshakeMessage: - handleHandshake(message as HandshakeMessage); - break; - default: - // not all events are handled here, the rest are handled by the onHidRpcMessage callback - break; - } + if (message instanceof HandshakeMessage) return; // handshake message is handled by the doRpcHidHandshake function - onHidRpcMessage?.(message); - }; + // to remove it from the production build, we need to use the /* @__PURE__ */ comment here + // setting `esbuild.pure` doesn't work + /* @__PURE__ */ logger.debug("Received message", message); - const openHandler = () => { - console.info("HID RPC channel opened"); - sendHandshake(); - }; - - const closeHandler = () => { - console.info("HID RPC channel closed"); - setRpcHidProtocolVersion(null); + onHidRpcMessage?.(message); }; const errorHandler = (e: Event) => { - console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${e}`) + logger.error(`Error on channel '${rpcHidChannel.label}'`, e); }; rpcHidChannel.addEventListener("message", messageHandler); - rpcHidChannel.addEventListener("close", closeHandler); rpcHidChannel.addEventListener("error", errorHandler); - rpcHidChannel.addEventListener("open", openHandler); return () => { rpcHidChannel.removeEventListener("message", messageHandler); - rpcHidChannel.removeEventListener("close", closeHandler); rpcHidChannel.removeEventListener("error", errorHandler); - rpcHidChannel.removeEventListener("open", openHandler); }; }, [ rpcHidChannel, onHidRpcMessage, setRpcHidProtocolVersion, - sendHandshake, - handleHandshake, hidRpcDisabled, ]); diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 1cc28a1b0..f5e8f759c 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -53,6 +53,7 @@ import { } from "@components/VideoOverlay"; import { FeatureFlagProvider } from "@providers/FeatureFlagProvider"; import { m } from "@localizations/messages.js"; +import { doRpcHidHandshake } from "@hooks/useHidRpc"; export type AuthMode = "password" | "noPassword" | null; @@ -127,6 +128,7 @@ export default function KvmIdRoute() { setRpcHidChannel, setRpcHidUnreliableNonOrderedChannel, setRpcHidUnreliableChannel, + setRpcHidProtocolVersion, } = useRTCStore(); const location = useLocation(); @@ -498,6 +500,7 @@ export default function KvmIdRoute() { rpcHidChannel.onopen = () => { setRpcHidChannel(rpcHidChannel); }; + doRpcHidHandshake(rpcHidChannel, setRpcHidProtocolVersion); const rpcHidUnreliableChannel = pc.createDataChannel("hidrpc-unreliable-ordered", { ordered: true, @@ -534,6 +537,7 @@ export default function KvmIdRoute() { setRpcHidChannel, setRpcHidUnreliableNonOrderedChannel, setRpcHidUnreliableChannel, + setRpcHidProtocolVersion, setTransceiver, ]); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 3935c2df3..b58aa96c4 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -39,7 +39,7 @@ export default defineConfig(({ mode, command }) => { return { plugins, esbuild: { - pure: ["console.debug"], + pure: command === "build" ? ["console.debug"]: [], }, assetsInclude: ["**/*.woff2"], build: {