mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
refactor(webui): clarify websocket routing
This commit is contained in:
parent
9ed5643d93
commit
8e421eb976
@ -730,23 +730,65 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"""Route an inbound HTTP request to a handler or to the WS upgrade path."""
|
"""Route an inbound HTTP request to a handler or to the WS upgrade path."""
|
||||||
got, query = _parse_request_path(request.path)
|
got, query = _parse_request_path(request.path)
|
||||||
|
|
||||||
# 1. Token issue endpoint (legacy, optional, gated by configured secret).
|
|
||||||
if self.config.token_issue_path:
|
if self.config.token_issue_path:
|
||||||
issue_expected = _normalize_config_path(self.config.token_issue_path)
|
issue_expected = _normalize_config_path(self.config.token_issue_path)
|
||||||
if got == issue_expected:
|
if got == issue_expected:
|
||||||
return self._handle_token_issue_http(connection, request)
|
return self._handle_token_issue_http(connection, request)
|
||||||
|
|
||||||
# 2. Bootstrap (`/webui/bootstrap`): mint WS/API tokens + shared session metadata.
|
|
||||||
if got == "/webui/bootstrap":
|
if got == "/webui/bootstrap":
|
||||||
return self._handle_bootstrap(connection, request)
|
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":
|
if got == "/api/sessions":
|
||||||
return self._handle_sessions_list(request)
|
return self._handle_sessions_list(request)
|
||||||
|
|
||||||
if got == "/api/settings":
|
|
||||||
return self._handle_settings(request)
|
|
||||||
|
|
||||||
if got == "/api/commands":
|
if got == "/api/commands":
|
||||||
return self._handle_commands(request)
|
return self._handle_commands(request)
|
||||||
|
|
||||||
@ -759,6 +801,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if got == "/api/webui/sidebar-state/update":
|
if got == "/api/webui/sidebar-state/update":
|
||||||
return self._handle_webui_sidebar_state_update(request)
|
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":
|
if got == "/api/settings/update":
|
||||||
return self._handle_settings_update(request)
|
return self._handle_settings_update(request)
|
||||||
|
|
||||||
@ -808,6 +860,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if mcp_action is not None:
|
if mcp_action is not None:
|
||||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
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)
|
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||||
if m:
|
if m:
|
||||||
return self._handle_session_messages(request, m.group(1))
|
return self._handle_session_messages(request, m.group(1))
|
||||||
@ -822,40 +881,36 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if m:
|
if m:
|
||||||
return self._handle_session_delete(request, m.group(1))
|
return self._handle_session_delete(request, m.group(1))
|
||||||
|
|
||||||
# Signed media fetch: ``<sig>`` is an HMAC over ``<payload>``; the
|
return None
|
||||||
# payload decodes to a path inside :func:`get_media_dir`. See
|
|
||||||
# :meth:`_sign_media_path` for the inverse direction used to build
|
def _dispatch_media_api_route(
|
||||||
# these URLs when replaying a session.
|
self,
|
||||||
|
request: WsRequest,
|
||||||
|
got: str,
|
||||||
|
) -> Response | None:
|
||||||
m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got)
|
m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got)
|
||||||
if m:
|
if m:
|
||||||
return self._handle_media_fetch(m.group(1), m.group(2), request)
|
return self._handle_media_fetch(m.group(1), m.group(2), request)
|
||||||
|
|
||||||
# 4. WebSocket upgrade (the channel's primary purpose). Only run the
|
return None
|
||||||
# handshake gate on requests that actually ask to upgrade; otherwise
|
|
||||||
# a bare ``GET /`` from the browser would be rejected as an
|
def _dispatch_websocket_upgrade(
|
||||||
# unauthorized WS handshake instead of serving the SPA's index.html.
|
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()
|
expected_ws = self._expected_path()
|
||||||
if got == expected_ws and _is_websocket_upgrade(request):
|
if got != expected_ws or not _is_websocket_upgrade(request):
|
||||||
client_id = _query_first(query, "client_id") or ""
|
return False, None
|
||||||
if len(client_id) > 128:
|
client_id = _query_first(query, "client_id") or ""
|
||||||
client_id = client_id[:128]
|
if len(client_id) > 128:
|
||||||
if not self.is_allowed(client_id):
|
client_id = client_id[:128]
|
||||||
return connection.respond(403, "Forbidden")
|
if not self.is_allowed(client_id):
|
||||||
return self._authorize_websocket_handshake(connection, query)
|
return True, connection.respond(403, "Forbidden")
|
||||||
|
return True, 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")
|
|
||||||
|
|
||||||
# -- HTTP route handlers ------------------------------------------------
|
# -- HTTP route handlers ------------------------------------------------
|
||||||
|
|
||||||
@ -1164,6 +1219,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._with_settings_restart_state(payload, section="runtime")
|
self._with_settings_restart_state(payload, section="runtime")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -- Session replay, transcript, and signed media ----------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_websocket_channel_session_key(key: str) -> bool:
|
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||||
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
||||||
@ -1376,6 +1433,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
delete_webui_thread(decoded_key)
|
delete_webui_thread(decoded_key)
|
||||||
return _http_json_response({"deleted": bool(deleted)})
|
return _http_json_response({"deleted": bool(deleted)})
|
||||||
|
|
||||||
|
# -- Static files and WebSocket handshake ------------------------------
|
||||||
|
|
||||||
def _serve_static(self, request_path: str) -> Response | None:
|
def _serve_static(self, request_path: str) -> Response | None:
|
||||||
"""Resolve *request_path* against the built SPA directory; SPA fallback to index.html."""
|
"""Resolve *request_path* against the built SPA directory; SPA fallback to index.html."""
|
||||||
assert self._static_dist_path is not None
|
assert self._static_dist_path is not None
|
||||||
@ -1440,6 +1499,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._take_issued_token_if_valid(supplied)
|
self._take_issued_token_if_valid(supplied)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# -- Server lifecycle and connection ingress ---------------------------
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
from nanobot.utils.logging_bridge import redirect_lib_logging
|
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||||
|
|
||||||
@ -1583,6 +1644,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
finally:
|
finally:
|
||||||
self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
|
|
||||||
|
# -- Inbound WebSocket envelopes ---------------------------------------
|
||||||
|
|
||||||
def _save_envelope_media(
|
def _save_envelope_media(
|
||||||
self,
|
self,
|
||||||
media: list[Any],
|
media: list[Any],
|
||||||
@ -1812,6 +1875,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# -- Outbound WebSocket events -----------------------------------------
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user