import { goingAway, pixelsPerRem } from "./app"; import { componentsById, updateComponentStates } from "./componentManagement"; import { requestFileUpload, registerFont, closeSession, setTitle, getUnittestClientLayoutInfo, getComponentLayouts, removeDialog, } from "./rpcFunctions"; import { setClipboard, getClipboard, ClipboardError, getPreferredPythonDateFormatString, sleep, } from "./utils"; import { AsyncQueue } from "./utils"; let websocket: WebSocket | null = null; let pingPongHandlerId: number; export let incomingMessageQueue: AsyncQueue = new AsyncQueue(); export type JsonRpcMessage = { jsonrpc: "2.0"; id?: number; method?: string; params?: any; }; export type JsonRpcResponse = { jsonrpc: "2.0"; id: number; result?: any; error?: { code: number; message: string; }; }; export function setConnectionLostPopupVisibleUnlessGoingAway( visible: boolean ): void { // If the user is intentionally leaving, don't annoy them with a popup if (goingAway) { return; } // Find the component let connectionLostPopupContainer = document.querySelector( ".rio-connection-lost-popup-container" ) as HTMLElement | null; if (connectionLostPopupContainer === null) { return; } // Update it if (visible) { connectionLostPopupContainer.classList.add( "rio-connection-lost-popup-visible" ); } else { connectionLostPopupContainer.classList.remove( "rio-connection-lost-popup-visible" ); } } globalThis.setConnectionLostPopupVisible = setConnectionLostPopupVisibleUnlessGoingAway; // Because processing incoming messages is async, we can't just attach our // function to `websocket.onMessage`. That could lead to multiple messages being // processed at the same time, which can cause problems. (For example, when a new // font is registered, we have to wait until the font is loaded before // processing any `updateComponentStates` messages. Otherwise the layouting will // happen with the incorrect font.) // // To work around this problem, all incoming messages are simply pushed into a // queue and then processed in order by this async worker here. async function processMessages(): Promise { while (true) { let message = await incomingMessageQueue.get(); let response = await processMessageReturnResponse(message); if (response !== null) { sendMessageOverWebsocket(response); } } } processMessages(); function createWebsocket(): void { // If the user is leaving the page, don't try to connect anymore. This could // prevent the user from leaving. if (goingAway) { return; } let url = new URL( `${globalThis.RIO_BASE_URL}rio/ws?sessionToken=${globalThis.SESSION_TOKEN}`, window.location.href ); url.protocol = url.protocol.replace("http", "ws"); console.log(`Connecting websocket to ${url.href}`); websocket = new WebSocket(url.href); websocket.addEventListener("open", onOpen); websocket.addEventListener("message", onMessage); websocket.addEventListener("error", onError); websocket.addEventListener("close", onClose); } export function initWebsocket(): void { createWebsocket(); websocket!.addEventListener("open", sendInitialMessage); } /// Send the initial message with user information to the server function sendInitialMessage(): void { // User Settings let userSettings = {}; for (let key in localStorage) { if (!key.startsWith("rio:userSetting:")) { continue; } try { userSettings[key.slice("rio:userSetting:".length)] = JSON.parse( localStorage[key] ); } catch (e) { console.warn(`Failed to parse user setting ${key}: ${e}`); } } // The names of all months const monthFormatter = new Intl.DateTimeFormat("default", { month: "long", }); const monthNamesLong: string[] = []; for (let month = 0; month < 12; month++) { const date = new Date(2000, month, 1); monthNamesLong.push(monthFormatter.format(date)); } // The names of all days const dayFormatter = new Intl.DateTimeFormat("default", { weekday: "long", }); const dayNamesLong: string[] = []; for (let day = 0; day < 7; day++) { const date = new Date(2000, 0, day + 3); dayNamesLong.push(dayFormatter.format(date)); } // Date format string let dateFormatString = getPreferredPythonDateFormatString("default"); // Decimal separator let decimalSeparator = (1.1).toLocaleString().replace(/1/g, ""); // Thousands separator let thousandsSeparator = (1111).toLocaleString().replace(/1/g, ""); let windowRect = document.documentElement.getBoundingClientRect(); sendMessageOverWebsocket({ url: document.location.href, userSettings: userSettings, prefersLightTheme: !window.matchMedia("(prefers-color-scheme: dark)") .matches, preferredLanguages: navigator.languages, monthNamesLong: monthNamesLong, dayNamesLong: dayNamesLong, dateFormatString: dateFormatString, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, decimalSeparator: decimalSeparator, thousandsSeparator: thousandsSeparator, windowWidth: windowRect.width / pixelsPerRem, windowHeight: windowRect.height / pixelsPerRem, }); } function onOpen(): void { console.log("Websocket connection opened"); setConnectionLostPopupVisibleUnlessGoingAway(false); // Some proxies kill idle websocket connections. Send pings occasionally to // keep the connection alive. pingPongHandlerId = setInterval(() => { sendMessageOverWebsocket({ jsonrpc: "2.0", method: "ping", params: ["ping"], id: `ping-${Date.now()}`, }); }, globalThis.PING_PONG_INTERVAL_SECONDS * 1000); } function onMessage(event: MessageEvent) { // Parse the message JSON let message = JSON.parse(event.data); // Print a copy of the message because some messages are modified in-place // when they're processed console.debug("Received message: ", JSON.parse(event.data)); // Push it into the queue, to be processed as soon as the previous message // has been processed incomingMessageQueue.push(message); } function onError(event: Event) { console.warn(`Websocket error`); } function onClose(event: CloseEvent) { console.log(`Websocket connection closed with code ${event.code}`); // Stop sending pings clearInterval(pingPongHandlerId); // If the user is leaving the page, do nothing. Reconnecting the websocket // might even prevent the browser from navigating away and trap the user // here. if (goingAway) { return; } // Show the user that the connection was lost setConnectionLostPopupVisibleUnlessGoingAway(true); // Check the status code if (event.code === 3000) { // Invalid session token console.error( "Reloading the page because the session token is invalid" ); window.location.reload(); return; } startTryingToReconnect(); } async function startTryingToReconnect() { // Some browsers deliberately slow down websocket reconnects, which is why // this function polls with HTTP requests instead. let maxAttempts = globalThis.RIO_DEBUG_MODE ? Infinity : 10; for ( let connectionAttempt = 1; connectionAttempt < maxAttempts; connectionAttempt++ ) { // Wait a bit before trying to reconnect (again) let delay: number; if (globalThis.RIO_DEBUG_MODE) { delay = 0.5; } else { delay = 2 ** connectionAttempt - 1; // 1 3 7 15 31 63 ... delay = Math.min(delay, 300); // Never wait longer than 5min } console.log(`Will attempt to reconnect in ${delay} seconds`); await sleep(delay); let tokenIsValid: boolean; try { let response = await fetch( `${globalThis.RIO_BASE_URL}rio/validate-token/${globalThis.SESSION_TOKEN}` ); tokenIsValid = await response.json(); } catch { continue; } if (tokenIsValid) { console.log( "Session token is still valid; re-establishing websocket connection" ); createWebsocket(); } else { console.log("Session token is no longer valid; reloading the page"); document.location.reload(); } return; } console.warn(`Websocket connection closed. Giving up trying to reconnect.`); } export function sendMessageOverWebsocket(message: object) { if (!websocket) { console.error( `Attempted to send message, but the websocket is not connected: ${message}` ); return; } console.debug("Sending message: ", message); websocket.send(JSON.stringify(message)); } export function callRemoteMethodDiscardResponse( method: string, params: object ) { sendMessageOverWebsocket({ jsonrpc: "2.0", method: method, params: params, }); } export async function processMessageReturnResponse( message: JsonRpcMessage ): Promise { // If this isn't a method call, ignore it if (message.method === undefined) { return null; } // Delegate to the appropriate handler let response: any; let responseIsError = false; switch (message.method) { case "updateComponentStates": // The component states have changed, and new components may have been // introduced. updateComponentStates( message.params.deltaStates, message.params.rootComponentId ); response = null; break; case "evaluateJavaScript": case "evaluateJavaScriptAndGetResult": // Allow the server to run JavaScript // // Avoid using `eval` so that the code can be minified try { const func = new Function(message.params.javaScriptSource); response = func(); if (response === undefined) { response = null; } } catch (e) { response = e.toString(); responseIsError = true; console.warn( `Uncaught exception in \`evaluateJavaScript\`: ${e}` ); } break; case "setKeyboardFocus": let component = componentsById[message.params.componentId]!; // @ts-expect-error component.grabKeyboardFocus(); response = null; break; case "setTitle": setTitle(message.params.title); response = null; break; case "requestFileUpload": // Upload a file to the server requestFileUpload(message.params); response = null; break; case "setUserSettings": // Persistently store user settings for (let key in message.params.deltaSettings) { localStorage.setItem( `rio:userSetting:${key}`, JSON.stringify(message.params.deltaSettings[key]) ); } response = null; break; case "registerFont": // Load and register a new font await registerFont(message.params.name, message.params.urls); response = null; break; case "applyTheme": // Set the CSS variables for (let key in message.params.cssVariables) { document.documentElement.style.setProperty( key, message.params.cssVariables[key] ); } // Set the theme variant document.documentElement.setAttribute( "data-theme", message.params.themeVariant ); // Remove the default anti-flashbang color document.documentElement.style.background = ""; response = null; break; case "closeSession": closeSession(); response = null; break; case "setClipboard": try { await setClipboard(message.params.text); response = null; } catch (e) { response = e.toString(); responseIsError = true; if (e instanceof ClipboardError) { console.warn(`ClipboardError: ${e.message}`); } else { console.warn( `Uncaught exception in \`setClipboard\`: ${e}` ); } } break; case "getClipboard": try { response = await getClipboard(); } catch (e) { response = e.toString(); responseIsError = true; if (e instanceof ClipboardError) { console.warn(`ClipboardError: ${e.message}`); } else { console.warn( `Uncaught exception in \`getClipboard\`: ${e}` ); } } break; case "getComponentLayouts": response = getComponentLayouts(message.params.componentIds); break; case "getUnittestClientLayoutInfo": response = getUnittestClientLayoutInfo(); break; case "removeDialog": removeDialog(message.params.rootComponentId); break; default: // Invalid method throw `Encountered unknown RPC method: ${message.method}`; } if (message.id === undefined) { return null; } let rpcResponse: JsonRpcResponse = { jsonrpc: "2.0", id: message.id, }; if (responseIsError) { rpcResponse["error"] = { code: -32000, message: response as string, }; } else { rpcResponse["result"] = response; } return rpcResponse; }