feat: real-time alerts via WebSocket push notifications

- Add ws WebSocket server (port 3001) with JWT auth and user-socket mapping
- Add WebSocket client with exponential backoff reconnection and heartbeat
- Add useRealtimeAlerts hook with toast notifications and unread badge
- Add alert.publisher service (WS → push → email fallback)
- Integrate publisher into DarkWatch, VoicePrint, HomeTitle, SpamShield, RemoveBrokers
- Update Navbar with connection status indicator and unread count
- Add comprehensive tests (14 passing) for server, client, and publisher
This commit is contained in:
2026-05-25 17:58:47 -04:00
parent 3a8e329f02
commit c02457c66a
16 changed files with 1197 additions and 26 deletions

216
web/src/server/websocket.ts Normal file
View File

@@ -0,0 +1,216 @@
import { WebSocketServer, WebSocket } from "ws";
import type { Server } from "ws";
import { IncomingMessage } from "node:http";
import { URL } from "node:url";
import { verifyJWT } from "~/server/auth/jwt";
const WS_PORT = parseInt(process.env.WS_PORT ?? "3001", 10);
const HEARTBEAT_INTERVAL = 30_000;
const PONG_TIMEOUT = 10_000;
interface AlertMessage {
type: "alert";
alert: {
id: string;
title: string;
message: string;
severity: string;
source: string;
category: string;
createdAt: string;
};
}
interface WsClient extends WebSocket {
userId?: string;
isAlive?: boolean;
pongTimer?: ReturnType<typeof setTimeout>;
}
const userSockets = new Map<string, Set<WsClient>>();
let wss: Server | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
function getTokenFromRequest(req: IncomingMessage): string | null {
const url = new URL(req.url ?? "/", "http://localhost");
return url.searchParams.get("token");
}
async function authenticateConnection(
ws: WsClient,
req: IncomingMessage,
): Promise<string | null> {
const token = getTokenFromRequest(req);
if (!token) return null;
try {
const payload = await verifyJWT<{ sub?: string; userId?: string }>(token);
const userId = payload.sub ?? payload.userId;
if (!userId) return null;
return userId;
} catch {
return null;
}
}
function addSocket(userId: string, ws: WsClient) {
let sockets = userSockets.get(userId);
if (!sockets) {
sockets = new Set();
userSockets.set(userId, sockets);
}
sockets.add(ws);
}
function removeSocket(userId: string, ws: WsClient) {
const sockets = userSockets.get(userId);
if (!sockets) return;
sockets.delete(ws);
if (sockets.size === 0) {
userSockets.delete(userId);
}
}
function heartbeat(ws: WsClient) {
ws.isAlive = true;
}
function startHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => {
if (!wss) return;
wss.clients.forEach((client) => {
const ws = client as WsClient;
if (ws.isAlive === false) {
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
ws.pongTimer = setTimeout(() => {
ws.terminate();
}, PONG_TIMEOUT);
});
}, HEARTBEAT_INTERVAL);
if (heartbeatTimer && typeof heartbeatTimer === "object") {
heartbeatTimer.unref();
}
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
export function broadcastToUser(userId: string, data: AlertMessage) {
const sockets = userSockets.get(userId);
if (!sockets || sockets.size === 0) return false;
const message = JSON.stringify(data);
let sent = false;
for (const ws of sockets) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
sent = true;
}
}
return sent;
}
export function getConnectedUsers(): string[] {
return Array.from(userSockets.keys());
}
export function getConnectionCount(): number {
let count = 0;
for (const sockets of userSockets.values()) {
count += sockets.size;
}
return count;
}
export function start(): Promise<void> {
return new Promise((resolve) => {
if (wss) {
resolve();
return;
}
wss = new WebSocketServer({ port: WS_PORT }, () => {
console.log(`[websocket] Server listening on port ${WS_PORT}`);
resolve();
});
wss.on("connection", async (ws: WsClient, req: IncomingMessage) => {
const userId = await authenticateConnection(ws, req);
if (!userId) {
ws.close(4001, "Authentication failed");
return;
}
ws.userId = userId;
ws.isAlive = true;
addSocket(userId, ws);
ws.on("pong", () => {
heartbeat(ws);
if (ws.pongTimer) {
clearTimeout(ws.pongTimer);
ws.pongTimer = undefined;
}
});
ws.on("message", (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
}
} catch {
// ignore invalid messages
}
});
ws.on("close", () => {
if (ws.userId) {
removeSocket(ws.userId, ws);
}
if (ws.pongTimer) {
clearTimeout(ws.pongTimer);
}
});
ws.on("error", (err) => {
console.error("[websocket] Client error:", err.message);
});
});
startHeartbeat();
});
}
export function stop(): Promise<void> {
return new Promise((resolve) => {
stopHeartbeat();
if (!wss) {
resolve();
return;
}
for (const ws of wss.clients) {
ws.close(1001, "Server shutting down");
}
wss.close(() => {
wss = null;
userSockets.clear();
console.log("[websocket] Server stopped");
resolve();
});
});
}