diff --git a/assets/js/keepalive.js b/assets/js/keepalive.js index 449e890b..58200dc2 100644 --- a/assets/js/keepalive.js +++ b/assets/js/keepalive.js @@ -1,21 +1,33 @@ /* -** keepalive.js // v1.0.0 // 6th January 2022 +** keepalive.js // v1.1.0 // 11 November 2024 ** Keeps alive the websocket connection by sending a ping every x seconds ** where x = Genie.config.webchannels_keepalive_frequency */ -function keepalive() { - if (window._lastMessageAt !== undefined) { - if (Date.now() - window._lastMessageAt < Genie.Settings.webchannels_keepalive_frequency) { - return +function keepalive(WebChannel) { + if (WebChannel.lastMessageAt !== undefined) { + dt = Date.now() - WebChannel.lastMessageAt; + // allow for a 200ms buffer + if (dt + 200 < Genie.Settings.webchannels_keepalive_frequency) { + keepaliveTimer(WebChannel, Genie.Settings.webchannels_keepalive_frequency - dt); + return; } } - if (Genie.Settings.env == 'dev') { - console.info('Keeping connection alive'); + if (!WebChannel.ws_disconnected) { + if (Genie.Settings.env == 'dev') { + console.info('Keeping connection alive'); + } + WebChannel.sendMessageTo(WebChannel.channel, 'keepalive', { + 'payload': {} + }); } - - Genie.WebChannels.sendMessageTo(CHANNEL, 'keepalive', { - 'payload': {} - }); } + +function keepaliveTimer(WebChannel, startDelay = Genie.Settings.webchannels_keepalive_frequency) { + clearInterval(WebChannel.keepalive_interval); + setTimeout(() => { + keepalive(WebChannel); + WebChannel.keepalive_interval = setInterval(() => keepalive(WebChannel), Genie.Settings.webchannels_keepalive_frequency); + }, startDelay) +} \ No newline at end of file diff --git a/assets/js/watchers.js b/assets/js/watchers.js index 6d87f2d6..9f973cc9 100644 --- a/assets/js/watchers.js +++ b/assets/js/watchers.js @@ -67,7 +67,7 @@ const watcherMixin = { }, push: function (field) { - Genie.WebChannels.sendMessageTo(CHANNEL, 'watchers', {'payload': { + this.WebChannel.sendMessageTo(this.channel_, 'watchers', {'payload': { 'field': field, 'newval': this[field], 'oldval': null, @@ -110,7 +110,7 @@ const eventMixin = { if (event_data === undefined) { event_data = {} } console.debug('event: ' + JSON.stringify(event_data) + ":" + event_handler) if (mode=='addclient') { event_data._addclient = true} - Genie.WebChannels.sendMessageTo(window.CHANNEL, 'events', { + this.WebChannel.sendMessageTo(this.channel_, 'events', { 'event': { 'name': event_handler, 'event': event_data diff --git a/src/Elements.jl b/src/Elements.jl index 08c61980..4f4947c7 100644 --- a/src/Elements.jl +++ b/src/Elements.jl @@ -130,7 +130,6 @@ function vue_integration(::Type{M}; debounce::Int = Stipple.JS_DEBOUNCE_TIME, transport::Module = Genie.WebChannels)::String where {M<:ReactiveModel} model = Base.invokelatest(M) - vue_app = json(model |> Stipple.render) vue_app = replace(vue_app, "\"$(getchannel(model))\"" => Stipple.channel_js_name) @@ -157,89 +156,88 @@ function vue_integration(::Type{M}; output = string( - " - - function initStipple(rootSelector){ - // components = Stipple.init($( core_theme ? "{theme: '$theme'}" : "" )); - const app = Vue.createApp($( replace(vue_app, "'$(Stipple.UNDEFINED_PLACEHOLDER)'"=>Stipple.UNDEFINED_VALUE) )) - /* Object.entries(components).forEach(([key, value]) => { - app.component(key, value) - }); */ - Stipple.init( app, $( core_theme ? "{theme: '$theme'}" : "" )); - $globalcomps - $comps - // gather legacy global options - app.prototype = {} - $(plugins(M)) - // apply legacy global options - Object.entries(app.prototype).forEach(([key, value]) => { - app.config.globalProperties[key] = value - }); - window.$vue_app_name = window.GENIEMODEL = app.mount(rootSelector); - } // end of initStipple - - " - - , - - " - - function initWatchers(){ - " - - , - join( - [Stipple.watch(string("window.", vue_app_name), field, Stipple.channel_js_name, debounce, model) for field in fieldnames(Stipple.get_concrete_type(M)) - if Stipple.has_frontend_watcher(field, model)] - ) - , - - " - } // end of initWatchers - - " - - , - """ - - window.parse_payload = function(payload){ - if (payload.key) { - window.$(vue_app_name).updateField(payload.key, payload.value); + window.parse_payload = function(WebChannel, payload){ + if (payload.key) { + WebChannel.parent.updateField(payload.key, payload.value); + } } - } - - function app_ready() { - $vue_app_name.channel_ = window.CHANNEL; - $vue_app_name.isready = true; - Genie.Revivers.addReviver(window.$(vue_app_name).revive_jsfunction); - $(transport == Genie.WebChannels && - " + + function app_ready(app) { + if (app.WebChannel == Genie.AllWebChannels[0]) Genie.Revivers.addReviver(app.revive_jsfunction); + app.isready = true; + """, + transport == Genie.WebChannels && + """ try { if (Genie.Settings.webchannels_keepalive_frequency > 0) { - clearInterval($vue_app_name.keepalive_interval); - $vue_app_name.keepalive_interval = setInterval(keepalive, Genie.Settings.webchannels_keepalive_frequency); + keepaliveTimer(app.WebChannel, 0); } } catch (e) { if (Genie.Settings.env === 'dev') { console.error('Error setting WebSocket keepalive interval: ' + e); } } - ") - + """, + """ if (Genie.Settings.env === 'dev') { console.info('App starting'); } - }; + }; - if ( window.autorun === undefined || window.autorun === true ) { - initStipple('#$vue_app_name'); - initWatchers(); + function initStipple$vue_app_name(appName, rootSelector, channel){ + // components = Stipple.init($( core_theme ? "{theme: '$theme'}" : "" )); + const app = Vue.createApp($( replace(vue_app, "'$(Stipple.UNDEFINED_PLACEHOLDER)'"=>Stipple.UNDEFINED_VALUE) )) + /* Object.entries(components).forEach(([key, value]) => { + app.component(key, value) + }); */ + Stipple.init( app, $( core_theme ? "{theme: '$theme'}" : "" )); + $globalcomps + $comps + // gather legacy global options + app.prototype = {} + $(plugins(M)) + // apply legacy global options + Object.entries(app.prototype).forEach(([key, value]) => { + app.config.globalProperties[key] = value + }); + + const stippleApp = window[appName] = window.GENIEMODEL = app.mount(rootSelector); + stippleApp.WebChannel = Genie.initWebChannel(channel); + stippleApp.WebChannel.parent = stippleApp; + stippleApp.channel_ = channel; + + return stippleApp; + } // end of initStipple + + function initWatchers$vue_app_name(app){ + """, + join( + [Stipple.watch("app", field, Stipple.channel_js_name, debounce, model) for field in fieldnames(Stipple.get_concrete_type(M)) + if Stipple.has_frontend_watcher(field, model)] + ), + + """ + } // end of initWatchers + + function create$vue_app_name(channel) { + window.counter$vue_app_name = window.counter$vue_app_name || 1 + const appName = '$vue_app_name' + ((counter$vue_app_name == 1) ? '' : '_' + window.counter$vue_app_name) + const rootSelector = '#$vue_app_name' + ((counter$vue_app_name == 1) ? '' : '-' + window.counter$vue_app_name) + counter$vue_app_name++ + + if ( window.autorun === undefined || window.autorun === true ) { + app = initStipple$vue_app_name(appName, rootSelector, channel); + initWatchers$vue_app_name(app); + + app.WebChannel.subscriptionHandlers.push(function(event) { + app_ready(app); + }); + } + } - Genie.WebChannels.subscriptionHandlers.push(function(event) { - app_ready(); - }); - } + // create$vue_app_name() + // is called via script with addEventListener to support multiple apps """ ) diff --git a/src/Layout.jl b/src/Layout.jl index 86be5b9a..4f02fc77 100644 --- a/src/Layout.jl +++ b/src/Layout.jl @@ -16,6 +16,25 @@ const THEMES = Ref(Function[]) const FLEXGRID_KWARGS = [:col, :xs, :sm, :md, :lg, :xl, :gutter, :xgutter, :ygutter] +""" + make_unique!(src::Vector, condition::Union{Nothing, Function} = nothing) + +Utility function for removing duplicates from a vector that fulfill a given condition. +""" +function make_unique!(src::Vector, condition::Union{Nothing, Function} = nothing) + seen = Int[] + dups = Int[] + for (i, name) in enumerate(src) + if name ∈ view(src, seen) && (condition === nothing || condition(name)) + push!(dups, i) + else + push!(seen, i) + end + end + + deleteat!(src, dups) +end + """ function layout(output::Union{String,Vector}; partial::Bool = false, title::String = "", class::String = "", style::String = "", head_content::String = "", channel::String = Genie.config.webchannels_default_route) :: String @@ -41,19 +60,22 @@ julia> layout([ "Hello\n" ``` """ -function layout(output::Union{S,Vector}, m::M; +function layout(output::Union{S,Vector}, m::Union{M, Vector{M}}; partial::Bool = false, title::String = "", class::String = "", style::String = "", head_content::Union{AbstractString, Vector{<:AbstractString}} = "", channel::String = Stipple.channel_js_name, core_theme::Bool = true)::ParsedHTMLString where {M<:ReactiveModel, S<:AbstractString} isa(output, Vector) && (output = join(output, '\n')) + m isa Vector || (m = [m]) content = [ output theme(; core_theme) - Stipple.deps(m) + Stipple.deps.(m)... ] + make_unique!(content, contains(r"src=|href="i)) + partial && return content Genie.Renderer.Html.doc( @@ -84,21 +106,34 @@ julia> page(:elemid, [ "\n
Hello
\n" ``` """ -function page(model::M, args...; +function page(model::Union{M, Vector{M}}, args...; + pagetemplate = (x...) -> join([x...], '\n'), partial::Bool = false, title::String = "", class::String = "container", style::String = "", channel::String = Genie.config.webchannels_default_route, head_content::Union{AbstractString, Vector{<:AbstractString}} = "", prepend::Union{S,Vector} = "", append::Union{T,Vector} = [], core_theme::Bool = true, kwargs...)::ParsedHTMLString where {M<:Stipple.ReactiveModel, S<:AbstractString,T<:AbstractString} + model isa Vector || (model = [model]) + uis = if !isempty(args) + args[1] isa Vector ? args[1] : [args[1]] + else + "" + end + counter = Dict{DataType, Int}() + + function rootselector(m::M) where M <:ReactiveModel + AM = Stipple.get_abstract_type(M) + counter[AM] = get(counter, AM, 0) + 1 + return (counter[AM] == 1) ? vm(m) : "$(vm(m))-$(counter[AM])" + end layout( [ join(prepend) - Genie.Renderer.Html.div(id = vm(M), args...; class = class, kwargs...) + pagetemplate([Genie.Renderer.Html.div(id = rootselector(m), ui, args[2:end]...; class = class, kwargs...) for (m, ui) in zip(model, uis)]...) join(append) ], model; - partial = partial, title = title, style = style, head_content = head_content, channel = channel, - core_theme = core_theme) + partial, title, style, head_content, channel, core_theme) end const app = page diff --git a/src/Stipple.jl b/src/Stipple.jl index 71ec16ac..e424feed 100644 --- a/src/Stipple.jl +++ b/src/Stipple.jl @@ -113,7 +113,7 @@ using .NamedTuples export JSONParser, JSONText, json, @json, jsfunction, @jsfunction_str const config = Genie.config -const channel_js_name = "window.CHANNEL" +const channel_js_name = "'not_assigned'" const OptDict = OrderedDict{Symbol, Any} opts(;kwargs...) = OptDict(kwargs...) @@ -342,10 +342,10 @@ changed on the frontend, it is pushed over to the backend using `channel`, at a function watch(vue_app_name::String, fieldname::Symbol, channel::String, debounce::Int, model::M; jsfunction::String = "")::String where {M<:ReactiveModel} js_channel = isempty(channel) ? "window.Genie.Settings.webchannels_default_route" : - (channel == Stipple.channel_js_name ? Stipple.channel_js_name : "'$channel'") + "$vue_app_name.channel_" isempty(jsfunction) && - (jsfunction = "Genie.WebChannels.sendMessageTo($js_channel, 'watchers', {'payload': {'field':'$fieldname', 'newval': newVal, 'oldval': oldVal, 'sesstoken': document.querySelector(\"meta[name='sesstoken']\")?.getAttribute('content')}});") + (jsfunction = "$vue_app_name.push('$fieldname')") output = IOBuffer() if fieldname == :isready @@ -845,11 +845,18 @@ function injectdeps(output::Vector{AbstractString}, M::Type{<:ReactiveModel}) :: output end - +# no longer needed, replaced by initscript function channelscript(channel::String) :: String - Genie.Renderer.Html.script(["window.CHANNEL = '$(channel)';"]) + Genie.Renderer.Html.script([""" + document.addEventListener('DOMContentLoaded', () => window.Genie.initWebChannel('$channel') ); + """]) end +function initscript(vue_app_name, channel) :: String + Genie.Renderer.Html.script([""" + document.addEventListener('DOMContentLoaded', () => window.create$vue_app_name('$channel') ); + """]) +end """ function deps(channel::String = Genie.config.webchannels_default_route) @@ -859,7 +866,7 @@ Outputs the HTML code necessary for injecting the dependencies in the page (the function deps(m::M) :: Vector{String} where {M<:ReactiveModel} channel = getchannel(m) output = [ - channelscript(channel), + initscript(vm(m), channel), (is_channels_webtransport() ? Genie.Assets.channels_script_tag(channel) : Genie.Assets.webthreads_script_tag(channel)), Genie.Renderer.Html.script(src = Genie.Assets.asset_path(assets_config, :js, file="underscore-min")), Genie.Renderer.Html.script(src = Genie.Assets.asset_path(assets_config, :js, file=(Genie.Configuration.isprod() ? "vue.global.prod" : "vue.global"))), diff --git a/src/stipple/jsmethods.jl b/src/stipple/jsmethods.jl index 646435dd..38ca1656 100644 --- a/src/stipple/jsmethods.jl +++ b/src/stipple/jsmethods.jl @@ -36,10 +36,11 @@ function js_methods(app::T)::String where {T<:ReactiveModel} "" end +# deprecated, now part of the model function js_methods_events()::String """ handle_event: function (event, handler) { - Genie.WebChannels.sendMessageTo(window.CHANNEL, 'events', { + Genie.WebChannels.sendMessageTo(GENIEMODEL.channel_, 'events', { 'event': { 'name': handler, 'event': event @@ -203,9 +204,9 @@ myreviver: function(key, value) { return (key.endsWith('_onebased') ? value - 1 """ function js_add_reviver(revivername::String) """ - Genie.WebChannels.subscriptionHandlers.push(function(event) { + document.addEventListener('DOMContentLoaded', () => Genie.WebChannels.subscriptionHandlers.push(function(event) { Genie.Revivers.addReviver($revivername); - }); + })); """ end @@ -221,9 +222,9 @@ It needs to be added to the dependencies of an app in order to be executed, e.g. """ function js_initscript(initscript::String) """ - Genie.WebChannels.subscriptionHandlers.push(function(event) { + document.addEventListener('DOMContentLoaded', () => Genie.WebChannels.subscriptionHandlers.push(function(event) { $(initscript) - }); + })); """ end diff --git a/src/stipple/rendering.jl b/src/stipple/rendering.jl index 156d078c..fc9c7913 100644 --- a/src/stipple/rendering.jl +++ b/src/stipple/rendering.jl @@ -135,7 +135,7 @@ function Stipple.render(app::M)::Dict{Symbol,Any} where {M<:ReactiveModel} for field in fieldnames(typeof(app)) f = getfield(app, field) - occursin(SETTINGS.private_pattern, String(field)) && continue + field != CHANNELFIELDNAME && occursin(SETTINGS.private_pattern, String(field)) && continue f isa Reactive && f.r_mode == PRIVATE && continue result[field] = Stipple.jsrender(f, field) diff --git a/test/runtests.jl b/test/runtests.jl index dffaf611..22e17548 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -10,7 +10,7 @@ function string_get(x; kwargs...) end function get_channel(s::String) - match(r"window.CHANNEL = '([^']+)'", s).captures[1] + match(r"\(\) => window.create[^']+'([^']+)'", s).captures[1] end function get_debounce(port, modelname)