pubsub-js-client 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+
2
+ import EventsDispatcher from "./events";
3
+ import logging from "./log";
4
+ import util from "./util";
5
+ import PubsubSocket from "./PubsubSocket";
6
+ import MyMap from "./mymap";
7
+
8
+ var logger = logging._getLogger("WebsocketClient");
9
+
10
+ const SOCKET_CLOSED_RECONNECT_TIME = 1 * 1000; // 1 second
11
+ const RESPONSE_TIMEOUT = 30 * 1000; // 30 seconds
12
+ const ERR_RESPONSE_TIMEOUT = "response timeout";
13
+ const NONCE_LENGTH = 30;
14
+ const FIRST_LISTEN_TIMEOUT = 45 * 1000; // 45 seconds
15
+
16
+ const addrProduction = "wss://pubsub-edge.twitch.tv:443/v1";
17
+ const addrDarklaunch = "wss://pubsub-edge-darklaunch.twitch.tv:443/v1";
18
+ const addrDevelopment = "ws://localhost:6900/v1";
19
+
20
+ class WebsocketClient extends EventsDispatcher {
21
+
22
+ constructor (opts) {
23
+ // opts should include: environment
24
+ super(opts);
25
+ this._opts = opts;
26
+ this._env = opts.env;
27
+
28
+ switch (this._env) {
29
+ case "production":
30
+ this._addr = addrProduction;
31
+ break;
32
+ case "darklaunch":
33
+ this._addr = addrDarklaunch;
34
+ break;
35
+ case "development":
36
+ this._addr = addrDevelopment;
37
+ break;
38
+ default:
39
+ this._addr = addrProduction;
40
+ }
41
+
42
+ // noop if WebSockets aren't supported
43
+ if (!window.WebSocket) {
44
+ return;
45
+ }
46
+
47
+ // Keep track of Listen/Unlisten requests that have queued up while Driver is disconnected
48
+ this._queuedRequests = [];
49
+ // Keep track of pending responses; map is from nonce -> {timeout to clear, isListen bool, un/listen opts}
50
+ this._pendingResponses = new MyMap();
51
+ // Keep track of nonces from outstanding listen replays
52
+ this._pendingReplayResponses = new MyMap();
53
+ // Keep track of messages we are listening to, and their callbacks
54
+ this._listens = new EventsDispatcher();
55
+ // Keep track of topic+auth token for each successful LISTEN callback
56
+ this._replays = new MyMap();
57
+ this._replaysSize = 0;
58
+
59
+ // Track the 'time to first Listen'
60
+ this._firstConnectTime = this._firstListenTime = 0;
61
+
62
+ // Instantiate websocket connection
63
+ this._connectCalled = this._reconnecting = false;
64
+ this._primarySocket = new PubsubSocket({
65
+ addr: this._addr
66
+ });
67
+ this._bindPrimary(this._primarySocket);
68
+ }
69
+
70
+ verify () {
71
+ this._trigger("verified");
72
+ }
73
+
74
+ connect () {
75
+ // noop if WebSockets aren't supported
76
+ if (!window.WebSocket) {
77
+ return;
78
+ }
79
+
80
+ if (this._connectCalled) {
81
+ // Noop for every "connect()" call after the first
82
+ if (this._primarySocket._isReady()) {
83
+ this._trigger("connected");
84
+ }
85
+ } else {
86
+ this._connectCalled = true;
87
+ this._primarySocket.connect();
88
+ }
89
+ }
90
+
91
+ _bindPrimary (socket) {
92
+ // Socket opening
93
+ socket.on('open', this._onPrimaryOpen, this);
94
+ // Pubsub messages
95
+ socket.on('response', this._onResponse, this);
96
+ socket.on('message', this._onMessage, this);
97
+ socket.on('reconnect', this._onReconnect, this);
98
+ // Errors
99
+ socket.on('connection_failure', this._onConnectionFailure, this);
100
+ }
101
+
102
+ _unbindPrimary (socket) {
103
+ // Socket opening
104
+ socket.off('open', this._onPrimaryOpen, this);
105
+ // Pubsub messages
106
+ socket.off('response', this._onResponse, this);
107
+ socket.off('message', this._onMessage, this);
108
+ socket.off('reconnect', this._onReconnect, this);
109
+ // Errors
110
+ socket.off('connection_failure', this._onConnectionFailure, this);
111
+ }
112
+
113
+ _onPrimaryOpen () {
114
+ logger.debug("primary open: " + this._primarySocket._id);
115
+ // Triggered when the PubsubDriver is ready to start receiving commands
116
+ if (this._firstConnectTime === 0) {
117
+ this._firstConnectTime = util.time.now();
118
+ }
119
+
120
+ this._connected = true;
121
+ this._trigger("connected");
122
+
123
+ this._flushQueuedRequests();
124
+ }
125
+
126
+ _onResponse (resp) {
127
+ logger.debug("primary response: " + JSON.stringify(resp));
128
+ if (this._pendingResponses.has(resp.nonce)) {
129
+ var responseInfo = this._pendingResponses.get(resp.nonce);
130
+ logger.debug("responseInfo: " + JSON.stringify(responseInfo));
131
+ clearTimeout(responseInfo.timeout);
132
+ this._pendingResponses.remove(resp.nonce);
133
+
134
+ if (resp.error === "") {
135
+ // Add/remove onMessage callback from the specified topic
136
+ // Also add/remove the auth token used for that topic/callback pair
137
+ if (responseInfo.message.type === "LISTEN") {
138
+ // Track time to first listen
139
+ if (this._firstListenTime === 0) {
140
+ this._firstListenTime = util.time.now();
141
+ }
142
+
143
+ this._replays.set(resp.nonce, {
144
+ nonce: resp.nonce,
145
+ message: responseInfo.callbacks.message,
146
+ topic: responseInfo.topic,
147
+ auth: responseInfo.auth
148
+ });
149
+
150
+ if (responseInfo.callbacks.message) {
151
+ this._listens.on(responseInfo.topic, responseInfo.callbacks.message, this);
152
+ }
153
+ } else if (responseInfo.message.type === "UNLISTEN") {
154
+ this._replays.remove(resp.nonce);
155
+
156
+ if (responseInfo.callbacks.message) {
157
+ this._listens.off(responseInfo.topic, responseInfo.callbacks.message, this);
158
+ }
159
+ }
160
+ // Call the specified onSuccess callback
161
+ if (responseInfo.callbacks.success) {
162
+ responseInfo.callbacks.success();
163
+ }
164
+ } else {
165
+ // Call the specified onFailure callback
166
+ if (responseInfo.callbacks.failure) {
167
+ responseInfo.callbacks.failure(resp.error);
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ _onResponseTimeout (nonce) {
174
+ if (this._pendingResponses.has(nonce)) {
175
+ var info = this._pendingResponses.get(nonce);
176
+ this._pendingResponses.remove(nonce);
177
+
178
+ if (info.callbacks.failure) {
179
+ info.callbacks.failure(ERR_RESPONSE_TIMEOUT);
180
+ }
181
+ }
182
+ }
183
+
184
+ _onMessage (msg) {
185
+ logger.debug("primary message: " + JSON.stringify(msg));
186
+ this._listens._trigger(msg.data.topic, msg.data.message);
187
+ }
188
+
189
+ _onConnectionFailure () {
190
+ logger.debug("connection failure");
191
+ // Call disconnection callback
192
+ this._trigger("disconnected");
193
+ // try to reconnect, using the same backupSocket flow as intentional reconnects
194
+ // will end up re-listening on all topics
195
+ this._notifyWhenOpen = true;
196
+ this._onReconnect();
197
+ }
198
+
199
+ // Smoothly reconnect, establishing a new socket before terminating the old one
200
+ _onReconnect () {
201
+ logger.debug("reconnecting...");
202
+ this._reconnecting = true;
203
+ this._backupSocket = new PubsubSocket({
204
+ addr: this._addr
205
+ });
206
+ this._bindBackup(this._backupSocket);
207
+ setTimeout(this._backupSocket.connect.bind(this._backupSocket), this._jitteredReconnectDelay());
208
+ }
209
+
210
+ _bindBackup (socket) {
211
+ socket.on('open', this._onBackupOpen, this);
212
+ socket.on('response', this._onBackupResponse, this);
213
+ }
214
+
215
+ _unbindBackup (socket) {
216
+ socket.off('open', this._onBackupOpen, this);
217
+ socket.off('response', this._onBackupResponse, this);
218
+ }
219
+
220
+ _onBackupOpen () {
221
+ logger.debug("Backup socket opened");
222
+ if (this._replays.size() > 0) {
223
+ this._replayBackup();
224
+ } else {
225
+ this._swapSockets();
226
+ if (this._notifyWhenOpen) {
227
+ logger.debug("triggering connected");
228
+ this._notifyWhenOpen = false;
229
+ this._trigger('connected');
230
+ }
231
+ }
232
+ }
233
+
234
+ // Get the backup socket up to speed by re-listening on topics
235
+ _replayBackup () {
236
+ var replays = this._replays.values();
237
+ for (var i = 0; i < replays.length; i++) {
238
+ var msg = {
239
+ type: "LISTEN",
240
+ nonce: this._generateNonce(),
241
+ data: {
242
+ topics: [replays[i].topic],
243
+ auth_token: replays[i].auth
244
+ }
245
+ };
246
+ this._pendingReplayResponses.set(msg.nonce, true);
247
+ this._backupSocket.send(msg);
248
+ }
249
+ }
250
+
251
+ _onBackupResponse (resp) {
252
+ if (this._pendingReplayResponses.has(resp.nonce) && resp.error === "") {
253
+ this._pendingReplayResponses.remove(resp.nonce);
254
+ if (this._pendingReplayResponses.size() === 0) {
255
+ // Finished getting the backup socket up to speed
256
+ this._swapSockets();
257
+ if (this._notifyWhenOpen) {
258
+ // Flag set when the reconnection is accidental, and we need to notify the client that the pubsub is ready again, rather than just silently switching
259
+ logger.debug("triggering connected");
260
+ this._notifyWhenOpen = false;
261
+ this._trigger('connected');
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ _swapSockets () {
268
+ logger.debug("swapping primary " + this._primarySocket._id + " and backup " + this._backupSocket._id);
269
+ this._unbindPrimary(this._primarySocket);
270
+ this._unbindBackup(this._backupSocket);
271
+ this._bindPrimary(this._backupSocket);
272
+ this._primarySocket.close();
273
+ this._primarySocket = this._backupSocket;
274
+ this._reconnecting = false;
275
+ this._flushQueuedRequests();
276
+ }
277
+
278
+ Listen (opts) {
279
+ // noop if WebSockets aren't supported
280
+ if (!window.WebSocket) {
281
+ return;
282
+ }
283
+
284
+ // opts should include: topic, auth, success, failure, message
285
+ logger.debug("listening on " + opts.topic);
286
+ var nonce = this._generateNonce();
287
+ var msg = {
288
+ type: "LISTEN",
289
+ nonce: nonce,
290
+ data: {
291
+ topics: [opts.topic],
292
+ auth_token: opts.auth
293
+ }
294
+ };
295
+ this._queuedSend(nonce, msg, opts);
296
+ }
297
+
298
+ Unlisten (opts) {
299
+ // noop if WebSockets aren't supported
300
+ if (!window.WebSocket) {
301
+ return;
302
+ }
303
+
304
+ // opts should include: topic, success, failure, message
305
+ logger.debug("unlistening on " + opts.topic + "(" + this._listens.count(opts.topic) + " listeners)");
306
+
307
+ // If there are more than one callbacks waiting on this topic, we can just remove the specified one rather than sending an UNLISTEN
308
+ if (this._listens.count(opts.topic) > 1) {
309
+ this._listens.off(opts.topic, opts.message);
310
+
311
+ // Delete from replays
312
+ for (var key in this._replays.map()) {
313
+ if (this._replays.get(key).message === opts.message) {
314
+ this._replays.remove(key);
315
+ break;
316
+ }
317
+ }
318
+
319
+ if (opts.success) {
320
+ opts.success();
321
+ }
322
+ logger.debug("now have " + this._listens.count(opts.topic) + " listeners");
323
+ return;
324
+ }
325
+
326
+ var nonce = this._generateNonce();
327
+ var msg = {
328
+ type: "UNLISTEN",
329
+ nonce: nonce,
330
+ data: {
331
+ topics: [opts.topic]
332
+ }
333
+ };
334
+ this._queuedSend(nonce, msg, opts);
335
+ }
336
+
337
+ _queuedSend (nonce, msg, opts) {
338
+ if (this._reconnecting || this._primarySocket._isReady() === false) {
339
+ // queue the message
340
+ logger.debug("queuing");
341
+ this._queuedRequests.push({nonce: nonce, msg: msg, opts: opts});
342
+ } else {
343
+ // send
344
+ logger.debug("sending immediately");
345
+ this._send(nonce, msg, opts);
346
+ }
347
+ }
348
+
349
+ _flushQueuedRequests () {
350
+ logger.debug("flushing " + this._queuedRequests.length + " listen/unlistens");
351
+ while (this._queuedRequests.length > 0) {
352
+ var req = this._queuedRequests.shift();
353
+ this._send(req.nonce, req.msg, req.opts);
354
+ }
355
+ }
356
+
357
+ _send (nonce, msg, opts) {
358
+ this._pendingResponses.set(nonce, {
359
+ timeout: setTimeout(this._onResponseTimeout.bind(this), RESPONSE_TIMEOUT, nonce),
360
+ topic: opts.topic,
361
+ auth: opts.auth,
362
+ message: msg,
363
+ callbacks: {
364
+ success: opts.success,
365
+ failure: opts.failure,
366
+ message: opts.message
367
+ }
368
+ });
369
+ this._primarySocket.send(msg);
370
+ }
371
+
372
+ // Utility functions
373
+ _generateNonce () {
374
+ return util.generateString(NONCE_LENGTH);
375
+ }
376
+
377
+ _jitteredReconnectDelay () {
378
+ return util.randomInt(2000);
379
+ }
380
+
381
+ }
382
+
383
+ export default WebsocketClient;
package/src/events.js ADDED
@@ -0,0 +1,43 @@
1
+ class EventsDispatcher {
2
+ on (name, callback, context) {
3
+ this._events = this._events || {};
4
+ this._events[name] = this._events[name] || [];
5
+ this._events[name].push(callback, context);
6
+ return this;
7
+ }
8
+
9
+ off (name, callback) {
10
+ if (this._events) {
11
+ var callbacks = this._events[name] || [];
12
+ var keep = this._events[name] = [];
13
+ for (var i = 0; i < callbacks.length; i += 2) {
14
+ if (callbacks[i] !== callback) {
15
+ keep.push(callbacks[i]);
16
+ keep.push(callbacks[i + 1]);
17
+ }
18
+ }
19
+ }
20
+ return this;
21
+ }
22
+
23
+ _trigger (name) {
24
+ if (this._events) {
25
+ var callbacks = this._events[name] || [];
26
+ for (var i = 1; i < callbacks.length; i += 2) {
27
+ callbacks[i - 1].apply(callbacks[i], Array.prototype.slice.call(arguments, 1));
28
+ }
29
+ }
30
+ return this;
31
+ }
32
+
33
+ count (name) {
34
+ if (this._events) {
35
+ var callbacks = this._events[name] || [];
36
+ return (callbacks.length / 2);
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ }
42
+
43
+ export default EventsDispatcher;
package/src/log.js ADDED
@@ -0,0 +1,121 @@
1
+ import util from "./util";
2
+
3
+ var noopLogFunc = function () {};
4
+ var _logFunc = noopLogFunc;
5
+ var _loggers = {};
6
+
7
+ var _logLevels = {
8
+ "DEBUG": 1,
9
+ "INFO": 2,
10
+ "WARNING": 3,
11
+ "ERROR": 4,
12
+ "CRITICAL": 5
13
+ };
14
+ var _currentLogLevel = _logLevels.WARNING;
15
+
16
+ class Logger {
17
+ constructor (opts) {
18
+ this._opts = opts;
19
+ }
20
+
21
+ debug (msg) {
22
+ if (_currentLogLevel <= _logLevels.DEBUG) {
23
+ this._log(`DEBUG: ${msg}`);
24
+ }
25
+ }
26
+
27
+ info (msg) {
28
+ if (_currentLogLevel <= _logLevels.INFO) {
29
+ this._log(`INFO: ${msg}`);
30
+ }
31
+ }
32
+
33
+ warning (msg) {
34
+ if (_currentLogLevel <= _logLevels.WARNING) {
35
+ this._log(`WARNING: ${msg}`);
36
+ }
37
+ }
38
+
39
+ error (msg) {
40
+ if (_currentLogLevel <= _logLevels.ERROR) {
41
+ this._log(`ERROR: ${msg}`);
42
+ }
43
+ }
44
+
45
+ critical (msg) {
46
+ if (_currentLogLevel <= _logLevels.CRITICAL) {
47
+ this._log(`CRITICAL: ${msg}`);
48
+ }
49
+ }
50
+
51
+ _log (msg) {
52
+ var logMsg = this._opts.prefix + msg;
53
+ if (this._opts.logFunc) {
54
+ this._opts.logFunc(logMsg);
55
+ } else {
56
+ _logFunc(logMsg);
57
+ }
58
+ }
59
+ }
60
+
61
+ var logging = {
62
+
63
+ setLogger: function (logFunc) {
64
+ _logFunc = (typeof(logFunc) === "function") ? logFunc : noopLogFunc;
65
+ },
66
+
67
+ setLevel: (function () {
68
+ var forcedLogLevel = (util.urlParams.pubsub_log_level || "").toUpperCase();
69
+ if (forcedLogLevel) {
70
+ var forced = _logLevels[forcedLogLevel];
71
+ if (forced) {
72
+ _currentLogLevel = forced;
73
+ // Return a noop -- attempting to change the log level should do nothing
74
+ return function () {};
75
+ }
76
+ }
77
+
78
+ return function (logLevel) {
79
+ if (!logLevel) {
80
+ _currentLogLevel = _logLevels.WARNING;
81
+ } else {
82
+ _currentLogLevel = _logLevels[logLevel.toUpperCase()] || _logLevels.WARNING;
83
+ }
84
+ };
85
+ })(),
86
+
87
+ _getLogger: function (name) {
88
+ if (!_loggers[name]) {
89
+ _loggers[name] = new Logger({
90
+ prefix: `pubsub.js [${name}] `
91
+ });
92
+ }
93
+ return _loggers[name];
94
+ },
95
+
96
+ _noopLogger: new Logger({
97
+ prefix: "",
98
+ logFunc: noopLogFunc
99
+ })
100
+
101
+ };
102
+
103
+ var console = window.console;
104
+ if (console && console.log) {
105
+ // Prefer console.log if it exists
106
+ if (console.log.apply) {
107
+ logging.setLogger(function () { console.log.apply(console, arguments); });
108
+ } else {
109
+ // IE
110
+ logging.setLogger(function () {
111
+ var args = [];
112
+ for (var i = 0; i < arguments.length; ++i) {
113
+ args.push(arguments[i]);
114
+ }
115
+ console.log(args.join(" "));
116
+ });
117
+ }
118
+ }
119
+
120
+ export default logging;
121
+
package/src/mymap.js ADDED
@@ -0,0 +1,49 @@
1
+
2
+ class MyMap {
3
+ constructor() {
4
+ this._map = {};
5
+ this._size = 0;
6
+ }
7
+
8
+ set (key, value) {
9
+ if (!this._map.hasOwnProperty(key)) {
10
+ this._size += 1;
11
+ }
12
+ this._map[key] = value;
13
+ }
14
+
15
+ get (key) {
16
+ return this._map[key];
17
+ }
18
+
19
+ has (key) {
20
+ return this._map.hasOwnProperty(key);
21
+ }
22
+
23
+ remove (key) {
24
+ if (this._map.hasOwnProperty(key)) {
25
+ this._size -= 1;
26
+ }
27
+ delete this._map[key];
28
+ }
29
+
30
+ size () {
31
+ return this._size;
32
+ }
33
+
34
+ map () {
35
+ return this._map;
36
+ }
37
+
38
+ values () {
39
+ var vals = [];
40
+ for (var key in this._map) {
41
+ if (this._map.hasOwnProperty(key)) {
42
+ vals.push(this._map[key]);
43
+ }
44
+ }
45
+ return vals;
46
+ }
47
+ }
48
+
49
+ export default MyMap;
package/src/util.js ADDED
@@ -0,0 +1,51 @@
1
+
2
+ var util = {};
3
+
4
+ util.randomInt = function (max) {
5
+ return Math.floor(Math.random() * max);
6
+ };
7
+
8
+ util.time = {
9
+ seconds: function (num) {
10
+ return num * 1000;
11
+ },
12
+
13
+ now: function () {
14
+ return new Date().getTime();
15
+ }
16
+ };
17
+
18
+ util.urlParams = (function () {
19
+ var urlParams = {};
20
+ var params = window.location.search.substr(1);
21
+ var keyValues = params.split("&");
22
+ for (var i = 0; i < keyValues.length; ++i) {
23
+ var keyValue = keyValues[i].split("=");
24
+ try {
25
+ urlParams[decodeURIComponent(keyValue[0])] = keyValue.length > 1 ? decodeURIComponent(keyValue[1]) : "";
26
+ } catch (e) {
27
+ // Sometimes decodeURIComponent throws errors if weird chars are in the URL
28
+ }
29
+ }
30
+ return urlParams;
31
+ }());
32
+
33
+ util.generateString = function (len) {
34
+ var text = "";
35
+ var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
36
+ for (var i = 0; i < len; i++) {
37
+ text += possible.charAt(util.randomInt(possible.length));
38
+ }
39
+ return text;
40
+ };
41
+
42
+ util.inIframe = function () {
43
+ try {
44
+ return window.self !== window.top;
45
+ } catch (e) {
46
+ // Sometimes browsers block an iframe's access to window.top
47
+ return true;
48
+ }
49
+ };
50
+
51
+ export default util;