Files
rio/frontend/code/rpc.ts
2024-04-03 19:23:29 +02:00

392 lines
11 KiB
TypeScript

import { goingAway, pixelsPerRem } from './app';
import { DebuggerConnectorComponent } from './components/debuggerConnector';
import { componentsById, updateComponentStates } from './componentManagement';
import {
requestFileUpload,
registerFont,
closeSession,
setTitle,
} from './rpcFunctions';
import { AsyncQueue, commitCss } from './utils';
let websocket: WebSocket | null = null;
let connectionAttempt: number = 1;
let pingPongHandlerId: number;
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 connectionLostPopup = document.querySelector(
'.rio-connection-lost-popup'
) as HTMLElement | null;
if (connectionLostPopup === null) {
return;
}
// Update it
if (visible) {
connectionLostPopup.style.display = 'block';
commitCss(connectionLostPopup); // TODO: Is this actually needed here?
connectionLostPopup.classList.add('rio-connection-lost-popup-visible');
} else {
connectionLostPopup.classList.remove(
'rio-connection-lost-popup-visible'
);
connectionLostPopup.style.display = 'none';
}
}
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(
`/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}`);
}
}
// Locale information:
// - Decimal separator
// - Thousands separator
let decimalSeparator = (1.1).toLocaleString().replace(/1/g, '');
let thousandsSeparator = (1111).toLocaleString().replace(/1/g, '');
sendMessageOverWebsocket({
websiteUrl: window.location.href,
preferredLanguages: navigator.languages,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
decimalSeparator: decimalSeparator,
thousandsSeparator: thousandsSeparator,
userSettings: userSettings,
prefersLightTheme: !window.matchMedia('(prefers-color-scheme: dark)')
.matches,
windowWidth: window.innerWidth / pixelsPerRem,
windowHeight: window.innerHeight / pixelsPerRem,
});
}
function onOpen(): void {
console.log('Websocket connection opened');
connectionAttempt = 1;
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.log('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: Event) {
// Stop sending pings
clearInterval(pingPongHandlerId);
// Show the user that the connection was lost
setConnectionLostPopupVisibleUnlessGoingAway(true);
// Wait a bit before trying to reconnect (again)
if (connectionAttempt >= 10 && !globalThis.RIO_DEBUG_MODE) {
console.warn(
`Websocket connection closed. Giving up trying to reconnect.`
);
return;
}
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(
`Websocket connection closed. Reconnecting in ${delay} seconds`
);
setTimeout(createWebsocket, delay * 1000);
}
export function sendMessageOverWebsocket(message: object) {
if (!websocket) {
console.error(
`Attempted to send message, but the websocket is not connected: ${message}`
);
return;
}
console.log('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: JsonRpcResponse | string | null;
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
);
// Notify the debugger, if any
if (globalThis.RIO_DEBUGGER !== null) {
let debuggerTree =
globalThis.RIO_DEBUGGER as DebuggerConnectorComponent;
debuggerTree.afterComponentStateChange(
message.params.deltaStates
);
}
response = null;
break;
case 'evaluateJavaScript':
// 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 'invalidSessionToken':
// The attempt to connect to the server has failed, because the session
// token is invalid
console.error(
'Reloading the page because the session token is invalid'
);
window.location.reload();
response = null;
break;
case 'closeSession':
closeSession();
response = null;
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;
}