Spaces:
Running
Running
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; | |