diff libervia/pages/_browser/bridge.py @ 1504:409d10211b20

server, browser: dynamic pages refactoring: dynamic pages has been reworked, to change the initial basic implementation. Pages are now dynamic by default, and a websocket is established by the first connected page of a session. The socket is used to transmit bridge signals, and then the signal is broadcasted to other tabs using broadcast channel. If the connecting tab is closed, an other one is chosen. Some tests are made to retry connecting in case of problem, and sometimes reload the pages (e.g. if profile is connected). Signals (or other data) are cached during reconnection phase, to avoid lost of data. All previous partial rendering mechanism have been removed, chat page is temporarily not working anymore, but will be eventually redone (one of the goal of this work is to have proper chat).
author Goffi <goffi@goffi.org>
date Wed, 01 Mar 2023 18:02:44 +0100
parents b28025a7cc28
children ce879da7fcf7
line wrap: on
line diff
--- a/libervia/pages/_browser/bridge.py	Wed Mar 01 17:55:25 2023 +0100
+++ b/libervia/pages/_browser/bridge.py	Wed Mar 01 18:02:44 2023 +0100
@@ -1,8 +1,251 @@
-from browser import window
+from browser import window, timer, console as log
+import time
+import random
+import json
+import dialog
 import javascript
 
 
+log.warning = log.warn
+tab_id = random.randint(0, 2**64)
+log.info(f"TAB ID is {tab_id}")
+
+
+class WebSocket:
+
+    def __init__(self, broadcast_channel):
+        self.broadcast_channel = broadcast_channel
+        self.token = window.ws_token
+        self.create_socket()
+        self.retrying = False
+        self.network_error = False
+
+    @property
+    def profile(self):
+        return self.broadcast_channel.profile
+
+    def retry_connect(self) -> None:
+        if self.retrying:
+            return
+        self.retrying = True
+        try:
+            notif = dialog.RetryNotification(self.create_socket)
+            notif.show(
+                "Can't connect to server",
+                delay=random.randint(0, 30)
+            )
+        except Exception as e:
+            # for security reasons, browser don't give the reason of the error with
+            # WebSockets, thus we try to detect network error here, as if we can't show
+            # the retry dialog, that probably means that it's not reachable
+            try:
+                name = e.name
+            except AttributeError:
+                name = None
+            if name == "NetworkError":
+                self.network_error = True
+                log.warning("network error detected, server may be down")
+                log.error(f"Can't show retry dialog: {e}")
+                log.info("retrying in 30s")
+                timer.set_timeout(self.create_socket, 30000)
+            else:
+                raise e
+        else:
+            # if we can show the retry dialog, the network is fine
+            self.network_error = False
+
+    def create_socket(self) -> None:
+        log.debug("creating socket")
+        self.retrying = False
+        self.socket = window.WebSocket.new(window.ws_url, "libervia-page")
+        self.socket_start = time.time()
+        self.socket.bind("open", self.on_open)
+        self.socket.bind("error", self.on_error)
+        self.socket.bind("close", self.on_close)
+        self.socket.bind("message", self.on_message)
+
+    def send(self, data_type: str, data: dict) -> None:
+        self.socket.send(json.dumps({
+            "type": data_type,
+            "data": data
+        }))
+
+    def close(self) -> None:
+        log.debug("closing socket")
+        self.broadcast_channel.ws = None
+        self.socket.close()
+
+    def on_open(self, evt) -> None:
+        log.info("websocket connection opened")
+        self.send("init", {"profile": self.profile, "token": self.token})
+
+    def on_error(self, evt) -> None:
+        if not self.network_error and time.time() - self.socket_start < 5:
+            # disconnection is happening fast, we try to reload
+            log.warning("Reloading due to suspected session error")
+            window.location.reload()
+        else:
+            self.retry_connect()
+
+    def on_close(self, evt) -> None:
+        log.warning(f"websocket is closed {evt.code=} {evt.reason=}")
+        if self.broadcast_channel.ws is None:
+            # this is a close requested locally
+            return
+        elif evt.code == 4401:
+            log.info(
+                "no authorized, the session is probably not valid anymore, reloading"
+            )
+            window.location.reload()
+        else:
+            # close event may be due to normal tab closing, thus we try to reconnect only
+            # after a delay
+            timer.set_timeout(self.retry_connect, 5000)
+
+    def on_message(self, message_evt):
+        msg_data = json.loads(message_evt.data)
+        msg_type = msg_data.get("type")
+        if msg_type == "bridge":
+            self.broadcast_channel.post(
+                msg_type,
+                msg_data["data"]
+            )
+        elif msg_type == "force_close":
+            log.warning(f"force closing connection: {msg_data.get('reason')}")
+            self.close()
+        else:
+            dialog.notification.show(
+                f"Unexpected message type {msg_type}"
+                "error"
+            )
+
+
+class BroadcastChannel:
+    handlers = {}
+
+    def __init__(self):
+        log.debug(f"BroadcastChannel init with profile {self.profile!r}")
+        self.start = time.time()
+        self.bc = window.BroadcastChannel.new("libervia")
+        self.bc.bind("message", self.on_message)
+        # there is no way to check if there is already a connection in BroadcastChannel
+        # API, thus we wait a bit to see if somebody is answering. If not, we are probably
+        # the first tab.
+        self.check_connection_timer = timer.set_timeout(self.establish_connection, 20)
+        self.ws = None
+        # set of all known tab ids
+        self.tabs_ids = {tab_id}
+        self.post("salut_a_vous", {
+            "id": tab_id,
+            "profile": self.profile
+        })
+        window.bind("unload", self.on_unload)
+
+    @property
+    def profile(self):
+        return window.profile or ""
+
+    @property
+    def connecting_tab(self) -> bool:
+        """True is this tab is the one establishing the websocket connection"""
+        return self.ws is not None
+
+    @connecting_tab.setter
+    def connecting_tab(self, connecting: bool) -> None:
+        if connecting:
+            if self.ws is None:
+                self.ws = WebSocket(self)
+                self.post("connection", {
+                    "tab_id": tab_id
+                })
+        elif self.ws is not None:
+            self.ws.close()
+
+    def establish_connection(self) -> None:
+        """Called when there is no existing connection"""
+        timer.clear_timeout(self.check_connection_timer)
+        log.debug(f"Establishing connection {tab_id=}")
+        self.connecting_tab = True
+
+    def handle_bridge_signal(self, data: dict) -> None:
+        """Forward bridge signals to registered handlers"""
+        signal = data["signal"]
+        handlers = self.handlers.get(signal, [])
+        for handler in handlers:
+            handler(*data["args"])
+
+    def on_message(self, evt) -> None:
+        data = json.loads(evt.data)
+        if data["type"] == "bridge":
+            self.handle_bridge_signal(data)
+        elif data["type"] == "salut_a_toi":
+            # this is a response from existing tabs
+            other_tab_id = data["id"]
+            if other_tab_id == tab_id:
+                # in the unlikely case that this happens, we simply reload this tab to get
+                # a new ID
+                log.warning("duplicate tab id, we reload the page: {tab_id=}")
+                window.location.reload()
+                return
+            self.tabs_ids.add(other_tab_id)
+            if data["connecting_tab"] and self.check_connection_timer is not None:
+                # this tab has the websocket connection to server
+                log.info(f"there is already a connection to server at tab {other_tab_id}")
+                timer.clear_timeout(self.check_connection_timer)
+                self.check_connection_timer = None
+        elif data["type"] == "salut_a_vous":
+            # a new tab has just been created
+            if data["profile"] != self.profile:
+                log.info(
+                    f"we are now connected with the profile {data['profile']}, "
+                    "reloading the page"
+                )
+                window.location.reload()
+            else:
+                self.tabs_ids.add(data["id"])
+                self.post("salut_a_toi", {
+                    "id": tab_id,
+                    "connecting_tab": self.connecting_tab
+                })
+        elif data["type"] == "connection":
+            log.info(f"tab {data['id']} is the new connecting tab")
+        elif data["type"] == "salut_a_rantanplan":
+            # a tab is being closed
+            other_tab_id = data["id"]
+            # it is unlikely that there is a collision, but just in case we check it
+            if other_tab_id != tab_id:
+                self.tabs_ids.discard(other_tab_id)
+            if data["connecting_tab"]:
+                log.info(f"connecting tab with id {other_tab_id} has been closed")
+                if max(self.tabs_ids) == tab_id:
+                    log.info("this is the new connecting tab, establish_connection")
+                    self.connecting_tab = True
+            else:
+                log.info(f"tab with id {other_tab_id} has been closed")
+        else:
+            log.warning(f"unknown message type: {data}")
+
+    def post(self, data_type, data: dict):
+        data["type"] = data_type
+        data["id"] = tab_id
+        self.bc.postMessage(json.dumps(data))
+        if data_type == "bridge":
+            self.handle_bridge_signal(data)
+
+    def on_unload(self, evt) -> None:
+        """Send a message to indicate that the tab is being closed"""
+        self.post("salut_a_rantanplan", {
+            "id": tab_id,
+            "connecting_tab": self.connecting_tab
+        })
+
+
 class Bridge:
+    bc: BroadcastChannel | None = None
+
+    def __init__(self) -> None:
+        if Bridge.bc is None:
+            Bridge.bc = BroadcastChannel()
 
     def __getattr__(self, attr):
         return lambda *args, **kwargs: self.call(attr, *args, **kwargs)
@@ -21,7 +264,9 @@
             if errback is not None:
                 errback(ret)
         else:
-            print(f"bridge called failed: code: {xhr.response}, text: {xhr.statusText}")
+            log.error(
+                f"bridge called failed: code: {xhr.response}, text: {xhr.statusText}"
+            )
             if errback is not None:
                 errback({"fullname": "BridgeInternalError", "message": xhr.statusText})
 
@@ -37,3 +282,7 @@
         })
         xhr.setRequestHeader('X-Csrf-Token', window.csrf_token)
         xhr.send(data)
+
+    def register_signal(self, signal: str, handler, iface=None) -> None:
+        BroadcastChannel.handlers.setdefault(signal, []).append(handler)
+        log.debug(f"signal {signal} has been registered")