Files
rio/frontend/code/rpc.ts
2024-09-26 19:37:51 +02:00

501 lines
14 KiB
TypeScript

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<JsonRpcMessage> = 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<void> {
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) as any;
}
function onMessage(event: MessageEvent<string>) {
// 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<JsonRpcResponse | null> {
// 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;
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;
}