chat-platform 3.0.0 → 3.1.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.
Files changed (2) hide show
  1. package/chat-platform.js +157 -1
  2. package/package.json +3 -2
package/chat-platform.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const _ = require('lodash');
2
+ const ws = require('ws');
2
3
  const clc = require('cli-color');
3
4
  const prettyjson = require('prettyjson');
4
5
  const { when } = require('./lib/utils');
@@ -23,11 +24,20 @@ if (global['redbot-chat-platform'] == null) {
23
24
  params: {}
24
25
  };
25
26
  }
27
+ // WebSocket endpoints are mounted on the raw http server, which is shared by every chatbot of the
28
+ // instance: the registry has to live in the global space like the others, and it has to survive
29
+ // ChatExpress.reset() since the "upgrade" listener attached to the server is never removed
30
+ if (global['redbot-chat-platform'].wsRoutes == null) {
31
+ global['redbot-chat-platform'].wsRoutes = {};
32
+ global['redbot-chat-platform'].wsServers = new WeakSet();
33
+ }
26
34
 
27
35
  let _messageTypes = global['redbot-chat-platform'].messageTypes;
28
36
  let _events = global['redbot-chat-platform'].events;
29
37
  let _platforms = global['redbot-chat-platform'].platforms;
30
38
  let _params = global['redbot-chat-platform'].params;
39
+ let _wsRoutes = global['redbot-chat-platform'].wsRoutes;
40
+ let _wsServers = global['redbot-chat-platform'].wsServers;
31
41
  let _globalCallbacks = {};
32
42
 
33
43
  const ChatExpress = function(options) {
@@ -50,6 +60,9 @@ const ChatExpress = function(options) {
50
60
  RED: null,
51
61
  routes: null,
52
62
  routesDescription: null,
63
+ wsRoutes: null,
64
+ wsRoutesDescription: null,
65
+ wsVerifyClient: null,
53
66
  events: null,
54
67
  relaxChatId: false,
55
68
  bundle: false,
@@ -634,6 +647,145 @@ const ChatExpress = function(options) {
634
647
  return when(true);
635
648
  }
636
649
 
650
+ /**
651
+ * @method wsPathname
652
+ * The pathname an upgrade request was sent to, without the query string
653
+ * @param {string} url
654
+ * @return {string}
655
+ */
656
+ function wsPathname(url) {
657
+ const pathname = String(url != null ? url : '').split('?')[0];
658
+ // a trailing slash is the same endpoint: "/redbot/my-bot/ws/" === "/redbot/my-bot/ws"
659
+ return pathname.length > 1 && pathname.slice(-1) === '/' ? pathname.slice(0, -1) : pathname;
660
+ }
661
+
662
+ /**
663
+ * @method wsFullPath
664
+ * Absolute path of a WebSocket endpoint. Like the Express ones, the paths declared by a platform are
665
+ * relative to `httpNodeRoot`, but an upgrade request never enters the Express router so the prefix
666
+ * has to be applied by hand
667
+ * @param {object} RED
668
+ * @param {string} route
669
+ * @return {string}
670
+ */
671
+ function wsFullPath(RED, route) {
672
+ const root = RED.settings != null && !_.isEmpty(RED.settings.httpNodeRoot) ?
673
+ RED.settings.httpNodeRoot : '/';
674
+ const prefix = root.slice(-1) === '/' ? root.slice(0, -1) : root;
675
+ return `${prefix}${route.charAt(0) === '/' ? route : `/${route}`}`;
676
+ }
677
+
678
+ /**
679
+ * @method dispatchUpgrade
680
+ * The one and only "upgrade" listener of the process: hand the request over to the WebSocket endpoint
681
+ * registered on that path, if any.
682
+ * Node-RED attaches its own listeners to the same server (the editor "comms" channel and the core
683
+ * WebSocket nodes) and, exactly like them, a request that matches nothing here is left alone instead
684
+ * of being destroyed, so that the other listeners can still handle it
685
+ */
686
+ function dispatchUpgrade(request, socket, head) {
687
+ const entry = _wsRoutes[wsPathname(request.url)];
688
+ if (entry == null) {
689
+ // not one of ours: don't destroy the socket, another listener may want to handle it
690
+ return;
691
+ }
692
+ entry.wss.handleUpgrade(request, socket, head, function done(connection) {
693
+ entry.wss.emit('connection', connection, request);
694
+ });
695
+ }
696
+
697
+ /**
698
+ * @method mountUpgradeListener
699
+ * Attach the dispatcher to the http server, once per server: a Node-RED deploy re-creates every
700
+ * configuration node, so a listener per chatbot would pile up at every deploy
701
+ * @param {object} server
702
+ */
703
+ function mountUpgradeListener(server) {
704
+ if (_wsServers.has(server)) {
705
+ return;
706
+ }
707
+ server.on('upgrade', dispatchUpgrade);
708
+ _wsServers.add(server);
709
+ }
710
+
711
+ /**
712
+ * @method mountWsRoutes
713
+ * Mount the WebSocket endpoints of a chat server. Unlike the Express routes these are matched exactly,
714
+ * so `wsRoutes` can also be a function (bound to the chat server) returning the routes, for a platform
715
+ * that needs to build the path out of its own configuration (i.e. a bot id in the middle of the path)
716
+ * @param {object} RED
717
+ * @param {object|function} wsRoutes
718
+ * @param {object} wsRoutesDescription
719
+ * @param {object} chatServer
720
+ */
721
+ // eslint-disable-next-line max-params
722
+ function mountWsRoutes(RED, wsRoutes, wsRoutesDescription, chatServer) {
723
+ const routes = _.isFunction(wsRoutes) ? wsRoutes.call(chatServer) : wsRoutes;
724
+ if (routes == null || _.isEmpty(routes)) {
725
+ return when(true);
726
+ }
727
+ // the descriptions are keyed by route: when the routes are generated, so are their descriptions
728
+ const descriptions = _.isFunction(wsRoutesDescription) ?
729
+ wsRoutesDescription.call(chatServer) : wsRoutesDescription;
730
+ if (RED == null || RED.server == null) {
731
+ chatServer.warn('Impossible to mount the WebSocket endpoints: ' +
732
+ (RED == null ? '"RED" param is empty' : '"RED.server" is not available'));
733
+ return when(true);
734
+ }
735
+ const options = chatServer.getOptions();
736
+ const uiPort = RED.settings.get('uiPort');
737
+ mountUpgradeListener(RED.server);
738
+ // eslint-disable-next-line no-console
739
+ console.log(lcd.timestamp() + '');
740
+ // eslint-disable-next-line no-console
741
+ console.log(lcd.timestamp() + grey('------ WebSockets for ' + options.transport.toUpperCase() + '--------------'));
742
+ _.each(routes, (handler, route) => {
743
+ const host = 'ws://localhost' + (uiPort != '80' ? ':' + uiPort : '');
744
+ const path = wsFullPath(RED, generateCallback(route, chatServer));
745
+ if (_wsRoutes[path] != null) {
746
+ chatServer.error(`The WebSocket endpoint ${path} is already mounted by another chatbot, skipped`);
747
+ return null;
748
+ }
749
+ let description = null;
750
+ if (descriptions != null && _.isString(descriptions[route])) {
751
+ description = descriptions[route];
752
+ } else if (descriptions != null && _.isFunction(descriptions[route])) {
753
+ description = descriptions[route].call(chatServer);
754
+ }
755
+ // eslint-disable-next-line no-console
756
+ console.log(lcd.timestamp() + green(host + path) + (description != null ? grey(' - ') + white(description) : ''));
757
+ const wss = new ws.Server(_.isFunction(options.wsVerifyClient) ?
758
+ { noServer: true, verifyClient: options.wsVerifyClient.bind(chatServer) } : { noServer: true });
759
+ wss.setMaxListeners(0);
760
+ wss.on('connection', handler.bind(chatServer));
761
+ wss.on('error', error => chatServer.error(`WebSocket endpoint ${path}: ${error.message}`));
762
+ _wsRoutes[path] = { wss: wss, chatServer: chatServer };
763
+ return null;
764
+ });
765
+ // eslint-disable-next-line no-console
766
+ console.log(lcd.timestamp() + '');
767
+ return when(true);
768
+ }
769
+
770
+ /**
771
+ * @method unmountWsRoutes
772
+ * Remove every WebSocket endpoint of a chat server. The dispatcher attached to the http server stays
773
+ * there (it's shared by the whole process and it does nothing without a matching endpoint)
774
+ * @param {object} chatServer
775
+ */
776
+ function unmountWsRoutes(chatServer) {
777
+ _.keys(_wsRoutes).forEach(path => {
778
+ const entry = _wsRoutes[path];
779
+ if (entry == null || entry.chatServer !== chatServer) {
780
+ return;
781
+ }
782
+ delete _wsRoutes[path];
783
+ // closing the server terminates the connected clients: the widgets will reconnect once the
784
+ // chatbot is up again, a Node-RED deploy re-creates the configuration nodes
785
+ entry.wss.close();
786
+ });
787
+ }
788
+
637
789
  var methods = {
638
790
 
639
791
  'in': function() {
@@ -1005,6 +1157,9 @@ const ChatExpress = function(options) {
1005
1157
  .then(function() {
1006
1158
  return mountRoutes(options.RED, options.routes, options.routesDescription, _this);
1007
1159
  })
1160
+ .then(function() {
1161
+ return mountWsRoutes(options.RED, options.wsRoutes, options.wsRoutesDescription, _this);
1162
+ })
1008
1163
  .then(function() {
1009
1164
  return mountEvents(options.events, _this);
1010
1165
  })
@@ -1018,7 +1173,7 @@ const ChatExpress = function(options) {
1018
1173
  }
1019
1174
  // listen to inbound event
1020
1175
  var connector = options.connector;
1021
- if (connector != null && options.inboundMessageEvent != null) {
1176
+ if (connector != null && options.inboundMessageEvent != null && _.isFunction(connector.on)) {
1022
1177
  connector.on(options.inboundMessageEvent, function (message) {
1023
1178
  inboundMessage(message, chatServer);
1024
1179
  });
@@ -1093,6 +1248,7 @@ const ChatExpress = function(options) {
1093
1248
  this.emit('stop');
1094
1249
  var options = this.getOptions();
1095
1250
  unmountRoutes(options.RED, options.routes, this);
1251
+ unmountWsRoutes(this);
1096
1252
  unmountEvents(options.events, this);
1097
1253
  var stack = when(true);
1098
1254
  if (_.isFunction(options.onStop)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chat-platform",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Universal Chat Platform",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -19,7 +19,8 @@
19
19
  "lodash": "^4.18.1",
20
20
  "prettyjson": "^1.2.1",
21
21
  "sequelize": "^6.37.8",
22
- "sqlite3": "^5.1.7"
22
+ "sqlite3": "^5.1.7",
23
+ "ws": "^7.5.7"
23
24
  },
24
25
  "devDependencies": {
25
26
  "chai": "^4.1.1",