mdb-engine 0.1.6__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.
Files changed (75) hide show
  1. mdb_engine/README.md +144 -0
  2. mdb_engine/__init__.py +37 -0
  3. mdb_engine/auth/README.md +631 -0
  4. mdb_engine/auth/__init__.py +128 -0
  5. mdb_engine/auth/casbin_factory.py +199 -0
  6. mdb_engine/auth/casbin_models.py +46 -0
  7. mdb_engine/auth/config_defaults.py +71 -0
  8. mdb_engine/auth/config_helpers.py +213 -0
  9. mdb_engine/auth/cookie_utils.py +158 -0
  10. mdb_engine/auth/decorators.py +350 -0
  11. mdb_engine/auth/dependencies.py +747 -0
  12. mdb_engine/auth/helpers.py +64 -0
  13. mdb_engine/auth/integration.py +578 -0
  14. mdb_engine/auth/jwt.py +225 -0
  15. mdb_engine/auth/middleware.py +241 -0
  16. mdb_engine/auth/oso_factory.py +323 -0
  17. mdb_engine/auth/provider.py +570 -0
  18. mdb_engine/auth/restrictions.py +271 -0
  19. mdb_engine/auth/session_manager.py +477 -0
  20. mdb_engine/auth/token_lifecycle.py +213 -0
  21. mdb_engine/auth/token_store.py +289 -0
  22. mdb_engine/auth/users.py +1516 -0
  23. mdb_engine/auth/utils.py +614 -0
  24. mdb_engine/cli/__init__.py +13 -0
  25. mdb_engine/cli/commands/__init__.py +7 -0
  26. mdb_engine/cli/commands/generate.py +105 -0
  27. mdb_engine/cli/commands/migrate.py +83 -0
  28. mdb_engine/cli/commands/show.py +70 -0
  29. mdb_engine/cli/commands/validate.py +63 -0
  30. mdb_engine/cli/main.py +41 -0
  31. mdb_engine/cli/utils.py +92 -0
  32. mdb_engine/config.py +217 -0
  33. mdb_engine/constants.py +160 -0
  34. mdb_engine/core/README.md +542 -0
  35. mdb_engine/core/__init__.py +42 -0
  36. mdb_engine/core/app_registration.py +392 -0
  37. mdb_engine/core/connection.py +243 -0
  38. mdb_engine/core/engine.py +749 -0
  39. mdb_engine/core/index_management.py +162 -0
  40. mdb_engine/core/manifest.py +2793 -0
  41. mdb_engine/core/seeding.py +179 -0
  42. mdb_engine/core/service_initialization.py +355 -0
  43. mdb_engine/core/types.py +413 -0
  44. mdb_engine/database/README.md +522 -0
  45. mdb_engine/database/__init__.py +31 -0
  46. mdb_engine/database/abstraction.py +635 -0
  47. mdb_engine/database/connection.py +387 -0
  48. mdb_engine/database/scoped_wrapper.py +1721 -0
  49. mdb_engine/embeddings/README.md +184 -0
  50. mdb_engine/embeddings/__init__.py +62 -0
  51. mdb_engine/embeddings/dependencies.py +193 -0
  52. mdb_engine/embeddings/service.py +759 -0
  53. mdb_engine/exceptions.py +167 -0
  54. mdb_engine/indexes/README.md +651 -0
  55. mdb_engine/indexes/__init__.py +21 -0
  56. mdb_engine/indexes/helpers.py +145 -0
  57. mdb_engine/indexes/manager.py +895 -0
  58. mdb_engine/memory/README.md +451 -0
  59. mdb_engine/memory/__init__.py +30 -0
  60. mdb_engine/memory/service.py +1285 -0
  61. mdb_engine/observability/README.md +515 -0
  62. mdb_engine/observability/__init__.py +42 -0
  63. mdb_engine/observability/health.py +296 -0
  64. mdb_engine/observability/logging.py +161 -0
  65. mdb_engine/observability/metrics.py +297 -0
  66. mdb_engine/routing/README.md +462 -0
  67. mdb_engine/routing/__init__.py +73 -0
  68. mdb_engine/routing/websockets.py +813 -0
  69. mdb_engine/utils/__init__.py +7 -0
  70. mdb_engine-0.1.6.dist-info/METADATA +213 -0
  71. mdb_engine-0.1.6.dist-info/RECORD +75 -0
  72. mdb_engine-0.1.6.dist-info/WHEEL +5 -0
  73. mdb_engine-0.1.6.dist-info/entry_points.txt +2 -0
  74. mdb_engine-0.1.6.dist-info/licenses/LICENSE +661 -0
  75. mdb_engine-0.1.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,813 @@
1
+ """
2
+ WebSocket Routing and Connection Management
3
+
4
+ This module provides OPTIONAL WebSocket support for MDB_ENGINE apps via manifest.json configuration.
5
+ Apps can declare WebSocket endpoints in their manifest, and the engine automatically
6
+ handles connection management, authentication, and message routing.
7
+
8
+ WebSocket support is OPTIONAL and only enabled when:
9
+ 1. Apps define "websockets" in their manifest.json
10
+ 2. FastAPI WebSocket support is available
11
+
12
+ Key Features:
13
+ - App-level isolation: Each app has its own WebSocket manager
14
+ - Automatic authentication: Integrates with mdb_engine auth system
15
+ - Manifest-driven configuration: Define endpoints in manifest.json
16
+ - Bi-directional communication: Supports broadcasting and listening to client messages
17
+ - Automatic ping/pong: Keeps connections alive
18
+ - Connection metadata: Tracks user_id, user_email, connected_at for each connection
19
+
20
+ This module is part of MDB_ENGINE - MongoDB Engine.
21
+ """
22
+
23
+ import asyncio
24
+ import json
25
+ import logging
26
+ from dataclasses import dataclass
27
+ from datetime import datetime
28
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
29
+
30
+ # Check if FastAPI WebSocket support is available (OPTIONAL dependency)
31
+ try:
32
+ from fastapi import WebSocket, WebSocketDisconnect
33
+
34
+ WEBSOCKETS_AVAILABLE = True
35
+ except ImportError:
36
+ WEBSOCKETS_AVAILABLE = False
37
+ # Create dummy classes for type hints when WebSockets aren't available
38
+ WebSocket = Any # type: ignore
39
+ WebSocketDisconnect = Exception # type: ignore
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ @dataclass
45
+ class WebSocketConnection:
46
+ """Metadata for a WebSocket connection."""
47
+
48
+ websocket: Any
49
+ app_slug: str
50
+ user_id: Optional[str] = None
51
+ user_email: Optional[str] = None
52
+ connected_at: datetime = None
53
+
54
+ def __post_init__(self):
55
+ if self.connected_at is None:
56
+ self.connected_at = datetime.utcnow()
57
+
58
+
59
+ class WebSocketConnectionManager:
60
+ """
61
+ Manages WebSocket connections for an app with secure isolation.
62
+
63
+ Each app has its own manager instance, ensuring complete isolation.
64
+ Provides connection tracking, broadcasting, and automatic cleanup.
65
+ """
66
+
67
+ def __init__(self, app_slug: str):
68
+ """
69
+ Initialize WebSocket connection manager for an app.
70
+
71
+ Args:
72
+ app_slug: App slug for scoping connections (ensures isolation)
73
+ """
74
+ self.app_slug = app_slug
75
+ self.active_connections: List[WebSocketConnection] = (
76
+ []
77
+ ) # List of connection metadata
78
+ self._lock = asyncio.Lock()
79
+ logger.debug(f"Initialized WebSocket manager for app: {app_slug}")
80
+
81
+ async def connect(
82
+ self,
83
+ websocket: Any,
84
+ user_id: Optional[str] = None,
85
+ user_email: Optional[str] = None,
86
+ ) -> WebSocketConnection:
87
+ """
88
+ Accept and register a WebSocket connection with metadata.
89
+
90
+ Args:
91
+ websocket: FastAPI WebSocket instance
92
+ user_id: Optional user ID for authenticated connections
93
+ user_email: Optional user email for authenticated connections
94
+
95
+ Returns:
96
+ WebSocketConnection instance with metadata
97
+ """
98
+ # Note: websocket should already be accepted by the endpoint handler
99
+ # This is just for tracking - don't accept again
100
+ if (
101
+ hasattr(websocket, "client_state")
102
+ and websocket.client_state.name != "CONNECTED"
103
+ ):
104
+ await websocket.accept()
105
+ connection = WebSocketConnection(
106
+ websocket=websocket,
107
+ app_slug=self.app_slug,
108
+ user_id=user_id,
109
+ user_email=user_email,
110
+ )
111
+ async with self._lock:
112
+ # Check if connection already exists (by websocket object identity)
113
+ if not any(conn.websocket is websocket for conn in self.active_connections):
114
+ self.active_connections.append(connection)
115
+ logger.info(
116
+ f"WebSocket connected for app '{self.app_slug}' "
117
+ f"(user: {user_email or 'anonymous'}). "
118
+ f"Total connections: {len(self.active_connections)}"
119
+ )
120
+ return connection
121
+
122
+ def disconnect(self, websocket: Any) -> None:
123
+ """
124
+ Remove a WebSocket connection from tracking.
125
+
126
+ Args:
127
+ websocket: WebSocket instance to remove
128
+ """
129
+
130
+ async def _disconnect():
131
+ async with self._lock:
132
+ self.active_connections = [
133
+ conn
134
+ for conn in self.active_connections
135
+ if conn.websocket is not websocket
136
+ ]
137
+ logger.info(
138
+ f"WebSocket disconnected for app '{self.app_slug}'. "
139
+ f"Remaining connections: {len(self.active_connections)}"
140
+ )
141
+
142
+ asyncio.create_task(_disconnect())
143
+
144
+ async def broadcast(
145
+ self, message: Dict[str, Any], filter_by_user: Optional[str] = None
146
+ ) -> int:
147
+ """
148
+ Broadcast a message to all connected clients for this app.
149
+
150
+ Args:
151
+ message: Message dictionary to broadcast (will be JSON serialized)
152
+ filter_by_user: Optional user_id to filter recipients (if None, broadcasts to all)
153
+
154
+ Returns:
155
+ Number of clients that received the message
156
+ """
157
+ if not self.active_connections:
158
+ return 0
159
+
160
+ # Add app context to message for security
161
+ message_with_context = {
162
+ **message,
163
+ "app_slug": self.app_slug, # Ensure message is scoped to this app
164
+ "timestamp": datetime.utcnow().isoformat(),
165
+ }
166
+ message_json = json.dumps(message_with_context)
167
+ disconnected = []
168
+ sent_count = 0
169
+
170
+ async with self._lock:
171
+ connections = list(self.active_connections)
172
+
173
+ for connection in connections:
174
+ # Filter by user if specified
175
+ if filter_by_user and connection.user_id != filter_by_user:
176
+ continue
177
+
178
+ # Check WebSocket state before attempting to send
179
+ try:
180
+ # Check if WebSocket is in a valid state for sending
181
+ if hasattr(connection.websocket, "client_state"):
182
+ state = connection.websocket.client_state.name
183
+ if state not in ["CONNECTED"]:
184
+ # WebSocket is not in a connected state, mark for cleanup
185
+ disconnected.append(connection.websocket)
186
+ continue
187
+ except AttributeError:
188
+ # Type 2: Recoverable - websocket doesn't have client_state, try to send anyway
189
+ pass
190
+
191
+ try:
192
+ await connection.websocket.send_text(message_json)
193
+ sent_count += 1
194
+ except (WebSocketDisconnect, RuntimeError, OSError) as e:
195
+ # WebSocket closed/disconnected errors are expected
196
+ error_msg = str(e).lower()
197
+ if "close" not in error_msg and "disconnect" not in error_msg:
198
+ logger.debug(f"Error sending WebSocket message to client: {e}")
199
+ disconnected.append(connection.websocket)
200
+
201
+ # Clean up disconnected clients
202
+ if disconnected:
203
+ for ws in disconnected:
204
+ self.disconnect(ws)
205
+
206
+ return sent_count
207
+
208
+ async def send_to_connection(self, websocket: Any, message: Dict[str, Any]) -> None:
209
+ """
210
+ Send a message to a specific WebSocket connection.
211
+
212
+ Args:
213
+ websocket: Target WebSocket instance
214
+ message: Message dictionary to send
215
+ """
216
+ # Check WebSocket state before attempting to send
217
+ if hasattr(websocket, "client_state"):
218
+ try:
219
+ state = websocket.client_state.name
220
+ if state not in ["CONNECTED"]:
221
+ # WebSocket is not in a connected state, disconnect and return
222
+ self.disconnect(websocket)
223
+ return
224
+ except AttributeError:
225
+ # Type 2: Recoverable - websocket doesn't have client_state, try to send anyway
226
+ pass
227
+
228
+ try:
229
+ # Add app context for security
230
+ message_with_context = {
231
+ **message,
232
+ "app_slug": self.app_slug,
233
+ "timestamp": datetime.utcnow().isoformat(),
234
+ }
235
+ message_json = json.dumps(message_with_context)
236
+ await websocket.send_text(message_json)
237
+ except (WebSocketDisconnect, RuntimeError, OSError) as e:
238
+ # WebSocket closed/disconnected errors are expected
239
+ error_msg = str(e).lower()
240
+ if "close" not in error_msg and "disconnect" not in error_msg:
241
+ logger.debug(f"Error sending message to specific WebSocket: {e}")
242
+ self.disconnect(websocket)
243
+
244
+ def get_connections_by_user(self, user_id: str) -> List[WebSocketConnection]:
245
+ """
246
+ Get all connections for a specific user.
247
+
248
+ Args:
249
+ user_id: User ID to filter by
250
+
251
+ Returns:
252
+ List of WebSocketConnection instances for that user
253
+ """
254
+ return [conn for conn in self.active_connections if conn.user_id == user_id]
255
+
256
+ def get_connection_count_by_user(self, user_id: str) -> int:
257
+ """
258
+ Get connection count for a specific user.
259
+
260
+ Args:
261
+ user_id: User ID to count
262
+
263
+ Returns:
264
+ Number of connections for that user
265
+ """
266
+ return len(self.get_connections_by_user(user_id))
267
+
268
+ def get_connection_count(self) -> int:
269
+ """Get the number of active connections."""
270
+ return len(self.active_connections)
271
+
272
+
273
+ # Global registry of WebSocket managers per app (app-level isolation)
274
+ _websocket_managers: Dict[str, WebSocketConnectionManager] = {}
275
+ _manager_lock = asyncio.Lock()
276
+
277
+ # Global registry of message handlers per app (for listening to client messages)
278
+ # Note: Registration happens synchronously during app startup, so no lock needed
279
+ _message_handlers: Dict[
280
+ str, Dict[str, Callable[[Any, Dict[str, Any]], Awaitable[None]]]
281
+ ] = {}
282
+
283
+
284
+ async def get_websocket_manager(app_slug: str) -> WebSocketConnectionManager:
285
+ """
286
+ Get or create a WebSocket connection manager for an app.
287
+
288
+ This ensures app-level isolation - each app has its own manager instance.
289
+ Connections are automatically scoped to the app_slug.
290
+
291
+ Args:
292
+ app_slug: App slug (ensures isolation)
293
+
294
+ Returns:
295
+ WebSocketConnectionManager instance for the app
296
+ """
297
+ async with _manager_lock:
298
+ if app_slug not in _websocket_managers:
299
+ _websocket_managers[app_slug] = WebSocketConnectionManager(app_slug)
300
+ logger.debug(f"Created WebSocket manager for app: {app_slug}")
301
+ return _websocket_managers[app_slug]
302
+
303
+
304
+ def get_websocket_manager_sync(app_slug: str) -> WebSocketConnectionManager:
305
+ """
306
+ Synchronous version of get_websocket_manager for use in non-async contexts.
307
+
308
+ Args:
309
+ app_slug: App slug
310
+
311
+ Returns:
312
+ WebSocketConnectionManager instance for the app
313
+ """
314
+ if app_slug not in _websocket_managers:
315
+ _websocket_managers[app_slug] = WebSocketConnectionManager(app_slug)
316
+ logger.debug(f"Created WebSocket manager for app: {app_slug}")
317
+ return _websocket_managers[app_slug]
318
+
319
+
320
+ async def authenticate_websocket(
321
+ websocket: Any, app_slug: str, require_auth: bool = True
322
+ ) -> Tuple[Optional[str], Optional[str]]:
323
+ """
324
+ Authenticate a WebSocket connection.
325
+
326
+ Args:
327
+ websocket: FastAPI WebSocket instance
328
+ app_slug: App slug for context
329
+ require_auth: Whether authentication is required
330
+
331
+ Returns:
332
+ Tuple of (user_id, user_email) or (None, None) if not authenticated
333
+
334
+ Raises:
335
+ WebSocketDisconnect: If authentication is required but fails
336
+ """
337
+ if not WEBSOCKETS_AVAILABLE:
338
+ raise ImportError(
339
+ "WebSocket support is not available. FastAPI WebSocket support must be installed."
340
+ )
341
+
342
+ from fastapi import WebSocketDisconnect
343
+
344
+ if not require_auth:
345
+ return None, None
346
+
347
+ try:
348
+ # Try to get token from query params or cookies
349
+ token = None
350
+ query_token = None
351
+ cookie_token = None
352
+
353
+ # Check query parameters
354
+ # FastAPI WebSocket query params are accessed via websocket.query_params
355
+ if hasattr(websocket, "query_params"):
356
+ if websocket.query_params:
357
+ query_token = websocket.query_params.get("token")
358
+ logger.info(
359
+ f"WebSocket query_params for app '{app_slug}': {dict(websocket.query_params)}"
360
+ )
361
+ if query_token:
362
+ token = query_token
363
+ logger.info(
364
+ f"WebSocket token found in query params for app "
365
+ f"'{app_slug}' (length: {len(query_token)})"
366
+ )
367
+ else:
368
+ logger.info(f"WebSocket query_params is empty for app '{app_slug}'")
369
+ else:
370
+ logger.warning(
371
+ f"WebSocket has no query_params attribute for app '{app_slug}'"
372
+ )
373
+
374
+ # If no token in query, try to get from cookies (if available)
375
+ # Check both ws_token (non-httponly, for JS access) and token (httponly)
376
+ if not token:
377
+ if hasattr(websocket, "cookies"):
378
+ cookie_token = websocket.cookies.get(
379
+ "ws_token"
380
+ ) or websocket.cookies.get("token")
381
+ if cookie_token:
382
+ token = cookie_token
383
+ logger.debug(
384
+ f"WebSocket token found in cookies for app '{app_slug}'"
385
+ )
386
+ else:
387
+ logger.debug(f"WebSocket has no cookies attribute for app '{app_slug}'")
388
+
389
+ logger.info(
390
+ f"WebSocket auth check for app '{app_slug}': "
391
+ f"query_token={bool(query_token)}, "
392
+ f"cookie_token={bool(cookie_token)}, "
393
+ f"final_token={bool(token)}, require_auth={require_auth}"
394
+ )
395
+
396
+ if not token:
397
+ logger.warning(
398
+ f"No token found for WebSocket connection to app '{app_slug}' "
399
+ f"(require_auth={require_auth})"
400
+ )
401
+ if require_auth:
402
+ # Don't close before accepting - return error info instead
403
+ # The caller will handle closing after accept
404
+ return None, None # Signal auth failure
405
+ return None, None
406
+
407
+ from ..auth.dependencies import SECRET_KEY
408
+ from ..auth.jwt import decode_jwt_token
409
+
410
+ try:
411
+ payload = decode_jwt_token(token, str(SECRET_KEY))
412
+ user_id = payload.get("sub") or payload.get("user_id")
413
+ user_email = payload.get("email")
414
+
415
+ logger.info(
416
+ f"WebSocket authenticated successfully for app '{app_slug}': {user_email}"
417
+ )
418
+ return user_id, user_email
419
+ except Exception as decode_error:
420
+ logger.error(
421
+ f"JWT decode error for app '{app_slug}': {decode_error}", exc_info=True
422
+ )
423
+ raise
424
+
425
+ except WebSocketDisconnect:
426
+ raise
427
+ except (ValueError, TypeError, AttributeError, KeyError, RuntimeError) as e:
428
+ logger.error(
429
+ f"WebSocket authentication failed for app '{app_slug}': {e}", exc_info=True
430
+ )
431
+ if require_auth:
432
+ # Don't close before accepting - return error info instead
433
+ return None, None # Signal auth failure
434
+ return None, None
435
+
436
+
437
+ def register_message_handler(
438
+ app_slug: str,
439
+ endpoint_name: str,
440
+ handler: Callable[[Any, Dict[str, Any]], Awaitable[None]],
441
+ ) -> None:
442
+ """
443
+ Register a message handler for a WebSocket endpoint.
444
+
445
+ This allows your app to listen to and process messages from WebSocket clients.
446
+ Handlers are automatically called when clients send messages.
447
+
448
+ Args:
449
+ app_slug: App slug
450
+ endpoint_name: Endpoint name (key in manifest.json websockets config)
451
+ handler: Async function to handle messages.
452
+ Signature: async def handler(websocket, message: Dict[str, Any]) -> None
453
+
454
+ Example:
455
+ ```python
456
+ async def handle_client_message(websocket, message):
457
+ message_type = message.get("type")
458
+ if message_type == "subscribe":
459
+ # Handle subscription request
460
+ await broadcast_to_app("my_app", {
461
+ "type": "subscribed",
462
+ "channel": message.get("channel")
463
+ })
464
+
465
+ register_message_handler("my_app", "realtime", handle_client_message)
466
+ ```
467
+ """
468
+ # Called synchronously during app startup, so no async lock needed
469
+ if app_slug not in _message_handlers:
470
+ _message_handlers[app_slug] = {}
471
+ _message_handlers[app_slug][endpoint_name] = handler
472
+ logger.info(
473
+ f"Registered message handler for app '{app_slug}', endpoint '{endpoint_name}'"
474
+ )
475
+
476
+
477
+ def get_message_handler(
478
+ app_slug: str, endpoint_name: str
479
+ ) -> Optional[Callable[[Any, Dict[str, Any]], Awaitable[None]]]:
480
+ """
481
+ Get a registered message handler for an endpoint.
482
+
483
+ Args:
484
+ app_slug: App slug
485
+ endpoint_name: Endpoint name
486
+
487
+ Returns:
488
+ Handler function or None if not registered
489
+ """
490
+ # Called synchronously during route creation (startup is single-threaded)
491
+ return _message_handlers.get(app_slug, {}).get(endpoint_name)
492
+
493
+
494
+ async def _accept_websocket_connection(websocket: Any, app_slug: str) -> None:
495
+ """Accept WebSocket connection with error handling."""
496
+ try:
497
+ await websocket.accept()
498
+ print(f"✅ [WEBSOCKET ACCEPTED] App: '{app_slug}'")
499
+ logger.info(f"✅ WebSocket accepted for app '{app_slug}'")
500
+ except Exception as accept_error:
501
+ print(f"❌ [WEBSOCKET ACCEPT FAILED] App: '{app_slug}', Error: {accept_error}")
502
+ logger.error(
503
+ f"❌ Failed to accept WebSocket for app '{app_slug}': {accept_error}",
504
+ exc_info=True,
505
+ )
506
+ raise
507
+
508
+
509
+ async def _authenticate_websocket_connection(
510
+ websocket: Any, app_slug: str, require_auth: bool
511
+ ) -> tuple:
512
+ """Authenticate WebSocket connection and return (user_id, user_email)."""
513
+ try:
514
+ user_id, user_email = await authenticate_websocket(
515
+ websocket, app_slug, require_auth
516
+ )
517
+
518
+ if require_auth and not user_id:
519
+ logger.warning(
520
+ f"WebSocket authentication failed for app '{app_slug}' - closing connection"
521
+ )
522
+ try:
523
+ await websocket.close(code=1008, reason="Authentication required")
524
+ except (WebSocketDisconnect, RuntimeError, OSError) as e:
525
+ logger.debug(
526
+ f"WebSocket already closed during auth failure cleanup: {e}"
527
+ )
528
+ raise WebSocketDisconnect(code=1008)
529
+
530
+ return user_id, user_email
531
+
532
+ except WebSocketDisconnect:
533
+ logger.warning(
534
+ f"WebSocket connection rejected for app '{app_slug}' - authentication failed"
535
+ )
536
+ raise
537
+ except (
538
+ ValueError,
539
+ TypeError,
540
+ AttributeError,
541
+ KeyError,
542
+ RuntimeError,
543
+ ) as auth_error:
544
+ logger.error(
545
+ f"Unexpected error during WebSocket authentication for app "
546
+ f"'{app_slug}': {auth_error}",
547
+ exc_info=True,
548
+ )
549
+ try:
550
+ await websocket.close(
551
+ code=1011, reason="Internal server error during authentication"
552
+ )
553
+ except (WebSocketDisconnect, RuntimeError, OSError) as e:
554
+ logger.debug(f"WebSocket already closed during auth error cleanup: {e}")
555
+ raise WebSocketDisconnect(code=1011)
556
+
557
+
558
+ async def _handle_websocket_message(
559
+ websocket: Any,
560
+ message: Dict[str, Any],
561
+ manager: Any,
562
+ app_slug: str,
563
+ endpoint_name: str,
564
+ handler: Optional[Callable],
565
+ ) -> bool:
566
+ """Handle incoming WebSocket message. Returns True if should continue, False if disconnect."""
567
+ if message.get("type") == "websocket.disconnect":
568
+ logger.info(f"WebSocket client disconnected for app '{app_slug}'")
569
+ return False
570
+ elif message.get("type") == "websocket.receive":
571
+ if "text" in message:
572
+ try:
573
+ data = json.loads(message["text"])
574
+
575
+ if data.get("type") == "pong":
576
+ return True
577
+
578
+ current_handler = (
579
+ handler if handler else get_message_handler(app_slug, endpoint_name)
580
+ )
581
+
582
+ if current_handler:
583
+ # Type 4: Let handler errors bubble up to framework
584
+ await current_handler(websocket, data)
585
+ else:
586
+ logger.debug(
587
+ f"Received message on WebSocket for app '{app_slug}' "
588
+ f"but no handler registered. "
589
+ f"Use register_message_handler() to handle client messages."
590
+ )
591
+
592
+ except json.JSONDecodeError:
593
+ logger.debug("Received non-JSON message from WebSocket client")
594
+ return True
595
+
596
+
597
+ def create_websocket_endpoint(
598
+ app_slug: str,
599
+ path: str,
600
+ endpoint_name: str,
601
+ handler: Optional[Callable[[Any, Dict[str, Any]], Awaitable[None]]] = None,
602
+ require_auth: bool = True,
603
+ ping_interval: int = 30,
604
+ ) -> Callable:
605
+ """
606
+ Create a WebSocket endpoint handler for an app.
607
+
608
+ WebSocket support is OPTIONAL - this will raise ImportError if dependencies are missing.
609
+
610
+ This function returns a FastAPI WebSocket route handler that:
611
+ - Manages connections via WebSocketConnectionManager
612
+ - Handles authentication if required
613
+ - Routes messages to registered handlers (via register_message_handler)
614
+ - Provides automatic ping/pong for keepalive
615
+
616
+ Args:
617
+ app_slug: App slug
618
+ path: WebSocket path (e.g., "/ws", "/events")
619
+ endpoint_name: Endpoint name (key in manifest.json) - used to lookup handlers
620
+ handler: Optional async function to handle incoming messages
621
+ (deprecated, use register_message_handler instead).
622
+ Signature: async def handler(websocket, message: Dict[str, Any]) -> None
623
+ require_auth: Whether to require authentication (default: True)
624
+ ping_interval: Ping interval in seconds (default: 30)
625
+
626
+ Returns:
627
+ FastAPI WebSocket route handler function
628
+
629
+ Raises:
630
+ ImportError: If WebSocket dependencies are not available
631
+ """
632
+ if not WEBSOCKETS_AVAILABLE:
633
+ raise ImportError(
634
+ "WebSocket support is not available. "
635
+ "FastAPI WebSocket support must be installed to use WebSocket endpoints. "
636
+ "Install with: pip install 'fastapi[standard]' or ensure WebSocket "
637
+ "support is available."
638
+ )
639
+
640
+ # Get manager (will be created if needed)
641
+ # Note: In async context, use await get_websocket_manager()
642
+ # For route creation, we use sync version since routes are created at startup
643
+ manager = get_websocket_manager_sync(app_slug)
644
+
645
+ # Use proper WebSocket type if available, otherwise Any
646
+ websocket_type = WebSocket if WEBSOCKETS_AVAILABLE else Any
647
+
648
+ async def websocket_endpoint(websocket: websocket_type):
649
+ """WebSocket endpoint handler with authentication and app isolation."""
650
+ # CRITICAL: Log immediately - this proves the handler is being called
651
+ # This print should appear in server logs when a WebSocket connection is attempted
652
+ import sys
653
+
654
+ print(
655
+ f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}",
656
+ file=sys.stderr,
657
+ flush=True,
658
+ )
659
+ print(
660
+ f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}", flush=True
661
+ )
662
+ logger.info(f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}")
663
+ connection = None
664
+ try:
665
+ # Log connection attempt with query params (can access before accept)
666
+ query_str = "None"
667
+ try:
668
+ if hasattr(websocket, "query_params"):
669
+ if websocket.query_params:
670
+ query_str = str(dict(websocket.query_params))
671
+ else:
672
+ query_str = "Empty"
673
+ else:
674
+ query_str = "No query_params attr"
675
+ except (AttributeError, TypeError) as e:
676
+ query_str = f"Error accessing query_params: {e}"
677
+
678
+ # CRITICAL: Log immediately to verify handler is being called
679
+ print(
680
+ f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}, Query: {query_str}"
681
+ )
682
+ logger.info(
683
+ f"🔌 WebSocket connection attempt for app '{app_slug}' "
684
+ f"(require_auth={require_auth}, query_params={query_str})"
685
+ )
686
+
687
+ # Accept connection FIRST (required before we can do anything)
688
+ await _accept_websocket_connection(websocket, app_slug)
689
+
690
+ # Authenticate connection (after accept, so we can close properly if needed)
691
+ user_id, user_email = await _authenticate_websocket_connection(
692
+ websocket, app_slug, require_auth
693
+ )
694
+
695
+ # Connect with metadata (websocket already accepted)
696
+ connection = await manager.connect(
697
+ websocket, user_id=user_id, user_email=user_email
698
+ )
699
+
700
+ # Send initial connection confirmation
701
+ await manager.send_to_connection(
702
+ websocket,
703
+ {
704
+ "type": "connected",
705
+ "app_slug": app_slug,
706
+ "message": "WebSocket connected successfully",
707
+ "authenticated": user_id is not None,
708
+ "user_email": user_email,
709
+ "timestamp": datetime.utcnow().isoformat(),
710
+ },
711
+ )
712
+
713
+ # Keep connection alive and handle messages
714
+ while True:
715
+ try:
716
+ # Wait for messages with timeout for ping/pong
717
+ message = await asyncio.wait_for(
718
+ websocket.receive(), timeout=float(ping_interval)
719
+ )
720
+
721
+ should_continue = await _handle_websocket_message(
722
+ websocket, message, manager, app_slug, endpoint_name, handler
723
+ )
724
+ if not should_continue:
725
+ break
726
+
727
+ except asyncio.TimeoutError:
728
+ # Send ping to keep connection alive
729
+ try:
730
+ await manager.send_to_connection(
731
+ websocket,
732
+ {
733
+ "type": "ping",
734
+ "timestamp": datetime.utcnow().isoformat(),
735
+ },
736
+ )
737
+ except (
738
+ WebSocketDisconnect,
739
+ RuntimeError,
740
+ OSError,
741
+ ):
742
+ # Type 2: Recoverable - connection error during ping, break loop
743
+ break
744
+
745
+ except (
746
+ WebSocketDisconnect,
747
+ RuntimeError,
748
+ OSError,
749
+ ) as e:
750
+ error_msg = str(e).lower()
751
+ if any(
752
+ keyword in error_msg
753
+ for keyword in ["disconnect", "closed", "connection", "broken"]
754
+ ):
755
+ logger.info(f"WebSocket disconnected for app '{app_slug}': {e}")
756
+ break
757
+ logger.warning(f"WebSocket receive error for app '{app_slug}': {e}")
758
+ await asyncio.sleep(0.1)
759
+
760
+ except (
761
+ WebSocketDisconnect,
762
+ RuntimeError,
763
+ OSError,
764
+ ValueError,
765
+ TypeError,
766
+ AttributeError,
767
+ ) as e:
768
+ logger.error(
769
+ f"WebSocket connection error for app '{app_slug}': {e}", exc_info=True
770
+ )
771
+ finally:
772
+ if connection:
773
+ manager.disconnect(websocket)
774
+
775
+ return websocket_endpoint
776
+
777
+
778
+ async def broadcast_to_app(
779
+ app_slug: str, message: Dict[str, Any], user_id: Optional[str] = None
780
+ ) -> int:
781
+ """
782
+ Convenience function to broadcast a message to all WebSocket clients for an app.
783
+
784
+ This is the simplest way to send WebSocket messages from anywhere in your app code.
785
+ Messages are automatically scoped to the app for security.
786
+
787
+ Args:
788
+ app_slug: App slug (ensures isolation)
789
+ message: Message dictionary to broadcast
790
+ user_id: Optional user_id to filter recipients (if None, broadcasts to all)
791
+
792
+ Returns:
793
+ Number of clients that received the message
794
+
795
+ Example:
796
+ ```python
797
+ from mdb_engine.routing.websockets import broadcast_to_app
798
+
799
+ # Broadcast to all clients for this app
800
+ await broadcast_to_app("my_app", {
801
+ "type": "update",
802
+ "data": {"status": "completed"}
803
+ })
804
+
805
+ # Broadcast to specific user only
806
+ await broadcast_to_app("my_app", {
807
+ "type": "notification",
808
+ "data": {"message": "Hello"}
809
+ }, user_id="user123")
810
+ ```
811
+ """
812
+ manager = await get_websocket_manager(app_slug)
813
+ return await manager.broadcast(message, filter_by_user=user_id)