@arcblock/ws 1.6.10

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/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
+ }