Spaces:
Runtime error
Runtime error
| 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; | |