termbridge 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
termbridge/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """TermBridge fastapi."""
termbridge/api.py ADDED
@@ -0,0 +1,588 @@
1
+ import asyncio
2
+ import base64
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import httpx
8
+ import websockets
9
+ from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response, WebSocket, WebSocketDisconnect, status
10
+ from fastapi.exceptions import RequestValidationError
11
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
12
+ from starlette.exceptions import HTTPException as StarletteHTTPException
13
+ from websockets.typing import Origin, Subprotocol
14
+
15
+ from termbridge.di import SessionServiceDep, TerminalServiceDep, WorkspaceBrowserServiceDep
16
+ from termbridge.exceptions import (
17
+ InvalidTerminalCommandError,
18
+ InvalidTerminalConfigError,
19
+ NoAvailablePortError,
20
+ SessionNotFoundError,
21
+ SessionRepositoryError,
22
+ ShortcutNotFoundError,
23
+ ShortcutRepositoryError,
24
+ UnknownRuntimeError,
25
+ WorkspaceBrowserError,
26
+ WorkspaceNotFoundError,
27
+ WorkspacePathNotDirectoryError,
28
+ WorkspacePathNotFoundError,
29
+ )
30
+ from termbridge.logging import configure_logging
31
+ from termbridge.middleware import RequestLoggingMiddleware
32
+ from termbridge.models import (
33
+ CloseAllSessionsResponse,
34
+ CreateSessionRequest,
35
+ CreateShortcutRequest,
36
+ EnvironmentListResponse,
37
+ LinuxCheckResponse,
38
+ RuntimeCheckRequest,
39
+ RuntimeCheckResponse,
40
+ SessionResponse,
41
+ SessionTreeResponse,
42
+ Shortcut,
43
+ ShortcutListResponse,
44
+ TerminalSettings,
45
+ UpdateShortcutRequest,
46
+ UpdateTerminalSettingsRequest,
47
+ WindowsCygwinCheckRequest,
48
+ WindowsCygwinCheckResponse,
49
+ WindowsCygwinSettings,
50
+ WindowsWslCheckResponse,
51
+ WindowsWslSettings,
52
+ WorkspaceRootsResponse,
53
+ WorkspaceTreeResponse,
54
+ )
55
+ from termbridge.settings import load_settings
56
+
57
+ router = APIRouter()
58
+ logger = logging.getLogger(__name__)
59
+
60
+ STATUS_ERROR_CODES = {
61
+ status.HTTP_400_BAD_REQUEST: "bad_request",
62
+ status.HTTP_404_NOT_FOUND: "not_found",
63
+ status.HTTP_409_CONFLICT: "conflict",
64
+ status.HTTP_422_UNPROCESSABLE_CONTENT: "validation_error",
65
+ status.HTTP_500_INTERNAL_SERVER_ERROR: "internal_error",
66
+ status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable",
67
+ }
68
+ HOP_BY_HOP_HEADERS = {
69
+ "connection",
70
+ "keep-alive",
71
+ "proxy-authenticate",
72
+ "proxy-authorization",
73
+ "te",
74
+ "trailer",
75
+ "transfer-encoding",
76
+ "upgrade",
77
+ "content-encoding",
78
+ "content-length",
79
+ }
80
+
81
+
82
+ def _error_code(status_code: int) -> str:
83
+ return STATUS_ERROR_CODES.get(status_code, "request_failed")
84
+
85
+
86
+ def _error_message(detail: Any, fallback: str) -> str:
87
+ if isinstance(detail, str) and detail:
88
+ return detail
89
+ return fallback
90
+
91
+
92
+ def _error_response(status_code: int, code: str, error: str) -> JSONResponse:
93
+ return JSONResponse(status_code=status_code, content={"code": code, "error": error})
94
+
95
+
96
+ def _basic_auth_header(username: str, password: str) -> str:
97
+ token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
98
+ return f"Basic {token}"
99
+
100
+
101
+ def _target_headers(credential: Any | None) -> dict[str, str]:
102
+ if credential is None:
103
+ return {}
104
+ return {"Authorization": _basic_auth_header(credential.username, credential.password)}
105
+
106
+
107
+ def _proxy_headers(headers: httpx.Headers) -> dict[str, str]:
108
+ return {key: value for key, value in headers.items() if key.lower() not in HOP_BY_HOP_HEADERS}
109
+
110
+
111
+ async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
112
+ if not isinstance(exc, StarletteHTTPException):
113
+ return await unhandled_exception_handler(request, exc)
114
+ code = _error_code(exc.status_code)
115
+ error = _error_message(exc.detail, "Request failed")
116
+ return _error_response(exc.status_code, code, error)
117
+
118
+
119
+ async def validation_exception_handler(request: Request, exc: Exception) -> JSONResponse:
120
+ if not isinstance(exc, RequestValidationError):
121
+ return await unhandled_exception_handler(request, exc)
122
+ return _error_response(status.HTTP_422_UNPROCESSABLE_CONTENT, "validation_error", "Validation error")
123
+
124
+
125
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
126
+ logger.exception("Unhandled request error path=%s", request.url.path)
127
+ return _error_response(status.HTTP_500_INTERNAL_SERVER_ERROR, "internal_error", "Internal server error")
128
+
129
+
130
+ @router.get("/health")
131
+ def health() -> dict[str, str]:
132
+ return {"status": "ok"}
133
+
134
+
135
+ @router.get("/api/workspaces/roots", response_model=WorkspaceRootsResponse)
136
+ def list_workspace_roots(service: WorkspaceBrowserServiceDep) -> WorkspaceRootsResponse:
137
+ try:
138
+ return service.list_roots()
139
+ except WorkspaceBrowserError as exc:
140
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
141
+
142
+
143
+ @router.get("/api/workspaces/tree", response_model=WorkspaceTreeResponse)
144
+ def list_workspace_tree(
145
+ service: WorkspaceBrowserServiceDep,
146
+ path: str = Query(min_length=1),
147
+ show_hidden: bool = False,
148
+ ) -> WorkspaceTreeResponse:
149
+ try:
150
+ return service.list_children(Path(path), show_hidden=show_hidden)
151
+ except WorkspacePathNotFoundError as exc:
152
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
153
+ except WorkspacePathNotDirectoryError as exc:
154
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
155
+ except WorkspaceBrowserError as exc:
156
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
157
+
158
+
159
+ @router.get("/api/shortcuts", response_model=ShortcutListResponse)
160
+ def list_shortcuts(service: TerminalServiceDep) -> ShortcutListResponse:
161
+ try:
162
+ return service.list_shortcuts()
163
+ except ShortcutRepositoryError as exc:
164
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
165
+
166
+
167
+ @router.post("/api/shortcuts", response_model=Shortcut, status_code=status.HTTP_201_CREATED)
168
+ def create_shortcut(request: CreateShortcutRequest, service: TerminalServiceDep) -> Shortcut:
169
+ try:
170
+ return service.create_shortcut(request)
171
+ except InvalidTerminalConfigError as exc:
172
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
173
+ except ShortcutRepositoryError as exc:
174
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
175
+
176
+
177
+ @router.put("/api/shortcuts/{shortcut_id}", response_model=Shortcut)
178
+ def update_shortcut(shortcut_id: str, request: UpdateShortcutRequest, service: TerminalServiceDep) -> Shortcut:
179
+ try:
180
+ return service.update_shortcut(shortcut_id, request)
181
+ except ShortcutNotFoundError as exc:
182
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shortcut not found") from exc
183
+ except InvalidTerminalConfigError as exc:
184
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
185
+ except ShortcutRepositoryError as exc:
186
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
187
+
188
+
189
+ @router.delete("/api/shortcuts/{shortcut_id}", status_code=status.HTTP_204_NO_CONTENT)
190
+ def delete_shortcut(shortcut_id: str, service: TerminalServiceDep, session_service: SessionServiceDep) -> Response:
191
+ try:
192
+ if any(session.shortcut_id == shortcut_id for session in session_service.list_sessions()):
193
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Shortcut is in use")
194
+ service.delete_shortcut(shortcut_id)
195
+ except HTTPException:
196
+ raise
197
+ except ShortcutNotFoundError as exc:
198
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shortcut not found") from exc
199
+ except (SessionRepositoryError, ShortcutRepositoryError) as exc:
200
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
201
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
202
+
203
+
204
+ @router.get("/api/terminal-settings", response_model=TerminalSettings)
205
+ def get_terminal_settings(service: TerminalServiceDep) -> TerminalSettings:
206
+ try:
207
+ return service.get_settings()
208
+ except ShortcutRepositoryError as exc:
209
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
210
+
211
+
212
+ @router.put("/api/terminal-settings", response_model=TerminalSettings)
213
+ def update_terminal_settings(request: UpdateTerminalSettingsRequest, service: TerminalServiceDep) -> TerminalSettings:
214
+ try:
215
+ return service.update_settings(request)
216
+ except InvalidTerminalConfigError as exc:
217
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
218
+ except ShortcutRepositoryError as exc:
219
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
220
+
221
+
222
+ @router.post("/api/environment/ttyd/check", response_model=RuntimeCheckResponse)
223
+ def check_ttyd(request: RuntimeCheckRequest, service: TerminalServiceDep) -> RuntimeCheckResponse:
224
+ return service.check_ttyd(request.path)
225
+
226
+
227
+ @router.get("/api/environments", response_model=EnvironmentListResponse)
228
+ def list_environments(service: TerminalServiceDep) -> EnvironmentListResponse:
229
+ try:
230
+ return service.list_environments()
231
+ except ShortcutRepositoryError as exc:
232
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
233
+
234
+
235
+ @router.get("/api/environment/windows-cygwin/settings", response_model=WindowsCygwinSettings)
236
+ def get_windows_cygwin_settings(service: TerminalServiceDep) -> WindowsCygwinSettings:
237
+ try:
238
+ return service.get_windows_cygwin_settings()
239
+ except ShortcutRepositoryError as exc:
240
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
241
+
242
+
243
+ @router.put("/api/environment/windows-cygwin/settings", response_model=WindowsCygwinSettings)
244
+ def update_windows_cygwin_settings(
245
+ request: WindowsCygwinSettings, service: TerminalServiceDep
246
+ ) -> WindowsCygwinSettings:
247
+ try:
248
+ return service.update_windows_cygwin_settings(request)
249
+ except InvalidTerminalConfigError as exc:
250
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
251
+ except ShortcutRepositoryError as exc:
252
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
253
+
254
+
255
+ @router.post("/api/environment/windows-cygwin/check", response_model=WindowsCygwinCheckResponse)
256
+ def check_windows_cygwin(
257
+ request: WindowsCygwinCheckRequest, service: TerminalServiceDep
258
+ ) -> WindowsCygwinCheckResponse:
259
+ return service.check_windows_cygwin(request.bash_path)
260
+
261
+
262
+ @router.get("/api/environment/windows-wsl/settings", response_model=WindowsWslSettings)
263
+ def get_windows_wsl_settings(service: TerminalServiceDep) -> WindowsWslSettings:
264
+ try:
265
+ return service.get_windows_wsl_settings()
266
+ except ShortcutRepositoryError as exc:
267
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
268
+
269
+
270
+ @router.put("/api/environment/windows-wsl/settings", response_model=WindowsWslSettings)
271
+ def update_windows_wsl_settings(request: WindowsWslSettings, service: TerminalServiceDep) -> WindowsWslSettings:
272
+ try:
273
+ return service.update_windows_wsl_settings(request)
274
+ except ShortcutRepositoryError as exc:
275
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
276
+
277
+
278
+ @router.post("/api/environment/windows-wsl/check", response_model=WindowsWslCheckResponse)
279
+ def check_windows_wsl(service: TerminalServiceDep) -> WindowsWslCheckResponse:
280
+ return service.check_windows_wsl()
281
+
282
+
283
+ @router.post("/api/environment/linux/check", response_model=LinuxCheckResponse)
284
+ def check_linux(service: TerminalServiceDep) -> LinuxCheckResponse:
285
+ return service.check_linux()
286
+
287
+
288
+ @router.post("/api/sessions", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
289
+ def create_session(request: CreateSessionRequest, service: SessionServiceDep) -> SessionResponse:
290
+ try:
291
+ return service.create(request)
292
+ except (
293
+ UnknownRuntimeError,
294
+ InvalidTerminalCommandError,
295
+ InvalidTerminalConfigError,
296
+ WorkspaceNotFoundError,
297
+ ) as exc:
298
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
299
+ except ShortcutNotFoundError as exc:
300
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shortcut not found") from exc
301
+ except NoAvailablePortError as exc:
302
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
303
+ except SessionRepositoryError as exc:
304
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
305
+
306
+
307
+ @router.get("/api/sessions", response_model=list[SessionResponse])
308
+ def list_sessions(service: SessionServiceDep) -> list[SessionResponse]:
309
+ try:
310
+ return service.list_sessions()
311
+ except SessionRepositoryError as exc:
312
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
313
+
314
+
315
+ @router.get("/api/session-tree", response_model=SessionTreeResponse)
316
+ def list_session_tree(service: SessionServiceDep) -> SessionTreeResponse:
317
+ try:
318
+ return service.list_tree()
319
+ except SessionRepositoryError as exc:
320
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
321
+
322
+
323
+ @router.post("/api/sessions/close-all", response_model=CloseAllSessionsResponse)
324
+ def close_all_sessions(service: SessionServiceDep) -> CloseAllSessionsResponse:
325
+ try:
326
+ return service.close_all()
327
+ except SessionRepositoryError as exc:
328
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
329
+
330
+
331
+ @router.delete("/api/session-workspaces/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
332
+ def delete_session_workspace(workspace_id: str, service: SessionServiceDep) -> Response:
333
+ try:
334
+ service.delete_workspace(workspace_id)
335
+ except SessionNotFoundError as exc:
336
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session workspace not found") from exc
337
+ except SessionRepositoryError as exc:
338
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
339
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
340
+
341
+
342
+ @router.get("/api/sessions/{session_id}", response_model=SessionResponse)
343
+ def get_session(session_id: str, service: SessionServiceDep) -> SessionResponse:
344
+ try:
345
+ return service.get(session_id)
346
+ except SessionNotFoundError as exc:
347
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
348
+ except SessionRepositoryError as exc:
349
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
350
+
351
+
352
+ @router.post("/api/sessions/{session_id}/start", response_model=SessionResponse)
353
+ def start_session(session_id: str, service: SessionServiceDep) -> SessionResponse:
354
+ try:
355
+ return service.start(session_id)
356
+ except SessionNotFoundError as exc:
357
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
358
+ except NoAvailablePortError as exc:
359
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
360
+ except InvalidTerminalConfigError as exc:
361
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
362
+ except SessionRepositoryError as exc:
363
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
364
+
365
+
366
+ @router.post("/api/sessions/{session_id}/stop", response_model=SessionResponse)
367
+ def stop_session(session_id: str, service: SessionServiceDep) -> SessionResponse:
368
+ try:
369
+ return service.stop(session_id)
370
+ except SessionNotFoundError as exc:
371
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
372
+ except SessionRepositoryError as exc:
373
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
374
+
375
+
376
+ @router.delete("/api/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
377
+ def delete_session(session_id: str, service: SessionServiceDep) -> Response:
378
+ try:
379
+ service.delete(session_id)
380
+ except SessionNotFoundError as exc:
381
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
382
+ except SessionRepositoryError as exc:
383
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
384
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
385
+
386
+
387
+ @router.get("/terminal/{session_id}", include_in_schema=False)
388
+ def redirect_terminal_root(session_id: str) -> RedirectResponse:
389
+ return RedirectResponse(url=f"/terminal/{session_id}/", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
390
+
391
+
392
+ @router.get("/terminal/{session_id}/{path:path}", include_in_schema=False)
393
+ async def proxy_terminal_http(session_id: str, path: str, request: Request, service: SessionServiceDep) -> Response:
394
+ try:
395
+ target = service.terminal_proxy_target(session_id)
396
+ except SessionNotFoundError as exc:
397
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
398
+ except InvalidTerminalConfigError as exc:
399
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
400
+ target_path = path or ""
401
+ target_url = f"{target.base_url.rstrip('/')}/{target_path}"
402
+ if request.url.query:
403
+ target_url = f"{target_url}?{request.url.query}"
404
+ headers = _target_headers(target.credential)
405
+ async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
406
+ try:
407
+ upstream = await client.request(request.method, target_url, headers=headers, content=await request.body())
408
+ except httpx.HTTPError as exc:
409
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Terminal proxy request failed") from exc
410
+ return Response(content=upstream.content, status_code=upstream.status_code, headers=_proxy_headers(upstream.headers))
411
+
412
+
413
+ @router.websocket("/terminal/{session_id}/ws")
414
+ async def proxy_terminal_websocket(session_id: str, websocket: WebSocket, service: SessionServiceDep) -> None:
415
+ try:
416
+ target = service.terminal_proxy_target(session_id)
417
+ except (SessionNotFoundError, InvalidTerminalConfigError):
418
+ await websocket.close(code=1008)
419
+ return
420
+ target_url = target.base_url.replace("http://", "ws://", 1).replace("https://", "wss://", 1).rstrip("/")
421
+ query = websocket.url.query
422
+ if query:
423
+ target_url = f"{target_url}/ws?{query}"
424
+ else:
425
+ target_url = f"{target_url}/ws"
426
+ requested_subprotocols = [
427
+ item.strip()
428
+ for item in websocket.headers.get("sec-websocket-protocol", "").split(",")
429
+ if item.strip()
430
+ ]
431
+ subprotocol = Subprotocol("tty") if "tty" in requested_subprotocols else None
432
+ accepted = False
433
+ try:
434
+ async with websockets.connect(
435
+ target_url,
436
+ additional_headers=_target_headers(target.credential),
437
+ origin=Origin(target.base_url),
438
+ subprotocols=[subprotocol] if subprotocol else None,
439
+ proxy=None,
440
+ ) as upstream:
441
+ await websocket.accept(subprotocol=subprotocol)
442
+ accepted = True
443
+ logger.info(
444
+ "Terminal websocket proxy connected session_id=%s target=%s subprotocol=%s",
445
+ session_id,
446
+ target_url,
447
+ subprotocol,
448
+ )
449
+ client_message_count = 0
450
+ upstream_message_count = 0
451
+
452
+ async def client_to_upstream() -> None:
453
+ nonlocal client_message_count
454
+ while True:
455
+ message = await websocket.receive()
456
+ if message["type"] == "websocket.disconnect":
457
+ logger.info(
458
+ "Terminal websocket client disconnected session_id=%s code=%s client_messages=%s upstream_messages=%s",
459
+ session_id,
460
+ message.get("code"),
461
+ client_message_count,
462
+ upstream_message_count,
463
+ )
464
+ await upstream.close()
465
+ return
466
+ if message.get("bytes") is not None:
467
+ client_message_count += 1
468
+ logger.debug(
469
+ "Terminal websocket client bytes session_id=%s length=%s count=%s",
470
+ session_id,
471
+ len(message["bytes"]),
472
+ client_message_count,
473
+ )
474
+ await upstream.send(message["bytes"])
475
+ elif message.get("text") is not None:
476
+ client_message_count += 1
477
+ logger.debug(
478
+ "Terminal websocket client text session_id=%s length=%s count=%s",
479
+ session_id,
480
+ len(message["text"]),
481
+ client_message_count,
482
+ )
483
+ await upstream.send(message["text"])
484
+
485
+ async def upstream_to_client() -> None:
486
+ nonlocal upstream_message_count
487
+ try:
488
+ async for message in upstream:
489
+ upstream_message_count += 1
490
+ if isinstance(message, bytes):
491
+ logger.debug(
492
+ "Terminal websocket upstream bytes session_id=%s length=%s count=%s",
493
+ session_id,
494
+ len(message),
495
+ upstream_message_count,
496
+ )
497
+ await websocket.send_bytes(message)
498
+ else:
499
+ logger.debug(
500
+ "Terminal websocket upstream text session_id=%s length=%s count=%s",
501
+ session_id,
502
+ len(message),
503
+ upstream_message_count,
504
+ )
505
+ await websocket.send_text(message)
506
+ except websockets.ConnectionClosed as exc:
507
+ logger.info(
508
+ "Terminal websocket upstream disconnected session_id=%s code=%s reason=%s detail=%s client_messages=%s upstream_messages=%s",
509
+ session_id,
510
+ upstream.close_code,
511
+ upstream.close_reason,
512
+ exc,
513
+ client_message_count,
514
+ upstream_message_count,
515
+ )
516
+ await websocket.close(code=1000)
517
+ return
518
+ logger.info(
519
+ "Terminal websocket upstream closed session_id=%s code=%s reason=%s client_messages=%s upstream_messages=%s",
520
+ session_id,
521
+ upstream.close_code,
522
+ upstream.close_reason,
523
+ client_message_count,
524
+ upstream_message_count,
525
+ )
526
+ await websocket.close(code=1000)
527
+
528
+ done, pending = await asyncio.wait(
529
+ {asyncio.create_task(client_to_upstream()), asyncio.create_task(upstream_to_client())},
530
+ return_when=asyncio.FIRST_COMPLETED,
531
+ )
532
+ for task in pending:
533
+ task.cancel()
534
+ for task in done:
535
+ task.result()
536
+ logger.info(
537
+ "Terminal websocket proxy finished session_id=%s client_messages=%s upstream_messages=%s",
538
+ session_id,
539
+ client_message_count,
540
+ upstream_message_count,
541
+ )
542
+ except WebSocketDisconnect:
543
+ return
544
+ except Exception:
545
+ logger.exception("Terminal websocket proxy failed session_id=%s", session_id)
546
+ if accepted:
547
+ await websocket.close(code=1011)
548
+ else:
549
+ await websocket.close(code=1008)
550
+
551
+
552
+ def _default_web_dir() -> Path:
553
+ return Path(__file__).parent / "static"
554
+
555
+
556
+ def _resolve_web_dir(web_dir: Path | None) -> Path | None:
557
+ static_dir = web_dir or _default_web_dir()
558
+ index_file = static_dir / "index.html"
559
+ if index_file.is_file():
560
+ return static_dir
561
+ return None
562
+
563
+
564
+ def create_app(*, serve_web: bool = True, web_dir: Path | None = None) -> FastAPI:
565
+ settings = load_settings()
566
+ configure_logging(settings)
567
+
568
+ app = FastAPI(title="TermBridge")
569
+ app.add_exception_handler(StarletteHTTPException, http_exception_handler)
570
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
571
+ app.add_exception_handler(Exception, unhandled_exception_handler)
572
+ app.add_middleware(RequestLoggingMiddleware)
573
+ app.include_router(router)
574
+
575
+ static_dir = _resolve_web_dir(web_dir) if serve_web else None
576
+ if static_dir is not None:
577
+
578
+ @app.get("/{path:path}", include_in_schema=False)
579
+ def serve_web_app(path: str) -> FileResponse:
580
+ if path == "health" or path.startswith("api/"):
581
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
582
+
583
+ requested_file = (static_dir / path).resolve()
584
+ if requested_file.is_file() and requested_file.is_relative_to(static_dir.resolve()):
585
+ return FileResponse(requested_file)
586
+ return FileResponse(static_dir / "index.html")
587
+
588
+ return app
termbridge/di.py ADDED
@@ -0,0 +1,63 @@
1
+ from functools import lru_cache
2
+ from typing import Annotated
3
+
4
+ from fastapi import Depends
5
+
6
+ from termbridge.ports import PortAllocator
7
+ from termbridge.process import ProcessAdapter, TtydProcessAdapter
8
+ from termbridge.repositories import FileSessionRepository, FileTerminalRepository
9
+ from termbridge.runtime import RuntimeRegistry
10
+ from termbridge.services import SessionService, TerminalService, WorkspaceBrowserService
11
+ from termbridge.settings import Settings, load_settings
12
+
13
+ SettingsDep = Annotated[Settings, Depends(load_settings)]
14
+
15
+
16
+ @lru_cache
17
+ def get_process_adapter() -> ProcessAdapter:
18
+ return TtydProcessAdapter()
19
+
20
+
21
+ @lru_cache
22
+ def get_runtime_registry() -> RuntimeRegistry:
23
+ return RuntimeRegistry()
24
+
25
+
26
+ def get_session_repository(settings: SettingsDep) -> FileSessionRepository:
27
+ return FileSessionRepository(settings.sessions_file)
28
+
29
+
30
+ def get_terminal_repository(settings: SettingsDep) -> FileTerminalRepository:
31
+ return FileTerminalRepository(settings.terminals_file)
32
+
33
+
34
+ def get_terminal_service(
35
+ settings: SettingsDep,
36
+ repository: Annotated[FileTerminalRepository, Depends(get_terminal_repository)],
37
+ ) -> TerminalService:
38
+ return TerminalService(repository, settings=settings)
39
+
40
+
41
+ def get_port_allocator(settings: SettingsDep) -> PortAllocator:
42
+ return PortAllocator(settings.host, settings.port_start, settings.port_end)
43
+
44
+
45
+ def get_session_service(
46
+ settings: SettingsDep,
47
+ repository: Annotated[FileSessionRepository, Depends(get_session_repository)],
48
+ runtime_registry: Annotated[RuntimeRegistry, Depends(get_runtime_registry)],
49
+ port_allocator: Annotated[PortAllocator, Depends(get_port_allocator)],
50
+ process_adapter: Annotated[ProcessAdapter, Depends(get_process_adapter)],
51
+ terminal_service: Annotated[TerminalService, Depends(get_terminal_service)],
52
+ ) -> SessionService:
53
+ return SessionService(settings, repository, runtime_registry, port_allocator, process_adapter, terminal_service)
54
+
55
+
56
+ @lru_cache
57
+ def get_workspace_browser_service() -> WorkspaceBrowserService:
58
+ return WorkspaceBrowserService()
59
+
60
+
61
+ SessionServiceDep = Annotated[SessionService, Depends(get_session_service)]
62
+ TerminalServiceDep = Annotated[TerminalService, Depends(get_terminal_service)]
63
+ WorkspaceBrowserServiceDep = Annotated[WorkspaceBrowserService, Depends(get_workspace_browser_service)]