|
| 1 | +/* |
| 2 | +** keepalive.js // v2.0.0 // 8 June 2026 |
| 3 | +** Part of Genie.jl WebChannels |
| 4 | +** Keeps alive the websocket connection by sending a ping every x seconds |
| 5 | +** where x = Genie.config.webchannels_keepalive_frequency |
| 6 | +** Includes pong verification to detect backend channel failures |
| 7 | +*/ |
| 8 | + |
| 9 | +function keepalive(WebChannel) { |
| 10 | + if (WebChannel.lastMessageAt !== undefined) { |
| 11 | + dt = Date.now() - WebChannel.lastMessageAt; |
| 12 | + // allow for a 200ms buffer |
| 13 | + if (dt + 200 < Genie.Settings.webchannels_keepalive_frequency) { |
| 14 | + keepaliveTimer(WebChannel, Genie.Settings.webchannels_keepalive_frequency - dt); |
| 15 | + return; |
| 16 | + } |
| 17 | + } |
| 18 | + |
| 19 | + // Don't send keepalive if websocket is disconnected or in wrong state |
| 20 | + if (WebChannel.ws_disconnected || !WebChannel.socket || WebChannel.socket.readyState !== 1) { |
| 21 | + // Reset pending state since we can't expect a pong anyway |
| 22 | + WebChannel._keepalive_pending = false; |
| 23 | + return; |
| 24 | + } |
| 25 | + |
| 26 | + // Check if previous keepalive is still pending (no pong received) |
| 27 | + if (WebChannel._keepalive_pending) { |
| 28 | + const timeoutMs = Genie.Settings.webchannels_keepalive_timeout || 5000; |
| 29 | + const timeSincePing = Date.now() - WebChannel._keepalive_ping_sent; |
| 30 | + |
| 31 | + if (timeSincePing > timeoutMs) { |
| 32 | + if (Genie.Settings.env == 'dev') { |
| 33 | + console.warn('[Genie.WebChannels] Keepalive pong not received - backend channel may be unresponsive', { |
| 34 | + channel: WebChannel.channel, |
| 35 | + timeSincePing: timeSincePing + 'ms', |
| 36 | + socketState: WebChannel.socket?.readyState, |
| 37 | + wsDisconnected: WebChannel.ws_disconnected |
| 38 | + }); |
| 39 | + } |
| 40 | + |
| 41 | + // Mark channel as not alive |
| 42 | + WebChannel.channel_alive = false; |
| 43 | + |
| 44 | + // Only trigger alert and close if socket is still open |
| 45 | + // (avoid double-alert if network disconnect already triggered it) |
| 46 | + if (WebChannel.socket.readyState === 1 && !WebChannel.ws_disconnected) { |
| 47 | + // Trigger reconnection |
| 48 | + if (typeof displayAlert === 'function') { |
| 49 | + displayAlert(WebChannel, 'Backend channel is not responding. Attempting to reconnect...'); |
| 50 | + } |
| 51 | + |
| 52 | + // Force reconnection by closing the socket |
| 53 | + WebChannel.socket.close(1000, 'Keepalive timeout'); |
| 54 | + } |
| 55 | + |
| 56 | + // Reset pending state |
| 57 | + WebChannel._keepalive_pending = false; |
| 58 | + return; |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + // Send keepalive ping |
| 63 | + if (Genie.Settings.env == 'dev') { |
| 64 | + console.info('[Genie.WebChannels] Sending keepalive ping', { channel: WebChannel.channel }); |
| 65 | + } |
| 66 | + |
| 67 | + WebChannel._keepalive_pending = true; |
| 68 | + WebChannel._keepalive_ping_sent = Date.now(); |
| 69 | + |
| 70 | + WebChannel.sendMessageTo(WebChannel.channel, 'keepalive', { |
| 71 | + 'payload': { timestamp: Date.now() } |
| 72 | + }); |
| 73 | +} |
| 74 | + |
| 75 | +function keepaliveTimer(WebChannel, startDelay = Genie.Settings.webchannels_keepalive_frequency) { |
| 76 | + clearInterval(WebChannel.keepalive_interval); |
| 77 | + clearTimeout(WebChannel._keepaliveTimeout); |
| 78 | + WebChannel._keepaliveTimeout = setTimeout(() => { |
| 79 | + keepalive(WebChannel); |
| 80 | + WebChannel.keepalive_interval = setInterval(() => keepalive(WebChannel), Genie.Settings.webchannels_keepalive_frequency); |
| 81 | + }, startDelay); |
| 82 | +} |
| 83 | + |
| 84 | +function stopKeepalive(WebChannel) { |
| 85 | + clearInterval(WebChannel.keepalive_interval); |
| 86 | + clearTimeout(WebChannel._keepaliveTimeout); |
| 87 | + WebChannel._keepalive_pending = false; |
| 88 | + WebChannel.keepalive_interval = null; |
| 89 | + WebChannel._keepaliveTimeout = null; |
| 90 | +} |
| 91 | + |
| 92 | +// Initialize keepalive state on WebChannel creation |
| 93 | +function initKeepalive(WebChannel) { |
| 94 | + WebChannel._keepalive_pending = false; |
| 95 | + WebChannel._keepalive_ping_sent = null; |
| 96 | + WebChannel.channel_alive = true; |
| 97 | + |
| 98 | + // Public API to check if backend channel is alive |
| 99 | + WebChannel.isChannelAlive = function() { |
| 100 | + return WebChannel.socket && |
| 101 | + WebChannel.socket.readyState === 1 && |
| 102 | + WebChannel.channel_alive && |
| 103 | + !WebChannel.ws_disconnected; |
| 104 | + }; |
| 105 | + |
| 106 | + // Backwards compatibility alias for Stipple |
| 107 | + WebChannel.isModelAlive = WebChannel.isChannelAlive; |
| 108 | + |
| 109 | + // Register pong handler |
| 110 | + WebChannel.messageHandlers.unshift(function(event) { |
| 111 | + try { |
| 112 | + let ed = event.data.trim(); |
| 113 | + |
| 114 | + // Handle base64 encoded payloads |
| 115 | + if (ed.startsWith(Genie.Settings.webchannels_base64_marker)) { |
| 116 | + ed = atob(ed.substring(Genie.Settings.webchannels_base64_marker.length).trim()); |
| 117 | + } |
| 118 | + |
| 119 | + if (ed.startsWith('{') && ed.endsWith('}')) { |
| 120 | + const payload = JSON.parse(ed, Genie.Revivers.reviver); |
| 121 | + |
| 122 | + // Check for keepalive pong response |
| 123 | + if (payload.message === 'keepalive') { |
| 124 | + WebChannel._keepalive_pending = false; |
| 125 | + WebChannel.channel_alive = true; |
| 126 | + |
| 127 | + if (Genie.Settings.env == 'dev') { |
| 128 | + const latency = Date.now() - WebChannel._keepalive_ping_sent; |
| 129 | + console.info('[Genie.WebChannels] Keepalive pong received', { |
| 130 | + channel: WebChannel.channel, |
| 131 | + latency: latency + 'ms' |
| 132 | + }); |
| 133 | + } |
| 134 | + |
| 135 | + // Mark this message as handled so it doesn't propagate further |
| 136 | + return true; |
| 137 | + } |
| 138 | + } |
| 139 | + } catch (ex) { |
| 140 | + // Ignore parsing errors, let other handlers deal with it |
| 141 | + } |
| 142 | + |
| 143 | + return false; // Not handled, continue to other handlers |
| 144 | + }); |
| 145 | + |
| 146 | + // Reset keepalive state on reconnection |
| 147 | + WebChannel.openHandlers.push(function() { |
| 148 | + WebChannel._keepalive_pending = false; |
| 149 | + WebChannel.channel_alive = true; |
| 150 | + if (Genie.Settings.env == 'dev') { |
| 151 | + console.info('[Genie.WebChannels] Keepalive state reset on connection', { channel: WebChannel.channel }); |
| 152 | + } |
| 153 | + }); |
| 154 | + |
| 155 | + // Clear keepalive state on close |
| 156 | + WebChannel.closeHandlers.push(function() { |
| 157 | + if (Genie.Settings.env == 'dev') { |
| 158 | + console.info('[Genie.WebChannels] Stopping keepalive on close', { channel: WebChannel.channel }); |
| 159 | + } |
| 160 | + stopKeepalive(WebChannel); |
| 161 | + WebChannel.channel_alive = false; |
| 162 | + }); |
| 163 | +} |
0 commit comments