refactor(webui): clarify websocket routing

This commit is contained in:
Xubin Ren 2026-05-29 17:17:22 +08:00
parent 9ed5643d93
commit 8e421eb976

View File

@ -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: ``<sig>`` is an HMAC over ``<payload>``; 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