townsquare/server/index.js

261 lines
7.0 KiB
JavaScript
Raw Permalink Normal View History

2020-05-27 20:33:51 +00:00
const fs = require("fs");
const https = require("https");
const WebSocket = require("ws");
2020-12-23 19:49:02 +00:00
const client = require("prom-client");
// Create a Registry which registers the metrics
const register = new client.Registry();
// Add a default label which is added to all metrics
register.setDefaultLabels({
app: "clocktower-online"
});
2020-05-27 20:33:51 +00:00
2020-12-12 20:33:38 +00:00
const PING_INTERVAL = 30000; // 30 seconds
2022-01-25 11:25:48 +00:00
const options = {};
if (process.env.NODE_ENV !== "development") {
options.cert = fs.readFileSync("cert.pem");
options.key = fs.readFileSync("key.pem");
}
const server = https.createServer(options);
2020-05-27 20:33:51 +00:00
const wss = new WebSocket.Server({
2020-06-04 19:44:25 +00:00
...(process.env.NODE_ENV === "development" ? { port: 8081 } : { server }),
2020-05-27 20:33:51 +00:00
verifyClient: info =>
info.origin &&
!!info.origin.match(
2020-12-22 11:53:39 +00:00
/^https?:\/\/([^.]+\.github\.io|localhost|clocktower\.online|eddbra1nprivatetownsquare\.xyz)/i
)
2020-05-27 20:33:51 +00:00
});
2020-06-04 19:44:25 +00:00
function noop() {}
// calculate latency on heartbeat
2020-06-04 19:44:25 +00:00
function heartbeat() {
this.latency = Math.round((new Date().getTime() - this.pingStart) / 2);
2020-12-12 20:33:38 +00:00
this.counter = 0;
2020-06-04 19:44:25 +00:00
this.isAlive = true;
}
// map of channels currently in use
const channels = {};
2020-12-23 19:49:02 +00:00
// metrics
const metrics = {
players_concurrent: new client.Gauge({
name: "players_concurrent",
help: "Concurrent Players",
collect() {
this.set(wss.clients.size);
}
}),
channels_concurrent: new client.Gauge({
name: "channels_concurrent",
help: "Concurrent Channels",
collect() {
this.set(Object.keys(channels).length);
}
}),
2020-12-28 15:15:29 +00:00
channels_list: new client.Gauge({
name: "channel_players",
help: "Players in each channel",
labelNames: ["name"],
collect() {
for (let channel in channels) {
2020-12-28 19:18:45 +00:00
this.set(
{ name: channel },
channels[channel].filter(
ws =>
ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)
).length
);
2020-12-28 15:15:29 +00:00
}
}
}),
2020-12-23 19:49:02 +00:00
messages_incoming: new client.Counter({
name: "messages_incoming",
help: "Incoming messages"
}),
messages_outgoing: new client.Counter({
name: "messages_outgoing",
help: "Outgoing messages"
}),
connection_terminated_host: new client.Counter({
name: "connection_terminated_host",
help: "Terminated connection due to host already present"
}),
connection_terminated_spam: new client.Counter({
name: "connection_terminated_spam",
help: "Terminated connection due to message spam"
}),
connection_terminated_timeout: new client.Counter({
name: "connection_terminated_timeout",
help: "Terminated connection due to timeout"
})
};
// register metrics
for (let metric in metrics) {
register.registerMetric(metrics[metric]);
}
// a new client connects
2020-05-27 20:33:51 +00:00
wss.on("connection", function connection(ws, req) {
// url pattern: clocktower.online/<channel>/<playerId|host>
const url = req.url.toLocaleLowerCase().split("/");
ws.playerId = url.pop();
ws.channel = url.pop();
// check for another host on this channel
if (
ws.playerId === "host" &&
channels[ws.channel] &&
channels[ws.channel].some(
client =>
client !== ws &&
client.readyState === WebSocket.OPEN &&
client.playerId === "host"
)
) {
console.log(ws.channel, "duplicate host");
ws.close(1000, `The channel "${ws.channel}" already has a host`);
2020-12-23 19:49:02 +00:00
metrics.connection_terminated_host.inc();
return;
}
2020-06-04 19:44:25 +00:00
ws.isAlive = true;
ws.pingStart = new Date().getTime();
2020-12-12 20:33:38 +00:00
ws.counter = 0;
// add channel to list
if (!channels[ws.channel]) {
channels[ws.channel] = [];
}
channels[ws.channel].push(ws);
// start ping pong
ws.ping(noop);
2020-06-04 19:44:25 +00:00
ws.on("pong", heartbeat);
// handle message
2020-05-27 20:33:51 +00:00
ws.on("message", function incoming(data) {
2020-12-23 19:49:02 +00:00
metrics.messages_incoming.inc();
2020-12-12 20:33:38 +00:00
// check rate limit (max 5msg/second)
ws.counter++;
if (ws.counter > (5 * PING_INTERVAL) / 1000) {
console.log(ws.channel, "disconnecting user due to spam");
ws.close(
1000,
"Your app seems to be malfunctioning, please clear your browser cache."
);
2020-12-23 19:49:02 +00:00
metrics.connection_terminated_spam.inc();
2020-12-12 20:33:38 +00:00
return;
}
const messageType = data
.toLocaleLowerCase()
.substr(1)
.split(",", 1)
.pop();
2021-01-29 20:54:27 +00:00
switch (messageType) {
case '"ping"':
// ping messages will only be sent host -> all or all -> host
channels[ws.channel].forEach(function each(client) {
if (
client !== ws &&
client.readyState === WebSocket.OPEN &&
2021-01-29 20:54:27 +00:00
(ws.playerId === "host" || client.playerId === "host")
) {
2021-01-29 20:54:27 +00:00
client.send(
data.replace(/latency/, (client.latency || 0) + (ws.latency || 0))
);
2020-12-23 19:49:02 +00:00
metrics.messages_outgoing.inc();
}
});
2021-01-29 20:54:27 +00:00
break;
case '"direct"':
// handle "direct" messages differently
console.log(
new Date(),
wss.clients.size,
ws.channel,
ws.playerId,
data
);
try {
const dataToPlayer = JSON.parse(data)[1];
channels[ws.channel].forEach(function each(client) {
if (
client !== ws &&
client.readyState === WebSocket.OPEN &&
dataToPlayer[client.playerId]
) {
client.send(JSON.stringify(dataToPlayer[client.playerId]));
metrics.messages_outgoing.inc();
}
});
} catch (e) {
console.log("error parsing direct message JSON", e);
}
break;
default:
// all other messages
console.log(
new Date(),
wss.clients.size,
ws.channel,
ws.playerId,
data
);
channels[ws.channel].forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data);
2021-01-29 20:54:27 +00:00
metrics.messages_outgoing.inc();
}
2021-01-29 20:54:27 +00:00
});
break;
}
2020-05-27 20:33:51 +00:00
});
});
// start ping interval timer
2020-06-04 19:44:25 +00:00
const interval = setInterval(function ping() {
2020-12-24 14:38:28 +00:00
// ping each client
2020-06-04 19:44:25 +00:00
wss.clients.forEach(function each(ws) {
2020-12-23 19:49:02 +00:00
if (ws.isAlive === false) {
metrics.connection_terminated_timeout.inc();
return ws.terminate();
}
2020-06-04 19:44:25 +00:00
ws.isAlive = false;
ws.pingStart = new Date().getTime();
2020-06-04 19:44:25 +00:00
ws.ping(noop);
});
2020-12-24 14:38:28 +00:00
// clean up empty channels
for (let channel in channels) {
if (
!channels[channel].length ||
!channels[channel].some(
ws =>
ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)
)
) {
2022-01-25 11:25:48 +00:00
metrics.channels_list.remove({ name: channel });
2020-12-24 14:38:28 +00:00
delete channels[channel];
}
}
2020-12-12 20:33:38 +00:00
}, PING_INTERVAL);
2020-06-04 19:44:25 +00:00
// handle server shutdown
2020-06-04 19:44:25 +00:00
wss.on("close", function close() {
clearInterval(interval);
});
// prod mode with stats API
2020-06-04 19:44:25 +00:00
if (process.env.NODE_ENV !== "development") {
console.log("server starting");
2020-12-28 19:22:48 +00:00
server.listen(8080);
server.on("request", (req, res) => {
2020-12-23 19:49:02 +00:00
res.setHeader("Content-Type", register.contentType);
register.metrics().then(out => res.end(out));
});
2020-06-04 19:44:25 +00:00
}