@arcblock/ws 1.28.9 → 1.29.0

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.
@@ -1,442 +0,0 @@
1
- const EventEmitter = require('node:events');
2
- const cluster = require('node:cluster');
3
-
4
- const get = require('lodash/get');
5
- const WebSocket = require('ws');
6
- const eventHub = cluster.isMaster ? require('@arcblock/event-hub/single') : require('@arcblock/event-hub');
7
-
8
- const createLogger = require('../logger');
9
-
10
- const nanoid = (length = 16) => [...Array(length)].map(() => Math.random().toString(36)[2]).join('');
11
-
12
- const sleep = (timeout) =>
13
- new Promise((resolve) => {
14
- setTimeout(resolve, timeout);
15
- });
16
-
17
- const reply = ({ socket, topic, event, data = {}, status = 'ok', ref = '', joinRef = '' }) => {
18
- if (socket.readyState === WebSocket.OPEN) {
19
- const res = JSON.stringify([joinRef, ref, topic, event, { status, response: data }]);
20
- socket.send(res);
21
- }
22
- };
23
-
24
- const noop = () => {};
25
- const defaultHooks = {
26
- authenticateJoinChannel: noop,
27
- preJoinChannel: noop,
28
- postJoinChannel: noop,
29
- preLeaveChannel: noop,
30
- postLeaveChannel: noop,
31
- postBroadcast: noop,
32
- postSend: noop,
33
- receiveMessage: noop,
34
- };
35
-
36
- const refreshHeartbeat = (socket) => {
37
- socket.heartbeatAt = Date.now();
38
- };
39
-
40
- const HEARTBEAT_TIMEOUT = 5 * 60 * 1000;
41
-
42
- /**
43
- * Create a websocket server
44
- *
45
- * @param {Object} opts
46
- * @param {String} opts.pathname - which path to mount the socket server
47
- * @param {Object} opts.authenticate - authentication function to be called on connection
48
- * @param {Object} opts.hooks - hooks to be called on events
49
- * @param {Object} opts.logger - logger used to log messages
50
- * @param {Object} opts.broadcastEventName - used in cluster mode, default is '@arcblock/ws:broadcast'
51
- * @param {Object} opts.heartbeatTimeout - maximum non-response time of a connection socket
52
- * @class WsServer
53
- * @extends {EventEmitter}
54
- */
55
- class WsServer extends EventEmitter {
56
- constructor(opts = {}) {
57
- super();
58
- this.pathname = opts.pathname;
59
- this.authenticate = opts.authenticate || null;
60
- this.hooks = Object.assign({}, defaultHooks, opts.hooks || {});
61
- this.logger = opts.logger || createLogger('server', opts.silent);
62
- this.skipLogOnHookError = opts.skipLogOnHookError || false;
63
- this.heartbeatTimeout = opts.heartbeatTimeout || HEARTBEAT_TIMEOUT;
64
-
65
- this.wss = new WebSocket.Server({ noServer: true, clientTracking: false });
66
- this.wss.on('connection', this.onWssConnection.bind(this));
67
- this.wss.on('close', this.onWssClose.bind(this));
68
- this.wss.on('error', this.onWssError.bind(this));
69
-
70
- this.topics = {}; // <topic>: Set<socket>
71
-
72
- this.broadcastEventName = opts.broadcastEventName || '@arcblock/ws:broadcast';
73
- eventHub.on(this.broadcastEventName, (data) => this._doBroadCast(data));
74
- }
75
-
76
- attach(server) {
77
- server.on('upgrade', this.onConnect.bind(this));
78
- }
79
-
80
- onConnect(request, socket, head) {
81
- const { pathname } = new URL(request.url, `http://${request.headers.host || 'unknown'}`);
82
- this.logger.debug('connect attempt', { pathname });
83
- if (this.pathname && pathname !== this.pathname) {
84
- socket.write('HTTP/1.1 404 Pathname mismatch\r\n\r\n');
85
- socket.destroy();
86
- return;
87
- }
88
-
89
- if (!this.authenticate) {
90
- this.wss.handleUpgrade(request, socket, head, (ws) => {
91
- this.wss.emit('connection', ws, request);
92
- });
93
- return;
94
- }
95
-
96
- this.authenticate(request, (err, authInfo) => {
97
- if (err) {
98
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
99
- socket.destroy();
100
- return;
101
- }
102
- this.wss.handleUpgrade(request, socket, head, (ws) => {
103
- ws.authInfo = authInfo;
104
- this.wss.emit('connection', ws, request);
105
- });
106
- });
107
- }
108
-
109
- /**
110
- * Broadcast message to all subscribers of a topic, can be used as
111
- * - broadcast(event, data) ==> broadcast(event, event, data)
112
- * - broadcast(topic, event, data)
113
- * - broadcast(topic, event, data, options)
114
- */
115
- async broadcast(...args) {
116
- let topic;
117
- let event;
118
- let data;
119
- let options = {};
120
- let cb = () => {};
121
-
122
- if (typeof args[args.length - 1] === 'function') {
123
- cb = args.pop();
124
- }
125
-
126
- if (args.length < 2) {
127
- throw new Error('Broadcasting requires at least 2 arguments');
128
- }
129
- if (args.length === 2) {
130
- [event, data] = args;
131
- topic = event;
132
- } else if (args.length === 3) {
133
- [topic, event, data] = args;
134
- } else {
135
- [topic, event, data, options] = args;
136
- }
137
-
138
- const enableLog = !!options.enableLog;
139
- const { socketFilters, noCluster } = options;
140
- const replyId = nanoid();
141
-
142
- // Count of clients what will receive the message
143
- // The count is NOT reliable
144
- let count = 0;
145
-
146
- if (noCluster) {
147
- const { count: c } = this._doBroadCast({ topic, event, data, enableLog, socketFilters });
148
- count = c;
149
- } else {
150
- eventHub.on(replyId, ({ count: c } = {}) => {
151
- if (c) {
152
- count += c;
153
- }
154
- });
155
-
156
- eventHub.broadcast(this.broadcastEventName, { topic, event, data, options, enableLog, replyId, socketFilters });
157
-
158
- // wait 600ms for message sending by each process
159
- await sleep(600);
160
- eventHub.off(replyId);
161
- }
162
-
163
- const opts = { count, topic, event, data, options };
164
- cb(opts);
165
- try {
166
- await this.hooks.postBroadcast(opts);
167
- } catch (error) {
168
- if (!this.skipLogOnHookError) {
169
- this.logger.error('postBroadcast error', { error });
170
- }
171
- }
172
- }
173
-
174
- async _doBroadCast({ topic, event, data, enableLog, replyId, socketFilters } = {}) {
175
- try {
176
- let count = 0;
177
-
178
- if (this.topics[topic]?.size) {
179
- let conditions = null;
180
- if (socketFilters && Object.keys(socketFilters).length) {
181
- conditions = Object.entries(socketFilters);
182
- }
183
-
184
- this.topics[topic].forEach((socket) => {
185
- const noHeartbeatTime = Date.now() - socket.heartbeatAt;
186
- if (noHeartbeatTime > this.heartbeatTimeout) {
187
- this.logger.error(`Socket has no heartbeat within ${Math.floor(noHeartbeatTime / 1000)} seconds`, {
188
- topic,
189
- id: socket.id,
190
- });
191
- this.topics[topic].delete(socket);
192
- return;
193
- }
194
-
195
- if (conditions && !conditions.every(([key, value]) => get(socket, key) === value)) {
196
- return;
197
- }
198
-
199
- count++;
200
- if (enableLog) {
201
- this.logger.info('broadcast message to', { topic, event, id: socket.id });
202
- }
203
- reply({ socket, topic, event, data });
204
- });
205
- }
206
-
207
- if (count > 0 && replyId) {
208
- eventHub.broadcast(replyId, { count });
209
- }
210
-
211
- return { count };
212
- } catch (error) {
213
- this.logger.error('_doBroadcast error', { error });
214
- return { error };
215
- }
216
- }
217
-
218
- /**
219
- * Send message to 1 subscriber of a topic, can be used as
220
- * - send(socket, event, data)
221
- * - send(socket, topic, event, data)
222
- * - send(socket, topic, event, data, options)
223
- */
224
- async send(...args) {
225
- let socket;
226
- let topic;
227
- let event;
228
- let data;
229
- let options = {};
230
-
231
- if (args.length < 3) {
232
- throw new Error('send requires at least 3 arguments');
233
- }
234
- if (args.length === 3) {
235
- [socket, event, data] = args;
236
- topic = event;
237
- } else if (args.length === 4) {
238
- [socket, topic, event, data] = args;
239
- } else {
240
- [socket, topic, event, data, options] = args;
241
- }
242
-
243
- const opts = { enableLog: true, ...options };
244
- if (!socket) {
245
- this.logger.error('socket does not exist');
246
- return;
247
- }
248
-
249
- if (opts.enableLog) {
250
- this.logger.info('send message to', { topic, event, id: socket.id });
251
- }
252
-
253
- reply({ socket, topic, event, data });
254
-
255
- try {
256
- await this.hooks.postSend({ topic, event, data, options });
257
- } catch (error) {
258
- if (!this.skipLogOnHookError) {
259
- this.logger.error('postSend error', { error });
260
- }
261
- }
262
- }
263
-
264
- /**
265
- * private
266
- */
267
- async onWssConnection(socket) {
268
- socket.id = nanoid();
269
- socket.channel = {}; // This should be renamed to channels
270
-
271
- refreshHeartbeat(socket);
272
- this.logger.debug('socket connected', { id: socket.id });
273
-
274
- socket.on('message', async (msg) => {
275
- this.logger.debug('socket onmessage', msg.toString());
276
- let joinRef;
277
- let ref;
278
- let topic;
279
- let event;
280
- let payload;
281
- try {
282
- [joinRef, ref, topic, event, payload] = JSON.parse(msg);
283
- } catch (err) {
284
- this.logger.error('parse socket message error', { id: socket.id, error: err });
285
- return;
286
- }
287
-
288
- if (!topic || !event) {
289
- this.logger.warn('Invalid message format, topic/event fields are required');
290
- return;
291
- }
292
-
293
- if (topic === 'phoenix' && event === 'heartbeat') {
294
- // heartbeat
295
- reply({ socket, topic, event, ref });
296
- refreshHeartbeat(socket);
297
- return;
298
- }
299
-
300
- if (event === 'phx_join') {
301
- // pre hook
302
- try {
303
- const authInfo = await this.hooks.authenticateJoinChannel({ socket, joinRef, ref, topic, event, payload });
304
- await this.hooks.preJoinChannel({ socket, joinRef, ref, topic, event, payload });
305
- socket.channel[topic] = { authInfo };
306
- } catch (error) {
307
- if (!this.skipLogOnHookError) {
308
- this.logger.error('preJoinChannel error', { error });
309
- }
310
- reply({
311
- socket,
312
- topic,
313
- event: `chan_reply_${ref}`,
314
- data: { message: error.message },
315
- status: 'error',
316
- ref,
317
- joinRef,
318
- });
319
- return;
320
- }
321
-
322
- // join
323
- if (!this.topics[topic]) {
324
- this.topics[topic] = new Set();
325
- }
326
- this.topics[topic].add(socket);
327
-
328
- reply({ socket, topic, event: `chan_reply_${ref}`, ref, joinRef });
329
- this.emit('channel.join', { socket, topic, event, payload });
330
-
331
- // post hook
332
- try {
333
- await this.hooks.postJoinChannel({ socket, joinRef, ref, topic, event, payload });
334
- } catch (error) {
335
- if (!this.skipLogOnHookError) {
336
- this.logger.error('postJoinChannel error', { error });
337
- }
338
- }
339
-
340
- return;
341
- }
342
-
343
- if (event === 'phx_leave') {
344
- // pre hook
345
- try {
346
- await this.hooks.preLeaveChannel({ socket, joinRef, ref, topic, event, payload });
347
- } catch (error) {
348
- if (!this.skipLogOnHookError) {
349
- this.logger.error('preLeaveChannel error', { error });
350
- }
351
- reply({
352
- socket,
353
- topic,
354
- event: `chan_reply_${ref}`,
355
- data: { message: error.message },
356
- status: 'error',
357
- ref,
358
- joinRef,
359
- });
360
- return;
361
- }
362
-
363
- // leave
364
- this._leaveChannel(socket, topic);
365
- reply({ socket, topic, event: `chan_reply_${ref}`, ref, joinRef });
366
-
367
- // post hook
368
- try {
369
- await this.hooks.postLeaveChannel({ socket, joinRef, ref, topic, event, payload });
370
- } catch (error) {
371
- if (!this.skipLogOnHookError) {
372
- this.logger.error('postLeaveChannel error', { error });
373
- }
374
- }
375
-
376
- return;
377
- }
378
-
379
- // pre hook
380
- try {
381
- await this.hooks.receiveMessage({ socket, joinRef, ref, topic, event, payload });
382
- } catch (error) {
383
- if (!this.skipLogOnHookError) {
384
- this.logger.error('receiveMessage error', { error });
385
- }
386
- reply({
387
- socket,
388
- topic,
389
- event: `chan_reply_${ref}`,
390
- data: { message: error.message },
391
- status: 'error',
392
- ref,
393
- joinRef,
394
- });
395
- return;
396
- }
397
-
398
- reply({ socket, topic, event: `chan_reply_${ref}`, ref, joinRef });
399
- });
400
-
401
- socket.on('close', () => {
402
- this.logger.debug('socket onclose', { id: socket.id });
403
-
404
- Object.keys(this.topics).forEach((topic) => this._leaveChannel(socket, topic));
405
- });
406
-
407
- socket.on('error', (err) => {
408
- this.logger.error('socket onerror', { id: socket.id, error: err });
409
- Object.keys(this.topics).forEach((topic) => this._leaveChannel(socket, topic));
410
- });
411
- }
412
-
413
- /**
414
- * private
415
- */
416
- onWssClose() {
417
- this.logger.debug('ws server onclose');
418
- this.emit('close');
419
- }
420
-
421
- /**
422
- * private
423
- */
424
- onWssError(error) {
425
- this.logger.error('ws server error', { error });
426
- this.emit('error', error);
427
- }
428
-
429
- _leaveChannel(socket, topic) {
430
- // unsubscribe
431
- if (this.topics[topic]) {
432
- this.topics[topic].delete(socket);
433
- }
434
-
435
- this.emit('channel.leave', { socket, topic });
436
- if (!this.topics[topic] || !this.topics[topic].size) {
437
- this.emit('channel.destroy', { socket, topic });
438
- }
439
- }
440
- }
441
-
442
- module.exports = WsServer;