@arcblock/ws 1.6.10

Sign up to get free protection for your applications and to get access to all the features.
package/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2018-2019 ArcBlock
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # Blocklet Server WebSocket
2
+
3
+ > Blocklet Server PubSub base on Websocket and Phoenix Protocol
4
+
5
+ ## Usage
6
+
7
+ 1. WsServer
8
+
9
+ ```javascript
10
+ const { WsServer } = require('@arcblock/ws');
11
+
12
+ /**
13
+ * @params {Object} opts
14
+ * @params {http.Server} opts.httpServer
15
+ * @params {String} opts.pathname default to '/websocket'
16
+ * @params {Function} opts.authenticate
17
+ */
18
+ const wsServer = new WsServer({
19
+ httpServer: http.createServer(),
20
+ authenticate: (req, cb) => {
21
+ const { searchParams } = new URL(req.url, `http://${req.headers.host || 'unknown'}`);
22
+ const token = searchParams.get('token');
23
+ if (!token) {
24
+ cb(new Error('token not found'), null);
25
+ return;
26
+ }
27
+
28
+ // custom logic for validate token
29
+ const authInfo = validateToken(token);
30
+
31
+ // if validate success
32
+ cb(null, authInfo);
33
+
34
+ // if validate error
35
+ cb(new Error('validate fail'), null);
36
+ },
37
+ });
38
+
39
+ // attach to httpServer(httpServer has passed by constructor)
40
+ wsSerer.attach();
41
+
42
+ // push message
43
+ wsServer.push('blocklet.installed', data);
44
+ wsServer.push('notification.create', data);
45
+ ```
46
+
47
+ 2. WsClient
48
+
49
+ WsClient is inherited from [Phoenix](https://www.npmjs.com/package/phoenix)([source](https://github.com/phoenixframework/phoenix/blob/master/assets/js/phoenix.js)),
50
+
51
+ ```javascript
52
+ import WsClient from '@arcblock/ws/lib/client';
53
+
54
+ // create instance
55
+ const socket = new WsClient(`//${window.location.hostname}`, {
56
+ // params will be passed to server through url
57
+ params: () => ({
58
+ // token is used for authentication
59
+ token: window.localStorage.getItem('abt_node_login_token'),
60
+ }),
61
+
62
+ // Defaults to none
63
+ logger: (type, msg, data) => console.log(type, msg, data),
64
+ });
65
+
66
+ // connect
67
+ socket.connect();
68
+
69
+ // add subscriber
70
+ socket.on('blocklet.installed', callback1);
71
+ socket.on('notification.create', callback2);
72
+
73
+ // remove subscriber
74
+ socket.off('blocklet.installed', callback1);
75
+ socket.off('notification.create', callback2);
76
+
77
+ // disconnect
78
+ socket.disconnect(() => {
79
+ // after disconnected...
80
+ });
81
+ ```
82
+
83
+ 3. Hooks
84
+
85
+ It's very simple to create hooks in react apps.
package/lib/browser.js ADDED
@@ -0,0 +1,5 @@
1
+ const WsClient = require('./client/browser');
2
+
3
+ module.exports = {
4
+ WsClient,
5
+ };
@@ -0,0 +1,108 @@
1
+ const createLogger = require('../logger');
2
+
3
+ module.exports = (Socket, EventEmitter, transport) => {
4
+ return class WsClient extends Socket {
5
+ constructor(endpoint, opts = {}) {
6
+ super(endpoint, { transport, ...opts });
7
+
8
+ this._logger = createLogger('client', opts.silent);
9
+ this.emitter = new EventEmitter();
10
+
11
+ this.onOpen(() => {
12
+ this._logger.debug('socket open', endpoint);
13
+ });
14
+ this.onClose(() => {
15
+ this._logger.debug('socket close', endpoint);
16
+ });
17
+ this.onError((err) => {
18
+ this._logger.error('socket error', err.error);
19
+ });
20
+ this.onMessage((message) => {
21
+ this._logger.debug('socket message', { message });
22
+ });
23
+ }
24
+
25
+ on(event, handler) {
26
+ this.ensureJoinChannel(event);
27
+ this.emitter.on(event, handler);
28
+ }
29
+
30
+ off(event, handler) {
31
+ if (handler) {
32
+ this.emitter.off(event, handler);
33
+ } else {
34
+ this.emitter.removeAllListeners(event);
35
+ }
36
+
37
+ this.ensureLeaveChannel(event);
38
+ }
39
+
40
+ disconnect(callback, code, reason) {
41
+ this.emitter.eventNames().forEach((event) => {
42
+ this.emitter.removeAllListeners(event);
43
+ });
44
+ super.disconnect(callback, code, reason);
45
+ }
46
+
47
+ /**
48
+ * private
49
+ */
50
+ ensureJoinChannel(event) {
51
+ const count = this.emitter.listenerCount(event);
52
+ if (count > 0) {
53
+ return;
54
+ }
55
+
56
+ const topic = event;
57
+ const channel = this.channel(topic);
58
+ channel
59
+ .join()
60
+ .receive('ok', (message) => {
61
+ this._logger.debug('join success', { event, message });
62
+ })
63
+ .receive('error', (error) => {
64
+ this._logger.error('join error', { event, error });
65
+ })
66
+ .receive('timeout', () => {
67
+ this._logger.debug('join timeout', { event });
68
+ });
69
+ channel.on(event, ({ status, response: data }) => {
70
+ if (status === 'ok') {
71
+ this.emitter.emit(event, data);
72
+ } else {
73
+ this._logger.debug('response error', { event, status, data });
74
+ }
75
+ });
76
+ }
77
+
78
+ /**
79
+ * private
80
+ */
81
+ ensureLeaveChannel(event) {
82
+ const count = this.emitter.listenerCount(event);
83
+ if (count > 0) {
84
+ return;
85
+ }
86
+
87
+ const topic = event;
88
+ const channel = this.channels.find((c) => c.topic === topic);
89
+ if (!channel) {
90
+ return;
91
+ }
92
+
93
+ this.remove(channel);
94
+ channel
95
+ .leave()
96
+ .receive('ok', (message) => {
97
+ this._logger.debug('leave success', { event, message });
98
+ })
99
+ .receive('error', (err) => {
100
+ this._logger.error('leave error', { event, err });
101
+ })
102
+ .receive('timeout', () => {
103
+ this._logger.debug('leave timeout', { event });
104
+ });
105
+ channel.off(event);
106
+ }
107
+ };
108
+ };
@@ -0,0 +1,6 @@
1
+ const EventEmitter = require('eventemitter3');
2
+ const { Socket } = require('phoenix');
3
+
4
+ const createClient = require('./base');
5
+
6
+ module.exports = createClient(Socket, EventEmitter);
@@ -0,0 +1,7 @@
1
+ const EventEmitter = require('events');
2
+ const { Socket } = require('phoenix');
3
+ const WebSocket = require('ws');
4
+
5
+ const createClient = require('./base');
6
+
7
+ module.exports = createClient(Socket, EventEmitter, WebSocket);
package/lib/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const WsServer = require('./server');
2
+ const WsClient = require('./client');
3
+
4
+ module.exports = {
5
+ WsServer,
6
+ WsClient,
7
+ };
package/lib/logger.js ADDED
@@ -0,0 +1,12 @@
1
+ const debug = require('debug');
2
+
3
+ module.exports = (subModule, silent = false) => {
4
+ const d = debug(['@arcblock/ws', subModule].join(':'));
5
+ return {
6
+ debug: d,
7
+ warn: d,
8
+ info: d,
9
+ trace: silent ? d : console.error,
10
+ error: silent ? d : console.error,
11
+ };
12
+ };
@@ -0,0 +1,381 @@
1
+ const EventEmitter = require('events');
2
+ const cluster = require('cluster');
3
+
4
+ const uuid = require('uuid');
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 sleep = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout));
11
+
12
+ const reply = (socket, topic, event, response, status = 'ok') => {
13
+ if (socket.readyState === WebSocket.OPEN) {
14
+ const res = JSON.stringify([socket.joinRef, socket.ref, topic, event, { status, response }]);
15
+ socket.send(res);
16
+ }
17
+ };
18
+
19
+ const noop = () => {};
20
+ const defaultHooks = {
21
+ preJoinChannel: noop,
22
+ postJoinChannel: noop,
23
+ preLeaveChannel: noop,
24
+ postLeaveChannel: noop,
25
+ postBroadcast: noop,
26
+ postSend: noop,
27
+ receiveMessage: noop,
28
+ };
29
+
30
+ const refreshHeartbeat = (socket) => {
31
+ socket.heartbeatAt = Date.now();
32
+ };
33
+
34
+ const HEARTBEAT_TIMEOUT = 60 * 1000;
35
+
36
+ /**
37
+ * Create a websocket server
38
+ *
39
+ * @param {Object} opts
40
+ * @param {String} opts.pathname - which path to mount the socket server
41
+ * @param {Object} opts.authenticate - authentication function to be called on connection
42
+ * @param {Object} opts.hooks - hooks to be called on events
43
+ * @param {Object} opts.logger - logger used to log messages
44
+ * @param {Object} opts.broadcastEventName - used in cluster mode, default is '@arcblock/ws:broadcast'
45
+ * @param {Object} opts.heartbeatTimeout - maximum non-response time of a connection socket
46
+ * @class WsServer
47
+ * @extends {EventEmitter}
48
+ */
49
+ class WsServer extends EventEmitter {
50
+ constructor(opts = {}) {
51
+ super();
52
+ this.pathname = opts.pathname;
53
+ this.authenticate = opts.authenticate || null;
54
+ this.hooks = Object.assign({}, defaultHooks, opts.hooks || {});
55
+ this.logger = opts.logger || createLogger('server', opts.silent);
56
+ this.heartbeatTimeout = opts.heartbeatTimeout || HEARTBEAT_TIMEOUT;
57
+
58
+ this.wss = new WebSocket.Server({ noServer: true, clientTracking: false });
59
+ this.wss.on('connection', this.onWssConnection.bind(this));
60
+ this.wss.on('close', this.onWssClose.bind(this));
61
+ this.wss.on('error', this.onWssError.bind(this));
62
+
63
+ this.topics = {}; // <topic>: Set<socket>
64
+
65
+ this.broadcastEventName = opts.broadcastEventName || '@arcblock/ws:broadcast';
66
+ eventHub.on(this.broadcastEventName, (data) => this._doBroadCast(data));
67
+ }
68
+
69
+ attach(server) {
70
+ server.on('upgrade', this.onConnect.bind(this));
71
+ }
72
+
73
+ onConnect(request, socket, head) {
74
+ const { pathname } = new URL(request.url, `http://${request.headers.host || 'unknown'}`);
75
+ this.logger.debug('connect attempt', { pathname });
76
+ if (this.pathname && pathname !== this.pathname) {
77
+ socket.destroy();
78
+ return;
79
+ }
80
+
81
+ if (!this.authenticate) {
82
+ this.wss.handleUpgrade(request, socket, head, (ws) => {
83
+ this.wss.emit('connection', ws);
84
+ });
85
+ return;
86
+ }
87
+
88
+ this.authenticate(request, (err, authInfo) => {
89
+ if (err) {
90
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
91
+ socket.destroy();
92
+ return;
93
+ }
94
+ this.wss.handleUpgrade(request, socket, head, (ws) => {
95
+ ws.authInfo = authInfo;
96
+ this.wss.emit('connection', ws);
97
+ });
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Broadcast message to all subscribers of a topic, can be used as
103
+ * - broadcast(event, data)
104
+ * - broadcast(topic, event, data)
105
+ * - broadcast(topic, event, data, options)
106
+ */
107
+ async broadcast(...args) {
108
+ let topic;
109
+ let event;
110
+ let data;
111
+ let options = {};
112
+ let cb = () => {};
113
+
114
+ if (typeof args[args.length - 1] === 'function') {
115
+ cb = args.pop();
116
+ }
117
+
118
+ if (args.length < 2) {
119
+ throw new Error('Broadcasting requires at least 2 arguments');
120
+ }
121
+ if (args.length === 2) {
122
+ [event, data] = args;
123
+ topic = event;
124
+ } else if (args.length === 3) {
125
+ [topic, event, data] = args;
126
+ } else {
127
+ [topic, event, data, options] = args;
128
+ }
129
+
130
+ const enableLog = options.enableLog !== undefined ? !!options.enableLog : true;
131
+ const replyId = uuid.v4();
132
+
133
+ // Count of clients what will receive the message
134
+ // The count is NOT reliable
135
+ let count = 0;
136
+ eventHub.on(replyId, ({ count: c } = {}) => {
137
+ if (c) {
138
+ count += c;
139
+ }
140
+ });
141
+
142
+ eventHub.broadcast(this.broadcastEventName, { topic, event, data, options, enableLog, replyId });
143
+
144
+ // wait 600ms for message sending by each process
145
+ await sleep(600);
146
+ eventHub.off(replyId);
147
+
148
+ const opts = { count, topic, event, data, options };
149
+ cb(opts);
150
+ try {
151
+ await this.hooks.postBroadcast(opts);
152
+ } catch (error) {
153
+ this.logger.error('postBroadcast error', { error });
154
+ }
155
+ }
156
+
157
+ async _doBroadCast({ topic, event, data, enableLog, replyId } = {}) {
158
+ try {
159
+ let count = 0;
160
+
161
+ if (this.topics[topic] && this.topics[topic].size) {
162
+ this.topics[topic].forEach((socket) => {
163
+ const noHeartbeatTime = Date.now() - socket.heartbeatAt;
164
+ if (noHeartbeatTime > this.heartbeatTimeout) {
165
+ this.logger.error(`Socket has no heartbeat within ${Math.floor(noHeartbeatTime / 1000)} seconds`, {
166
+ topic,
167
+ id: socket.id,
168
+ });
169
+ this.topics[topic].delete(socket);
170
+ return;
171
+ }
172
+
173
+ count++;
174
+ if (enableLog) {
175
+ this.logger.info('broadcast message to', { topic, event, id: socket.id });
176
+ }
177
+ reply(socket, topic, event, data);
178
+ });
179
+ }
180
+
181
+ if (count === 0 && enableLog) {
182
+ this.logger.info('no connections when broadcast message', { topic, event });
183
+ }
184
+
185
+ if (count > 0 && replyId) {
186
+ eventHub.broadcast(replyId, { count });
187
+ }
188
+ } catch (error) {
189
+ this.logger.error('_doBroadcast error', { error });
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Send message to 1 subscriber of a topic, can be used as
195
+ * - send(socket, event, data)
196
+ * - send(socket, topic, event, data)
197
+ * - send(socket, topic, event, data, options)
198
+ */
199
+ async send(...args) {
200
+ let socket;
201
+ let topic;
202
+ let event;
203
+ let data;
204
+ let options = {};
205
+
206
+ if (args.length < 3) {
207
+ throw new Error('send requires at least 3 arguments');
208
+ }
209
+ if (args.length === 3) {
210
+ [socket, event, data] = args;
211
+ topic = event;
212
+ } else if (args.length === 4) {
213
+ [socket, topic, event, data] = args;
214
+ } else {
215
+ [socket, topic, event, data, options] = args;
216
+ }
217
+
218
+ const opts = { enableLog: true, ...options };
219
+ if (!socket) {
220
+ this.logger.error('socket does not exist');
221
+ return;
222
+ }
223
+
224
+ if (opts.enableLog) {
225
+ this.logger.info('send message to', { topic, event, id: socket.id });
226
+ }
227
+
228
+ reply(socket, topic, event, data);
229
+
230
+ try {
231
+ await this.hooks.postSend({ topic, event, data, options });
232
+ } catch (error) {
233
+ this.logger.error('postSend error', { error });
234
+ }
235
+ }
236
+
237
+ /**
238
+ * private
239
+ * @param {socket} socket
240
+ */
241
+ async onWssConnection(socket) {
242
+ socket.id = uuid.v4();
243
+ refreshHeartbeat(socket);
244
+ this.logger.debug('socket connected', { id: socket.id });
245
+
246
+ socket.on('message', async (msg) => {
247
+ this.logger.debug('socket onmessage', { msg });
248
+ let joinRef;
249
+ let ref;
250
+ let topic;
251
+ let event;
252
+ let payload;
253
+ try {
254
+ [joinRef, ref, topic, event, payload] = JSON.parse(msg);
255
+ } catch (err) {
256
+ this.logger.error('parse socket message error', { id: socket.id, error: err });
257
+ return;
258
+ }
259
+
260
+ if (!topic || !event) {
261
+ this.logger.warn('Invalid message format, topic/event fields are required');
262
+ return;
263
+ }
264
+
265
+ socket.joinRef = joinRef;
266
+ socket.ref = ref;
267
+
268
+ if (topic === 'phoenix' && event === 'heartbeat') {
269
+ // heartbeat
270
+ reply(socket, topic, event);
271
+ refreshHeartbeat(socket);
272
+ return;
273
+ }
274
+
275
+ if (event === 'phx_join') {
276
+ // pre hook
277
+ try {
278
+ await this.hooks.preJoinChannel({ joinRef, ref, topic, event, payload });
279
+ } catch (error) {
280
+ this.logger.error('preJoinChannel error', { error });
281
+ reply(socket, topic, `chan_reply_${ref}`, { message: error.message }, 'error');
282
+ return;
283
+ }
284
+
285
+ // join
286
+ if (!this.topics[topic]) {
287
+ this.topics[topic] = new Set();
288
+ }
289
+ this.topics[topic].add(socket);
290
+
291
+ reply(socket, topic, `chan_reply_${ref}`);
292
+ this.emit('channel.join', { socket, topic, event, payload });
293
+
294
+ // post hook
295
+ try {
296
+ await this.hooks.postJoinChannel({ joinRef, ref, topic, event, payload });
297
+ } catch (error) {
298
+ this.logger.error('postJoinChannel error', { error });
299
+ }
300
+
301
+ return;
302
+ }
303
+
304
+ if (event === 'phx_leave') {
305
+ // pre hook
306
+ try {
307
+ await this.hooks.preLeaveChannel({ joinRef, ref, topic, event, payload });
308
+ } catch (error) {
309
+ this.logger.error('preLeaveChannel error', { error });
310
+ reply(socket, topic, `chan_reply_${ref}`, { message: error.message }, 'error');
311
+ return;
312
+ }
313
+
314
+ // leave
315
+ this._leaveChannel(socket, topic);
316
+ reply(socket, topic, `chan_reply_${ref}`);
317
+
318
+ // post hook
319
+ try {
320
+ await this.hooks.postLeaveChannel({ joinRef, ref, topic, event, payload });
321
+ } catch (error) {
322
+ this.logger.error('postLeaveChannel error', { error });
323
+ }
324
+
325
+ return;
326
+ }
327
+
328
+ // pre hook
329
+ try {
330
+ await this.hooks.receiveMessage({ socket, joinRef, ref, topic, event, payload });
331
+ } catch (error) {
332
+ this.logger.error('receiveMessage error', { error });
333
+ reply(socket, topic, `chan_reply_${ref}`, { message: error.message }, 'error');
334
+ return;
335
+ }
336
+
337
+ reply(socket, topic, `chan_reply_${ref}`, {});
338
+ });
339
+
340
+ socket.on('close', () => {
341
+ this.logger.debug('socket onclose', { id: socket.id });
342
+
343
+ Object.keys(this.topics).forEach((topic) => this._leaveChannel(socket, topic));
344
+ });
345
+
346
+ socket.on('error', (err) => {
347
+ this.logger.error('socket onerror', { id: socket.id, error: err });
348
+ Object.keys(this.topics).forEach((topic) => this._leaveChannel(socket, topic));
349
+ });
350
+ }
351
+
352
+ /**
353
+ * private
354
+ */
355
+ onWssClose() {
356
+ this.logger.debug('ws server onclose');
357
+ this.emit('close');
358
+ }
359
+
360
+ /**
361
+ * private
362
+ */
363
+ onWssError(error) {
364
+ this.logger.error('ws server error', { error });
365
+ this.emit('error', error);
366
+ }
367
+
368
+ _leaveChannel(socket, topic) {
369
+ // unsubscribe
370
+ if (this.topics[topic]) {
371
+ this.topics[topic].delete(socket);
372
+ }
373
+
374
+ this.emit('channel.leave', { socket, topic });
375
+ if (!this.topics[topic] || !this.topics[topic].size) {
376
+ this.emit('channel.destroy', { socket, topic });
377
+ }
378
+ }
379
+ }
380
+
381
+ module.exports = WsServer;
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@arcblock/ws",
3
+ "version": "1.6.10",
4
+ "description": "OCAP Chain websocket server and client",
5
+ "keywords": [
6
+ "websocket"
7
+ ],
8
+ "author": "arcblock <engineer@arcblock.io>",
9
+ "homepage": "https://github.com/ArcBlock/asset-chain#readme",
10
+ "license": "Apache-2.0",
11
+ "main": "./lib/index.js",
12
+ "browser": "./lib/browser.js",
13
+ "directories": {
14
+ "lib": "lib",
15
+ "test": "__tests__"
16
+ },
17
+ "files": [
18
+ "lib"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/ArcBlock/asset-chain.git"
26
+ },
27
+ "scripts": {
28
+ "lint": "eslint tests lib",
29
+ "lint:fix": "eslint --fix tests lib",
30
+ "test": "jest --forceExit --detectOpenHandles",
31
+ "coverage": "npm run test -- --coverage"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/ArcBlock/asset-chain/issues"
35
+ },
36
+ "dependencies": {
37
+ "@arcblock/event-hub": "1.6.10",
38
+ "debug": "^4.3.3",
39
+ "eventemitter3": "^4.0.4",
40
+ "phoenix": "1.5.12",
41
+ "uuid": "^8.3.0",
42
+ "ws": "^8.2.2"
43
+ },
44
+ "devDependencies": {
45
+ "get-port": "^5.1.1"
46
+ },
47
+ "gitHead": "ab272e8db3a15c6571cc7fae7cc3d3e0fdd4bdb1"
48
+ }