diff --git a/assets/js/channels.js b/assets/js/channels.js index a89e1ed58..37fd066ff 100644 --- a/assets/js/channels.js +++ b/assets/js/channels.js @@ -1,21 +1,42 @@ /* -** channels.js v1.4 // 7th Nov 2024 +** channels.js v1.3 // 7th July 2023 ** Author: Adrian Salceanu and contributors // @essenciary ** GenieFramework.com // Genie.jl */ -Genie.WebChannels = {}; -var _lastMessageAt = 0; - -Genie.WebChannels.initialize = function() { - Genie.WebChannels.sendMessageTo = sendMessageTo; - Genie.WebChannels.messageHandlers = []; - Genie.WebChannels.errorHandlers = []; - Genie.WebChannels.openHandlers = []; - Genie.WebChannels.closeHandlers = []; - Genie.WebChannels.subscriptionHandlers = []; - Genie.WebChannels.processingHandlers = []; - - const waitForOpenConnection = () => { + +Genie.initWebChannel = function(channel = Genie.Settings.webchannels_default_route) { + // A message maps to a channel route so that channel + message = /action/controller + // The payload is the data exposed in the Channel Controller + var WebChannel = {}; + WebChannel.sendMessageTo = async (channel, message, payload = {}) => { + let msg = JSON.stringify({ + 'channel': channel, + 'message': message, + 'payload': payload + }); + if (WebChannel.socket.readyState === 1) { + WebChannel.socket.send(msg); + } else if (Object.keys(payload).length > 0) { + try { + await waitForOpenConnection(WebChannel) + WebChannel.socket.send(msg); + } catch (err) { + console.error(err); + console.warn('Could not send message: ' + msg); + } + } + WebChannel.lastMessageAt = Date.now(); + } + + WebChannel.channel = channel; + WebChannel.messageHandlers = []; + WebChannel.errorHandlers = []; + WebChannel.openHandlers = []; + WebChannel.closeHandlers = []; + WebChannel.subscriptionHandlers = []; + WebChannel.processingHandlers = []; + + const waitForOpenConnection = (WebChannel) => { return new Promise((resolve, reject) => { const maxNumberOfAttempts = Genie.Settings.webchannels_connection_attempts; const delay = Genie.Settings.webchannels_reconnect_delay; @@ -25,7 +46,7 @@ Genie.WebChannels.initialize = function() { if (currentAttempt > maxNumberOfAttempts - 1) { clearInterval(interval); reject(new Error('Maximum number of attempts exceeded: Message not sent.')); - } else if (eval('Genie.WebChannels.socket.readyState') === 1) { + } else if (WebChannel.socket.readyState === 1) { clearInterval(interval); resolve(); }; @@ -34,84 +55,140 @@ Genie.WebChannels.initialize = function() { }) } - // A message maps to a channel route so that channel + message = /action/controller - // The payload is the data exposed in the Channel Controller - async function sendMessageTo(channel, message, payload = {}) { - let msg = JSON.stringify({ - 'channel': channel, - 'message': message, - 'payload': payload - }); - if (Genie.WebChannels.socket.readyState === 1) { - Genie.WebChannels.socket.send(msg); - } else if (Object.keys(payload).length > 0) { - try { - await waitForOpenConnection() - eval('Genie.WebChannels.socket').send(msg); - } catch (err) { - console.error(err); - console.warn('Could not send message: ' + msg); + WebChannel.socket = newSocketConnection(WebChannel); + + WebChannel.processingHandlers.push(event => { + window.parse_payload(WebChannel, event.data); + }); + + WebChannel.messageHandlers.push(event => { + try { + let ed = event.data.trim(); + + // if payload is marked as base64 encoded, remove the marker and decode + if (ed.startsWith(Genie.Settings.webchannels_base64_marker)) { + ed = atob(ed.substring(Genie.Settings.webchannels_base64_marker.length).trim()); } + + if (ed.startsWith('{') && ed.endsWith('}')) { + window.parse_payload(WebChannel, JSON.parse(ed, Genie.Revivers.reviver)); + } else if (ed.startsWith(Genie.Settings.webchannels_eval_command)) { + return Function('"use strict";return (' + ed.substring(Genie.Settings.webchannels_eval_command.length).trim() + ')')(); + } else if (ed == 'Subscription: OK') { + window.subscription_ready(WebChannel); + } else { + window.process_payload(WebChannel, event); + } + } catch (ex) { + console.error(ex); + console.error(event.data); } - _lastMessageAt = Date.now(); - } -} - -let wsconnectionalert_triggered = false; -let wsconnectionalert_elemid = 'wsconnectionalert'; + }); + + WebChannel.errorHandlers.push(event => { + if (isDev()) { + console.error(event.target); + } + }); + + WebChannel.closeHandlers.push(event => { + if (isDev()) { + console.warn('WebSocket connection closed: ' + event.code + ' ' + event.reason + ' ' + event.wasClean); + } + }); + + WebChannel.closeHandlers.push(event => { + displayAlert(WebChannel); + if (Genie.Settings.webchannels_autosubscribe) { + if (isDev()) console.info('Attempting to reconnect! '); + setTimeout(function() { + WebChannel.socket = newSocketConnection(WebChannel); + }, Genie.Settings.webchannels_reconnect_delay); + } + }); + + WebChannel.openHandlers.push(event => { + if (Genie.Settings.webchannels_autosubscribe) { + subscribe(WebChannel); + } + }); + + window.addEventListener('beforeunload', _ => { + if (isDev()) { + console.info('Preparing to unload'); + } + + if (Genie.Settings.webchannels_autosubscribe) { + unsubscribe(WebChannel); + } + + if (WebChannel.socket.readyState === 1) { + WebChannel.socket.close(); + } + }); -function displayAlert(content = 'Can not reach the server. Trying to reconnect...') { - if (document.getElementById(wsconnectionalert_elemid) !== null || wsconnectionalert_triggered) return; + Genie.AllWebChannels = Genie.AllWebChannels || []; + Genie.AllWebChannels.push(WebChannel); + Genie.WebChannels = WebChannel; // for compatibility with older code - let elem = document.createElement('div'); - elem.id = wsconnectionalert_elemid; - elem.style.cssText = 'position:fixed;top:0;width:100%;z-index:100;background:#e63946;color:#f1faee;text-align:center;'; - elem.style.height = '1.8em'; - elem.innerHTML = content; + return WebChannel +} - let elemspacer = document.createElement('div'); - elemspacer.id = wsconnectionalert_elemid + 'spacer'; - elemspacer.style.height = (Genie.Settings.webchannels_alert_overlay) ? 0 : elem.style.height; +Genie.wsconnectionalert_elemid = 'wsconnectionalert'; +Genie.allConnected = function() { + for (let i = 0; i < Genie.AllWebChannels.length; i++) { + if (Genie.AllWebChannels[i].ws_disconnected) { + return false + } + } + return true +} - wsconnectionalert_triggered = true; +function displayAlert(WebChannel, content = 'Can not reach the server. Trying to reconnect...') { + if (document.getElementById(Genie.wsconnectionalert_elemid) || WebChannel.ws_disconnected) return; + + let allConnected = Genie.allConnected(); + WebChannel.ws_disconnected = true; + + WebChannel.alertTimeout = setTimeout(() => { + if (Genie.Settings.webchannels_show_alert && allConnected) { + let elem = document.createElement('div'); + elem.id = Genie.wsconnectionalert_elemid; + elem.style.cssText = 'position:fixed;top:0;width:100%;z-index:100;background:#e63946;color:#f1faee;text-align:center;'; + elem.style.height = '1.8em'; + elem.innerHTML = content; + + let elemspacer = document.createElement('div'); + elemspacer.id = Genie.wsconnectionalert_elemid + 'spacer'; + elemspacer.style.height = (Genie.Settings.webchannels_alert_overlay) ? 0 : elem.style.height; - Genie.WebChannels.alertTimeout = setTimeout(() => { - if (Genie.Settings.webchannels_show_alert) { document.body.prepend(elem); document.body.prepend(elemspacer); } - if (window.GENIEMODEL) GENIEMODEL.ws_disconnected = true; + if (WebChannel.parent) WebChannel.parent.ws_disconnected = true; }, Genie.Settings.webchannels_server_gone_alert_timeout); } -function deleteAlert() { - clearInterval(Genie.WebChannels.alertTimeout); - - if (window.GENIEMODEL) GENIEMODEL.ws_disconnected = false; - - let elem = document.getElementById(wsconnectionalert_elemid); - let elemspacer = document.getElementById(wsconnectionalert_elemid + 'spacer'); - - if (elem !== null) { - elem.remove(); - } +function deleteAlert(WebChannel) { + WebChannel.ws_disconnected = false; + clearInterval(WebChannel.alertTimeout); + if (WebChannel.parent) WebChannel.parent.ws_disconnected = false; - if (elemspacer !== null) { - elemspacer.remove(); + if (Genie.allConnected()) { + document.getElementById(Genie.wsconnectionalert_elemid)?.remove(); + document.getElementById(Genie.wsconnectionalert_elemid + 'spacer')?.remove(); } - - wsconnectionalert_triggered = false; } -function newSocketConnection(host = Genie.Settings.websockets_exposed_host) { +function newSocketConnection(WebChannel, host = Genie.Settings.websockets_exposed_host) { let ws = new WebSocket(Genie.Settings.websockets_protocol + '//' + host + (Genie.Settings.websockets_exposed_port > 0 ? (':' + Genie.Settings.websockets_exposed_port) : '') + ( ((Genie.Settings.base_path.trim() === '' || Genie.Settings.base_path.startsWith('/')) ? '' : '/') + Genie.Settings.base_path) + ( ((Genie.Settings.websockets_base_path.trim() === '' || Genie.Settings.websockets_base_path.startsWith('/')) ? '' : '/') + Genie.Settings.websockets_base_path)); ws.addEventListener('open', event => { - for (let i = 0; i < Genie.WebChannels.openHandlers.length; i++) { - let f = Genie.WebChannels.openHandlers[i]; + for (let i = 0; i < WebChannel.openHandlers.length; i++) { + let f = WebChannel.openHandlers[i]; if (typeof f === 'function') { f(event); } @@ -119,18 +196,18 @@ function newSocketConnection(host = Genie.Settings.websockets_exposed_host) { }); ws.addEventListener('message', event => { - for (let i = 0; i < Genie.WebChannels.messageHandlers.length; i++) { - let f = Genie.WebChannels.messageHandlers[i]; + for (let i = 0; i < WebChannel.messageHandlers.length; i++) { + let f = WebChannel.messageHandlers[i]; if (typeof f === 'function') { f(event); } } - _lastMessageAt = Date.now(); + WebChannel.lastMessageAt = Date.now(); }); ws.addEventListener('error', event => { - for (let i = 0; i < Genie.WebChannels.errorHandlers.length; i++) { - let f = Genie.WebChannels.errorHandlers[i]; + for (let i = 0; i < WebChannel.errorHandlers.length; i++) { + let f = WebChannel.errorHandlers[i]; if (typeof f === 'function') { f(event); } @@ -138,8 +215,8 @@ function newSocketConnection(host = Genie.Settings.websockets_exposed_host) { }); ws.addEventListener('close', event => { - for (let i = 0; i < Genie.WebChannels.closeHandlers.length; i++) { - let f = Genie.WebChannels.closeHandlers[i]; + for (let i = 0; i < WebChannel.closeHandlers.length; i++) { + let f = WebChannel.closeHandlers[i]; if (typeof f === 'function') { f(event); } @@ -152,15 +229,12 @@ function newSocketConnection(host = Genie.Settings.websockets_exposed_host) { }); ws.addEventListener('error', _ => { - // Genie.WebChannels.socket = newSocketConnection(); + // WebChannel.socket = newSocketConnection(); }); return ws } -Genie.WebChannels.initialize(); -Genie.WebChannels.socket = newSocketConnection(); - // --------------- Revivers --------------- Genie.Revivers = {}; @@ -171,6 +245,7 @@ Genie.Revivers.rebuildReviver = function() { } Genie.Revivers.addReviver = function(reviver) { + if (Genie.Revivers.revivers.includes(reviver)) return Genie.Revivers.revivers.push(reviver) Genie.Revivers.rebuildReviver() } @@ -186,122 +261,55 @@ Genie.Revivers.revive_undefined = function(key, value) { Genie.Revivers.revivers = [Genie.Revivers.revive_undefined] Genie.Revivers.rebuildReviver() -window.addEventListener('beforeunload', _ => { - if (isDev()) { - console.info('Preparing to unload'); - } - - if (Genie.Settings.webchannels_autosubscribe) { - unsubscribe(); - } - - if (Genie.WebChannels.socket.readyState === 1) { - Genie.WebChannels.socket.close(); - } -}); - -Genie.WebChannels.processingHandlers.push(event => { - window.parse_payload(event.data); -}); - -Genie.WebChannels.messageHandlers.push(event => { - try { - let ed = event.data.trim(); - - // if payload is marked as base64 encoded, remove the marker and decode - if (ed.startsWith(Genie.Settings.webchannels_base64_marker)) { - ed = atob(ed.substring(Genie.Settings.webchannels_base64_marker.length).trim()); - } - - if (ed.startsWith('{') && ed.endsWith('}')) { - window.parse_payload(JSON.parse(ed, Genie.Revivers.reviver)); - } else if (ed.startsWith(Genie.Settings.webchannels_eval_command)) { - return Function('"use strict";return (' + ed.substring(Genie.Settings.webchannels_eval_command.length).trim() + ')')(); - } else if (ed == 'Subscription: OK') { - window.subscription_ready(); - } else { - window.process_payload(event); - } - } catch (ex) { - console.error(ex); - console.error(event.data); - } -}); - -Genie.WebChannels.errorHandlers.push(event => { - if (isDev()) { - console.error(event.data); - } -}); - -Genie.WebChannels.closeHandlers.push(event => { - if (isDev()) { - console.warn('WebSocket connection closed: ' + event.code + ' ' + event.reason + ' ' + event.wasClean); - } -}); - -Genie.WebChannels.closeHandlers.push(event => { - if (Genie.Settings.webchannels_autosubscribe) { - if (isDev()) console.info('Attempting to reconnect! '); - // setTimeout(function() { - Genie.WebChannels.socket = newSocketConnection(); - subscribe(); - // }, Genie.Settings.webchannels_timeout); - } -}); - -Genie.WebChannels.openHandlers.push(event => { - if (Genie.Settings.webchannels_autosubscribe) { - subscribe(); - } -}); - -function parse_payload(json_data) { +function parse_payload(WebChannel, json_data) { if (isDev()) { console.info('Overwrite window.parse_payload to handle messages from the server'); console.info(json_data); } }; -function process_payload(event) { - for (let i = 0; i < Genie.WebChannels.processingHandlers.length; i++) { - let f = Genie.WebChannels.processingHandlers[i]; +function process_payload(WebChannel, event) { + for (let i = 0; i < WebChannel.processingHandlers.length; i++) { + let f = WebChannel.processingHandlers[i]; if (typeof f === 'function') { f(event); } } }; -function subscription_ready() { - for (let i = 0; i < Genie.WebChannels.subscriptionHandlers.length; i++) { - let f = Genie.WebChannels.subscriptionHandlers[i]; +function subscription_ready(WebChannel) { + for (let i = 0; i < WebChannel.subscriptionHandlers.length; i++) { + let f = WebChannel.subscriptionHandlers[i]; if (typeof f === 'function') { f(); } } - - deleteAlert(); + deleteAlert(WebChannel); if (isDev()) console.info('Subscription ready'); }; -function subscribe(trial = 1) { - if (Genie.WebChannels.socket.readyState && (document.readyState === 'complete' || document.readyState === 'interactive')) { - Genie.WebChannels.sendMessageTo(window.Genie.Settings.webchannels_default_route, window.Genie.Settings.webchannels_subscribe_channel); - } else if (trial < Genie.Settings.webchannels_subscription_trails) { +function subscribe(WebChannel, trial = 1) { + if (WebChannel.socket.readyState == 1 && (document.readyState === 'complete' || document.readyState === 'interactive')) { + WebChannel.sendMessageTo(WebChannel.channel, window.Genie.Settings.webchannels_subscribe_channel); + } else if (trial < Genie.Settings.webchannels_subscription_trials) { if (isDev()) console.warn('Queuing subscription'); trial++; - setTimeout(subscribe.bind(this, trial), Genie.Settings.webchannels_timeout); + setTimeout(subscribe.bind(this, WebChannel, trial), Genie.Settings.webchannels_timeout); } else { - displayAlert(); + displayAlert(WebChannel); } }; -function unsubscribe() { - Genie.WebChannels.sendMessageTo(window.Genie.Settings.webchannels_default_route, window.Genie.Settings.webchannels_unsubscribe_channel); +function unsubscribe(WebChannel) { + WebChannel.sendMessageTo(WebChannel.channel, window.Genie.Settings.webchannels_unsubscribe_channel); if (isDev()) console.info('Unsubscription completed'); }; function isDev() { return Genie.Settings.env === 'dev'; -} \ No newline at end of file +} + +// --------------- Initialize WebChannel --------------- + +// Genie.WebChannels = Genie.initWebChannel(); diff --git a/assets/js/webthreads.js b/assets/js/webthreads.js index 68021a485..192544d59 100644 --- a/assets/js/webthreads.js +++ b/assets/js/webthreads.js @@ -97,7 +97,7 @@ function subscribe(trial = 1) { if (document.readyState === 'complete' || document.readyState === 'interactive') { Genie.WebChannels.channel.start('GET', uri_factory(Genie.Settings.webchannels_subscribe_channel), {}, ''); pull(); - } else if (trial < Genie.Settings.webchannels_subscription_trails) { + } else if (trial < Genie.Settings.webchannels_subscription_trials) { if (isDev()) console.warn('Queuing subscription'); trial++; setTimeout(subscribe.bind(this, trial), Genie.WebChannels.poll_interval); diff --git a/src/Assets.jl b/src/Assets.jl index 551002934..c97a50f07 100644 --- a/src/Assets.jl +++ b/src/Assets.jl @@ -240,7 +240,7 @@ function js_settings(channel::String = Genie.config.webchannels_default_route) : :webchannels_server_gone_alert_timeout => Genie.config.webchannels_server_gone_alert_timeout, :webchannels_connection_attempts => Genie.config.webchannels_connection_attempts, :webchannels_reconnect_delay => Genie.config.webchannels_reconnect_delay, - :webchannels_subscription_trails => Genie.config.webchannels_subscription_trails, + :webchannels_subscription_trials => Genie.config.webchannels_subscription_trials, :webchannels_show_alert => Genie.config.webchannels_show_alert, :webchannels_alert_overlay => Genie.config.webchannels_alert_overlay, @@ -403,9 +403,9 @@ end function channels_script_tag(channel::AbstractString = Genie.config.webchannels_default_route) :: String if ! external_assets() - Genie.Renderer.Html.script(src = assets_endpoint()) + Genie.Renderer.Html.script(src = assets_endpoint(), defer = true) else - Genie.Renderer.Html.script([channels(channel)]) + Genie.Renderer.Html.script([channels(channel)], defer = true) end end diff --git a/src/Configuration.jl b/src/Configuration.jl index 29e150361..0a9b498ea 100755 --- a/src/Configuration.jl +++ b/src/Configuration.jl @@ -259,7 +259,7 @@ Base.@kwdef mutable struct Settings webchannels_server_gone_alert_timeout::Int = 10_000 # 10 seconds webchannels_connection_attempts = 10 webchannels_reconnect_delay = 500 # milliseconds - webchannels_subscription_trails = 4 + webchannels_subscription_trials = 4 webchannels_show_alert::Bool = true webchannels_alert_overlay::Bool = false diff --git a/test/tests_Assets.jl b/test/tests_Assets.jl index 263c92231..91c6c5e43 100644 --- a/test/tests_Assets.jl +++ b/test/tests_Assets.jl @@ -14,7 +14,7 @@ using Genie, Genie.Assets Genie.config.websockets_port = 8000 # state gets affected depending on how tests are run -- let's set it explicitly - @test strip(js_settings()) == strip("window.Genie = {};\nGenie.Settings = {\"websockets_exposed_port\":window.location.port,\"server_host\":\"127.0.0.1\",\"webchannels_autosubscribe\":true,\"webchannels_reconnect_delay\":500,\"webchannels_subscription_trails\":4,\"env\":\"dev\",\"webchannels_eval_command\":\">eval:\",\"webchannels_alert_overlay\":false,\"websockets_host\":\"127.0.0.1\",\"webchannels_show_alert\":true,\"webthreads_js_file\":\"webthreads.js\",\"webchannels_base64_marker\":\"base64:\",\"webchannels_unsubscribe_channel\":\"unsubscribe\",\"webthreads_default_route\":\"____\",\"webchannels_subscribe_channel\":\"subscribe\",\"server_port\":8000,\"webchannels_keepalive_frequency\":30000,\"websockets_exposed_host\":window.location.hostname,\"webchannels_connection_attempts\":10,\"base_path\":\"\",\"websockets_protocol\":window.location.protocol.replace('http', 'ws'),\"webthreads_pull_route\":\"pull\",\"webchannels_default_route\":\"____\",\"webchannels_server_gone_alert_timeout\":10000,\"webchannels_timeout\":1000,\"webthreads_push_route\":\"push\",\"websockets_port\":8000,\"websockets_base_path\":\"\"};\n") + @test strip(js_settings()) == strip("window.Genie = {};\nGenie.Settings = {\"websockets_exposed_port\":window.location.port,\"server_host\":\"127.0.0.1\",\"webchannels_autosubscribe\":true,\"webchannels_reconnect_delay\":500,\"env\":\"dev\",\"webchannels_eval_command\":\">eval:\",\"webchannels_alert_overlay\":false,\"websockets_host\":\"127.0.0.1\",\"webchannels_show_alert\":true,\"webthreads_js_file\":\"webthreads.js\",\"webchannels_base64_marker\":\"base64:\",\"webchannels_unsubscribe_channel\":\"unsubscribe\",\"webthreads_default_route\":\"____\",\"webchannels_subscription_trials\":4,\"webchannels_subscribe_channel\":\"subscribe\",\"server_port\":8000,\"webchannels_keepalive_frequency\":30000,\"websockets_exposed_host\":window.location.hostname,\"webchannels_connection_attempts\":10,\"base_path\":\"\",\"websockets_protocol\":window.location.protocol.replace('http', 'ws'),\"webthreads_pull_route\":\"pull\",\"webchannels_default_route\":\"____\",\"webchannels_server_gone_alert_timeout\":10000,\"webchannels_timeout\":1000,\"webthreads_push_route\":\"push\",\"websockets_port\":8000,\"websockets_base_path\":\"\"};") end @safetestset "Embedded assets" begin @@ -23,7 +23,7 @@ @test Assets.channels()[1:18] == "window.Genie = {};" @test channels_script()[1:27] == "" + @test channels_support() == "" @test Genie.Router.routes()[1].path == "/genie.jl/$(Genie.Assets.package_version("Genie"))/assets/js/channels.js" @test Genie.Router.channels()[1].path == "/$(Genie.config.webchannels_default_route)/unsubscribe" @test Genie.Router.channels()[2].path == "/$(Genie.config.webchannels_default_route)/subscribe"