From 9d0ae6b2cb0dfae55b748aef941a9adaf99a8c9f Mon Sep 17 00:00:00 2001
From: hhaensel <helmut.haensel@gmx.com>
Date: Thu, 12 Dec 2024 06:42:04 +0100
Subject: [PATCH] add Julia proxy

---
 examples/StippleMakieDemo.jl |  25 ++++++--
 src/StippleMakie.jl          |   5 ++
 src/proxy.jl                 | 120 +++++++++++++++++++++++++++++++++++
 3 files changed, 143 insertions(+), 7 deletions(-)
 create mode 100644 src/proxy.jl

diff --git a/examples/StippleMakieDemo.jl b/examples/StippleMakieDemo.jl
index ab5b755..26d56e0 100644
--- a/examples/StippleMakieDemo.jl
+++ b/examples/StippleMakieDemo.jl
@@ -1,6 +1,7 @@
 using Stipple
 using Stipple.ReactiveTools
 using StippleUI
+import Genie.Server.openbrowser
 
 using StippleMakie
 
@@ -8,14 +9,19 @@ Stipple.enable_model_storage(false)
 
 # ------------------------------------------------------------------------------------------------
 
-# if required set a different port, url or proxy_port for Makie's websocket communication, e.g.
-# otherwise, Genie's settings are applied for listen_url and proxy_url and Makie's (Bonito's) settings are applied for the ports
-configure_makie_server!(listen_port = 8001)
+# if required set a different port, url or proxy_port for Makie's websocket communication, e.g. 8001
+# if not specified, Genie's settings are applied for listen_url and proxy_url and Makie's (Bonito's) settings
+# are applied for the ports
+# configure_makie_server!(listen_port = 8001)
 
 # Example settings for a proxy configuration:
-# configure_makie_server!(listen_port = 8001, proxy_url = "/makie", proxy_port = 8080)
-
+# specify the proxy_port explicitly
+configure_makie_server!(listen_port = 8001, proxy_url = "/makie", proxy_port = 8080)
+# proxy_port will be taken from the serving port
+#configure_makie_server!(listen_port = 8001, proxy_url = "/makie")
+startproxy(8080)
 
+# in production settings it might be favorable to use a reverse proxy for the websocket communication, e.g. nginx.
 # The appropriate nginx configuration can be generated using `nginx_config()` either after setting the configuration
 # or by passing the desired settings directly to the function.
 # nginx_config()
@@ -44,7 +50,7 @@ UI::ParsedHTMLString = column(style = "height: 80vh; width: 100%", [
     cell(col = 4, class = "full-width", makie_figure(:fig1))
     h4("MakiePlot 2")
     cell(col = 5, class = "full-width", makie_figure(:fig2))
-    (btn("Hello", @click(:hello), color = "primary"))
+    btn("Hello", @click(:hello), color = "primary")
 ])
 
 ui() = UI
@@ -58,4 +64,9 @@ route("/") do
     # page(model, ui, prepend = makie_dom(model)) |> html
 end
 
-up(open_browser = true)
\ No newline at end of file
+up()
+openbrowser("http://localhost:8080")
+
+# down()
+# close_proxy(8080; force = true)
+# close_all_proxies(force = true)
\ No newline at end of file
diff --git a/src/StippleMakie.jl b/src/StippleMakie.jl
index 3227bde..61a63c3 100644
--- a/src/StippleMakie.jl
+++ b/src/StippleMakie.jl
@@ -1,16 +1,21 @@
 module StippleMakie
 
 using Stipple
+using Stipple.HTTP
+using Stipple.HTTP.WebSockets
 
 using WGLMakie
 using WGLMakie.Bonito
 using WGLMakie.Bonito.URIs
 using WGLMakie.Bonito.Observables
 
+include("proxy.jl")
+
 # import Stipple.Genie.Router.WS_PROXIES
 WS_PROXIES = isdefined(Genie.Router, :WS_PROXIES) ? Genie.Router.WS_PROXIES : Dict{String, Any}()
 
 export MakieFigure, init_makiefigures, makie_figure, makie_dom, configure_makie_server!, WGLMakie, Makie, nginx_config, once, onready
+export startproxy, closeproxy
 
 """
     once(f::Function, o::Observable)
diff --git a/src/proxy.jl b/src/proxy.jl
new file mode 100644
index 0000000..2597055
--- /dev/null
+++ b/src/proxy.jl
@@ -0,0 +1,120 @@
+using Stipple
+using Stipple.HTTP.URIs
+using Stipple.Genie.Generator.UUIDs
+ 
+const BACKEND_HOST = "127.0.0.1"
+const SOCKETS = Dict{Int, Dict{UUID, WebSocket}}()
+const SERVERS = Dict{Int, HTTP.Server}()
+
+function proxy_handler(stream::HTTP.Stream;
+    genie_port = Genie.config.server_port,
+    genie_ws_port = something(Genie.config.websockets_port, genie_port),
+    makie_path = URI(Bonito.SERVER_CONFIGURATION.proxy_url[]).path,
+    makie_port = Bonito.SERVER_CONFIGURATION.listen_port[])
+ 
+    startswith(makie_path, "/") || (makie_path = "/$makie_path")
+ 
+    req = stream.message
+    host_port = parse(Int, split(Dict(req.headers)["Host"], ":")[2])
+    is_makie_path = startswith(req.target, Regex(makie_path * "(/|\$)"))
+    
+    is_makie_path && (req.target = req.target[length(makie_path) + 1:end])
+ 
+    if WebSockets.isupgrade(req)
+        backend_port = is_makie_path ? makie_port : genie_ws_port
+        HTTP.WebSockets.upgrade(stream) do client_ws
+            client_id = uuid4()
+            sockets = get!(Dict{UUID, WebSocket}, SOCKETS, host_port)
+            push!(sockets, client_id => client_ws)
+ 
+            HTTP.WebSockets.open("http://$BACKEND_HOST:$backend_port$(req.target)") do backend_ws
+                backend_id = uuid4()
+                push!(sockets, backend_id => backend_ws)
+                @async begin
+                    for msg in backend_ws
+                        try
+                            @debug("Sending to client '$(backend_ws.request.target)': $msg")
+                            HTTP.WebSockets.send(client_ws, msg)
+                        catch err
+                            error("Proxy error in backend connection: $err")
+                            close(client_ws)
+                            break
+                        end
+                    end
+                    @debug "Closing backend connection."
+                    close(client_ws)
+                    delete!(sockets, client_id)
+                end
+ 
+                for msg in client_ws
+                    try
+                        @debug("Sending to backend  '$(client_ws.request.target)': $msg")
+                        HTTP.WebSockets.send(backend_ws, msg)
+                    catch err
+                        error("Proxy error in client connection: $err\nclosing Stream handler")
+                        break
+                    end
+                end
+                @debug "Closing client connection."
+                close(backend_ws)
+                delete!(sockets, backend_id)
+                delete!(sockets, client_id) # just to be sure ...
+            end
+        end
+    else
+        backend_port = is_makie_path ? makie_port : genie_port
+        backend_url = "http://$BACKEND_HOST:$backend_port$(req.target)"
+        backend_response = HTTP.request(req.method, backend_url; headers=req.headers, body=req.body)
+        closeread(stream)
+        req.body = read(stream)
+        req.response = backend_response
+        req.response.request = req
+        startwrite(stream)
+        write(stream, req.response.body)
+        nothing
+    end
+end
+
+function startproxy(port = 8080;
+    genie_port = Genie.config.server_port,
+    genie_ws_port = something(Genie.config.websockets_port, genie_port),
+    makie_path = URI(Bonito.SERVER_CONFIGURATION.proxy_url[]).path,
+    makie_port = Bonito.SERVER_CONFIGURATION.listen_port[])
+
+    function handler(stream::HTTP.Stream)
+        proxy_handler(stream; genie_port, genie_ws_port, makie_path, makie_port)
+    end
+
+    server = HTTP.listen!(handler, port)
+    SERVERS[port] = server
+end
+
+function closeproxy(server; force::Bool = false)
+    if force
+        HTTP.Servers.shutdown(server.on_shutdown)
+        close(server.listener)
+        Base.@lock server.connections_lock begin
+            for c in server.connections
+                HTTP.Servers.requestclose!(c)
+            end
+        end
+        port = parse(Int, server.listener.hostport)
+        haskey(SOCKETS, port) && [try close(ws) finally end for ws in values(SOCKETS[port])]
+        delete!(SOCKETS, port)
+    end
+    close(server)
+    return true
+end
+
+function closeproxy(port::Integer; force::Bool = false)
+    haskey(Servers, port) || return false
+    closeproxy(SERVERS[port]; force)
+    delete!(SERVERS, port)
+    return true
+end
+
+function close_all_proxies(; force::Bool = false)
+    for port in keys(SERVERS)
+        closeproxy(port; force)
+    end
+end
\ No newline at end of file