Skip to main content

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 broadcasts
  • crates/bf_user/src/notifications.rs - Side effects processing
  • crates/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 TypeDescriptionServer Response
pingHeartbeat checkpong with timestamp
subscribeSubscribe to updatessubscribed confirmation
get_stateRequest full state snapshotstate_snapshot with all data

Server → Client Messages

Message TypeTriggerPayload
connectedConnection established{ timestamp }
pongResponse to ping{ timestamp }
subscribedResponse to subscribe{ message, timestamp }
state_snapshotResponse to get_stateFull property state
device_state_updatedDevice state change{ device_id, state }
door_unlocked / door_lockedLock event{ device_id, method, user }
access_log_createdAccess event{ access_log }
guest_created / guest_revokedGuest change{ guest }
pin_code_created / pin_code_revokedPIN change{ pin_code }
notification_createdNew notification{ notification }
energy_reading_savedEnergy 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

  1. Active State: DO processes messages, broadcasts updates
  2. Idle Detection: No messages for a period
  3. Hibernation: DO evicted from memory, but WebSocket connections remain open
  4. 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

SmartEventWebSocket BroadcastPush NotificationAccess Log
LockStatusChangeddoor_locked / door_unlockedAlert (device name in title)Yes
ContactChangeddevice_state_updated--
DoorLeftAjardevice_state_updatedAlert-
DoorbellPresseddoorbell_pressedAlert-
AlarmHubTriggeredalarm_triggeredCriticalYes
AlarmHubModeChangeddevice_state_updatedAlert-
PinCodeManagedpin_code_status_updated--
DaikinClimateChangeddevice_state_updated--
DaikinHotWaterChangeddevice_state_updated--
ParcelBoxDeliveryRequesteddevice_state_updatedAlert (guest gated on parcel_delivery)Yes
ParcelBoxDelivereddevice_state_updatedAlertYes
ParcelBoxErrordevice_state_updatedAlert-
ParcelBoxEmptieddevice_state_updated-Yes
ParcelBoxLockeddevice_state_updated--
ParcelBoxStatusChangeddevice_state_updated--
BatteryLowdevice_state_updatedAlert-
DeviceOfflinedevice_state_updatedAlert-

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::ParcelDeliveryPushType::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