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