From d8ee7668608e08687add8db55f39d18a7a6e8e97 Mon Sep 17 00:00:00 2001
From: Nick O'Leary <nick.oleary@gmail.com>
Date: Thu, 8 Jul 2021 10:51:36 +0100
Subject: [PATCH] Allow websocket client node to send pings

---
 .../nodes/core/network/22-websocket.html      | 24 ++++++++-
 .../nodes/core/network/22-websocket.js        | 53 +++++++++++++++++--
 .../nodes/locales/en-US/messages.json         |  1 +
 3 files changed, 73 insertions(+), 5 deletions(-)

diff --git a/packages/node_modules/@node-red/nodes/core/network/22-websocket.html b/packages/node_modules/@node-red/nodes/core/network/22-websocket.html
index 093256495..e3ee30eeb 100644
--- a/packages/node_modules/@node-red/nodes/core/network/22-websocket.html
+++ b/packages/node_modules/@node-red/nodes/core/network/22-websocket.html
@@ -176,7 +176,8 @@
         defaults: {
             path: {value:"",required:true,validate:RED.validators.regex(/^((?!\/debug\/ws).)*$/)},
             tls: {type:"tls-config",required: false},
-            wholemsg: {value:"false"}
+            wholemsg: {value:"false"},
+            hb: {value: "", validate: RED.validators.number(/*blank allowed*/true) }
         },
         inputs:0,
         outputs:0,
@@ -188,11 +189,24 @@
                 $(".node-config-row-tls").toggle(/^wss:/i.test($(this).val()))
             });
             $("#node-config-input-path").change();
+
+            var heartbeatActive = (this.hb && this.hb != "0");
+            $("#node-config-input-hb-cb").prop("checked",heartbeatActive);
+            $("#node-config-input-hb-cb").on("change", function(evt) {
+                $("#node-config-input-hb-row").toggle(this.checked);
+            })
+            $("#node-config-input-hb-cb").trigger("change");
+            if (!heartbeatActive) {
+                $("#node-config-input-hb").val("");
+            }
         },
         oneditsave: function() {
             if (!/^wss:/i.test($("#node-config-input-path").val())) {
                 $("#node-config-input-tls").val("_ADD_");
             }
+            if (!$("#node-config-input-hb-cb").prop("checked")) {
+                $("#node-config-input-hb").val("0");
+            }
         }
     });
 
@@ -259,6 +273,14 @@
             <option value="true" data-i18n="websocket.message"></option>
         </select>
     </div>
+    <div class="form-row" style="display: flex; align-items: center; min-height: 34px">
+        <label for="node-config-input-hb-cb" data-i18n="websocket.sendheartbeat"></label>
+        <input type="checkbox" style="margin: 0 8px; width:auto" id="node-config-input-hb-cb">
+        <span id="node-config-input-hb-row" class="hide" >
+            <input type="text" style="width: 70px; margin-right: 3px" id="node-config-input-hb">
+            <span  data-i18n="inject.seconds"></span>
+        </span>
+    </div>
     <div class="form-tips">
         <p><span data-i18n="[html]websocket.tip.url1"></span></p>
         <span data-i18n="[html]websocket.tip.url2"></span>
diff --git a/packages/node_modules/@node-red/nodes/core/network/22-websocket.js b/packages/node_modules/@node-red/nodes/core/network/22-websocket.js
index e47439f5f..3373c6e72 100644
--- a/packages/node_modules/@node-red/nodes/core/network/22-websocket.js
+++ b/packages/node_modules/@node-red/nodes/core/network/22-websocket.js
@@ -55,6 +55,13 @@ module.exports = function(RED) {
         node.closing = false;
         node.tls = n.tls;
 
+        if (n.hb) {
+            var heartbeat = parseInt(n.hb);
+            if (heartbeat > 0) {
+                node.heartbeat = heartbeat * 1000;
+            }
+        }
+
         function startconn() {    // Connect to remote endpoint
             node.tout = null;
             var prox, noprox;
@@ -93,9 +100,24 @@ module.exports = function(RED) {
 
         function handleConnection(/*socket*/socket) {
             var id = RED.util.generateId();
+            socket.nrId = id;
+            socket.nrPendingHeartbeat = false;
             if (node.isServer) {
                 node._clients[id] = socket;
                 node.emit('opened',{count:Object.keys(node._clients).length,id:id});
+            } else {
+                if (node.heartbeat) {
+                    node.heartbeatInterval = setInterval(function() {
+                        if (socket.nrPendingHeartbeat) {
+                            // No pong received
+                            socket.terminate();
+                            socket.nrErrorHandler(new Error("timeout"));
+                            return;
+                        }
+                        socket.nrPendingHeartbeat = true;
+                        socket.ping();
+                    },node.heartbeat);
+                }
             }
             socket.on('open',function() {
                 if (!node.isServer) {
@@ -103,6 +125,7 @@ module.exports = function(RED) {
                 }
             });
             socket.on('close',function() {
+                clearInterval(node.heartbeatInterval);
                 if (node.isServer) {
                     delete node._clients[id];
                     node.emit('closed',{count:Object.keys(node._clients).length,id:id});
@@ -117,13 +140,21 @@ module.exports = function(RED) {
             socket.on('message',function(data,flags) {
                 node.handleEvent(id,socket,'message',data,flags);
             });
-            socket.on('error', function(err) {
+            socket.nrErrorHandler = function(err) {
+                clearInterval(node.heartbeatInterval);
                 node.emit('erro',{err:err,id:id});
                 if (!node.closing && !node.isServer) {
                     clearTimeout(node.tout);
                     node.tout = setTimeout(function() { startconn(); }, 3000); // try to reconnect every 3 secs... bit fast ?
                 }
-            });
+            }
+            socket.on('error',socket.nrErrorHandler);
+            socket.on('ping', function() {
+                socket.nrPendingHeartbeat = false;
+            })
+            socket.on('pong', function() {
+                socket.nrPendingHeartbeat = false;
+            })
         }
 
         if (node.isServer) {
@@ -152,6 +183,19 @@ module.exports = function(RED) {
             node.server = new ws.Server(serverOptions);
             node.server.setMaxListeners(0);
             node.server.on('connection', handleConnection);
+            // Not adding server-initiated heartbeats yet
+            // node.heartbeatInterval = setInterval(function() {
+            //     node.server.clients.forEach(function(ws) {
+            //         if (ws.nrPendingHeartbeat) {
+            //             // No pong received
+            //             ws.terminate();
+            //             ws.nrErrorHandler(new Error("timeout"));
+            //             return;
+            //         }
+            //         ws.nrPendingHeartbeat = true;
+            //         ws.ping();
+            //     });
+            // })
         }
         else {
             node.closing = false;
@@ -159,6 +203,9 @@ module.exports = function(RED) {
         }
 
         node.on("close", function() {
+            if (node.heartbeatInterval) {
+                clearInterval(node.heartbeatInterval);
+            }
             if (node.isServer) {
                 delete listenerNodes[node.fullPath];
                 node.server.close();
@@ -168,8 +215,6 @@ module.exports = function(RED) {
                 //     RED.server.removeListener('upgrade', handleServerUpgrade);
                 //     serverUpgradeAdded = false;
                 // }
-
-
             }
             else {
                 node.closing = true;
diff --git a/packages/node_modules/@node-red/nodes/locales/en-US/messages.json b/packages/node_modules/@node-red/nodes/locales/en-US/messages.json
index f2ca607da..dd09e29f0 100755
--- a/packages/node_modules/@node-red/nodes/locales/en-US/messages.json
+++ b/packages/node_modules/@node-red/nodes/locales/en-US/messages.json
@@ -512,6 +512,7 @@
         "sendrec": "Send/Receive",
         "payload": "payload",
         "message": "entire message",
+        "sendheartbeat": "Send heartbeat",
         "tip": {
             "path1": "By default, <code>payload</code> will contain the data to be sent over, or received from a websocket. The listener can be configured to send or receive the entire message object as a JSON formatted string.",
             "path2": "This path will be relative to <code>__path__</code>.",