diff libervia/web/pages/_browser/bridge.py @ 1518:eb00d593801d

refactoring: rename `libervia` to `libervia.web` + update imports following backend changes
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 16:49:28 +0200
parents libervia/pages/_browser/bridge.py@5ea06e8b06ed
children be20e6ac9f22
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/bridge.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,348 @@
+from browser import window, aio, 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 BridgeException(Exception):
+    """An exception which has been raised from the backend and arrived to the frontend."""
+
+    def __init__(self, name, message="", condition=""):
+        """
+
+        @param name (str): full exception class name (with module)
+        @param message (str): error message
+        @param condition (str) : error condition
+        """
+        Exception.__init__(self)
+        self.fullname = str(name)
+        self.message = str(message)
+        self.condition = str(condition) if condition else ""
+        self.module, __, self.classname = str(self.fullname).rpartition(".")
+
+    def __str__(self):
+        return f"{self.classname}: {self.message or ''}"
+
+    def __eq__(self, other):
+        return self.classname == other
+
+
+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":
+            log.debug(
+                f"==> bridge message: {msg_data=}"
+            )
+            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)
+
+    def on_load(self, xhr, ev, callback, errback):
+        if xhr.status == 200:
+            ret = javascript.JSON.parse(xhr.response)
+            if callback is not None:
+                if ret is None:
+                    callback()
+                else:
+                    callback(ret)
+        elif xhr.status == 502:
+            # PROXY_ERROR is used for bridge error
+            ret = javascript.JSON.parse(xhr.response)
+            if errback is not None:
+                errback(ret)
+        else:
+            log.error(
+                f"bridge call failed: code: {xhr.response}, text: {xhr.statusText}"
+            )
+            if errback is not None:
+                errback({"fullname": "BridgeInternalError", "message": xhr.statusText})
+
+    def call(self, method_name, *args, callback, errback, **kwargs):
+        xhr = window.XMLHttpRequest.new()
+        xhr.bind('load', lambda ev: self.on_load(xhr, ev, callback, errback))
+        xhr.bind('error', lambda ev: errback(
+            {"fullname": "ConnectionError", "message": xhr.statusText}))
+        xhr.open("POST", f"/_bridge/{method_name}", True)
+        data = javascript.JSON.stringify({
+            "args": args,
+            "kwargs": kwargs,
+        })
+        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")
+
+
+class AsyncBridge:
+
+    def __getattr__(self, attr):
+        return lambda *args, **kwargs: self.call(attr, *args, **kwargs)
+
+    async def call(self, method_name, *args, **kwargs):
+        print(f"calling {method_name}")
+        data = javascript.JSON.stringify({
+            "args": args,
+            "kwargs": kwargs,
+        })
+        url = f"/_bridge/{method_name}"
+        r = await aio.post(
+            url,
+            headers={
+                'X-Csrf-Token': window.csrf_token,
+            },
+            data=data,
+        )
+
+        if r.status == 200:
+            return javascript.JSON.parse(r.data)
+        elif r.status == 502:
+            ret = javascript.JSON.parse(r.data)
+            raise BridgeException(ret['fullname'], ret['message'], ret['condition'])
+        else:
+            print(f"bridge called failed: code: {r.status}, text: {r.statusText}")
+            raise BridgeException("InternalError", r.statusText)
+
+    def register_signal(self, signal: str, handler, iface=None) -> None:
+        BroadcastChannel.handlers.setdefault(signal, []).append(handler)
+        log.debug(f"signal {signal} has been registered")