root commited on
Commit
ebd1d0a
·
0 Parent(s):
Files changed (9) hide show
  1. .dockerignore +5 -0
  2. .gitignore +3 -0
  3. Dockerfile +16 -0
  4. README.md +20 -0
  5. package.json +14 -0
  6. public/app.js +103 -0
  7. public/index.html +56 -0
  8. public/styles.css +188 -0
  9. server.js +214 -0
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ node_modules
2
+ npm-debug.log
3
+ .git
4
+ .DS_Store
5
+
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ node_modules/
2
+ .DS_Store
3
+
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json /app/package.json
6
+ COPY server.js /app/server.js
7
+ COPY public /app/public
8
+
9
+ ENV NODE_ENV=production
10
+ ENV HOST=0.0.0.0
11
+ ENV PORT=7860
12
+
13
+ EXPOSE 7860
14
+
15
+ CMD ["node", "server.js"]
16
+
README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node.js 聊天室(无依赖)
2
+
3
+ 这是一个最小可用的聊天室示例:服务端使用 Node.js 内置 `http`,消息推送使用 SSE(Server-Sent Events),不需要安装任何 npm 依赖。
4
+
5
+ ## 运行
6
+
7
+ ```bash
8
+ npm start
9
+ ```
10
+
11
+ 然后打开:
12
+
13
+ http://localhost:3000
14
+
15
+ ## 接口
16
+
17
+ - `GET /events?username=xxx`:SSE 消息流(加入/离开/消息)
18
+ - `POST /message`:发送消息(JSON:`{ "username": "xx", "text": "hello" }`)
19
+ - `GET /healthz`:健康检查
20
+
package.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "chatroom",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "A minimal Node.js chatroom (no deps) using HTTP + Server-Sent Events (SSE).",
6
+ "main": "server.js",
7
+ "scripts": {
8
+ "start": "node server.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18"
12
+ }
13
+ }
14
+
public/app.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const loginEl = document.getElementById("login");
2
+ const chatEl = document.getElementById("chat");
3
+ const statusEl = document.getElementById("status");
4
+ const messagesEl = document.getElementById("messages");
5
+ const loginForm = document.getElementById("loginForm");
6
+ const composer = document.getElementById("composer");
7
+ const usernameInput = document.getElementById("username");
8
+ const messageInput = document.getElementById("message");
9
+ const loginErrorEl = document.getElementById("loginError");
10
+
11
+ let username = null;
12
+ let es = null;
13
+
14
+ function setStatus(text) {
15
+ statusEl.textContent = text;
16
+ }
17
+
18
+ function escapeText(text) {
19
+ const div = document.createElement("div");
20
+ div.textContent = text;
21
+ return div.textContent;
22
+ }
23
+
24
+ function appendItem({ kind, at, from, text }) {
25
+ const li = document.createElement("li");
26
+ li.className = `msg ${kind}`;
27
+
28
+ const time = new Date(at || Date.now());
29
+ const ts = time.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
30
+
31
+ li.innerHTML =
32
+ kind === "message"
33
+ ? `<span class="meta">${escapeText(from)} · ${escapeText(ts)}</span><span class="text">${escapeText(text)}</span>`
34
+ : `<span class="meta">系统 · ${escapeText(ts)}</span><span class="text">${escapeText(text)}</span>`;
35
+
36
+ messagesEl.appendChild(li);
37
+ messagesEl.scrollTop = messagesEl.scrollHeight;
38
+ }
39
+
40
+ function connect() {
41
+ if (!username) return;
42
+ if (es) es.close();
43
+
44
+ setStatus("连接中…");
45
+ es = new EventSource(`/events?username=${encodeURIComponent(username)}`);
46
+
47
+ es.onopen = () => setStatus("已连接");
48
+ es.onerror = () => setStatus("连接异常,自动重连中…");
49
+
50
+ es.addEventListener("system", (e) => {
51
+ const data = JSON.parse(e.data);
52
+ if (data.type === "welcome") appendItem({ kind: "system", at: data.at, text: data.text });
53
+ if (data.type === "join" || data.type === "leave") appendItem({ kind: "system", at: data.at, text: data.text });
54
+ });
55
+
56
+ es.addEventListener("message", (e) => {
57
+ const data = JSON.parse(e.data);
58
+ appendItem({ kind: "message", at: data.at, from: data.username, text: data.text });
59
+ });
60
+ }
61
+
62
+ async function sendMessage(text) {
63
+ const resp = await fetch("/message", {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({ username, text })
67
+ });
68
+ if (!resp.ok) {
69
+ const payload = await resp.json().catch(() => ({}));
70
+ throw new Error(payload.error || "send_failed");
71
+ }
72
+ }
73
+
74
+ loginForm.addEventListener("submit", (e) => {
75
+ e.preventDefault();
76
+ loginErrorEl.textContent = "";
77
+
78
+ const raw = usernameInput.value.trim();
79
+ if (!raw) {
80
+ loginErrorEl.textContent = "请输入用户名";
81
+ return;
82
+ }
83
+
84
+ username = raw;
85
+ loginEl.classList.add("hidden");
86
+ chatEl.classList.remove("hidden");
87
+ messageInput.focus();
88
+ connect();
89
+ });
90
+
91
+ composer.addEventListener("submit", async (e) => {
92
+ e.preventDefault();
93
+ const text = messageInput.value.trim();
94
+ if (!text) return;
95
+ messageInput.value = "";
96
+
97
+ try {
98
+ await sendMessage(text);
99
+ } catch (err) {
100
+ appendItem({ kind: "system", at: Date.now(), text: `发送失败:${String(err.message || err)}` });
101
+ }
102
+ });
103
+
public/index.html ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Node.js 聊天室</title>
7
+ <link rel="stylesheet" href="/public/styles.css" />
8
+ </head>
9
+ <body>
10
+ <main class="app">
11
+ <header class="topbar">
12
+ <div class="brand">聊天室</div>
13
+ <div class="status" id="status">未连接</div>
14
+ </header>
15
+
16
+ <section class="panel">
17
+ <div class="login" id="login">
18
+ <div class="card">
19
+ <h1>进入聊天室</h1>
20
+ <p class="muted">用户名支持中文/字母/数字/下划线/短横线(1–24)</p>
21
+ <form id="loginForm">
22
+ <input
23
+ id="username"
24
+ name="username"
25
+ placeholder="输入用户名"
26
+ autocomplete="nickname"
27
+ maxlength="24"
28
+ required
29
+ />
30
+ <button type="submit">进入</button>
31
+ </form>
32
+ <div class="error" id="loginError" aria-live="polite"></div>
33
+ </div>
34
+ </div>
35
+
36
+ <div class="chat hidden" id="chat">
37
+ <ol class="messages" id="messages"></ol>
38
+ <form class="composer" id="composer">
39
+ <input
40
+ id="message"
41
+ name="message"
42
+ placeholder="输入消息,回车发送"
43
+ autocomplete="off"
44
+ maxlength="500"
45
+ required
46
+ />
47
+ <button type="submit">发送</button>
48
+ </form>
49
+ </div>
50
+ </section>
51
+ </main>
52
+
53
+ <script src="/public/app.js"></script>
54
+ </body>
55
+ </html>
56
+
public/styles.css ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0b1220;
3
+ --panel: #101b33;
4
+ --text: #eaf0ff;
5
+ --muted: #a8b4d6;
6
+ --accent: #6ea8fe;
7
+ --danger: #ff6b6b;
8
+ --border: rgba(255, 255, 255, 0.12);
9
+ }
10
+
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ html,
16
+ body {
17
+ height: 100%;
18
+ }
19
+
20
+ body {
21
+ margin: 0;
22
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "PingFang SC",
23
+ "Hiragino Sans GB", "Microsoft YaHei", Arial, "Apple Color Emoji", "Segoe UI Emoji";
24
+ background: radial-gradient(1000px 700px at 20% 20%, rgba(110, 168, 254, 0.18), transparent 55%),
25
+ radial-gradient(900px 700px at 80% 10%, rgba(255, 107, 107, 0.12), transparent 55%), var(--bg);
26
+ color: var(--text);
27
+ }
28
+
29
+ .app {
30
+ max-width: 900px;
31
+ margin: 0 auto;
32
+ padding: 16px;
33
+ height: 100%;
34
+ display: flex;
35
+ flex-direction: column;
36
+ gap: 12px;
37
+ }
38
+
39
+ .topbar {
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: space-between;
43
+ padding: 12px 14px;
44
+ background: rgba(16, 27, 51, 0.6);
45
+ border: 1px solid var(--border);
46
+ border-radius: 14px;
47
+ backdrop-filter: blur(10px);
48
+ }
49
+
50
+ .brand {
51
+ font-weight: 700;
52
+ letter-spacing: 0.5px;
53
+ }
54
+
55
+ .status {
56
+ font-size: 12px;
57
+ color: var(--muted);
58
+ }
59
+
60
+ .panel {
61
+ flex: 1;
62
+ min-height: 0;
63
+ background: rgba(16, 27, 51, 0.65);
64
+ border: 1px solid var(--border);
65
+ border-radius: 16px;
66
+ overflow: hidden;
67
+ backdrop-filter: blur(10px);
68
+ }
69
+
70
+ .hidden {
71
+ display: none !important;
72
+ }
73
+
74
+ .login {
75
+ height: 100%;
76
+ display: grid;
77
+ place-items: center;
78
+ padding: 24px;
79
+ }
80
+
81
+ .card {
82
+ width: min(520px, 100%);
83
+ background: rgba(11, 18, 32, 0.55);
84
+ border: 1px solid var(--border);
85
+ border-radius: 16px;
86
+ padding: 18px;
87
+ }
88
+
89
+ .card h1 {
90
+ margin: 0 0 8px;
91
+ font-size: 22px;
92
+ }
93
+
94
+ .muted {
95
+ margin: 0 0 12px;
96
+ color: var(--muted);
97
+ font-size: 13px;
98
+ }
99
+
100
+ form {
101
+ display: flex;
102
+ gap: 10px;
103
+ }
104
+
105
+ input {
106
+ flex: 1;
107
+ padding: 12px 12px;
108
+ border-radius: 12px;
109
+ border: 1px solid var(--border);
110
+ background: rgba(255, 255, 255, 0.06);
111
+ color: var(--text);
112
+ outline: none;
113
+ }
114
+
115
+ input:focus {
116
+ border-color: rgba(110, 168, 254, 0.6);
117
+ box-shadow: 0 0 0 4px rgba(110, 168, 254, 0.18);
118
+ }
119
+
120
+ button {
121
+ padding: 12px 14px;
122
+ border-radius: 12px;
123
+ border: 1px solid rgba(110, 168, 254, 0.55);
124
+ background: rgba(110, 168, 254, 0.18);
125
+ color: var(--text);
126
+ cursor: pointer;
127
+ }
128
+
129
+ button:hover {
130
+ background: rgba(110, 168, 254, 0.26);
131
+ }
132
+
133
+ .error {
134
+ margin-top: 10px;
135
+ color: var(--danger);
136
+ min-height: 18px;
137
+ font-size: 13px;
138
+ }
139
+
140
+ .chat {
141
+ height: 100%;
142
+ display: flex;
143
+ flex-direction: column;
144
+ min-height: 0;
145
+ }
146
+
147
+ .messages {
148
+ list-style: none;
149
+ margin: 0;
150
+ padding: 14px 14px 0;
151
+ overflow: auto;
152
+ flex: 1;
153
+ min-height: 0;
154
+ }
155
+
156
+ .msg {
157
+ padding: 10px 12px;
158
+ border: 1px solid rgba(255, 255, 255, 0.09);
159
+ border-radius: 14px;
160
+ margin-bottom: 10px;
161
+ background: rgba(11, 18, 32, 0.45);
162
+ }
163
+
164
+ .msg.system {
165
+ border-style: dashed;
166
+ color: var(--muted);
167
+ }
168
+
169
+ .msg .meta {
170
+ display: block;
171
+ font-size: 12px;
172
+ color: var(--muted);
173
+ margin-bottom: 6px;
174
+ }
175
+
176
+ .msg .text {
177
+ display: block;
178
+ white-space: pre-wrap;
179
+ word-break: break-word;
180
+ line-height: 1.4;
181
+ }
182
+
183
+ .composer {
184
+ padding: 14px;
185
+ border-top: 1px solid var(--border);
186
+ background: rgba(11, 18, 32, 0.35);
187
+ }
188
+
server.js ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const http = require("http");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { randomUUID } = require("crypto");
5
+
6
+ const HOST = process.env.HOST || "127.0.0.1";
7
+ const PORT = Number.parseInt(process.env.PORT || "3000", 10);
8
+ const PUBLIC_DIR = path.join(__dirname, "public");
9
+
10
+ const clients = new Map(); // clientId -> { res, username }
11
+ let nextEventId = 1;
12
+
13
+ function sendSse(res, { event, data, id }) {
14
+ if (typeof id === "number") res.write(`id: ${id}\n`);
15
+ if (event) res.write(`event: ${event}\n`);
16
+ if (data !== undefined) {
17
+ const payload = typeof data === "string" ? data : JSON.stringify(data);
18
+ for (const line of payload.split("\n")) res.write(`data: ${line}\n`);
19
+ }
20
+ res.write("\n");
21
+ }
22
+
23
+ function broadcast(event, data) {
24
+ const id = nextEventId++;
25
+ for (const { res } of clients.values()) {
26
+ sendSse(res, { event, data, id });
27
+ }
28
+ }
29
+
30
+ function sanitizeUsername(raw) {
31
+ const username = String(raw || "").trim();
32
+ if (!username) return null;
33
+ if (username.length > 24) return null;
34
+ if (!/^[\p{L}\p{N}_-]+$/u.test(username)) return null;
35
+ return username;
36
+ }
37
+
38
+ function sanitizeMessage(raw) {
39
+ const text = String(raw || "").trim();
40
+ if (!text) return null;
41
+ if (text.length > 500) return null;
42
+ return text;
43
+ }
44
+
45
+ function serveFile(req, res, filePath) {
46
+ fs.readFile(filePath, (err, buf) => {
47
+ if (err) {
48
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
49
+ res.end("Not found");
50
+ return;
51
+ }
52
+ const ext = path.extname(filePath).toLowerCase();
53
+ const contentType =
54
+ ext === ".html"
55
+ ? "text/html; charset=utf-8"
56
+ : ext === ".js"
57
+ ? "text/javascript; charset=utf-8"
58
+ : ext === ".css"
59
+ ? "text/css; charset=utf-8"
60
+ : "application/octet-stream";
61
+
62
+ res.writeHead(200, {
63
+ "content-type": contentType,
64
+ "cache-control": "no-store"
65
+ });
66
+ res.end(buf);
67
+ });
68
+ }
69
+
70
+ function readJson(req) {
71
+ return new Promise((resolve, reject) => {
72
+ let body = "";
73
+ req.on("data", (chunk) => {
74
+ body += chunk;
75
+ if (body.length > 64 * 1024) {
76
+ reject(new Error("payload_too_large"));
77
+ req.destroy();
78
+ }
79
+ });
80
+ req.on("end", () => {
81
+ try {
82
+ resolve(body ? JSON.parse(body) : {});
83
+ } catch {
84
+ reject(new Error("invalid_json"));
85
+ }
86
+ });
87
+ req.on("error", reject);
88
+ });
89
+ }
90
+
91
+ const server = http.createServer(async (req, res) => {
92
+ try {
93
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
94
+
95
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
96
+ return serveFile(req, res, path.join(PUBLIC_DIR, "index.html"));
97
+ }
98
+
99
+ if (req.method === "GET" && url.pathname.startsWith("/public/")) {
100
+ const relative = url.pathname.replace(/^\/public\//, "");
101
+ const safePath = path.normalize(relative).replace(/^(\.\.(\/|\\|$))+/, "");
102
+ return serveFile(req, res, path.join(PUBLIC_DIR, safePath));
103
+ }
104
+
105
+ if (req.method === "GET" && url.pathname === "/healthz") {
106
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
107
+ res.end(JSON.stringify({ ok: true, clients: clients.size }));
108
+ return;
109
+ }
110
+
111
+ if (req.method === "GET" && url.pathname === "/events") {
112
+ const username = sanitizeUsername(url.searchParams.get("username"));
113
+ if (!username) {
114
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
115
+ res.end(JSON.stringify({ error: "invalid_username" }));
116
+ return;
117
+ }
118
+
119
+ const clientId = randomUUID();
120
+ res.writeHead(200, {
121
+ "content-type": "text/event-stream; charset=utf-8",
122
+ "cache-control": "no-cache, no-transform",
123
+ connection: "keep-alive",
124
+ "x-accel-buffering": "no"
125
+ });
126
+
127
+ res.write(": connected\n\n");
128
+ clients.set(clientId, { res, username });
129
+
130
+ broadcast("system", {
131
+ type: "join",
132
+ at: Date.now(),
133
+ username,
134
+ text: `${username} 加入了聊天室`
135
+ });
136
+
137
+ sendSse(res, {
138
+ event: "system",
139
+ data: { type: "welcome", at: Date.now(), text: "欢迎来到聊天室!" },
140
+ id: nextEventId++
141
+ });
142
+
143
+ const keepAlive = setInterval(() => {
144
+ try {
145
+ res.write(": ping\n\n");
146
+ } catch {
147
+ // ignore
148
+ }
149
+ }, 25_000);
150
+
151
+ req.on("close", () => {
152
+ clearInterval(keepAlive);
153
+ const existing = clients.get(clientId);
154
+ clients.delete(clientId);
155
+ if (existing) {
156
+ broadcast("system", {
157
+ type: "leave",
158
+ at: Date.now(),
159
+ username: existing.username,
160
+ text: `${existing.username} 离开了聊天室`
161
+ });
162
+ }
163
+ });
164
+ return;
165
+ }
166
+
167
+ if (req.method === "POST" && url.pathname === "/message") {
168
+ const contentType = (req.headers["content-type"] || "").toLowerCase();
169
+ if (!contentType.includes("application/json")) {
170
+ res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
171
+ res.end(JSON.stringify({ error: "expected_application_json" }));
172
+ return;
173
+ }
174
+
175
+ let payload;
176
+ try {
177
+ payload = await readJson(req);
178
+ } catch (err) {
179
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
180
+ res.end(JSON.stringify({ error: err.message || "bad_request" }));
181
+ return;
182
+ }
183
+
184
+ const username = sanitizeUsername(payload.username);
185
+ const text = sanitizeMessage(payload.text);
186
+ if (!username || !text) {
187
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
188
+ res.end(JSON.stringify({ error: "invalid_message" }));
189
+ return;
190
+ }
191
+
192
+ broadcast("message", { at: Date.now(), username, text });
193
+
194
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
195
+ res.end(JSON.stringify({ ok: true }));
196
+ return;
197
+ }
198
+
199
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
200
+ res.end("Not found");
201
+ } catch {
202
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
203
+ res.end("Internal server error");
204
+ }
205
+ });
206
+
207
+ server.on("error", (err) => {
208
+ console.error("Server error:", err && err.message ? err.message : err);
209
+ process.exitCode = 1;
210
+ });
211
+
212
+ server.listen(PORT, HOST, () => {
213
+ console.log(`Chatroom running: http://${HOST}:${PORT}`);
214
+ });