File size: 9,179 Bytes
f2bee8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
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;