@sogni-ai/sogni-client 4.0.0-alpha.12 → 4.0.0-alpha.14
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.
- package/CHANGELOG.md +14 -0
- package/dist/Account/index.d.ts +1 -0
- package/dist/Account/index.js +11 -2
- package/dist/Account/index.js.map +1 -1
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/WSCoordinator.d.ts +101 -0
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/WSCoordinator.js +359 -0
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/WSCoordinator.js.map +1 -0
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.d.ts +34 -0
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js +195 -0
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js.map +1 -0
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/types.d.ts +101 -0
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/types.js +3 -0
- package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/types.js.map +1 -0
- package/dist/ApiClient/WebSocketClient/events.d.ts +1 -0
- package/dist/ApiClient/WebSocketClient/index.d.ts +2 -2
- package/dist/ApiClient/WebSocketClient/types.d.ts +13 -0
- package/dist/ApiClient/index.d.ts +2 -3
- package/dist/ApiClient/index.js +20 -2
- package/dist/ApiClient/index.js.map +1 -1
- package/dist/Projects/Job.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/DataEntity.js +4 -2
- package/dist/lib/DataEntity.js.map +1 -1
- package/package.json +4 -4
- package/src/Account/index.ts +11 -2
- package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/WSCoordinator.ts +425 -0
- package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/index.ts +206 -0
- package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/types.ts +107 -0
- package/src/ApiClient/WebSocketClient/events.ts +2 -0
- package/src/ApiClient/WebSocketClient/index.ts +2 -2
- package/src/ApiClient/WebSocketClient/types.ts +16 -0
- package/src/ApiClient/index.ts +25 -6
- package/src/index.ts +7 -3
- package/src/lib/DataEntity.ts +4 -2
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { Logger } from '../../../lib/DefaultLogger';
|
|
2
|
+
import getUUID from '../../../lib/getUUID';
|
|
3
|
+
import { AuthenticatedData, SocketEventMap, SocketEventName } from '../events';
|
|
4
|
+
import { MessageType, SocketMessageMap } from '../messages';
|
|
5
|
+
import { Balances } from '../../../Account/types';
|
|
6
|
+
import {
|
|
7
|
+
ChannelMessage,
|
|
8
|
+
Heartbeat,
|
|
9
|
+
MessageEnvelope,
|
|
10
|
+
SocketEventReceived,
|
|
11
|
+
SendSocketMessage
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
interface StartingEvents {
|
|
15
|
+
authenticated?: AuthenticatedData;
|
|
16
|
+
balanceUpdate?: Balances;
|
|
17
|
+
swarmModels?: Record<string, number>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface WSCoordinatorCallbacks {
|
|
21
|
+
/**
|
|
22
|
+
* Invoked when authentication state changes (authenticated/unauthenticated).
|
|
23
|
+
* @param isAuthenticated - true if client is authenticated, false if not.
|
|
24
|
+
*/
|
|
25
|
+
onAuthChanged: (isAuthenticated: boolean) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Invoked when role changes (primary/secondary).
|
|
28
|
+
* @param isPrimary - true if client is primary, false if secondary.
|
|
29
|
+
*/
|
|
30
|
+
onRoleChange: (isPrimary: boolean) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Invoked when connection state must change (connected/disconnected).
|
|
33
|
+
* @param isConnected
|
|
34
|
+
*/
|
|
35
|
+
onConnectionToggle: (isConnected: boolean) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Invoked when secondary client receives a socket event from primary.
|
|
38
|
+
* @param message
|
|
39
|
+
*/
|
|
40
|
+
onMessageFromPrimary: (message: SocketEventReceived | Heartbeat) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Invoked when primary client receives a socket send request from secondary.
|
|
43
|
+
* @param message
|
|
44
|
+
*/
|
|
45
|
+
onSendRequest: (message: SendSocketMessage) => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class WSCoordinator {
|
|
49
|
+
private static readonly HEARTBEAT_INTERVAL = 2000;
|
|
50
|
+
private static readonly PRIMARY_TIMEOUT = 3000;
|
|
51
|
+
private static readonly CHANNEL_NAME = 'sogni-websocket-clients';
|
|
52
|
+
private static readonly ACK_TIMEOUT = 5000;
|
|
53
|
+
|
|
54
|
+
private id: string;
|
|
55
|
+
private callbacks: WSCoordinatorCallbacks;
|
|
56
|
+
private channel: BroadcastChannel;
|
|
57
|
+
private _isPrimary: boolean;
|
|
58
|
+
private _isConnected = false;
|
|
59
|
+
private lastPrimaryHeartbeat: number = 0;
|
|
60
|
+
private logger: Logger;
|
|
61
|
+
private heartbeatInterval: NodeJS.Timeout | null = null;
|
|
62
|
+
private primaryCheckInterval: NodeJS.Timeout | null = null;
|
|
63
|
+
private startingEvents: StartingEvents = {};
|
|
64
|
+
private ackCallbacks: Record<string, (error?: any) => void> = {};
|
|
65
|
+
|
|
66
|
+
constructor(callbacks: WSCoordinatorCallbacks, logger: Logger) {
|
|
67
|
+
this.id = getUUID();
|
|
68
|
+
this.logger = logger;
|
|
69
|
+
this.callbacks = callbacks;
|
|
70
|
+
this.channel = new BroadcastChannel(WSCoordinator.CHANNEL_NAME);
|
|
71
|
+
this.channel.onmessage = this.handleMessage.bind(this);
|
|
72
|
+
this._isPrimary = false;
|
|
73
|
+
|
|
74
|
+
// Listen for tab closing to gracefully release primary role
|
|
75
|
+
if (typeof window !== 'undefined') {
|
|
76
|
+
window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Initialize tab coordination and determine role
|
|
82
|
+
*/
|
|
83
|
+
async initialize(): Promise<boolean> {
|
|
84
|
+
this.logger.info(`WSCoordinator ${this.id} initializing...`);
|
|
85
|
+
|
|
86
|
+
// Announce our presence
|
|
87
|
+
this.broadcast({
|
|
88
|
+
type: 'announce'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Wait to see if there's an existing primary
|
|
92
|
+
await this.waitForPrimaryResponse();
|
|
93
|
+
if (!this._isPrimary) {
|
|
94
|
+
this.logger.info(`Client ${this.id} is secondary, primary exists`);
|
|
95
|
+
this.startPrimaryCheck();
|
|
96
|
+
} else {
|
|
97
|
+
this.logger.info(`Client ${this.id} becoming primary`);
|
|
98
|
+
this.becomePrimary();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return this._isPrimary;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
connect() {
|
|
105
|
+
if (this._isPrimary) {
|
|
106
|
+
throw new Error('Primary should connect the socket directly.');
|
|
107
|
+
}
|
|
108
|
+
this.broadcast({
|
|
109
|
+
type: 'connection-toggle',
|
|
110
|
+
payload: { connected: true }
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
disconnect() {
|
|
115
|
+
if (this._isPrimary) {
|
|
116
|
+
throw new Error('Primary should disconnect socket directly.');
|
|
117
|
+
}
|
|
118
|
+
this.broadcast({
|
|
119
|
+
type: 'connection-toggle',
|
|
120
|
+
payload: { connected: false }
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async changeAuthState(isAuthenticated: boolean) {
|
|
125
|
+
this.broadcast({
|
|
126
|
+
type: 'authentication',
|
|
127
|
+
payload: { authenticated: isAuthenticated }
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wait briefly to see if a primary tab responds
|
|
133
|
+
*/
|
|
134
|
+
private waitForPrimaryResponse(): Promise<void> {
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
const timeout = setTimeout(() => {
|
|
137
|
+
// No primary responded, we become primary
|
|
138
|
+
this._isPrimary = true;
|
|
139
|
+
resolve();
|
|
140
|
+
}, 500);
|
|
141
|
+
|
|
142
|
+
const messageHandler = (e: MessageEvent<MessageEnvelope>) => {
|
|
143
|
+
const envelope = e.data;
|
|
144
|
+
const message = envelope.payload;
|
|
145
|
+
if (message.type === 'primary-present' || message.type === 'primary-claim') {
|
|
146
|
+
// Primary exists
|
|
147
|
+
this._isPrimary = false;
|
|
148
|
+
this.lastPrimaryHeartbeat = envelope.timestamp;
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
this.channel.removeEventListener('message', messageHandler);
|
|
151
|
+
resolve();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
this.channel.addEventListener('message', messageHandler);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Become the primary tab
|
|
161
|
+
*/
|
|
162
|
+
private becomePrimary() {
|
|
163
|
+
this._isPrimary = true;
|
|
164
|
+
this.callbacks.onRoleChange(true);
|
|
165
|
+
|
|
166
|
+
// Broadcast that we're claiming primary role
|
|
167
|
+
this.broadcast({
|
|
168
|
+
type: 'primary-claim'
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Start sending heartbeats
|
|
172
|
+
this.startHeartbeat();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Release primary role (when closing)
|
|
177
|
+
*/
|
|
178
|
+
private releasePrimary() {
|
|
179
|
+
if (this._isPrimary) {
|
|
180
|
+
this.broadcast({
|
|
181
|
+
type: 'primary-release'
|
|
182
|
+
});
|
|
183
|
+
this.stopHeartbeat();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Start sending heartbeat messages as primary
|
|
189
|
+
*/
|
|
190
|
+
private startHeartbeat() {
|
|
191
|
+
if (this.heartbeatInterval)
|
|
192
|
+
throw new Error('Heartbeat interval already started. This should never happen.');
|
|
193
|
+
this.heartbeatInterval = setInterval(() => {
|
|
194
|
+
this.broadcast({
|
|
195
|
+
type: 'primary-present',
|
|
196
|
+
payload: { connected: this._isConnected }
|
|
197
|
+
});
|
|
198
|
+
}, WSCoordinator.HEARTBEAT_INTERVAL);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Stop sending heartbeat messages
|
|
203
|
+
*/
|
|
204
|
+
private stopHeartbeat() {
|
|
205
|
+
if (this.heartbeatInterval) {
|
|
206
|
+
clearInterval(this.heartbeatInterval);
|
|
207
|
+
this.heartbeatInterval = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Start checking for primary heartbeat (as secondary)
|
|
213
|
+
*/
|
|
214
|
+
private startPrimaryCheck() {
|
|
215
|
+
this.primaryCheckInterval = setInterval(() => {
|
|
216
|
+
const timeSinceLastHeartbeat = Date.now() - this.lastPrimaryHeartbeat;
|
|
217
|
+
if (timeSinceLastHeartbeat > WSCoordinator.PRIMARY_TIMEOUT) {
|
|
218
|
+
this.logger.warn(`Primary tab timeout, becoming primary`);
|
|
219
|
+
this.stopPrimaryCheck();
|
|
220
|
+
this.becomePrimary();
|
|
221
|
+
}
|
|
222
|
+
}, 1000);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Stop checking for primary heartbeat
|
|
227
|
+
*/
|
|
228
|
+
private stopPrimaryCheck() {
|
|
229
|
+
if (this.primaryCheckInterval) {
|
|
230
|
+
clearInterval(this.primaryCheckInterval);
|
|
231
|
+
this.primaryCheckInterval = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private handleMessage(e: MessageEvent<MessageEnvelope>) {
|
|
236
|
+
const envelope = e.data;
|
|
237
|
+
const message = envelope.payload;
|
|
238
|
+
// If a message sent to specific recipient and not to us, ignore it
|
|
239
|
+
if (!!envelope.recipientId && envelope.recipientId !== this.id) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
switch (message.type) {
|
|
243
|
+
case 'announce':
|
|
244
|
+
if (this._isPrimary) {
|
|
245
|
+
this.broadcast({
|
|
246
|
+
type: 'primary-present',
|
|
247
|
+
payload: { connected: this._isConnected }
|
|
248
|
+
});
|
|
249
|
+
// Re-broadcast starting events for the new tab
|
|
250
|
+
Object.entries(this.startingEvents).forEach(([eventType, payload]) => {
|
|
251
|
+
this.broadcast(
|
|
252
|
+
{
|
|
253
|
+
type: 'socket-event',
|
|
254
|
+
payload: { eventType: eventType as SocketEventName, payload }
|
|
255
|
+
},
|
|
256
|
+
envelope.senderId
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
case 'primary-claim':
|
|
262
|
+
case 'primary-present':
|
|
263
|
+
this.lastPrimaryHeartbeat = envelope.timestamp;
|
|
264
|
+
if (this._isPrimary) {
|
|
265
|
+
this.logger.info(`Stepping down from primary, ${envelope.senderId} claimed it`);
|
|
266
|
+
this.stopHeartbeat();
|
|
267
|
+
this._isPrimary = false;
|
|
268
|
+
this.callbacks.onRoleChange(false);
|
|
269
|
+
this.startPrimaryCheck();
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
case 'primary-release':
|
|
273
|
+
if (!this._isPrimary) {
|
|
274
|
+
// Wait random time (0 - 300ms) before claiming "primary" role, so not all tabs try to claim at the same time
|
|
275
|
+
setTimeout(
|
|
276
|
+
() => {
|
|
277
|
+
const timeSinceRelease = Date.now() - envelope.timestamp;
|
|
278
|
+
const timeSinceLastHeartbeat = Date.now() - this.lastPrimaryHeartbeat;
|
|
279
|
+
if (timeSinceLastHeartbeat > timeSinceRelease) {
|
|
280
|
+
this.logger.info(`Primary released, becoming primary`);
|
|
281
|
+
this.stopPrimaryCheck();
|
|
282
|
+
this.becomePrimary();
|
|
283
|
+
} else {
|
|
284
|
+
this.logger.info(`Another primary exists, do nothing`);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
Math.round(Math.random() * 300)
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
case 'authentication':
|
|
292
|
+
this.callbacks.onAuthChanged(message.payload.authenticated);
|
|
293
|
+
return;
|
|
294
|
+
case 'connection-toggle':
|
|
295
|
+
if (this._isPrimary) {
|
|
296
|
+
this.logger.info(
|
|
297
|
+
`Should ${message.payload.connected ? 'connect' : 'disconnect'} socket.`
|
|
298
|
+
);
|
|
299
|
+
this.callbacks.onConnectionToggle(message.payload.connected);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
case 'socket-event': {
|
|
303
|
+
if (!this._isPrimary) {
|
|
304
|
+
this.callbacks.onMessageFromPrimary(message);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
case 'socket-send': {
|
|
309
|
+
if (this._isPrimary) {
|
|
310
|
+
this.callbacks.onSendRequest(message).then(() => {
|
|
311
|
+
//Acknowledge the request
|
|
312
|
+
this.broadcast({
|
|
313
|
+
type: 'socket-ack',
|
|
314
|
+
payload: {
|
|
315
|
+
envelopeId: envelope.id
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
case 'socket-ack': {
|
|
323
|
+
if (!this._isPrimary) {
|
|
324
|
+
if (this.ackCallbacks[message.payload.envelopeId]) {
|
|
325
|
+
this.ackCallbacks[message.payload.envelopeId]();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private broadcast(message: ChannelMessage, recipientId?: string): string {
|
|
333
|
+
const envelope: MessageEnvelope = {
|
|
334
|
+
id: getUUID(),
|
|
335
|
+
senderId: this.id,
|
|
336
|
+
timestamp: Date.now(),
|
|
337
|
+
payload: message
|
|
338
|
+
};
|
|
339
|
+
if (recipientId) {
|
|
340
|
+
envelope.recipientId = recipientId;
|
|
341
|
+
}
|
|
342
|
+
this.channel.postMessage(envelope);
|
|
343
|
+
return envelope.id;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Send a message to be transmitted over the socket (from secondary to primary)
|
|
348
|
+
*/
|
|
349
|
+
sendToSocket<T extends MessageType = MessageType>(messageType: T, data: SocketMessageMap[T]) {
|
|
350
|
+
if (this._isPrimary) {
|
|
351
|
+
throw new Error('Primary tab should send directly');
|
|
352
|
+
}
|
|
353
|
+
this.logger.debug(`Sending socket message ${messageType}`, data);
|
|
354
|
+
const messageId = this.broadcast({
|
|
355
|
+
type: 'socket-send',
|
|
356
|
+
payload: { messageType, data }
|
|
357
|
+
});
|
|
358
|
+
return new Promise<void>((resolve, reject) => {
|
|
359
|
+
const ackTimeout = setTimeout(() => {
|
|
360
|
+
//If callback is not called within 5 seconds, call it with an error
|
|
361
|
+
if (this.ackCallbacks[messageId]) {
|
|
362
|
+
this.ackCallbacks[messageId](new Error('Message delivery timeout'));
|
|
363
|
+
}
|
|
364
|
+
}, WSCoordinator.ACK_TIMEOUT);
|
|
365
|
+
this.ackCallbacks[messageId] = (error?: any) => {
|
|
366
|
+
delete this.ackCallbacks[messageId];
|
|
367
|
+
clearTimeout(ackTimeout);
|
|
368
|
+
if (error) {
|
|
369
|
+
reject(error);
|
|
370
|
+
} else {
|
|
371
|
+
resolve();
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Broadcast a socket event from primary to all secondaries
|
|
379
|
+
*/
|
|
380
|
+
broadcastSocketEvent<E extends SocketEventName = SocketEventName>(
|
|
381
|
+
eventType: E,
|
|
382
|
+
payload: SocketEventMap[E]
|
|
383
|
+
) {
|
|
384
|
+
if (!this._isPrimary) {
|
|
385
|
+
throw new Error('Only primary tab can broadcast socket events');
|
|
386
|
+
}
|
|
387
|
+
if (eventType === 'connected') {
|
|
388
|
+
this._isConnected = true;
|
|
389
|
+
} else if (eventType === 'disconnected') {
|
|
390
|
+
this._isConnected = false;
|
|
391
|
+
}
|
|
392
|
+
this.updateStartingState(eventType, payload);
|
|
393
|
+
this.logger.debug(`Broadcasting socket event ${eventType}`, payload);
|
|
394
|
+
this.broadcast({
|
|
395
|
+
type: 'socket-event',
|
|
396
|
+
payload: { eventType, payload }
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private updateStartingState<E extends SocketEventName>(eventType: E, payload: SocketEventMap[E]) {
|
|
401
|
+
if (eventType === 'authenticated') {
|
|
402
|
+
this.startingEvents.authenticated = payload as AuthenticatedData;
|
|
403
|
+
} else if (eventType === 'balanceUpdate') {
|
|
404
|
+
this.startingEvents.balanceUpdate = payload as Balances;
|
|
405
|
+
} else if (eventType === 'swarmModels') {
|
|
406
|
+
this.startingEvents.swarmModels = payload as Record<string, number>;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
isPrimary() {
|
|
411
|
+
return this._isPrimary;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Handle tab closing event
|
|
416
|
+
*/
|
|
417
|
+
private handleBeforeUnload = () => {
|
|
418
|
+
if (this._isPrimary) {
|
|
419
|
+
this.logger.info(`Client ${this.id} closing, releasing primary role`);
|
|
420
|
+
this.releasePrimary();
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export default WSCoordinator;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { IWebSocketClient, SupernetType } from '../types';
|
|
2
|
+
import { AuthManager, TokenAuthManager } from '../../../lib/AuthManager';
|
|
3
|
+
import { Logger } from '../../../lib/DefaultLogger';
|
|
4
|
+
import WebSocketClient from '../index';
|
|
5
|
+
import RestClient from '../../../lib/RestClient';
|
|
6
|
+
import { ServerDisconnectData, SocketEventMap } from '../events';
|
|
7
|
+
import WSCoordinator from './WSCoordinator';
|
|
8
|
+
import { MessageType, SocketMessageMap } from '../messages';
|
|
9
|
+
import { Heartbeat, SocketEventReceived, SendSocketMessage } from './types';
|
|
10
|
+
|
|
11
|
+
type EventInterceptor<T extends keyof SocketEventMap = keyof SocketEventMap> = (
|
|
12
|
+
eventType: T,
|
|
13
|
+
payload: SocketEventMap[T]
|
|
14
|
+
) => void;
|
|
15
|
+
|
|
16
|
+
class WrappedClient extends WebSocketClient {
|
|
17
|
+
private interceptor: EventInterceptor | undefined = undefined;
|
|
18
|
+
intercept(interceptor: EventInterceptor) {
|
|
19
|
+
this.interceptor = interceptor;
|
|
20
|
+
}
|
|
21
|
+
protected emit<T extends keyof SocketEventMap>(event: T, data: SocketEventMap[T]) {
|
|
22
|
+
super.emit(event, data);
|
|
23
|
+
if (this.interceptor) {
|
|
24
|
+
this.interceptor(event, data);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class BrowserWebSocketClient extends RestClient<SocketEventMap> implements IWebSocketClient {
|
|
30
|
+
appId: string;
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
private socketClient: WrappedClient;
|
|
33
|
+
private coordinator: WSCoordinator;
|
|
34
|
+
private isPrimary = false;
|
|
35
|
+
private _isConnected = false;
|
|
36
|
+
private _supernetType: SupernetType;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
baseUrl: string,
|
|
40
|
+
auth: AuthManager,
|
|
41
|
+
appId: string,
|
|
42
|
+
supernetType: SupernetType,
|
|
43
|
+
logger: Logger
|
|
44
|
+
) {
|
|
45
|
+
const socketClient = new WrappedClient(baseUrl, auth, appId, supernetType, logger);
|
|
46
|
+
super(socketClient.baseUrl, auth, logger);
|
|
47
|
+
this.socketClient = socketClient;
|
|
48
|
+
this.appId = appId;
|
|
49
|
+
this.baseUrl = socketClient.baseUrl;
|
|
50
|
+
this._supernetType = supernetType;
|
|
51
|
+
this.coordinator = new WSCoordinator(
|
|
52
|
+
{
|
|
53
|
+
onAuthChanged: this.handleAuthChanged.bind(this),
|
|
54
|
+
onRoleChange: this.handleRoleChange.bind(this),
|
|
55
|
+
onConnectionToggle: this.handleConnectionToggle.bind(this),
|
|
56
|
+
onMessageFromPrimary: this.handleMessageFromPrimary.bind(this),
|
|
57
|
+
onSendRequest: this.handleSendRequest.bind(this)
|
|
58
|
+
},
|
|
59
|
+
logger
|
|
60
|
+
);
|
|
61
|
+
this.auth.on('updated', this.handleAuthUpdated.bind(this));
|
|
62
|
+
this.socketClient.intercept(this.handleSocketEvent.bind(this));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get isConnected() {
|
|
66
|
+
return this.isPrimary ? this.socketClient.isConnected : this._isConnected;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get supernetType() {
|
|
70
|
+
return this.isPrimary ? this.socketClient.supernetType : this._supernetType;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async connect(): Promise<void> {
|
|
74
|
+
const isPrimary = await this.coordinator.initialize();
|
|
75
|
+
this.isPrimary = isPrimary;
|
|
76
|
+
if (isPrimary) {
|
|
77
|
+
await this.socketClient.connect();
|
|
78
|
+
} else {
|
|
79
|
+
this.coordinator.connect();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
disconnect() {
|
|
84
|
+
if (this.isPrimary) {
|
|
85
|
+
this.socketClient.disconnect();
|
|
86
|
+
} else {
|
|
87
|
+
this.coordinator.disconnect();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async switchNetwork(supernetType: SupernetType): Promise<SupernetType> {
|
|
92
|
+
if (this.isPrimary) {
|
|
93
|
+
return this.socketClient.switchNetwork(supernetType);
|
|
94
|
+
}
|
|
95
|
+
return new Promise<SupernetType>(async (resolve) => {
|
|
96
|
+
this.once('changeNetwork', ({ network }) => {
|
|
97
|
+
this._supernetType = network;
|
|
98
|
+
resolve(network);
|
|
99
|
+
});
|
|
100
|
+
await this.send('changeNetwork', supernetType);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async send<T extends MessageType>(messageType: T, data: SocketMessageMap[T]): Promise<void> {
|
|
105
|
+
if (this.isPrimary) {
|
|
106
|
+
return this.socketClient.send(messageType, data);
|
|
107
|
+
}
|
|
108
|
+
return this.coordinator.sendToSocket(messageType, data);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private handleAuthChanged(isAuthenticated: boolean) {
|
|
112
|
+
if (this.auth instanceof TokenAuthManager) {
|
|
113
|
+
throw new Error('TokenAuthManager is not supported in multi client mode');
|
|
114
|
+
}
|
|
115
|
+
if (this.auth.isAuthenticated !== isAuthenticated) {
|
|
116
|
+
if (isAuthenticated) {
|
|
117
|
+
this.auth.authenticate();
|
|
118
|
+
} else {
|
|
119
|
+
this.auth.clear();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private handleSocketEvent(eventType: keyof SocketEventMap, payload: any) {
|
|
125
|
+
if (this.isPrimary) {
|
|
126
|
+
this.coordinator.broadcastSocketEvent(eventType, payload);
|
|
127
|
+
this.emit(eventType, payload);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private handleAuthUpdated(isAuthenticated: boolean) {
|
|
132
|
+
this.coordinator.changeAuthState(isAuthenticated);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private handleRoleChange(isPrimary: boolean) {
|
|
136
|
+
this.isPrimary = isPrimary;
|
|
137
|
+
if (isPrimary && !this.socketClient.isConnected && this.isConnected) {
|
|
138
|
+
this.socketClient.connect();
|
|
139
|
+
} else if (!isPrimary && this.socketClient.isConnected) {
|
|
140
|
+
this.socketClient.disconnect();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private handleConnectionToggle(isConnected: boolean) {
|
|
145
|
+
if (this.isPrimary) {
|
|
146
|
+
if (isConnected && !this.socketClient.isConnected) {
|
|
147
|
+
this.socketClient.connect();
|
|
148
|
+
} else if (!isConnected && this.socketClient.isConnected) {
|
|
149
|
+
this.socketClient.disconnect();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Emit events from socket to listeners
|
|
156
|
+
* @param message
|
|
157
|
+
*/
|
|
158
|
+
private handleMessageFromPrimary(message: SocketEventReceived | Heartbeat) {
|
|
159
|
+
if (this.isPrimary) {
|
|
160
|
+
throw new Error('Received message from primary socket, but it is primary');
|
|
161
|
+
}
|
|
162
|
+
this._logger.debug('Received message from primary client:', message.type, message.payload);
|
|
163
|
+
if (message.type === 'primary-present') {
|
|
164
|
+
const shouldUpdateStatus = message.payload.connected !== this._isConnected;
|
|
165
|
+
if (shouldUpdateStatus) {
|
|
166
|
+
this._isConnected = message.payload.connected;
|
|
167
|
+
if (message.payload.connected) {
|
|
168
|
+
this._logger.debug('Primary socket is active emitting connected event');
|
|
169
|
+
this.emit('connected', { network: this._supernetType });
|
|
170
|
+
} else {
|
|
171
|
+
this._logger.debug('Primary socket is inactive emitting disconnected event');
|
|
172
|
+
this.emit('disconnected', { code: 5000, reason: 'Primary socket disconnected' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const event = message.payload;
|
|
178
|
+
switch (event.eventType) {
|
|
179
|
+
case 'connected': {
|
|
180
|
+
if (!this._isConnected) {
|
|
181
|
+
this._isConnected = true;
|
|
182
|
+
this.emit('connected', { network: this._supernetType });
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
case 'disconnected': {
|
|
187
|
+
this._isConnected = false;
|
|
188
|
+
this.emit('disconnected', event.payload as ServerDisconnectData);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
default: {
|
|
192
|
+
this.emit(event.eventType, event.payload as any);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private handleSendRequest(message: SendSocketMessage) {
|
|
198
|
+
if (!this.isPrimary) {
|
|
199
|
+
// Should never happen, but just in case
|
|
200
|
+
return Promise.resolve();
|
|
201
|
+
}
|
|
202
|
+
return this.socketClient.send(message.payload.messageType, message.payload.data);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default BrowserWebSocketClient;
|