mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-17 19:09:52 -06:00
501 lines
14 KiB
TypeScript
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;
|
|
}
|