const http = require("http"); const fs = require("fs"); const path = require("path"); const { randomUUID } = require("crypto"); const HOST = process.env.HOST || "127.0.0.1"; const PORT = Number.parseInt(process.env.PORT || "3000", 10); const PUBLIC_DIR = path.join(__dirname, "public"); const clients = new Map(); // clientId -> { res, username } let nextEventId = 1; const MAX_HISTORY = 200; const messageHistory = []; // [{ id, at, username, text }] function sendSse(res, { event, data, id }) { if (typeof id === "number") res.write(`id: ${id}\n`); if (event) res.write(`event: ${event}\n`); if (data !== undefined) { const payload = typeof data === "string" ? data : JSON.stringify(data); for (const line of payload.split("\n")) res.write(`data: ${line}\n`); } res.write("\n"); } function broadcast(event, data, { excludeClientId } = {}) { const id = nextEventId++; for (const [clientId, { res }] of clients.entries()) { if (excludeClientId && clientId === excludeClientId) continue; sendSse(res, { event, data, id }); } return id; } function pushMessageHistory(entry) { messageHistory.push(entry); if (messageHistory.length > MAX_HISTORY) { messageHistory.splice(0, messageHistory.length - MAX_HISTORY); } } function sanitizeUsername(raw) { const username = String(raw || "").trim(); if (!username) return null; if (username.length > 24) return null; if (!/^[\p{L}\p{N}_-]+$/u.test(username)) return null; return username; } function sanitizeMessage(raw) { const text = String(raw || "").trim(); if (!text) return null; if (text.length > 500) return null; return text; } function serveFile(req, res, filePath) { fs.readFile(filePath, (err, buf) => { if (err) { res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); res.end("Not found"); return; } const ext = path.extname(filePath).toLowerCase(); const contentType = ext === ".html" ? "text/html; charset=utf-8" : ext === ".js" ? "text/javascript; charset=utf-8" : ext === ".css" ? "text/css; charset=utf-8" : "application/octet-stream"; res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" }); res.end(buf); }); } function readJson(req) { return new Promise((resolve, reject) => { let body = ""; req.on("data", (chunk) => { body += chunk; if (body.length > 64 * 1024) { reject(new Error("payload_too_large")); req.destroy(); } }); req.on("end", () => { try { resolve(body ? JSON.parse(body) : {}); } catch { reject(new Error("invalid_json")); } }); req.on("error", reject); }); } const server = http.createServer(async (req, res) => { try { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { return serveFile(req, res, path.join(PUBLIC_DIR, "index.html")); } if (req.method === "GET" && url.pathname.startsWith("/public/")) { const relative = url.pathname.replace(/^\/public\//, ""); const safePath = path.normalize(relative).replace(/^(\.\.(\/|\\|$))+/, ""); return serveFile(req, res, path.join(PUBLIC_DIR, safePath)); } if (req.method === "GET" && url.pathname === "/healthz") { res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ ok: true, clients: clients.size })); return; } if (req.method === "GET" && url.pathname === "/events") { const username = sanitizeUsername(url.searchParams.get("username")); if (!username) { res.writeHead(400, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "invalid_username" })); return; } const clientId = randomUUID(); const lastEventId = Number.parseInt(req.headers["last-event-id"] || "0", 10) || 0; const historyLimit = Math.min( Math.max(Number.parseInt(url.searchParams.get("history") || "50", 10) || 50, 1), MAX_HISTORY ); res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" }); res.write(": connected\n\n"); clients.set(clientId, { res, username }); sendSse(res, { event: "system", data: { type: "welcome", at: Date.now(), text: "欢迎来到聊天室!" }, id: nextEventId++ }); if (lastEventId > 0) { const missed = messageHistory.filter((m) => m.id > lastEventId); for (const m of missed) { sendSse(res, { event: "message", data: { at: m.at, username: m.username, text: m.text }, id: m.id }); } } else { const slice = messageHistory.slice(-historyLimit); sendSse(res, { event: "history", data: { messages: slice.map(({ at, username: u, text }) => ({ at, username: u, text })) }, id: nextEventId++ }); } broadcast( "system", { type: "join", at: Date.now(), username, text: `${username} 加入了聊天室` }, { excludeClientId: clientId } ); const keepAlive = setInterval(() => { try { res.write(": ping\n\n"); } catch { // ignore } }, 25_000); req.on("close", () => { clearInterval(keepAlive); const existing = clients.get(clientId); clients.delete(clientId); if (existing) { broadcast("system", { type: "leave", at: Date.now(), username: existing.username, text: `${existing.username} 离开了聊天室` }); } }); return; } if (req.method === "GET" && url.pathname === "/history") { const limit = Math.min(Math.max(Number.parseInt(url.searchParams.get("limit") || "50", 10) || 50, 1), MAX_HISTORY); res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); res.end(JSON.stringify({ messages: messageHistory.slice(-limit).map(({ at, username, text }) => ({ at, username, text })) })); return; } if (req.method === "POST" && url.pathname === "/message") { const contentType = (req.headers["content-type"] || "").toLowerCase(); if (!contentType.includes("application/json")) { res.writeHead(415, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "expected_application_json" })); return; } let payload; try { payload = await readJson(req); } catch (err) { res.writeHead(400, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: err.message || "bad_request" })); return; } const username = sanitizeUsername(payload.username); const text = sanitizeMessage(payload.text); if (!username || !text) { res.writeHead(400, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "invalid_message" })); return; } const at = Date.now(); const id = broadcast("message", { at, username, text }); pushMessageHistory({ id, at, username, text }); res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ ok: true })); return; } res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); res.end("Not found"); } catch { res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); res.end("Internal server error"); } }); server.on("error", (err) => { console.error("Server error:", err && err.message ? err.message : err); process.exitCode = 1; }); server.listen(PORT, HOST, () => { console.log(`Chatroom running: http://${HOST}:${PORT}`); });