-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmanaged-websocket.js
More file actions
414 lines (351 loc) · 11.4 KB
/
managed-websocket.js
File metadata and controls
414 lines (351 loc) · 11.4 KB
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
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
/**
* JS code meant to be injected by content-script in the JS runtime.
*
* This file has 4 parts (not in order):
* - a helper function to emulate native EventTarget behavior.
* - a monkey-patch around WebSocket to create 'managed' WebSocket.
* - a register object to easily access websocket created by third party.
* - setting-up communication between the page and the content-script
*
* The main point of entry will be the global `_wsRegister` object,
* which is an instance of ManagedWebSocketRegister that gives control
* of (hopefuly) all the WebSocket created in the page.
*
* Since this monkey-patch the WebSocket object, and generally messes
* with the global scope, this code should be injected as little as possible.
* This is why it's scoped to localhost in the extension manifest.json.
*
* Special thanks to the answers in:
* https://stackoverflow.com/questions/62798510/how-can-an-extension-listen-to-a-websocket-and-what-if-the-websocket-is-within
* which helped flesh out the script-injection part, and gave a baseline
* to work for the WebSocket monkey-patch.
*/
// === ManagedWebSocket Register
const ManagedWebSocketRegister = (function() {
const AVAILABLE_EVENTS = ['update'];
const KEEP_REMOVED_WEBSOCKETS = true;
function Register() {
this._superEventHandling();
this._nextId = 1;
this._items = [];
this._removed = [];
this._plugged = 'all';
}
_sprinkleSomeEventHandling(Register, AVAILABLE_EVENTS);
Register.prototype.getItems = function() {
return this._items();
};
Register.prototype.add = function(ws, info) {
const id = this._nextId++;
const newItem = { id, ws, info };
this._items.push(newItem);
// Sometime new WebSockets are created when old ones goes AWOL,
// so to disable auto-refresh we need to unplug those too.
if (this._plugged === 'none') { ws.unplug(); }
this.dispatchEvent(new CustomEvent('update', {
detail: { type: 'new-item', newItem },
}));
return id;
};
Register.prototype.remove = function(itemId) {
const removed = this._items.find(d => d.id === itemId);
this._items = this._items.filter(d => d.id !== itemId);
this.dispatchEvent(new CustomEvent('update', {
detail: { type: 'removed-item', removed },
}));
if (KEEP_REMOVED_WEBSOCKETS) { this._removed.push(removed); }
return removed;
};
Register.prototype.getState = function() {
const sockets = this._items.map(d => ({
id: d.id,
info: d.info,
plugged: d.ws.isPlugged,
}));
return {
plugged: this._plugged,
sockets: sockets,
};
};
Register.prototype.plugAll = function() {
return this.setPluggedAll(true);
};
Register.prototype.unplugAll = function() {
return this.setPluggedAll(false);
};
Register.prototype.setPluggedAll = function(plug = true) {
const items = this._items;
let wasPlugged = 0, wasUnplugged = 0;
for (const item of items) {
const plugged = item.ws.isPlugged();
plugged ? wasPlugged++ : wasUnplugged++;
item.ws.setPlugged(plug);
}
this._plugged = plug ? 'all' : 'none';
return {
command: plug ? 'plug' : 'unplug',
total: items.length,
wasPlugged: wasPlugged,
wasUnplugged: wasUnplugged,
};
};
return Register;
})();
window._wsRegister = new ManagedWebSocketRegister();
// === Managed WebSocket
const ManagedWebSocket = (function() {
const WEBSOCKET_EVENTS = ['open', 'message', 'close', 'error'];
const AVAILABLE_EVENTS = [...WEBSOCKET_EVENTS];
// Keep a reference of real WebSocket implementation
const RealWebSocket = window.WebSocket;
const callRealWebSocket = RealWebSocket.apply.bind(RealWebSocket);
function ManagedWebSocket(url, protocols) {
this._superEventHandling();
this._id = null;
this._ws = null;
this._url = url;
this._protocols = protocols;
this._plugged = true;
this._backlog = [];
// Create real WebSocket, possibly throwing an error
let ws;
if (!(this instanceof ManagedWebSocket)) {
// Throw error: called without 'new'
ws = callRealWebSocket(this, arguments);
} else if (arguments.length === 1) {
ws = new RealWebSocket(url);
} else if (arguments.length >= 2) {
ws = new RealWebSocket(url, protocols);
} else {
// Throw error: No arguments
ws = new RealWebSocket();
}
this._ws = ws;
// Add current managed WS to register
this._id = window._wsRegister.add(this, { url, protocols });
// Listen to WebSocket
for (const eventType of WEBSOCKET_EVENTS) {
ws.addEventListener(
eventType,
event => this._handleEvent(eventType, event)
);
}
}
_sprinkleSomeEventHandling(ManagedWebSocket, AVAILABLE_EVENTS);
// (real) WebSocket instance methods
ManagedWebSocket.prototype.send = function(data) {
if (this._plugged) {
this._ws.send(data);
} else {
this._backlog.push({
type: 'send',
data: data,
at: new Date(),
});
}
};
ManagedWebSocket.prototype.close = function(code, reason) {
if (this._plugged) {
this._ws.close(code, reason);
window._wsRegister.remove(this._id);
} else {
this._backlog.push({
type: 'close',
data: { code, reason },
at: new Date(),
});
}
};
// Event handling
ManagedWebSocket.prototype._handleEvent = function(type, event) {
if (this._plugged) {
this.dispatchEvent(event);
} else {
this._backlog.push({
type: 'event',
data: { type, event },
at: new Date(),
});
}
};
// Plug/Unplug management
ManagedWebSocket.prototype.plug = function() {
this._plugged = true;
// Replay events
for (const event in this._backlog) {
switch (event.type) {
case 'event':
this._handleEvent(event.type, event.event);
break;
case 'send':
this.send(event.data);
break;
case 'close':
this.close(event.reason);
break;
}
}
this._backlog = [];
};
ManagedWebSocket.prototype.unplug = function() {
this._plugged = false;
};
ManagedWebSocket.prototype.isPlugged = function() {
return this._plugged;
};
ManagedWebSocket.prototype.setPlugged = function(plugged) {
return plugged ? this.plug() : this.unplug();
};
return ManagedWebSocket;
})();
window.WebSocket = ManagedWebSocket;
// === EventTarget emulation
/**
* Re-implements the 'EventTarget' behavior.
*
* The re-implemented behavior is so far:
* - allow attaching events with `.addEventListener()` and `on___ = ...`
* - allow attaching events with `on___ = ...` assignments.
* - allow removal of listener with `.removeEventListener()`
*
* A thing to note (and copying the behavior of Google Chrome,
* since to be honesty I simply glanced at the specs), is that
* - custom events can be listened and triggered with the
* `addEventListener` and `dispatchEvent` combo
* - only pre-defined events can use the `on___ = ...` syntax.
* - Calling `addEventListener('click', ...)` has no bearing into the
* value of `onclick` property.
*
* NOTE: With Chrome implementation, the order of execution
* depends on the order of assignation, there is no priority
* between 'onclick = (...)' versus 'addEventListener(...)'.
* This motivates the implementation choice of adding getter/setters
* for `on___` properties, which add the listeners to the same stack
* as the `addEventListener` method, in order to respect the Chrome
* (Spec ?) listener call order.
*
* NOTE: A customEvent will work with the addListener/dispatchEvent
* combo, but won't call the associated `on___` callback.
*
* ALSO NOTE: no care has been given for the propagation and bubbling
* behavior, since this re-implementation of EventTarget is only meant for
* 'flat' objects, outside of the classic DOM hierarchy.
*
*
* # How to use
*
* Let's say you have a constructor function MyObject, that you
* wish to augment with EventTarget-like behavior for events 'foo' and 'bar'.
* You need to do 2 things:
* - inside the MyObject constructor, call `this._superEventHandling();`
* - after the class/function definition, call
* `_sprinkeSomeEventHandling(MyObject, ['foo', 'bar']);`
*/
function _sprinkleSomeEventHandling(Obj, allowedEvents) {
Obj.prototype._superEventHandling = function() {
// Store the listeners reference in `this.__listeners`
const listenersDict = {};
allowedEvents.forEach(eventType => listenersDict['on'+eventType] = []);
Object.defineProperty(this, '__listeners', {
value: listenersDict,
enumerable: false,
});
// Only the non-custom events allows for the `onfoobar = ...` syntax.
for (const eventType of allowedEvents) {
Object.defineProperty(this, 'on'+eventType, {
enumerable: false,
get: function() {
const listeners = this.__listeners['on'+eventType];
return listeners.find(d => d.fromAssignement === true) || null;
},
set: function(callback) {
let listeners = this.__listeners['on'+eventType];
listeners = listeners.filter(d => d.fromAssignement === false);
if (typeof callback === 'function') {
listeners.push({
callback: callback,
options: null,
fromAssignement: true,
});
}
this.__listeners['on'+eventType] = listeners;
}
});
}
};
Obj.prototype.dispatchEvent = function(event) {
const toRemove = [];
const listeners = this.__listeners['on'+event.type];
// If no listener for event type exist, we can return early.
if (!listeners || listeners.length === 0) { return true; }
for (const listener of listeners) {
listener.callback.call(this, event);
// Remove listener if it was added with `once: true`.
if (listener.options && listener.options.once) {
toRemove.push(listener);
}
}
if (toRemove.length > 0) {
const newList = listeners.filter(l => !toRemove.includes(l));
this.__listeners['on'+event.type] = newList;
}
return !event.defaultPrevented;
};
Obj.prototype.addEventListener = function(type, listener, options) {
if (!this.__listeners['on'+type]) { this.__listeners['on'+type] = []; }
const listeners = this.__listeners['on'+type];
listeners.push({
callback: listener,
options: options || null,
fromAssignement: false,
});
};
Obj.prototype.removeEventListener = function(type, listener, options) {
if (!this.__listeners['on'+type]) { return; }
let listeners = this.__listeners['on'+type];
listeners = listeners
.filter(d => d.fromAssignement || d.callback !== listener);
this.__listeners['on'+type] = listeners;
};
}
// === Communication with content-script
// (See content-script comment on why we need to wrap with JSON.stringify)
(function () {
const INPUT_EVENT_TYPE = 'togglehmr-command';
const OUTPUT_EVENT_TYPE = 'togglehmr-event';
// Sending the websocket register state on initialization
window.dispatchEvent(new CustomEvent(OUTPUT_EVENT_TYPE, {
detail: JSON.stringify({
type: 'register',
origin: 'initialization',
data: window._wsRegister.getState(),
}),
}));
// Sending the websocket register state on each update
window._wsRegister.addEventListener('update', e => {
window.dispatchEvent(new CustomEvent(OUTPUT_EVENT_TYPE, {
detail: JSON.stringify({
type: 'register',
origin: 'onUpdate',
data: window._wsRegister.getState(),
}),
}));
});
// Listening to background script commands
window.addEventListener(INPUT_EVENT_TYPE, (e) => {
const data = e.detail ? JSON.parse(e.detail) : null;
if (!data.command) { return; }
executeCommand(data.command, data.scope, data.value);
});
function executeCommand(command, scope = null, value = null) {
if (scope === 'all-websockets') {
switch (command) {
case 'setPlugged':
window._wsRegister.setPluggedAll(!!value);
break;
}
} else {
// TODO: do per-websocket command
}
}
})();