comparison 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
comparison
equal deleted inserted replaced
1503:2796e73ed50c 1504:409d10211b20
1 from browser import window 1 from browser import window, timer, console as log
2 import time
3 import random
4 import json
5 import dialog
2 import javascript 6 import javascript
3 7
4 8
9 log.warning = log.warn
10 tab_id = random.randint(0, 2**64)
11 log.info(f"TAB ID is {tab_id}")
12
13
14 class WebSocket:
15
16 def __init__(self, broadcast_channel):
17 self.broadcast_channel = broadcast_channel
18 self.token = window.ws_token
19 self.create_socket()
20 self.retrying = False
21 self.network_error = False
22
23 @property
24 def profile(self):
25 return self.broadcast_channel.profile
26
27 def retry_connect(self) -> None:
28 if self.retrying:
29 return
30 self.retrying = True
31 try:
32 notif = dialog.RetryNotification(self.create_socket)
33 notif.show(
34 "Can't connect to server",
35 delay=random.randint(0, 30)
36 )
37 except Exception as e:
38 # for security reasons, browser don't give the reason of the error with
39 # WebSockets, thus we try to detect network error here, as if we can't show
40 # the retry dialog, that probably means that it's not reachable
41 try:
42 name = e.name
43 except AttributeError:
44 name = None
45 if name == "NetworkError":
46 self.network_error = True
47 log.warning("network error detected, server may be down")
48 log.error(f"Can't show retry dialog: {e}")
49 log.info("retrying in 30s")
50 timer.set_timeout(self.create_socket, 30000)
51 else:
52 raise e
53 else:
54 # if we can show the retry dialog, the network is fine
55 self.network_error = False
56
57 def create_socket(self) -> None:
58 log.debug("creating socket")
59 self.retrying = False
60 self.socket = window.WebSocket.new(window.ws_url, "libervia-page")
61 self.socket_start = time.time()
62 self.socket.bind("open", self.on_open)
63 self.socket.bind("error", self.on_error)
64 self.socket.bind("close", self.on_close)
65 self.socket.bind("message", self.on_message)
66
67 def send(self, data_type: str, data: dict) -> None:
68 self.socket.send(json.dumps({
69 "type": data_type,
70 "data": data
71 }))
72
73 def close(self) -> None:
74 log.debug("closing socket")
75 self.broadcast_channel.ws = None
76 self.socket.close()
77
78 def on_open(self, evt) -> None:
79 log.info("websocket connection opened")
80 self.send("init", {"profile": self.profile, "token": self.token})
81
82 def on_error(self, evt) -> None:
83 if not self.network_error and time.time() - self.socket_start < 5:
84 # disconnection is happening fast, we try to reload
85 log.warning("Reloading due to suspected session error")
86 window.location.reload()
87 else:
88 self.retry_connect()
89
90 def on_close(self, evt) -> None:
91 log.warning(f"websocket is closed {evt.code=} {evt.reason=}")
92 if self.broadcast_channel.ws is None:
93 # this is a close requested locally
94 return
95 elif evt.code == 4401:
96 log.info(
97 "no authorized, the session is probably not valid anymore, reloading"
98 )
99 window.location.reload()
100 else:
101 # close event may be due to normal tab closing, thus we try to reconnect only
102 # after a delay
103 timer.set_timeout(self.retry_connect, 5000)
104
105 def on_message(self, message_evt):
106 msg_data = json.loads(message_evt.data)
107 msg_type = msg_data.get("type")
108 if msg_type == "bridge":
109 self.broadcast_channel.post(
110 msg_type,
111 msg_data["data"]
112 )
113 elif msg_type == "force_close":
114 log.warning(f"force closing connection: {msg_data.get('reason')}")
115 self.close()
116 else:
117 dialog.notification.show(
118 f"Unexpected message type {msg_type}"
119 "error"
120 )
121
122
123 class BroadcastChannel:
124 handlers = {}
125
126 def __init__(self):
127 log.debug(f"BroadcastChannel init with profile {self.profile!r}")
128 self.start = time.time()
129 self.bc = window.BroadcastChannel.new("libervia")
130 self.bc.bind("message", self.on_message)
131 # there is no way to check if there is already a connection in BroadcastChannel
132 # API, thus we wait a bit to see if somebody is answering. If not, we are probably
133 # the first tab.
134 self.check_connection_timer = timer.set_timeout(self.establish_connection, 20)
135 self.ws = None
136 # set of all known tab ids
137 self.tabs_ids = {tab_id}
138 self.post("salut_a_vous", {
139 "id": tab_id,
140 "profile": self.profile
141 })
142 window.bind("unload", self.on_unload)
143
144 @property
145 def profile(self):
146 return window.profile or ""
147
148 @property
149 def connecting_tab(self) -> bool:
150 """True is this tab is the one establishing the websocket connection"""
151 return self.ws is not None
152
153 @connecting_tab.setter
154 def connecting_tab(self, connecting: bool) -> None:
155 if connecting:
156 if self.ws is None:
157 self.ws = WebSocket(self)
158 self.post("connection", {
159 "tab_id": tab_id
160 })
161 elif self.ws is not None:
162 self.ws.close()
163
164 def establish_connection(self) -> None:
165 """Called when there is no existing connection"""
166 timer.clear_timeout(self.check_connection_timer)
167 log.debug(f"Establishing connection {tab_id=}")
168 self.connecting_tab = True
169
170 def handle_bridge_signal(self, data: dict) -> None:
171 """Forward bridge signals to registered handlers"""
172 signal = data["signal"]
173 handlers = self.handlers.get(signal, [])
174 for handler in handlers:
175 handler(*data["args"])
176
177 def on_message(self, evt) -> None:
178 data = json.loads(evt.data)
179 if data["type"] == "bridge":
180 self.handle_bridge_signal(data)
181 elif data["type"] == "salut_a_toi":
182 # this is a response from existing tabs
183 other_tab_id = data["id"]
184 if other_tab_id == tab_id:
185 # in the unlikely case that this happens, we simply reload this tab to get
186 # a new ID
187 log.warning("duplicate tab id, we reload the page: {tab_id=}")
188 window.location.reload()
189 return
190 self.tabs_ids.add(other_tab_id)
191 if data["connecting_tab"] and self.check_connection_timer is not None:
192 # this tab has the websocket connection to server
193 log.info(f"there is already a connection to server at tab {other_tab_id}")
194 timer.clear_timeout(self.check_connection_timer)
195 self.check_connection_timer = None
196 elif data["type"] == "salut_a_vous":
197 # a new tab has just been created
198 if data["profile"] != self.profile:
199 log.info(
200 f"we are now connected with the profile {data['profile']}, "
201 "reloading the page"
202 )
203 window.location.reload()
204 else:
205 self.tabs_ids.add(data["id"])
206 self.post("salut_a_toi", {
207 "id": tab_id,
208 "connecting_tab": self.connecting_tab
209 })
210 elif data["type"] == "connection":
211 log.info(f"tab {data['id']} is the new connecting tab")
212 elif data["type"] == "salut_a_rantanplan":
213 # a tab is being closed
214 other_tab_id = data["id"]
215 # it is unlikely that there is a collision, but just in case we check it
216 if other_tab_id != tab_id:
217 self.tabs_ids.discard(other_tab_id)
218 if data["connecting_tab"]:
219 log.info(f"connecting tab with id {other_tab_id} has been closed")
220 if max(self.tabs_ids) == tab_id:
221 log.info("this is the new connecting tab, establish_connection")
222 self.connecting_tab = True
223 else:
224 log.info(f"tab with id {other_tab_id} has been closed")
225 else:
226 log.warning(f"unknown message type: {data}")
227
228 def post(self, data_type, data: dict):
229 data["type"] = data_type
230 data["id"] = tab_id
231 self.bc.postMessage(json.dumps(data))
232 if data_type == "bridge":
233 self.handle_bridge_signal(data)
234
235 def on_unload(self, evt) -> None:
236 """Send a message to indicate that the tab is being closed"""
237 self.post("salut_a_rantanplan", {
238 "id": tab_id,
239 "connecting_tab": self.connecting_tab
240 })
241
242
5 class Bridge: 243 class Bridge:
244 bc: BroadcastChannel | None = None
245
246 def __init__(self) -> None:
247 if Bridge.bc is None:
248 Bridge.bc = BroadcastChannel()
6 249
7 def __getattr__(self, attr): 250 def __getattr__(self, attr):
8 return lambda *args, **kwargs: self.call(attr, *args, **kwargs) 251 return lambda *args, **kwargs: self.call(attr, *args, **kwargs)
9 252
10 def on_load(self, xhr, ev, callback, errback): 253 def on_load(self, xhr, ev, callback, errback):
19 # PROXY_ERROR is used for bridge error 262 # PROXY_ERROR is used for bridge error
20 ret = javascript.JSON.parse(xhr.response) 263 ret = javascript.JSON.parse(xhr.response)
21 if errback is not None: 264 if errback is not None:
22 errback(ret) 265 errback(ret)
23 else: 266 else:
24 print(f"bridge called failed: code: {xhr.response}, text: {xhr.statusText}") 267 log.error(
268 f"bridge called failed: code: {xhr.response}, text: {xhr.statusText}"
269 )
25 if errback is not None: 270 if errback is not None:
26 errback({"fullname": "BridgeInternalError", "message": xhr.statusText}) 271 errback({"fullname": "BridgeInternalError", "message": xhr.statusText})
27 272
28 def call(self, method_name, *args, callback, errback, **kwargs): 273 def call(self, method_name, *args, callback, errback, **kwargs):
29 xhr = window.XMLHttpRequest.new() 274 xhr = window.XMLHttpRequest.new()
35 "args": args, 280 "args": args,
36 "kwargs": kwargs, 281 "kwargs": kwargs,
37 }) 282 })
38 xhr.setRequestHeader('X-Csrf-Token', window.csrf_token) 283 xhr.setRequestHeader('X-Csrf-Token', window.csrf_token)
39 xhr.send(data) 284 xhr.send(data)
285
286 def register_signal(self, signal: str, handler, iface=None) -> None:
287 BroadcastChannel.handlers.setdefault(signal, []).append(handler)
288 log.debug(f"signal {signal} has been registered")