add: persistant storage of elo ranking
All checks were successful
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2026-03-22 21:18:49 -07:00
parent 614a3d1eff
commit 9f60ab3cca
7 changed files with 643 additions and 62 deletions

View File

@@ -3,9 +3,16 @@ const http = require("http");
const path = require("path");
const { repoRoot } = require("./lib/meals");
const {
defaultStatePath,
loadRankingsState,
recordVote,
resetRankingsState,
} = require("./lib/rankings-state");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 4321;
const MAX_REQUEST_BODY_BYTES = 16 * 1024;
const MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
@@ -29,6 +36,7 @@ function parseArgs(argv) {
const options = {
host: process.env.HOST || DEFAULT_HOST,
port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10),
rankingsStatePath: path.resolve(process.env.RANKINGS_STATE_PATH || defaultStatePath),
};
for (let index = 0; index < argv.length; index += 1) {
@@ -44,6 +52,12 @@ function parseArgs(argv) {
if (arg === "--port" && value) {
options.port = Number.parseInt(value, 10);
index += 1;
continue;
}
if (arg === "--rankings-state-path" && value) {
options.rankingsStatePath = path.resolve(value);
index += 1;
}
}
@@ -58,9 +72,15 @@ function getContentType(filePath) {
return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
}
function resolveRequestPath(requestUrl) {
const url = new URL(requestUrl, "http://localhost");
const pathname = decodeURIComponent(url.pathname);
function hasHiddenSegment(pathname) {
return pathname.split("/").some((segment) => segment.startsWith(".") && segment.length > 1);
}
function resolveRequestPath(pathname) {
if (hasHiddenSegment(pathname)) {
return null;
}
const requestedPath = pathname === "/" ? "/index.html" : pathname;
const absolutePath = path.resolve(repoRoot, `.${requestedPath}`);
const withinRepo =
@@ -77,6 +97,56 @@ function resolveRequestPath(requestUrl) {
return absolutePath;
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
});
response.end(`${JSON.stringify(payload, null, 2)}\n`);
}
function sendText(response, statusCode, body) {
response.writeHead(statusCode, {
"Cache-Control": "no-store",
"Content-Type": "text/plain; charset=utf-8",
});
response.end(body);
}
function parseJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalBytes = 0;
request.on("data", (chunk) => {
totalBytes += chunk.length;
if (totalBytes > MAX_REQUEST_BODY_BYTES) {
reject(new Error("Request body is too large"));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => {
if (chunks.length === 0) {
resolve({});
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (error) {
reject(new Error("Request body must be valid JSON"));
}
});
request.on("error", reject);
});
}
function sendFile(response, filePath) {
const stream = fs.createReadStream(filePath);
@@ -87,24 +157,81 @@ function sendFile(response, filePath) {
stream.pipe(response);
stream.on("error", () => {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Failed to read file");
sendText(response, 500, "Failed to read file");
});
}
function createServer() {
return http.createServer((request, response) => {
const filePath = resolveRequestPath(request.url || "/");
async function handleApiRequest(request, response, pathname, options) {
if (pathname === "/api/rankings" && request.method === "GET") {
sendJson(response, 200, loadRankingsState({ statePath: options.rankingsStatePath }));
return true;
}
if (pathname === "/api/rankings/vote" && request.method === "POST") {
const body = await parseJsonBody(request);
sendJson(response, 200, recordVote(body, { statePath: options.rankingsStatePath }));
return true;
}
if (pathname === "/api/rankings/reset" && request.method === "POST") {
sendJson(response, 200, resetRankingsState({ statePath: options.rankingsStatePath }));
return true;
}
if (pathname === "/api/rankings" || pathname === "/api/rankings/vote" || pathname === "/api/rankings/reset") {
sendText(response, 405, "Method not allowed");
return true;
}
if (pathname.startsWith("/api/")) {
sendText(response, 404, "Not found");
return true;
}
return false;
}
function createServer(options) {
return http.createServer(async (request, response) => {
let pathname;
try {
pathname = decodeURIComponent(new URL(request.url || "/", "http://localhost").pathname);
} catch (error) {
sendText(response, 400, "Bad request");
return;
}
try {
if (await handleApiRequest(request, response, pathname, options)) {
return;
}
} catch (error) {
const statusCode =
error.message === "Request body must be valid JSON" ||
error.message === "Request body is too large" ||
error.message.startsWith("Vote payload") ||
error.message === "winnerId and loserId must be different"
? 400
: 500;
if (statusCode >= 500) {
console.error(error);
}
sendJson(response, statusCode, { error: error.message });
return;
}
const filePath = resolveRequestPath(pathname);
if (!filePath) {
response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Forbidden");
sendText(response, 403, "Forbidden");
return;
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
sendText(response, 404, "Not found");
return;
}
@@ -114,10 +241,11 @@ function createServer() {
function main() {
const options = parseArgs(process.argv.slice(2));
const server = createServer();
const server = createServer(options);
server.listen(options.port, options.host, () => {
console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`);
console.log(`Rankings state path: ${options.rankingsStatePath}`);
});
}