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