Skip to content
Closed

Main #204

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ if (isNaN(config.maxPayloadBytes) || config.maxPayloadBytes < 1) {
}

let wss;
let httpServer;
let markShuttingDown;
try {
({ wss } = createServer(config));
({ wss, httpServer, markShuttingDown } = createServer(config));
} catch (err) {
logger.error("Failed to start server", { error: err.message });
process.exit(1);
Expand All @@ -42,7 +44,7 @@ try {
logger.info("Gateway started", config);

/**
* Initiates a graceful shutdown of the WebSocket server.
* Initiates a graceful shutdown of the server.
*
* Closes the server and waits for existing connections to finish. If the
* server does not close within 5 seconds, a forced exit is triggered.
Expand All @@ -63,8 +65,16 @@ export function shutdown(server, signal) {
}, 5000);
}

process.on("SIGTERM", () => shutdown(wss, "SIGTERM"));
process.on("SIGINT", () => shutdown(wss, "SIGINT"));
process.on("SIGTERM", () => {
markShuttingDown();
wss.close();
shutdown(httpServer, "SIGTERM");
});
process.on("SIGINT", () => {
markShuttingDown();
wss.close();
shutdown(httpServer, "SIGINT");
});

process.on("uncaughtException", (err) => {
logger.error("Uncaught exception", { error: err.message });
Expand Down
91 changes: 90 additions & 1 deletion src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,89 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit
const ipConnectionCount = new Map();
const MAX_CONNS_PER_IP = maxConnectionsPerIp ?? (Number(process.env.MAX_CONNECTIONS_PER_IP) || 10);

const metrics = {
messages: { location_update: 0, join_room: 0, leave_room: 0 },
authFailures: 0,
rateLimitRejections: { connection: 0 },
eventLoopLagMs: 0,
};

let isReady = false;
let isShuttingDown = false;

const wss = new WebSocketServer({
noServer: true,
maxPayload: maxPayloadBytes ?? 1024,
});

const httpServer = http.createServer((req, res) => {
if (req.method !== "GET") {
res.writeHead(405);
res.end("Method Not Allowed");
return;
}

const pathname = new URL(req.url, `http://${req.headers.host ?? "localhost"}`).pathname;

if (pathname === "/healthz") {
if (isShuttingDown) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "shutting down" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", uptime: process.uptime() }));
} else if (pathname === "/readyz") {
if (isShuttingDown) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "not ready", reason: "server is shutting down" }));
return;
}
if (!isReady) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "not ready", reason: "initializing" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "ready",
connections: wss.clients.size,
rooms: rooms.roomCount,
}));
} else if (pathname === "/metrics") {
const mem = process.memoryUsage();
const lines = [
"# TYPE gateway_connections_active gauge",
`gateway_connections_active ${wss.clients.size}`,
"# TYPE gateway_rooms_active gauge",
`gateway_rooms_active ${rooms.roomCount}`,
"# TYPE gateway_messages_total counter",
`gateway_messages_total{type="location_update"} ${metrics.messages.location_update}`,
`gateway_messages_total{type="join_room"} ${metrics.messages.join_room}`,
`gateway_messages_total{type="leave_room"} ${metrics.messages.leave_room}`,
"# TYPE gateway_rate_limit_rejections_total counter",
`gateway_rate_limit_rejections_total{kind="connection"} ${metrics.rateLimitRejections.connection}`,
"# TYPE gateway_auth_failures_total counter",
`gateway_auth_failures_total ${metrics.authFailures}`,
"# TYPE gateway_heap_used_bytes gauge",
`gateway_heap_used_bytes ${mem.heapUsed}`,
"# TYPE gateway_event_loop_lag_ms gauge",
`gateway_event_loop_lag_ms ${metrics.eventLoopLagMs}`,
];
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
res.end(lines.join("\n") + "\n");
} else {
res.writeHead(404);
res.end("Not Found");
}
});

httpServer.on("upgrade", (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});

function heartbeat() {
this.isAlive = true;
}
Expand All @@ -52,13 +135,15 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit

if (!connRateLimiter.check(ip)) {
logger.warn("Connection rate limit exceeded", { ip });
metrics.rateLimitRejections.connection++;
ws.close(4029, "Connection rate limit exceeded");
return;
}

const currentCount = ipConnectionCount.get(ip) ?? 0;
if (currentCount >= MAX_CONNS_PER_IP) {
logger.warn("Max connections per IP exceeded", { ip });
metrics.rateLimitRejections.connection++;
ws.close(4029, "Too many connections from this IP");
return;
}
Expand All @@ -79,6 +164,7 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit

if (!authResult.ok) {
logger.warn("Authentication failed", { clientId, reason: authResult.error });
metrics.authFailures++;
ws.close(4001, authResult.error);
return;
}
Expand All @@ -102,17 +188,20 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit
switch (msg.type) {
case "join_room": {
rooms.join(actualClientId, msg.roomId, ws);
metrics.messages.join_room++;
logger.info("Client joined room", { clientId: actualClientId, roomId: msg.roomId });
ws.send(JSON.stringify({ type: "room_joined", payload: { roomId: msg.roomId } }));
break;
}
case "leave_room": {
rooms.leave(actualClientId, msg.roomId);
metrics.messages.leave_room++;
logger.info("Client left room", { clientId: actualClientId, roomId: msg.roomId });
ws.send(JSON.stringify({ type: "room_left", payload: { roomId: msg.roomId } }));
break;
}
case "location_update": {
metrics.messages.location_update++;
const roomIds = rooms.getClientRooms(actualClientId);
for (const roomId of roomIds) {
rooms.broadcast(roomId, {
Expand Down Expand Up @@ -148,7 +237,7 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit
});
});

const interval = setInterval(() => {
const heartbeatInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
logger.warn("Terminating zombie connection", { clientId: ws._clientId ?? "unknown" });
Expand Down
Loading
Loading