From 8e421eb9762b479ac16a25665294ea19d1cdf27d Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Fri, 29 May 2026 17:17:22 +0800 Subject: [PATCH] refactor(webui): clarify websocket routing --- nanobot/channels/websocket.py | 135 +++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 35 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 48d73535b..478d20b3f 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -730,23 +730,65 @@ class WebSocketChannel(BaseChannel): """Route an inbound HTTP request to a handler or to the WS upgrade path.""" got, query = _parse_request_path(request.path) - # 1. Token issue endpoint (legacy, optional, gated by configured secret). if self.config.token_issue_path: issue_expected = _normalize_config_path(self.config.token_issue_path) if got == issue_expected: return self._handle_token_issue_http(connection, request) - # 2. Bootstrap (`/webui/bootstrap`): mint WS/API tokens + shared session metadata. if got == "/webui/bootstrap": return self._handle_bootstrap(connection, request) - # 3. REST handlers co-located with this channel (sessions, settings, …). + api_response = await self._dispatch_api_route(connection, request, got) + if api_response is not None: + return api_response + + ws_matched, ws_response = self._dispatch_websocket_upgrade( + connection, request, got, query + ) + if ws_matched: + return ws_response + + # API clients should never receive the SPA shell for an unknown route. + # Returning HTML here makes the WebUI fail with "Unexpected token <" + # when a dev server is pointed at an older gateway. + if got.startswith("/api/"): + return _http_error(404, "API route not found") + + if self._static_dist_path is not None: + response = self._serve_static(got) + if response is not None: + return response + + return connection.respond(404, "Not Found") + + async def _dispatch_api_route( + self, + connection: Any, + request: WsRequest, + got: str, + ) -> Any | None: + """Route REST-ish WebUI requests served beside the WebSocket endpoint.""" + response = await self._dispatch_settings_api_route(request, got) + if response is not None: + return response + response = self._dispatch_session_api_route(request, got) + if response is not None: + return response + response = self._dispatch_media_api_route(request, got) + if response is not None: + return response + return self._dispatch_misc_api_route(connection, request, got) + + def _dispatch_misc_api_route( + self, + connection: Any, + request: WsRequest, + got: str, + ) -> Response | None: + """Route small API endpoints that do not belong to a larger route group.""" if got == "/api/sessions": return self._handle_sessions_list(request) - if got == "/api/settings": - return self._handle_settings(request) - if got == "/api/commands": return self._handle_commands(request) @@ -759,6 +801,16 @@ class WebSocketChannel(BaseChannel): if got == "/api/webui/sidebar-state/update": return self._handle_webui_sidebar_state_update(request) + return None + + async def _dispatch_settings_api_route( + self, + request: WsRequest, + got: str, + ) -> Response | None: + if got == "/api/settings": + return self._handle_settings(request) + if got == "/api/settings/update": return self._handle_settings_update(request) @@ -808,6 +860,13 @@ class WebSocketChannel(BaseChannel): if mcp_action is not None: return await self._handle_settings_mcp_presets(request, mcp_action) + return None + + def _dispatch_session_api_route( + self, + request: WsRequest, + got: str, + ) -> Response | None: m = re.match(r"^/api/sessions/([^/]+)/messages$", got) if m: return self._handle_session_messages(request, m.group(1)) @@ -822,40 +881,36 @@ class WebSocketChannel(BaseChannel): if m: return self._handle_session_delete(request, m.group(1)) - # Signed media fetch: ```` is an HMAC over ````; the - # payload decodes to a path inside :func:`get_media_dir`. See - # :meth:`_sign_media_path` for the inverse direction used to build - # these URLs when replaying a session. + return None + + def _dispatch_media_api_route( + self, + request: WsRequest, + got: str, + ) -> Response | None: m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got) if m: return self._handle_media_fetch(m.group(1), m.group(2), request) - # 4. WebSocket upgrade (the channel's primary purpose). Only run the - # handshake gate on requests that actually ask to upgrade; otherwise - # a bare ``GET /`` from the browser would be rejected as an - # unauthorized WS handshake instead of serving the SPA's index.html. + return None + + def _dispatch_websocket_upgrade( + self, + connection: Any, + request: WsRequest, + got: str, + query: dict[str, list[str]], + ) -> tuple[bool, Any | None]: + """Authorize only real WS upgrade requests for the configured path.""" expected_ws = self._expected_path() - if got == expected_ws and _is_websocket_upgrade(request): - client_id = _query_first(query, "client_id") or "" - if len(client_id) > 128: - client_id = client_id[:128] - if not self.is_allowed(client_id): - return connection.respond(403, "Forbidden") - return self._authorize_websocket_handshake(connection, query) - - # API clients should never receive the SPA shell for an unknown route. - # Returning HTML here makes the WebUI fail with "Unexpected token <" - # when a dev server is pointed at an older gateway. - if got.startswith("/api/"): - return _http_error(404, "API route not found") - - # 5. Static SPA serving (only if a build directory was wired in). - if self._static_dist_path is not None: - response = self._serve_static(got) - if response is not None: - return response - - return connection.respond(404, "Not Found") + if got != expected_ws or not _is_websocket_upgrade(request): + return False, None + client_id = _query_first(query, "client_id") or "" + if len(client_id) > 128: + client_id = client_id[:128] + if not self.is_allowed(client_id): + return True, connection.respond(403, "Forbidden") + return True, self._authorize_websocket_handshake(connection, query) # -- HTTP route handlers ------------------------------------------------ @@ -1164,6 +1219,8 @@ class WebSocketChannel(BaseChannel): self._with_settings_restart_state(payload, section="runtime") ) + # -- Session replay, transcript, and signed media ---------------------- + @staticmethod def _is_websocket_channel_session_key(key: str) -> bool: """True when *key* is a ``websocket:…`` session exposed on this HTTP surface.""" @@ -1376,6 +1433,8 @@ class WebSocketChannel(BaseChannel): delete_webui_thread(decoded_key) return _http_json_response({"deleted": bool(deleted)}) + # -- Static files and WebSocket handshake ------------------------------ + def _serve_static(self, request_path: str) -> Response | None: """Resolve *request_path* against the built SPA directory; SPA fallback to index.html.""" assert self._static_dist_path is not None @@ -1440,6 +1499,8 @@ class WebSocketChannel(BaseChannel): self._take_issued_token_if_valid(supplied) return None + # -- Server lifecycle and connection ingress --------------------------- + async def start(self) -> None: from nanobot.utils.logging_bridge import redirect_lib_logging @@ -1583,6 +1644,8 @@ class WebSocketChannel(BaseChannel): finally: self._cleanup_connection(connection) + # -- Inbound WebSocket envelopes --------------------------------------- + def _save_envelope_media( self, media: list[Any], @@ -1812,6 +1875,8 @@ class WebSocketChannel(BaseChannel): ) return None + # -- Outbound WebSocket events ----------------------------------------- + async def stop(self) -> None: if not self._running: return