Mercurial > libervia-web
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") |