class CloudManager { constructor (parent) { this.parent = parent; this.providers = []; this.overrides = new Map(); } hasCloudData () { return this.parent.vm.runtime.hasCloudData(); } projectReady () { if (this.hasCloudData()) { for (const provider of this.providers) { provider.enable(); } } } setVariable (provider, name, value) { if (this.overrides.has(name) && this.overrides.get(name) !== provider) { return; } this.parent.vm.postIOData('cloud', { varUpdate: { name, value } }); } getUsername () { return this.parent._username; } addProvider (provider) { provider.manager = this; if (this.hasCloudData()) { provider.enable(); } this.providers.push(provider); } requestCloseConnection () { // no-op } createVariable (name, value) { // no-op } renameVariable (oldName, newName) { // no-op } deleteVariable (name) { // no-op } addProviderOverride (name, provider) { if (provider && !this.providers.includes(provider)) { throw new Error('Manager is not aware of this provider'); } this.overrides.set(name, provider); } updateVariable (name, value) { if (this.overrides.has(name)) { const provider = this.overrides.get(name); if (provider) { provider.handleUpdateVariable(name, value); } return; } for (const provider of this.providers) { provider.handleUpdateVariable(name, value); } } } class WebSocketProvider { /** * @param {string[]|string} cloudHost URLs of servers to connect to. Must start with ws: or wss: * If cloudHost is an array, the server will consecutively try each server until one connects. * @param {string} projectId The ID of the project */ constructor(cloudHost, projectId) { this.cloudHosts = Array.isArray(cloudHost) ? cloudHost : [cloudHost]; this.projectId = projectId; this.attemptedConnections = 0; this.bufferedMessages = []; this.scheduledBufferedSend = null; this.reconnectTimeout = null; this.openConnection = this.openConnection.bind(this); this._scheduledSendBufferedMessages = this._scheduledSendBufferedMessages.bind(this); } enable () { this.openConnection(); } setProjectId (id) { this.projectId = id; this.closeAndReconnect(); } openConnection () { this.currentCloudHost = this.cloudHosts[this.attemptedConnections % this.cloudHosts.length]; this.attemptedConnections++; console.log(`Connecting to ${this.currentCloudHost} with ID ${this.projectId}, username ${this.manager.getUsername()}`); try { // Don't try to validate the cloud host ourselves. Let the browser do it. // Edge cases like ws://localhost being considered secure are too complex to handle correctly. this.ws = new WebSocket(this.currentCloudHost); } catch (e) { console.error(e); // The error message from the browser (especially Firefox) is sometimes very generic and not helpful. throw new Error(`Cloud host ${this.currentCloudHost} is invalid: ${e}`); } this.ws.onerror = this.onerror.bind(this); this.ws.onmessage = this.onmessage.bind(this); this.ws.onopen = this.onopen.bind(this); this.ws.onclose = this.onclose.bind(this); } onerror (event) { console.error('WebSocket error', event); } onmessage (event) { for (const line of event.data.split('\n')) { if (line) { const parsed = JSON.parse(line); if (parsed.method === 'set') { this.manager.setVariable(this, parsed.name, parsed.value); } } } } onopen () { this.attemptedConnections = 0; this.writeToServer({ method: 'handshake' }); this.sendBufferedMessages(); console.log('WebSocket connected'); } onclose (e) { // https://github.com/TurboWarp/cloud-server/blob/master/doc/protocol.md#status-codes if (e && e.code === 4002) { console.log('Username is invalid; not reconnecting.'); return; } if (e && e.code === 4003) { console.log('Cloud variable server is full; not reconnecting.'); return; } if (e && e.code === 4004) { console.log('Project is blocked; not reconnecting.'); return; } const timeout = Math.random() * (Math.pow(2, Math.min(this.attemptedConnections + 1, 5)) - 1) * 1000; console.log(`Connection lost; reconnecting in ${Math.round(timeout)}ms`); this.reconnectTimeout = setTimeout(this.openConnection, timeout); } closeAndReconnect () { console.log('Closing connection and reconnecting.'); if (this.ws) { this.ws.onclose = null; this.ws.onerror = null; this.ws.close(); } clearTimeout(this.reconnectTimeout); // There should be a slight delay so that repeated project ID changes won't trigger too many connections. const delay = 1000 / 30; this.reconnectTimeout = setTimeout(this.openConnection, delay); } canWriteToServer () { return this.ws && this.ws.readyState === WebSocket.OPEN; } scheduleBufferedSend () { if (!this.scheduledBufferedSend) { this.scheduledBufferedSend = true; Promise.resolve().then(this._scheduledSendBufferedMessages); } } _scheduledSendBufferedMessages () { this.scheduledBufferedSend = false; if (this.canWriteToServer()) { this.sendBufferedMessages(); } } sendBufferedMessages () { for (const message of this.bufferedMessages) { this.writeToServer(message); } this.bufferedMessages.length = 0; } bufferedWriteToServer (message) { this.bufferedMessages.push(message); this.scheduleBufferedSend(); } writeToServer (message) { message.project_id = this.projectId; message.user = this.manager.getUsername(); this.ws.send(JSON.stringify(message)); } handleUpdateVariable (name, value) { // If this variable already has an update queued, we'll replace its value instead of adding another update. for (const i of this.bufferedMessages) { if (i.name === name) { i.value = value; return; } } this.bufferedWriteToServer({ method: 'set', name, value }); } } class LocalStorageProvider { constructor (key='p4:cloudvariables') { this.key = key; this.variables = {}; this.handleStorageEvent = this.handleStorageEvent.bind(this); } readFromLocalStorage () { let parsed; try { parsed = JSON.parse(localStorage.getItem(this.key)); if (!parsed || typeof parsed !== 'object') { return; } } catch (e) { return; } this.variables = parsed; for (const key of Object.keys(this.variables)) { this.manager.setVariable(this, key, this.variables[key]); } } storeToLocalStorage () { try { localStorage.setItem(this.key, JSON.stringify(this.variables)) } catch (e) { // ignore } } handleStorageEvent (e) { if (e.key === this.key && e.storageArea === localStorage) { this.readFromLocalStorage(); } } enable () { this.readFromLocalStorage(); window.addEventListener('storage', this.handleStorageEvent); } handleUpdateVariable (name, value) { this.variables[name] = value; this.storeToLocalStorage(); } } export default { CloudManager, WebSocketProvider, LocalStorageProvider };