Spaces:
Sleeping
Sleeping
| 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}`); | |
| }); | |