penguinmod-editor-2 / src /lib /cloud-provider.js
soiz1's picture
Upload 2891 files
6bcb42f verified
import log from './log.js';
import throttle from 'lodash.throttle';
const anonymizeUsername = username => {
if (/^player\d{2,7}$/i.test(username)) {
return 'player';
}
return username;
};
class CloudProvider {
/**
* A cloud data provider which creates and manages a web socket connection
* to the Scratch cloud data server. This provider is responsible for
* interfacing with the VM's cloud io device.
* @param {string} cloudHost The url for the cloud data server
* @param {VirtualMachine} vm The Scratch virtual machine to interface with
* @param {string} username The username to associate cloud data updates with
* @param {string} projectId The id associated with the project containing
* cloud data.
*/
constructor (cloudHost, vm, username, projectId) {
this.vm = vm;
this.username = anonymizeUsername(username);
this.projectId = projectId;
this.cloudHost = cloudHost;
this.connectionAttempts = 0;
// A queue of messages to send which were received before the
// connection was ready
this.queuedData = [];
this.openConnection();
// Send a message to the cloud server at a rate of no more
// than 10 messages/sec.
// tw: we let cloud variables change at a greater rate
this.sendCloudData = throttle(this._sendCloudData, 50);
}
/**
* Open a new websocket connection to the clouddata server.
* @param {string} cloudHost The cloud data server to connect to.
*/
openConnection () {
this.connectionAttempts += 1;
try {
// tw: only add ws:// or wss:// if it not already present in the cloudHost
if (!this.cloudHost || (!this.cloudHost.includes('ws://') && !this.cloudHost.includes('wss://'))) {
this.cloudHost = (location.protocol === 'http:' ? 'ws://' : 'wss://') + this.cloudHost;
}
this.connection = new WebSocket(this.cloudHost);
} catch (e) {
log.warn('Websocket support is not available in this browser', e);
this.connection = null;
return;
}
this.connection.onerror = this.onError.bind(this);
this.connection.onmessage = this.onMessage.bind(this);
this.connection.onopen = this.onOpen.bind(this);
this.connection.onclose = this.onClose.bind(this);
}
onError (event) {
log.error(`Websocket connection error: ${JSON.stringify(event)}`);
// Error is always followed by close, which handles reconnect logic.
}
onMessage (event) {
const messageString = event.data;
// Multiple commands can be received, newline separated
messageString.split('\n').forEach(message => {
if (message) { // .split can also contain '' in the array it returns
const parsedData = this.parseMessage(JSON.parse(message));
this.vm.postIOData('cloud', parsedData);
}
});
}
onOpen () {
// Reset connection attempts to 1 to make sure any subsequent reconnects
// use connectionAttempts=1 to calculate timeout
this.connectionAttempts = 1;
this.writeToServer('handshake');
log.info(`Successfully connected to clouddata server.`);
// Go through the queued data and send off messages that we weren't
// ready to send before
this.queuedData.forEach(data => {
this.sendCloudData(data);
});
// Reset the queue
this.queuedData = [];
}
onClose (e) {
// tw: code 4002 is "Username Error" -- do not try to reconnect
if (e && e.code === 4002) {
log.info('Cloud username is invalid. Not reconnecting.');
this.onInvalidUsername();
return;
}
// tw: code 4004 is "Project Unavailable" -- do not try to reconnect
if (e && e.code === 4004) {
log.info('Cloud variables are disabled for this project. Not reconnecting.');
return;
}
log.info(`Closed connection to websocket`);
const randomizedTimeout = this.randomizeDuration(this.exponentialTimeout());
this.setTimeout(this.openConnection.bind(this), randomizedTimeout);
}
// tw: method called when username is invalid
onInvalidUsername () { /* no-op */ }
exponentialTimeout () {
return (Math.pow(2, Math.min(this.connectionAttempts, 5)) - 1) * 1000;
}
randomizeDuration (t) {
return Math.random() * t;
}
setTimeout (fn, time) {
log.info(`Reconnecting in ${(time / 1000).toFixed(1)}s, attempt ${this.connectionAttempts}`);
this._connectionTimeout = window.setTimeout(fn, time);
}
parseMessage (message) {
const varData = {};
switch (message.method) {
case 'set': {
varData.varUpdate = {
name: message.name,
value: message.value
};
break;
}
}
return varData;
}
/**
* Format and send a message to the cloud data server.
* @param {string} methodName The message method, indicating the action to perform.
* @param {string} dataName The name of the cloud variable this message pertains to
* @param {string | number} dataValue The value to set the cloud variable to
* @param {string} dataNewName The new name for the cloud variable (if renaming)
*/
writeToServer (methodName, dataName, dataValue, dataNewName) {
const msg = {};
msg.method = methodName;
msg.user = this.username;
msg.project_id = this.projectId;
// Optional string params can use simple falsey undefined check
if (dataName) msg.name = dataName;
if (dataNewName) msg.new_name = dataNewName;
// Optional number params need different undefined check
if (typeof dataValue !== 'undefined' && dataValue !== null) msg.value = dataValue;
const dataToWrite = JSON.stringify(msg);
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
this.sendCloudData(dataToWrite);
} else if (msg.method === 'create' || msg.method === 'delete' || msg.method === 'rename') {
// Save data for sending when connection is open, iff the data
// is a create, rename, or delete
this.queuedData.push(dataToWrite);
}
}
/**
* Send a formatted message to the cloud data server.
* @param {string} data The formatted message to send.
*/
_sendCloudData (data) {
this.connection.send(`${data}\n`);
}
/**
* Provides an API for the VM's cloud IO device to create
* a new cloud variable on the server.
* @param {string} name The name of the variable to create
* @param {string | number} value The value of the new cloud variable.
*/
createVariable (name, value) {
this.writeToServer('create', name, value);
}
/**
* Provides an API for the VM's cloud IO device to update
* a cloud variable on the server.
* @param {string} name The name of the variable to update
* @param {string | number} value The new value for the variable
*/
updateVariable (name, value) {
this.writeToServer('set', name, value);
}
/**
* Provides an API for the VM's cloud IO device to rename
* a cloud variable on the server.
* @param {string} oldName The old name of the variable to rename
* @param {string} newName The new name for the cloud variable.
*/
renameVariable (oldName, newName) {
this.writeToServer('rename', oldName, null, newName);
}
/**
* Provides an API for the VM's cloud IO device to delete
* a cloud variable on the server.
* @param {string} name The name of the variable to delete
*/
deleteVariable (name) {
this.writeToServer('delete', name);
}
/**
* Closes the connection to the web socket and clears the cloud
* provider of references related to the cloud data project.
*/
requestCloseConnection () {
if (this.connection &&
this.connection.readyState !== WebSocket.CLOSING &&
this.connection.readyState !== WebSocket.CLOSED) {
log.info('Request close cloud connection without reconnecting');
// Remove listeners, after this point we do not want to react to connection updates
this.connection.onclose = () => {};
this.connection.onerror = () => {};
this.connection.close();
}
this.clear();
}
/**
* Clear this provider of references related to the project
* and current state.
*/
clear () {
this.connection = null;
this.vm = null;
this.username = null;
this.projectId = null;
if (this._connectionTimeout) {
clearTimeout(this._connectionTimeout);
this._connectionTimeout = null;
}
this.connectionAttempts = 0;
}
}
export default CloudProvider;