Added intial plugin
This commit is contained in:
283
src/server.ts
Normal file
283
src/server.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import dgram from "node:dgram";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
||||
import { extname, join, normalize } from "node:path";
|
||||
import { getLeaderboard, updateLeaderboard } from "./leaderboard.js";
|
||||
import { parsePacket, UDP_PORT, type ParsedPacket } from "./parser.js";
|
||||
|
||||
const HTTP_PORT = Number(process.env.HTTP_PORT ?? 3000);
|
||||
const publicDir = join(process.cwd(), "public");
|
||||
const docsDir = join(process.cwd(), "docs");
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
const latestByType = new Map<string, ParsedPacket>();
|
||||
const recentPackets: ParsedPacket[] = [];
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8"
|
||||
};
|
||||
|
||||
function sendEvent(response: ServerResponse, event: string, payload: unknown) {
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function broadcast(packet: ParsedPacket) {
|
||||
const state = {
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets
|
||||
};
|
||||
const leaderboard = getLeaderboard(latestByType, recentPackets);
|
||||
|
||||
for (const client of sseClients) {
|
||||
sendEvent(client, "packet", { packet, state, leaderboard });
|
||||
sendEvent(client, "leaderboard", leaderboard);
|
||||
}
|
||||
}
|
||||
|
||||
function processPacket(packet: ParsedPacket) {
|
||||
const mergedPacket = mergeLatestPacket(packet);
|
||||
latestByType.set(String(mergedPacket.base.packetType), mergedPacket);
|
||||
recentPackets.unshift(packet);
|
||||
recentPackets.splice(50);
|
||||
updateLeaderboard(latestByType);
|
||||
broadcast(mergedPacket);
|
||||
}
|
||||
|
||||
function readRequestBody(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function isParsedPacket(value: unknown): value is ParsedPacket {
|
||||
if (!isRecord(value) || !isRecord(value.base) || !isRecord(value.data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof value.base.packetType === "number";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function records(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function mergeArrayByKey(
|
||||
previous: unknown,
|
||||
next: unknown,
|
||||
key: string,
|
||||
fallbackKey = "slot"
|
||||
): Record<string, unknown>[] {
|
||||
const merged = new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const item of [...records(previous), ...records(next)]) {
|
||||
const keyValue = item[key] ?? item[fallbackKey];
|
||||
if (keyValue === undefined || keyValue === null) continue;
|
||||
merged.set(String(keyValue), item);
|
||||
}
|
||||
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function mergeLatestPacket(packet: ParsedPacket): ParsedPacket {
|
||||
const existing = latestByType.get(String(packet.base.packetType));
|
||||
if (!existing) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
if (packet.base.packetType === 2) {
|
||||
return {
|
||||
...packet,
|
||||
data: {
|
||||
...existing.data,
|
||||
...packet.data,
|
||||
participants: mergeArrayByKey(
|
||||
existing.data.participants,
|
||||
packet.data.participants,
|
||||
"slot"
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (packet.base.packetType === 8) {
|
||||
return {
|
||||
...packet,
|
||||
data: {
|
||||
...existing.data,
|
||||
...packet.data,
|
||||
vehicles: mergeArrayByKey(existing.data.vehicles, packet.data.vehicles, "index"),
|
||||
classes: mergeArrayByKey(existing.data.classes, packet.data.classes, "classIndex")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return packet;
|
||||
}
|
||||
|
||||
function serveStatic(pathname: string, response: ServerResponse) {
|
||||
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
||||
const filePath = normalize(join(publicDir, requestedPath));
|
||||
|
||||
if (!filePath.startsWith(publicDir) || !existsSync(filePath)) {
|
||||
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": contentTypes[extname(filePath)] ?? "application/octet-stream"
|
||||
});
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
function serveFile(filePath: string, response: ServerResponse, contentType?: string) {
|
||||
if (!existsSync(filePath)) {
|
||||
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": contentType ?? contentTypes[extname(filePath)] ?? "application/octet-stream"
|
||||
});
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
const httpServer = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204, {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||||
"access-control-allow-headers": "content-type"
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/events") {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.write("\n");
|
||||
sseClients.add(response);
|
||||
sendEvent(response, "state", {
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets,
|
||||
leaderboard: getLeaderboard(latestByType, recentPackets)
|
||||
});
|
||||
request.on("close", () => sseClients.delete(response));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/state") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/leaderboard") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(getLeaderboard(latestByType, recentPackets)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/ingest/shared-memory" && request.method === "POST") {
|
||||
void readRequestBody(request)
|
||||
.then((body) => {
|
||||
const payload = JSON.parse(body) as unknown;
|
||||
const incomingPackets = isRecord(payload) && Array.isArray(payload.packets)
|
||||
? payload.packets.filter(isParsedPacket)
|
||||
: [];
|
||||
|
||||
if (!incomingPackets.length) {
|
||||
response.writeHead(400, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({ ok: false, error: "Expected a packets array." }));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const packet of incomingPackets) {
|
||||
processPacket({
|
||||
...packet,
|
||||
receivedAt: packet.receivedAt ?? new Date().toISOString(),
|
||||
source: packet.source || "shared-memory"
|
||||
});
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({ ok: true, packets: incomingPackets.length }));
|
||||
})
|
||||
.catch((error) => {
|
||||
response.writeHead(400, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/openapi.json") {
|
||||
serveFile(join(publicDir, "openapi.json"), response, "application/json; charset=utf-8");
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/schemas/udp-packets.schema.json") {
|
||||
serveFile(
|
||||
join(docsDir, "udp-packets.schema.json"),
|
||||
response,
|
||||
"application/schema+json; charset=utf-8"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(url.pathname, response);
|
||||
});
|
||||
|
||||
const udpServer = dgram.createSocket("udp4");
|
||||
|
||||
udpServer.on("message", (message, remote) => {
|
||||
try {
|
||||
const packet = parsePacket(message, `${remote.address}:${remote.port}`);
|
||||
processPacket(packet);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse UDP packet:", error);
|
||||
}
|
||||
});
|
||||
|
||||
udpServer.on("listening", () => {
|
||||
const address = udpServer.address();
|
||||
console.log(`UDP listener ready on ${address.address}:${address.port}`);
|
||||
});
|
||||
|
||||
udpServer.bind(UDP_PORT, "0.0.0.0");
|
||||
|
||||
httpServer.listen(HTTP_PORT, () => {
|
||||
console.log(`Frontend ready at http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user