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:
216
web/src/server/websocket.ts
Normal file
216
web/src/server/websocket.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user