diff --git a/docs/src/index.md b/docs/src/index.md index 7e6798d1..9db21838 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -827,6 +827,52 @@ end serve() ``` +In case you need the client side component, Bonito needs an asset server. A convenient option here is to use `NoServer`, which tells Bonito to include all assets inline in the html. + +```julia +force_asset_server!(NoServer()) +``` + +In case you need to server side component, Bonito will use its own `WebSocketConnection` by default. In this case Bonito will start another HTTP server on another port and serve. While this may work fine locally, in production you will typically want to have the websocket endpoint served by Oxygen so that it will be easily handled by any infastructure you have configured such as reverse proxies, load balancers, and firewall. For this case, you can use `OxygenWebSocketConnection`. + +*Note that the following part of the Bonito instructions is currently experimental and the API may change outside normal semver guarantees.* + +In order to make use the following functions defined in the Bonito extension, you must load `Bonito` before first loading `Oxygen`. + +```julia +using Bonito +using Oxygen +''' + +There are two parts to integrating Bonito with Oxygen: + + 1. Registering a websocket handler to pass requests to Bonito's ahndler + 3. Forcing Bonito to use this connection object `OxygenWebSocketConnection` object which points to the correct Oxygen context and URL + +To do both you can simply use the following command: + +```julia +function __init__() + # This must be done in __init__ so that functions from the Oxygen Bonito extension are available + Oxygen.setup_bonito_connection(CONTEXT[]; setup_all=true) +end +``` + +In case you want to change the url of the Bonito websocket route, you can do so via the `route_base` keyword argument, which defaults to `"/bonito_websocket/"`. + +You may like to add some custom behaviour to the route such as authentication. In this case, you will need to setup the route yourself like so: + +```julia +function __init__() + const route_base = "/foobar/" + oxygen_bonito = Oxygen.setup_bonito_connection(CONTEXT[]; setup_register_connection=true, route_base=route_base) + route_pattern = route_base * "{session_id}" + @websocket route_pattern function mybonitohandler(websocket::HTTP.WebSocket, session_id::String) + # Add custom behaviour here + oxygen_bonito.handler(websocket, session_id) + end +end +``` ## Templating diff --git a/src/context.jl b/src/context.jl index b404dc9c..2f057a57 100644 --- a/src/context.jl +++ b/src/context.jl @@ -55,6 +55,7 @@ end docs :: Documenation = Documenation() cron :: CronContext = CronContext() tasks :: TasksContext = TasksContext() + ext :: Dict{Symbol, Any} = Dict{Symbol, Any}() end Base.isopen(service::Service) = !isnothing(service.server[]) && isopen(service.server[]) diff --git a/src/extensions/load.jl b/src/extensions/load.jl index 1b6f12f4..af7a817a 100644 --- a/src/extensions/load.jl +++ b/src/extensions/load.jl @@ -43,7 +43,11 @@ function __init__() # Plotting Extensions # ################################################################ @require CairoMakie="13f3f980-e62b-5c42-98c6-ff1f3baf88f0" include("plotting/cairomakie.jl") - @require Bonito="824d6782-a2ef-11e9-3a09-e5662e0c26f8" include("plotting/bonito.jl") + @require Bonito="824d6782-a2ef-11e9-3a09-e5662e0c26f8" begin + @info "Loading Bonito plotting extension..." + include("plotting/bonito.jl") + include("plotting/bonito_connection.jl") + end @require WGLMakie="276b4fcb-3e11-5398-bf8b-a0c2d153d008" begin @require Bonito="824d6782-a2ef-11e9-3a09-e5662e0c26f8" begin include("plotting/wglmakie.jl") diff --git a/src/extensions/plotting/bonito.jl b/src/extensions/plotting/bonito.jl index 15b9a7fc..f2059e67 100644 --- a/src/extensions/plotting/bonito.jl +++ b/src/extensions/plotting/bonito.jl @@ -4,12 +4,16 @@ import .Bonito: Page, App export html + +const BONITO_OFFLINE::Ref{Bool} = Ref(true) + + """ Converts a Figure object to the designated MIME type and wraps it inside an HTTP response. """ -function response(content::App, mime_type::MIME, status::Int, headers::Vector) +function response(content::App, mime_type::MIME, status::Int, headers::Vector, offline=BONITO_OFFLINE[]) # Force inlining all data & js dependencies - Page(exportable=true, offline=true) + Page(exportable=true, offline=offline) # Convert & load the figure into an IOBuffer io = IOBuffer() @@ -29,4 +33,4 @@ end Convert a Bonito.App to HTML and wrap it inside an HTTP response. """ -html(app::App, status=200, headers=[]) :: HTTP.Response = response(app, HTML, status, headers) +html(app::App, status=200, headers=[], offline=BONITO_OFFLINE[]) :: HTTP.Response = response(app, HTML, status, headers, offline) diff --git a/src/extensions/plotting/bonito_connection.jl b/src/extensions/plotting/bonito_connection.jl new file mode 100644 index 00000000..432fc2c5 --- /dev/null +++ b/src/extensions/plotting/bonito_connection.jl @@ -0,0 +1,162 @@ +using HTTP.WebSockets: WebSocket, WebSocketError +using HTTP.WebSockets: receive, isclosed +using HTTP.WebSockets + +import .Bonito: setup_connection +using .Bonito: FrontendConnection, WebSocketHandler, Session, setup_websocket_connection_js +using .Bonito: run_connection_loop, register_connection! +using .Bonito: CleanupPolicy, DefaultCleanupPolicy, allow_soft_close, soft_close, should_cleanup + +export OxygenWebSocketConnection, mk_bonito_websocket_handler, setup_bonito_connection + +mutable struct OxygenWebSocketConnection <: FrontendConnection + context::Context + endpoint::String + session::Union{Nothing,Session} + handler::WebSocketHandler +end + +function OxygenWebSocketConnection(context::Context, endpoint::String) + return OxygenWebSocketConnection(context, endpoint, nothing, WebSocketHandler()) +end + +Base.isopen(ws::OxygenWebSocketConnection) = isopen(ws.handler) +Base.write(ws::OxygenWebSocketConnection, binary) = write(ws.handler, binary) +Base.close(ws::OxygenWebSocketConnection) = close(ws.handler) + +mutable struct BonitoConnectionContext + cleanup_policy::CleanupPolicy + open_connections::Dict{String, OxygenWebSocketConnection} + cleanup_task::Union{Task, Nothing} + lock::ReentrantLock +end + +BonitoConnectionContext(policy=DefaultCleanupPolicy()) = BonitoConnectionContext(policy, Dict{String, Session{OxygenWebSocketConnection}}(), nothing, ReentrantLock()) + +""" + setup_bonito_connection( + context::Context; + setup_all=false, + setup_route=setup_all, + setup_register_connection=setup_all, + route_base="/bonito_websocket/" + ) + +This is the high level function to setup bonito connection. It will register the route and create a connection if needed. + +In the simplest use case you can simply pass `setup_bonito_connection(CONTEXT[]; setup_all=true)` and it will set up everything for you. Please see the guide for advanced usage. +""" +function setup_bonito_connection( + context::Context; + setup_all=false, + setup_route=setup_all, + setup_register_connection=setup_all, + route_base="/bonito-websocket/" +) + BONITO_OFFLINE[] = false + context.ext[:bonito_connection] = BonitoConnectionContext() + handler = mk_bonito_websocket_handler(context) + if setup_route + Oxygen.Core.register(context, WEBSOCKET, route_base * "{session_id}", handler) + end + function mk_connection() + return OxygenWebSocketConnection(context, route_base) + end + if setup_register_connection + register_connection!(mk_connection, OxygenWebSocketConnection) + end + return (; + mk_connection, + handler + ) +end + + +function mk_bonito_websocket_handler(context::Context) + if !(:bonito_connection in keys(context.ext)) + error("bonito_connection not setup in context (did you call setup_bonito_connection(...)?)") + end + bonito_context = context.ext[:bonito_connection] + function bonito_websocket_handler(websocket::HTTP.WebSocket, session_id::String) + local connection + lock(bonito_context.lock) do + connection = bonito_context.open_connections[session_id] + end + session = connection.session + handler = connection.handler + + @debug("opening ws connection for session: $(session.id)") + try + run_connection_loop(session, handler, websocket) + finally + if allow_soft_close(bonito_context.cleanup_policy) + @debug("Soft closing: $(session.id)") + soft_close(session) + else + @debug("Closing: $(session.id)") + # might as well close it immediately + close(session) + lock(bonito_context.lock) do + delete!(bonito_context.open_connections, session.id) + end + end + end + end + return bonito_websocket_handler +end + +function cleanup_bonito_context(bonito_context) + remove = Set{OxygenWebSocketConnection}() + lock(bonito_context.lock) do + for (session_id, connection) in bonito_context.open_connections + if should_cleanup(bonito_context.cleanup_policy, connection.session) + push!(remove, connection) + end + end + for connection in remove + if !isnothing(connection.session) + session = connection.session + delete!(bonito_context.open_connections, session.id) + close(session) + end + end + end +end + +function cleanup_loop(bonito_context) + while true + try + sleep(1) + cleanup_bonito_context(bonito_context) + catch e + if !(e isa EOFError) + @warn "error while cleaning up server" exception=(e, Base.catch_backtrace()) + end + end + end +end + +function setup_connection(session::Session, connection::OxygenWebSocketConnection) + connection.session = session + context = connection.context + if !(:bonito_connection in keys(context.ext)) + error("bonito_connection not setup in context (did you call setup_bonito_connection(...)?)") + end + bonito_context = context.ext[:bonito_connection] + if context.service.external_url[] === nothing + error("external_url not set in context (did you call start the server yet?)") + end + external_url_base = context.service.external_url[] + lock(bonito_context.lock) do + if bonito_context.cleanup_task === nothing + bonito_context.cleanup_task = Threads.@spawn cleanup_loop(bonito_context) + end + bonito_context.open_connections[session.id] = connection + end + external_url = external_url_base * session.connection.endpoint + return setup_websocket_connection_js(external_url, session) +end + +function setup_connection(session::Session{OxygenWebSocketConnection}) + return setup_connection(session, session.connection) +end diff --git a/src/extensions/plotting/wglmakie.jl b/src/extensions/plotting/wglmakie.jl index 975a68fb..57cf5301 100644 --- a/src/extensions/plotting/wglmakie.jl +++ b/src/extensions/plotting/wglmakie.jl @@ -8,9 +8,9 @@ export html """ Converts a Figure object to the designated MIME type and wraps it inside an HTTP response. """ -function response(content::FigureLike, mime_type::MIME, status::Int, headers::Vector) +function response(content::FigureLike, mime_type::MIME, status::Int, headers::Vector, offline=BONITO_OFFLINE[]) # Force inlining all data & js dependencies - Page(exportable=true, offline=true) + Page(exportable=true, offline=offline) # Convert & load the figure into an IOBuffer io = IOBuffer() @@ -30,5 +30,5 @@ end Convert a Makie figure to HTML and wrap it inside an HTTP response. """ -html(fig::FigureLike, status=200, headers=[]) :: HTTP.Response = response(fig, HTML, status, headers) +html(fig::FigureLike, status=200, headers=[], offline=BONITO_OFFLINE[]) :: HTTP.Response = response(fig, HTML, status, headers, offline)