Chatroom / server.js
root
Improve UI, persist username, add history
2c2d6b9
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}`);
});