Real-time Events & WebSocket Infrastructure
The system provides real-time updates to connected clients through WebSocket connections maintained by the User Durable Object. When device events occur, they are broadcast to all connected clients while also being processed for push notifications.
Key Files
crates/bf_user/src/durable_object_sqlx.rs- WebSocket handlers and broadcastscrates/bf_user/src/notifications.rs- Side effects processingcrates/bf_user/src/storage_sqlx.rs- Access log storage
WebSocket Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ Client Applications │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Resident │ │ Guest │ │ Web │ │ Mobile │ │
│ │ App │ │ App │ │Dashboard │ │ App │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
└────────┼───────────────┼───────────────┼───────────────┼───────────────┘
│ │ │ │
└───────────────┴───────────────┴───────────────┘
│
WebSocket Connections
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ User Durable Object │
│ (one per property) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ WebSocket Connection State │ │
│ │ │ │
│ │ • Maintains all connected clients using Hibernation API │ │
│ │ • Automatic cleanup on disconnect │ │
│ │ • Supports ping/pong heartbeat │ │
│ │ • Can hibernate (sleep) while connections remain open │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ broadcast_update() │ │
│ │ │ │
│ │ 1. Serialize event as JSON │ │
│ │ 2. Get all connected WebSocket clients from state │ │
│ │ 3. Send to each connected client │ │
│ │ 4. Handle failures gracefully (log, don't crash) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
WebSocket Connection Flow
Client Request (GET /ws with Upgrade: websocket)
│
▼
┌────────────────────────────────────────┐
│ 1. Validate Upgrade Header │
│ • Check for "Upgrade: websocket" │
│ • Return 400 if missing │
└────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 2. Create WebSocketPair │
│ • client: Returned to caller │
│ • server: Kept in DO state │
└────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 3. Accept Connection (Hibernation API) │
│ • state.acceptWebSocket(server) │
│ • Enables DO hibernation │
└────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 4. Send Connection Confirmation │
│ { "type": "connected", │
│ "timestamp": 1234567890 } │
└────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 5. Return Client Socket (HTTP 101) │
│ • WebSocket connection established │
└────────────────────────────────────────┘
WebSocket Message Protocol
Client → Server Messages
| Message Type | Description | Server Response |
|---|---|---|
ping | Heartbeat check | pong with timestamp |
subscribe | Subscribe to updates | subscribed confirmation |
get_state | Request full state snapshot | state_snapshot with all data |
Server → Client Messages
| Message Type | Trigger | Payload |
|---|---|---|
connected | Connection established | { timestamp } |
pong | Response to ping | { timestamp } |
subscribed | Response to subscribe | { message, timestamp } |
state_snapshot | Response to get_state | Full property state |
device_state_updated | Device state change | { device_id, state } |
door_unlocked / door_locked | Lock event | { device_id, method, user } |
access_log_created | Access event | { access_log } |
guest_created / guest_revoked | Guest change | { guest } |
pin_code_created / pin_code_revoked | PIN change | { pin_code } |
notification_created | New notification | { notification } |
energy_reading_saved | Energy reading | { reading } |
Message Format
{
"type": "device_state_updated",
"timestamp": 1706123456,
"data": {
"device_id": "dev_123",
"device_name": "Front Door Lock",
"state": {
"device_type": "lock",
"locked": true,
"battery": 85
}
}
}
State Snapshot
When a client requests full state (or on reconnection), the DO sends a complete snapshot:
{
"type": "state_snapshot",
"timestamp": 1706123456,
"data": {
"property": {
"id": "prop_123",
"address": "123 Main St",
"unit": "Apt 4B",
"bedrooms": 2,
"floor": 4
},
"devices": [
{ "id": "dev_1", "name": "Front Door", "type": "smart_lock" },
{ "id": "dev_2", "name": "Living Room", "type": "thermostat" }
],
"device_states": [
{ "device_id": "dev_1", "state": { "locked": true, "battery": 85 } },
{ "device_id": "dev_2", "state": { "temperature": 21.5, "setpoint": 22 } }
],
"guests": [
{ "id": "guest_1", "name": "John", "email": "john@example.com" }
],
"pin_codes": [{ "id": "pin_1", "name": "Cleaner", "status": "active" }],
"notifications": [
{ "id": "notif_1", "title": "Door unlocked", "read": false }
],
"unread_notification_count": 3
}
}
Cloudflare Hibernation API
The WebSocket implementation uses Cloudflare's Hibernation API for cost efficiency:
How It Works
- Active State: DO processes messages, broadcasts updates
- Idle Detection: No messages for a period
- Hibernation: DO evicted from memory, but WebSocket connections remain open
- Wake on Message: When a message arrives, DO wakes up with full state
Benefits
- Cost Reduction: Only billed when processing messages
- Connection Persistence: Clients stay connected during hibernation
- Automatic Wake: DO automatically wakes on incoming message
- Memory Efficiency: Idle DOs don't consume memory
Code Pattern
// Accept connection with Hibernation API
self.state.acceptWebSocket(server_ws);
// Later: get all connected sockets
let sockets = self.state.getWebSockets();
// Broadcast to all
for socket in sockets {
socket.send(message)?;
}
Event Processing Flow
┌─────────────────────────────────────────────────────────────────────────┐
│ SmartEvent from Webhook │
│ │
│ LockStatusChanged { locked: true, method: "pin", user_id: "..." } │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ process_side_effects() │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Update device state in DO storage │
│ │
│ 2. Create access log (for lock events) │
│ │
│ 3. Create notification in DO storage │
│ │
│ 4. Broadcast to WebSocket clients ─────────────────────────────┐ │
│ │ │
│ 5. Send FCM push to device tokens │ │
│ │ │
│ 6. Update PIN code state (if PIN event) │ │
│ │ │
└────────────────────────────────────────────────────────────────────┼────┘
│
┌───────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ broadcast_update() │
│ │
│ for socket in state.getWebSockets() { │
│ socket.send(json!({ │
│ "type": "device_state_updated", │
│ "timestamp": now, │
│ "data": { device_id, state } │
│ })); │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────────┘
SideEffectContext
pub struct SideEffectContext<'a> {
pub env: &'a Env,
pub conn: &'a mut DOConnection,
pub property_id: &'a str,
pub property_address: &'a str,
// Callbacks for real-time updates
pub broadcast_notification: Box<dyn Fn(&Notification)>,
pub broadcast_pin_code_status: Box<dyn Fn(&str, &PinCodeStatus)>,
pub broadcast_device_state: Box<dyn Fn(&str, &DeviceStateData)>,
}
Client Implementation Example
JavaScript/TypeScript
class PropertyWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private heartbeatInterval: number | null = null;
constructor(
private propertyId: string,
private onMessage: (msg: WebSocketMessage) => void,
) {}
connect() {
this.ws = new WebSocket(
`wss://api.broadfordlife.com/user/${this.propertyId}/ws`,
);
this.ws.onopen = () => {
console.log("WebSocket connected");
this.reconnectAttempts = 0;
// Subscribe to updates
this.send({ type: "subscribe" });
// Request initial state
this.send({ type: "get_state" });
// Start heartbeat
this.heartbeatInterval = setInterval(() => {
this.send({ type: "ping" });
}, 30000);
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.onMessage(message);
};
this.ws.onclose = () => {
console.log("WebSocket disconnected");
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
}
private send(message: object) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
private scheduleReconnect() {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => this.connect(), delay);
}
disconnect() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.ws?.close();
}
}
React Hook
function usePropertyWebSocket(propertyId: string) {
const [isConnected, setIsConnected] = useState(false);
const [deviceStates, setDeviceStates] = useState<Map<string, DeviceState>>(
new Map(),
);
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const ws = new PropertyWebSocket(propertyId, (message) => {
switch (message.type) {
case "connected":
setIsConnected(true);
break;
case "state_snapshot":
// Initialize all state from snapshot
const stateMap = new Map();
message.data.device_states.forEach((s) =>
stateMap.set(s.device_id, s),
);
setDeviceStates(stateMap);
setNotifications(message.data.notifications);
break;
case "device_state_updated":
setDeviceStates((prev) => {
const next = new Map(prev);
next.set(message.data.device_id, message.data.state);
return next;
});
break;
case "notification_created":
setNotifications((prev) => [message.data, ...prev]);
break;
}
});
ws.connect();
return () => ws.disconnect();
}, [propertyId]);
return { isConnected, deviceStates, notifications };
}
Event to Action Mapping
| SmartEvent | WebSocket Broadcast | Push Notification | Access Log |
|---|---|---|---|
LockStatusChanged | door_locked / door_unlocked | Alert (device name in title) | Yes |
ContactChanged | device_state_updated | - | - |
DoorLeftAjar | device_state_updated | Alert | - |
DoorbellPressed | doorbell_pressed | Alert | - |
AlarmHubTriggered | alarm_triggered | Critical | Yes |
AlarmHubModeChanged | device_state_updated | Alert | - |
PinCodeManaged | pin_code_status_updated | - | - |
DaikinClimateChanged | device_state_updated | - | - |
DaikinHotWaterChanged | device_state_updated | - | - |
ParcelBoxDeliveryRequested | device_state_updated | Alert (guest gated on parcel_delivery) | Yes |
ParcelBoxDelivered | device_state_updated | Alert | Yes |
ParcelBoxError | device_state_updated | Alert | - |
ParcelBoxEmptied | device_state_updated | - | Yes |
ParcelBoxLocked | device_state_updated | - | - |
ParcelBoxStatusChanged | device_state_updated | - | - |
BatteryLow | device_state_updated | Alert | - |
DeviceOffline | device_state_updated | Alert | - |
Multi-Lock Notification Context
Lock notifications include the device name for multi-lock properties (e.g., "Front Door Lock Locked" vs "Parcel Lock Locked"). The action_url includes a lockId query parameter so tapping the notification navigates to the correct lock's detail page.
Parcel Box Notification Context
ParcelBoxDeliveryRequested is the headline parcel event (a courier needs permission for a repeat delivery, like a doorbell press): an actionable Alert whose action_url deep-links to the parcel-box page (/parcel-box/{deviceId}) so the resident can allow or deny from the notification. Guest pushes for all parcel events are gated on the parcel_delivery permission via RequiredPermission::ParcelDelivery (mapped to NotificationType::ParcelDelivery → PushType::Alert). ParcelBoxEmptied, ParcelBoxLocked, and ParcelBoxStatusChanged are state-only (no push). Battery and online/offline reuse the generic BatteryLow / DeviceOffline events.
Performance Considerations
Broadcast Efficiency
- Single JSON serialization, multiple sends
- Failed sends logged but don't block others
- Cloudflare handles connection pooling
Heartbeat Recommendations
- Client should ping every 30-60 seconds
- Server responds with pong immediately
- Helps detect stale connections
State Snapshot Size
- Includes only essential data
- Notifications limited to last 50
- Device states are minimal JSON
See Also
- notification-system.md - FCM push delivery
- webhook-handling.md - Event sources
- smart-device-abstraction.md - Event types