All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m47s
376 lines
13 KiB
Markdown
376 lines
13 KiB
Markdown
# ADR-022: Real-time Notification System
|
|
|
|
**Date**: 2025-12-12
|
|
|
|
**Status**: Accepted
|
|
|
|
**Implemented**: 2026-01-19
|
|
|
|
## Context
|
|
|
|
A core feature is providing "Active Deal Alerts" to users. The current HTTP-based architecture is not suitable for pushing real-time updates to clients efficiently. Relying on traditional polling would be inefficient and slow.
|
|
|
|
Users need to be notified immediately when:
|
|
|
|
1. **New deals are found** on their watched items
|
|
2. **System announcements** need to be broadcast
|
|
3. **Background jobs complete** that affect their data
|
|
|
|
Traditional approaches:
|
|
|
|
- **HTTP Polling**: Inefficient, creates unnecessary load, delays up to polling interval
|
|
- **Server-Sent Events (SSE)**: One-way only, no client-to-server messaging
|
|
- **WebSockets**: Bi-directional, real-time, efficient
|
|
|
|
## Decision
|
|
|
|
We will implement a real-time communication system using **WebSockets** with the `ws` library. This will involve:
|
|
|
|
1. **WebSocket Server**: Manages connections, authentication, and message routing
|
|
2. **React Hook**: Provides easy integration for React components
|
|
3. **Event Bus Integration**: Bridges WebSocket messages to in-app events
|
|
4. **Background Job Integration**: Emits WebSocket notifications when deals are found
|
|
|
|
### Design Principles
|
|
|
|
- **JWT Authentication**: WebSocket connections authenticated via JWT tokens
|
|
- **Type-Safe Messages**: Strongly-typed message formats prevent errors
|
|
- **Auto-Reconnect**: Client automatically reconnects with exponential backoff
|
|
- **Graceful Degradation**: Email + DB notifications remain for offline users
|
|
- **Heartbeat Ping/Pong**: Detect and cleanup dead connections
|
|
- **Singleton Service**: Single WebSocket service instance shared across app
|
|
|
|
## Implementation Details
|
|
|
|
### WebSocket Message Types
|
|
|
|
Located in `src/types/websocket.ts`:
|
|
|
|
```typescript
|
|
export interface WebSocketMessage<T = unknown> {
|
|
type: WebSocketMessageType;
|
|
data: T;
|
|
timestamp: string;
|
|
}
|
|
|
|
export type WebSocketMessageType =
|
|
| 'deal-notification'
|
|
| 'system-message'
|
|
| 'ping'
|
|
| 'pong'
|
|
| 'error'
|
|
| 'connection-established';
|
|
|
|
// Deal notification payload
|
|
export interface DealNotificationData {
|
|
notification_id?: string;
|
|
deals: DealInfo[];
|
|
user_id: string;
|
|
message: string;
|
|
}
|
|
|
|
// Type-safe message creators
|
|
export const createWebSocketMessage = {
|
|
dealNotification: (data: DealNotificationData) => ({ ... }),
|
|
systemMessage: (data: SystemMessageData) => ({ ... }),
|
|
error: (data: ErrorMessageData) => ({ ... }),
|
|
// ...
|
|
};
|
|
```
|
|
|
|
### WebSocket Server Service
|
|
|
|
Located in `src/services/websocketService.server.ts`:
|
|
|
|
```typescript
|
|
export class WebSocketService {
|
|
private wss: WebSocketServer | null = null;
|
|
private clients: Map<string, Set<AuthenticatedWebSocket>> = new Map();
|
|
private pingInterval: NodeJS.Timeout | null = null;
|
|
|
|
initialize(server: HTTPServer): void {
|
|
this.wss = new WebSocketServer({
|
|
server,
|
|
path: '/ws',
|
|
});
|
|
|
|
this.wss.on('connection', (ws, request) => {
|
|
this.handleConnection(ws, request);
|
|
});
|
|
|
|
this.startHeartbeat(); // Ping every 30s
|
|
}
|
|
|
|
// Authentication via JWT from query string or cookie
|
|
private extractToken(request: IncomingMessage): string | null {
|
|
// Extract from ?token=xxx or Cookie: accessToken=xxx
|
|
}
|
|
|
|
// Broadcast to specific user
|
|
broadcastDealNotification(userId: string, data: DealNotificationData): void {
|
|
const message = createWebSocketMessage.dealNotification(data);
|
|
this.broadcastToUser(userId, message);
|
|
}
|
|
|
|
// Broadcast to all users
|
|
broadcastToAll(data: SystemMessageData): void {
|
|
// Send to all connected clients
|
|
}
|
|
|
|
shutdown(): void {
|
|
// Gracefully close all connections
|
|
}
|
|
}
|
|
|
|
export const websocketService = new WebSocketService(globalLogger);
|
|
```
|
|
|
|
### Server Integration
|
|
|
|
Located in `server.ts`:
|
|
|
|
```typescript
|
|
import { websocketService } from './src/services/websocketService.server';
|
|
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
const server = app.listen(PORT, () => {
|
|
logger.info(`Authentication server started on port ${PORT}`);
|
|
});
|
|
|
|
// Initialize WebSocket server (ADR-022)
|
|
websocketService.initialize(server);
|
|
logger.info('WebSocket server initialized for real-time notifications');
|
|
|
|
// Graceful shutdown
|
|
const handleShutdown = (signal: string) => {
|
|
websocketService.shutdown();
|
|
gracefulShutdown(signal);
|
|
};
|
|
|
|
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
}
|
|
```
|
|
|
|
### React Client Hook
|
|
|
|
Located in `src/hooks/useWebSocket.ts`:
|
|
|
|
```typescript
|
|
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
|
const [state, setState] = useState<WebSocketState>({
|
|
isConnected: false,
|
|
isConnecting: false,
|
|
error: null,
|
|
});
|
|
|
|
const connect = useCallback(() => {
|
|
const url = getWebSocketUrl(); // wss://host/ws?token=xxx
|
|
const ws = new WebSocket(url);
|
|
|
|
ws.onmessage = (event) => {
|
|
const message = JSON.parse(event.data) as WebSocketMessage;
|
|
|
|
// Emit to event bus for cross-component communication
|
|
switch (message.type) {
|
|
case 'deal-notification':
|
|
eventBus.dispatch('notification:deal', message.data);
|
|
break;
|
|
case 'system-message':
|
|
eventBus.dispatch('notification:system', message.data);
|
|
break;
|
|
// ...
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
// Auto-reconnect with exponential backoff
|
|
if (reconnectAttempts < maxReconnectAttempts) {
|
|
setTimeout(connect, reconnectDelay * Math.pow(2, reconnectAttempts));
|
|
reconnectAttempts++;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (autoConnect) connect();
|
|
return () => disconnect();
|
|
}, [autoConnect, connect, disconnect]);
|
|
|
|
return { ...state, connect, disconnect, send };
|
|
}
|
|
```
|
|
|
|
### Background Job Integration
|
|
|
|
Located in `src/services/backgroundJobService.ts`:
|
|
|
|
```typescript
|
|
private async _processDealsForUser({ userProfile, deals }: UserDealGroup) {
|
|
// ... existing email notification logic ...
|
|
|
|
// Send real-time WebSocket notification (ADR-022)
|
|
const { websocketService } = await import('./websocketService.server');
|
|
websocketService.broadcastDealNotification(userProfile.user_id, {
|
|
user_id: userProfile.user_id,
|
|
deals: deals.map((deal) => ({
|
|
item_name: deal.item_name,
|
|
best_price_in_cents: deal.best_price_in_cents,
|
|
store_name: deal.store.name,
|
|
store_id: deal.store.store_id,
|
|
})),
|
|
message: `You have ${deals.length} new deal(s) on your watched items!`,
|
|
});
|
|
}
|
|
```
|
|
|
|
### Usage in React Components
|
|
|
|
```typescript
|
|
import { useWebSocket } from '../hooks/useWebSocket';
|
|
import { useEventBus } from '../hooks/useEventBus';
|
|
import { useCallback } from 'react';
|
|
|
|
function NotificationComponent() {
|
|
// Connect to WebSocket
|
|
const { isConnected, error } = useWebSocket({ autoConnect: true });
|
|
|
|
// Listen for deal notifications via event bus
|
|
const handleDealNotification = useCallback((data: DealNotificationData) => {
|
|
toast.success(`${data.deals.length} new deals found!`);
|
|
}, []);
|
|
|
|
useEventBus('notification:deal', handleDealNotification);
|
|
|
|
return (
|
|
<div>
|
|
{isConnected ? '🟢 Live' : '🔴 Offline'}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Architecture Diagram
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ WebSocket Architecture │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
|
|
Server Side:
|
|
┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
│ Background Job │─────▶│ WebSocket │─────▶│ Connected │
|
|
│ (Deal Checker) │ │ Service │ │ Clients │
|
|
└──────────────────┘ └──────────────────┘ └─────────────────┘
|
|
│ ▲
|
|
│ │
|
|
▼ │
|
|
┌──────────────────┐ │
|
|
│ Email Queue │ │
|
|
│ (BullMQ) │ │
|
|
└──────────────────┘ │
|
|
│ │
|
|
▼ │
|
|
┌──────────────────┐ ┌──────────────────┐
|
|
│ DB Notification │ │ Express Server │
|
|
│ Storage │ │ + WS Upgrade │
|
|
└──────────────────┘ └──────────────────┘
|
|
|
|
Client Side:
|
|
┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
│ useWebSocket │◀────▶│ WebSocket │◀────▶│ Event Bus │
|
|
│ Hook │ │ Connection │ │ Integration │
|
|
└──────────────────┘ └──────────────────┘ └─────────────────┘
|
|
│
|
|
▼
|
|
┌──────────────────┐
|
|
│ UI Components │
|
|
│ (Notifications) │
|
|
└──────────────────┘
|
|
```
|
|
|
|
## Security Considerations
|
|
|
|
1. **Authentication**: JWT tokens required for WebSocket connections
|
|
2. **User Isolation**: Messages routed only to authenticated user's connections
|
|
3. **Rate Limiting**: Heartbeat ping/pong prevents connection flooding
|
|
4. **Graceful Shutdown**: Notifies clients before server shutdown
|
|
5. **Error Handling**: Failed WebSocket sends don't crash the server
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- **Real-time Updates**: Users see deals immediately when found
|
|
- **Better UX**: No page refresh needed, instant notifications
|
|
- **Efficient**: Single persistent connection vs polling every N seconds
|
|
- **Scalable**: Connection pooling per user, heartbeat cleanup
|
|
- **Type-Safe**: TypeScript types prevent message format errors
|
|
- **Resilient**: Auto-reconnect with exponential backoff
|
|
- **Observable**: Connection stats available via `getConnectionStats()`
|
|
- **Testable**: Comprehensive unit tests for message types and service
|
|
|
|
### Negative
|
|
|
|
- **Complexity**: WebSocket server adds new infrastructure component
|
|
- **Memory**: Each connection consumes server memory
|
|
- **Scaling**: Single-server implementation (multi-server requires Redis pub/sub)
|
|
- **Browser Support**: Requires WebSocket-capable browsers (all modern browsers)
|
|
- **Network**: Persistent connections require stable network
|
|
|
|
### Mitigation
|
|
|
|
- **Graceful Degradation**: Email + DB notifications remain for offline users
|
|
- **Connection Limits**: Can add max connections per user if needed
|
|
- **Monitoring**: Connection stats exposed for observability
|
|
- **Future Scaling**: Can add Redis pub/sub for multi-instance deployments
|
|
- **Heartbeat**: 30s ping/pong detects and cleans up dead connections
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
|
|
Located in `src/services/websocketService.server.test.ts`:
|
|
|
|
```typescript
|
|
describe('WebSocketService', () => {
|
|
it('should initialize without errors', () => { ... });
|
|
it('should handle broadcasting with no active connections', () => { ... });
|
|
it('should shutdown gracefully', () => { ... });
|
|
});
|
|
```
|
|
|
|
Located in `src/types/websocket.test.ts`:
|
|
|
|
```typescript
|
|
describe('WebSocket Message Creators', () => {
|
|
it('should create valid deal notification messages', () => { ... });
|
|
it('should generate valid ISO timestamps', () => { ... });
|
|
});
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
Future work: Add integration tests that:
|
|
|
|
- Connect WebSocket clients to test server
|
|
- Verify authentication and message routing
|
|
- Test reconnection logic
|
|
- Validate message delivery
|
|
|
|
## Key Files
|
|
|
|
- `src/types/websocket.ts` - WebSocket message types and creators
|
|
- `src/services/websocketService.server.ts` - WebSocket server service
|
|
- `src/hooks/useWebSocket.ts` - React hook for WebSocket connections
|
|
- `src/services/backgroundJobService.ts` - Integration point for deal notifications
|
|
- `server.ts` - Express + WebSocket server initialization
|
|
- `src/services/websocketService.server.test.ts` - Unit tests
|
|
- `src/types/websocket.test.ts` - Message type tests
|
|
|
|
## Related ADRs
|
|
|
|
- [ADR-036](./0036-event-bus-and-pub-sub-pattern.md) - Event Bus Pattern (used by client hook)
|
|
- [ADR-042](./0042-email-and-notification-architecture.md) - Email Notifications (fallback mechanism)
|
|
- [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Jobs (triggers WebSocket notifications)
|