import { goingAway, pixelsPerRem } from './app'; import { componentsById, updateComponentStates } from './componentManagement'; import { requestFileUpload, registerFont, closeSession, setTitle, getUnittestClientLayoutInfo, getComponentLayouts, } 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( `/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) as any; } 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( `/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 gray 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; 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; }