Appearance
WebSocketManager
Manages WebSocket connections using the Cloudflare Hibernation API for real-time portfolio event broadcasting.
Purpose
WebSocketManager handles the full lifecycle of WebSocket connections within a UserPaperTradePortfolio Durable Object. It uses the Cloudflare Hibernation API to maintain long-lived connections that survive DO eviction from memory, enabling real-time push notifications without keeping the DO permanently active.
High-Level Design
Data Flow
Client (browser) Web Worker UserPaperTradePortfolio DO
│ │ │
│ GET /api/ws/portfolio │ │
│ Upgrade: websocket │ │
│─────────────────────────────────>│ withWalletAuth │
│ │ authenticate user │
│ │─────────────────────────────────>│
│ │ portfolioDO.fetch(request) │
│ │ │
│ │ WebSocketManager │
│ │ handleUpgrade() │
│ │ ┌─────────────────┐ │
│ │ │ WebSocketPair │ │
│ │ │ [client, server]│ │
│ │ │ │ │
│ │ │ acceptWebSocket │ │
│ │ │ (Hibernation) │ │
│<─────────────────────────────────│<──────────│ 101 Switching │ │
│ WebSocket established │ └─────────────────┘ │
│ │ │
│ ... time passes, DO may be evicted ... │
│ │ │
│ │ Domain manager calls │
│ │ broadcast(PortfolioEvent) │
│ │ ┌─────────────────┐ │
│ { type, data } │ │ JSON.stringify │ │
│<─────────────────────────────────│───────────│ → send to ALL │ │
│ (all connected sessions) │ │ sessions │ │
│ │ └─────────────────┘ │Hibernation Lifecycle
The Hibernation API is what makes this manager scalable — WebSocket connections survive DO memory eviction:
Connect —
handleUpgrade()accepts the socket viastate.acceptWebSocket(server)and serializes session metadata ({ id, userId }) as a WebSocket attachment.Hibernate — After inactivity, the DO is evicted from memory. Connections stay open. Ping/pong auto-response (
setWebSocketAutoResponse) keeps connections alive without waking the DO.Wake — When an event needs broadcasting (trade executed, alert triggered), the DO wakes and
initialize()restores the session map by callingstate.getWebSockets()and deserializing each socket's attachment.Close — Client disconnects or send fails. Session is removed from the map and the server-side socket is closed.
Auto-Subscribe & Cross-Device Sync
The manager uses an auto-subscribe pattern — there is no subscription protocol. Every connected client receives every portfolio event. This works because:
- The initiating client already has data from the HTTP response (e.g.,
POST /portfolio/openreturns the trade). The WebSocket event is redundant for that client. - Other tabs/devices for the same user get the event via WebSocket and update their UI.
- The frontend deduplicates by entity ID (
tradeId,limitOrderId,orderId), so duplicate processing is harmless.
Event Categories
Events are a discriminated union (PortfolioEvent) with four categories:
| Category | Events | Trigger |
|---|---|---|
| User-initiated | POSITION_OPENED, POSITION_CLOSED, POSITION_UPDATED, LEVERAGE_CHANGED, PENDING_ORDER_CREATED/EXECUTED/CANCELLED, LIMIT_ORDER_CREATED/CANCELLED/UPDATED | User action via HTTP → domain manager broadcasts |
| Server-initiated | LIMIT_ORDER_TRIGGERED, STOP_LOSS_TRIGGERED, TAKE_PROFIT_TRIGGERED, POSITION_LIQUIDATED | PriceAlert alarm detects threshold cross → calls onAlert() → domain manager broadcasts |
| Portfolio state | PORTFOLIO_RESET, POSITION_ALERTS_UPDATED, CHALLENGE_STARTED/PASSED/FAILED/DEACTIVATED | State transitions from various managers |
| Notification & risk | NOTIFICATION_CREATED/SEEN/DELETED, NOTIFICATIONS_MARKED_SEEN/ALL_SEEN, MLL_STATUS_CHANGED | NotificationManager and AccountHealthManager |
All trade-related events include balance snapshot fields (newBalance, newTotalRealizedPnL, newTotalFeesPaid) so the frontend can update account value in real time without a separate API call.
Broadcast Failure Isolation
When broadcast() fails to send to a specific client, that connection is closed and removed — other sessions continue receiving events. This prevents one bad connection from breaking the broadcast for all clients.