Direct Connect server — HTTP ``/sessions`` + WS ``/ws/ ``. Lifecycle: srv = DirectConnectServer(config, manager, spawn_agent) await srv.start() await srv.serve_forever() # or use srv.stop() to shut down await srv.stop() Two listeners: HTTP on ``config.p
| 159 | |
| 160 | @dataclass |
| 161 | class DirectConnectServer: |
| 162 | """Direct Connect server — HTTP ``/sessions`` + WS ``/ws/<sid>``. |
| 163 | |
| 164 | Lifecycle: |
| 165 | srv = DirectConnectServer(config, manager, spawn_agent) |
| 166 | await srv.start() |
| 167 | await srv.serve_forever() # or use srv.stop() to shut down |
| 168 | await srv.stop() |
| 169 | |
| 170 | Two listeners: HTTP on ``config.port`` (or ephemeral) and WS on a |
| 171 | separate ephemeral port. ``ws_url`` in the create-session response |
| 172 | points at the WS port. Auth is per-session: ``POST /sessions`` |
| 173 | returns ``auth_token``; the WS upgrade requires it via |
| 174 | ``Authorization: Bearer <token>`` or ``?token=<token>``. |
| 175 | """ |
| 176 | |
| 177 | config: ServerConfig |
| 178 | manager: SessionManager |
| 179 | spawn_agent: SpawnAgent |
| 180 | _http_server: asyncio.AbstractServer | None = None |
| 181 | _ws_server: asyncio.Server | None = None |
| 182 | _session_tokens: dict[str, str] = field(default_factory=dict) |
| 183 | _ws_port: int | None = None |
| 184 | |
| 185 | # ─── Public lifecycle ──────────────────────────────────────────── |
| 186 | |
| 187 | async def start(self) -> None: |
| 188 | """Bind both listeners; ``serve_forever`` to actually run.""" |
| 189 | # WS listener first so we know its port for the ``ws_url`` we |
| 190 | # hand out from the HTTP route. |
| 191 | self._ws_server = await ws_serve( |
| 192 | self._handle_ws_connection, |
| 193 | host=self.config.host or '127.0.0.1', |
| 194 | port=0, # ephemeral |
| 195 | ) |
| 196 | ws_sockets = list(self._ws_server.sockets or []) |
| 197 | if not ws_sockets: |
| 198 | raise RuntimeError('DirectConnectServer: WS listener has no socket') |
| 199 | self._ws_port = ws_sockets[0].getsockname()[1] |
| 200 | |
| 201 | # HTTP listener. |
| 202 | if self.config.unix: |
| 203 | self._http_server = await asyncio.start_unix_server( |
| 204 | self._handle_http_connection, |
| 205 | path=self.config.unix, |
| 206 | ) |
| 207 | else: |
| 208 | self._http_server = await asyncio.start_server( |
| 209 | self._handle_http_connection, |
| 210 | host=self.config.host or '127.0.0.1', |
| 211 | port=self.config.port, |
| 212 | ) |
| 213 | |
| 214 | async def serve_forever(self) -> None: |
| 215 | if self._http_server is None or self._ws_server is None: |
| 216 | raise RuntimeError('start() must be called before serve_forever()') |
| 217 | # Both servers run concurrently; cancellation of either tears |
| 218 | # down both atomically via the gather. |
no outgoing calls