Skip to main content

搭建匿名聊天室

搭建匿名聊天室

基于 Cloudflare Workers + Durable Objects 的实时匿名聊天室,支持 WSS 和 E2EE 端到端加密。

支持发送文字和图片消息。

所有数据都临时保存在本地浏览器中,关闭或者刷新浏览器则所有数据清除。

Durable Objects

Durable Objects 是 Cloudflare 在 Workers 体系里提供的一种“有状态计算单元”,用来解决传统 Serverless(无状态)架构里“状态难保存、并发难协调”的问题。

免费额度:10万次/天

项目结构

C:\Users\kk\Desktop\gochat\newchat>tree /F
文件夹 PATH 列表
卷序列号为 AA59-C1A0
C:.
│ package-lock.json
│ package.json
│ README.md
│ wrangler.toml

└─src
index.js

Durable Object 配置文件

newchat\wrangler.toml

name = "webchat-worker"
main = "src/index.js"
compatibility_date = "2024-01-01"

[[durable_objects.bindings]]
name = "WS"
class_name = "ChatRoom"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]

聊天页面

newchat\src\index.js

// =============================================================================
// webchat - Cloudflare Workers + Durable Objects 版本
// =============================================================================

export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);

if (url.pathname === "/ws" && request.headers.get("Upgrade") === "websocket") {
const roomId = url.searchParams.get("room");
if (!roomId || !/^[a-f0-9]{32}$/.test(roomId)) {
return new Response("无效的房间 ID", { status: 400 });
}
const doId = env.WS.idFromName(roomId);
const doStub = env.WS.get(doId);
return doStub.fetch(request);
}

if (url.pathname === "/" || url.pathname === "/index.html") {
return new Response(HTML_PAGE, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}

return new Response("Not Found", { status: 404 });
},
};

// =============================================================================
// Durable Object:管理房间和 WebSocket 连接
// =============================================================================

export class ChatRoom {
constructor(state, env) {
this.state = state;
this.env = env;
this.clients = new Map(); // clientId -> { ws, name, lastMsgTime, authenticated }
this.passwordHash = null; // SHA-256 密码哈希 (null = 无密码)
this.msgCount = 0;
this.msgCountResetAt = Date.now() + 1000;
this.state.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
)
`);
}

async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/ws" && request.headers.get("Upgrade") === "websocket") {
return this.handleWebSocket(request);
}
return new Response("Not Found", { status: 404 });
}

canAcceptConnection() {
return this.clients.size < 200;
}

canSendMessage(clientId) {
const client = this.clients.get(clientId);
if (!client) return false;
const now = Date.now();
return now - client.lastMsgTime >= 500;
}

canBroadcast() {
const now = Date.now();
if (now >= this.msgCountResetAt) {
this.msgCount = 0;
this.msgCountResetAt = now + 1000;
}
return this.msgCount < 50;
}

async handleWebSocket(request) {
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];

if (!this.canAcceptConnection()) {
server.close(1008, "房间人数已达上限 (200人)");
return new Response(null, { status: 101, webSocket: client });
}

const name = randomName();
const clientId = crypto.randomUUID();
const needsAuth = !!this.passwordHash;

this.clients.set(clientId, {
id: clientId, name, ws: server,
lastMsgTime: 0, authenticated: false,
});

this.state.storage.sql.exec(
`INSERT INTO clients (id, name) VALUES ('${clientId}', '${name.replace(/'/g, "''")}')`
);

server.accept();

if (needsAuth) {
this.sendJSON(server, { type: "need_auth" });
} else {
this.clients.get(clientId).authenticated = true;
this.sendJSON(server, { type: "welcome", name, content: "欢迎加入聊天室" });
this.broadcast({ type: "system", name, content: "加入了聊天室" }, server);
}

server.addEventListener("message", async (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch {
return;
}

const clientData = this.clients.get(clientId);

// ---- 认证消息处理 ----
if (data.type === "auth") {
const hash = await sha256(data.password || "");
if (!this.passwordHash) {
// 第一个用户设置密码(welcome 已在 handleWebSocket 中发送)
this.passwordHash = hash;
clientData.authenticated = true;
this.sendJSON(server, { type: "auth_result", ok: true });
this.broadcast({ type: "system", name, content: "加入了聊天室" }, server);
} else if (hash === this.passwordHash) {
clientData.authenticated = true;
this.sendJSON(server, { type: "auth_result", ok: true });
this.sendJSON(server, { type: "welcome", name, content: "欢迎加入聊天室" });
this.broadcast({ type: "system", name, content: "加入了聊天室" }, server);
} else {
// 密码错误 - 不关闭连接,让客户端重试
this.sendJSON(server, { type: "auth_result", ok: false, content: "密码错误,请重试" });
}
return;
}

// ---- 未认证的消息忽略 ----
if (!clientData || !clientData.authenticated) {
return;
}

// ---- 普通聊天消息 ----
const content = data.content || "";
const image = data.image || null;

if (!this.canSendMessage(clientId)) {
this.sendJSON(server, { type: "system", name: "", content: "发送太频繁,请稍后再试" });
return;
}
if (!this.canBroadcast()) {
this.sendJSON(server, { type: "system", name: "", content: "房间消息太多,请稍后再试" });
return;
}

clientData.lastMsgTime = Date.now();
this.msgCount++;

if (image || (content && content.length <= 5000)) {
this.broadcast({ type: "chat", name, content, image, encrypted: !!data.encrypted }, null);
}
});

server.addEventListener("close", () => {
this.clients.delete(clientId);
this.state.storage.sql.exec(
`DELETE FROM clients WHERE id = '${clientId}'`
);
this.broadcast({ type: "system", name, content: "离开了聊天室" }, null);
});

return new Response(null, { status: 101, webSocket: client });
}

broadcast(msg, excludeWs) {
const msgStr = JSON.stringify(msg);
for (const [id, client] of this.clients) {
if (excludeWs && client.ws === excludeWs) continue;
try {
client.ws.send(msgStr);
} catch (e) {
// ignore
}
}
}

sendJSON(ws, obj) {
try {
ws.send(JSON.stringify(obj));
} catch (e) {
// ignore
}
}
}

// =============================================================================
// 工具函数
// =============================================================================

function randomName() {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const animal = animals[Math.floor(Math.random() * animals.length)];
const num = Math.floor(Math.random() * 900) + 100;
return adj + animal + num;
}

const adjectives = ["快乐的", "神秘的", "慵懒的", "勇敢的", "聪明的", "可爱的", "安静的", "活泼的"];
const animals = ["熊猫", "狐狸", "企鹅", "海豚", "考拉", "柴犬", "小鹿", "猫头鹰"];

async function sha256(str) {
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
return btoa(String.fromCharCode(...new Uint8Array(hash)));
}

// =============================================================================
// HTML 页面
// =============================================================================

const HTML_PAGE = `<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>匿名聊天室</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect fill='%236366f1' rx='20' width='100' height='100'/%3E%3Ctext x='50' y='65' font-size='50' text-anchor='middle' fill='white'%3E%3C/text%3E%3C/svg%3E" />
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg-gradient:linear-gradient(135deg,#0f172a 0%,#1e1b4b 50%,#312e81 100%);
--card-bg:rgba(255,255,255,0.06);
--card-border:rgba(255,255,255,0.12);
--text-primary:#f1f5f9;
--text-secondary:#94a3b8;
--accent:#818cf8;
--accent-hover:#6366f1;
--bubble-other:rgba(255,255,255,0.1);
--bubble-mine:linear-gradient(135deg,#6366f1,#8b5cf6);
--success:#34d399;
--danger:#f87171;
--shadow:0 25px 50px -12px rgba(0,0,0,0.45);
}
body{font-family:"Segoe UI",system-ui,-apple-system,sans-serif;min-height:100vh;background:var(--bg-gradient);color:var(--text-primary);display:flex;align-items:center;justify-content:center;padding:24px}
.app{width:100%;max-width:480px;height:min(920px,calc(100vh - 48px));display:flex;flex-direction:column;background:var(--card-bg);border:1px solid var(--card-border);border-radius:20px;backdrop-filter:blur(16px);box-shadow:var(--shadow);overflow:hidden}
.header{padding:20px 24px;border-bottom:1px solid var(--card-border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.header-left{display:flex;align-items:center;gap:12px}
.logo{width:40px;height:40px;border-radius:12px;background:linear-gradient(135deg,#6366f1,#a78bfa);display:flex;align-items:center;justify-content:center;font-size:20px}
.header h1{font-size:1.15rem;font-weight:600;letter-spacing:-0.02em}
.header p{font-size:0.75rem;color:var(--text-secondary);margin-top:2px}
.status{display:flex;align-items:center;gap:6px;font-size:0.75rem;color:var(--text-secondary);padding:6px 12px;border-radius:999px;background:rgba(0,0,0,0.2)}
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--text-secondary);transition:background 0.3s}
.status.connected .status-dot{background:var(--success);box-shadow:0 0 8px var(--success)}
.status.disconnected .status-dot{background:var(--danger)}
.room-bar{padding:10px 20px;border-bottom:1px solid var(--card-border);display:flex;align-items:center;gap:8px;flex-shrink:0}
.room-bar label{font-size:0.75rem;color:var(--text-secondary);white-space:nowrap}
.room-bar input{flex:1;min-width:0;padding:8px 12px;border:1px solid var(--card-border);border-radius:10px;background:rgba(0,0,0,0.25);color:var(--text-secondary);font-size:0.75rem;outline:none}
.room-bar button{padding:8px 14px;border:none;border-radius:10px;background:rgba(129,140,248,0.25);color:var(--accent);font-size:0.75rem;font-weight:600;cursor:pointer;white-space:nowrap}
.room-bar button:hover{background:rgba(129,140,248,0.4)}
.lobby{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 32px;text-align:center;gap:16px}
.lobby-icon{font-size:3rem;margin-bottom:8px}
.lobby h2{font-size:1.25rem;font-weight:600}
.lobby p{font-size:0.9rem;color:var(--text-secondary);line-height:1.6;max-width:300px}
.lobby button{margin-top:12px;padding:14px 32px;border:none;border-radius:14px;background:var(--accent);color:#fff;font-size:1rem;font-weight:600;cursor:pointer;transition:background 0.2s,transform 0.1s}
.lobby button:hover{background:var(--accent-hover)}
.lobby button:active{transform:scale(0.97)}
.chat-view{flex:1;display:flex;flex-direction:column;min-height:0}
.chat{flex:1;overflow-y:auto;padding:20px 16px;display:flex;flex-direction:column;gap:12px;scroll-behavior:smooth}
.chat::-webkit-scrollbar{width:6px}
.chat::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.15);border-radius:3px}
.message-wrap{display:flex;flex-direction:column;max-width:78%;animation:fadeIn 0.25s ease}
.message-wrap.mine{align-self:flex-end;align-items:flex-end}
.message-wrap.other{align-self:flex-start;align-items:flex-start}
.message-wrap.system{align-self:center;max-width:90%}
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.sender{font-size:0.7rem;color:var(--text-secondary);margin-bottom:4px;padding:0 4px}
.message{padding:10px 14px;border-radius:16px;font-size:0.9rem;line-height:1.5;word-break:break-word}
.message.other{background:var(--bubble-other);border-bottom-left-radius:4px}
.message.mine{background:var(--bubble-mine);border-bottom-right-radius:4px;color:#fff}
.message.system{background:transparent;color:var(--text-secondary);font-size:0.75rem;padding:4px 12px;border-radius:999px;border:1px dashed rgba(255,255,255,0.1)}
.message .time{display:block;font-size:0.65rem;opacity:0.65;margin-top:4px}
.composer{padding:16px 20px 20px;border-top:1px solid var(--card-border);display:flex;gap:10px;flex-shrink:0}
.composer input{flex:1;padding:12px 16px;border:1px solid var(--card-border);border-radius:14px;background:rgba(0,0,0,0.25);color:var(--text-primary);font-size:0.9rem;outline:none;transition:border-color 0.2s,box-shadow 0.2s}
.composer input::placeholder{color:var(--text-secondary)}
.composer input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(129,140,248,0.2)}
.composer button{padding:12px 20px;border:none;border-radius:14px;background:var(--accent);color:#fff;font-size:0.9rem;font-weight:600;cursor:pointer;transition:background 0.2s,transform 0.1s;white-space:nowrap}
.composer button:hover{background:var(--accent-hover)}
.composer button:active{transform:scale(0.97)}
.composer button:disabled,.composer input:disabled{opacity:0.5;cursor:not-allowed}
.badge{display:inline-flex;align-items:center;gap:4px;font-size:0.7rem;margin-left:4px}
.badge-secure{color:var(--success)}
.badge-e2ee{color:#fbbf24}
.badge-name{color:var(--accent)}
.hidden{display:none!important}
.img-modal{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;align-items:center;justify-content:center;z-index:9999;cursor:pointer}
.img-modal img{max-width:90%;max-height:90%;border-radius:8px;object-fit:contain}
.img-modal .close-btn{position:absolute;top:20px;right:30px;color:#fff;font-size:2.5rem;cursor:pointer;line-height:1;z-index:10000}
.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9998;backdrop-filter:blur(4px)}
.modal-box{background:linear-gradient(135deg,#1e293b,#0f172a);border:1px solid var(--card-border);border-radius:20px;padding:32px;width:90%;max-width:360px;box-shadow:var(--shadow)}
.modal-box h3{font-size:1.15rem;margin-bottom:8px}
.modal-box p{font-size:0.85rem;color:var(--text-secondary);line-height:1.5;margin-bottom:20px}
.modal-box input{width:100%;padding:12px 16px;border:1px solid var(--card-border);border-radius:12px;background:rgba(0,0,0,0.4);color:var(--text-primary);font-size:0.9rem;outline:none;transition:border-color 0.2s}
.modal-box input:focus{border-color:var(--accent)}
.modal-box .error-msg{color:var(--danger);font-size:0.8rem;margin-top:8px;min-height:1.2em}
.modal-actions{display:flex;gap:10px;margin-top:20px;justify-content:flex-end}
.modal-actions button{padding:10px 20px;border:none;border-radius:12px;font-size:0.9rem;font-weight:600;cursor:pointer;transition:background 0.2s}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent-hover)}
.btn-secondary{background:rgba(255,255,255,0.1);color:var(--text-secondary)}
.btn-secondary:hover{background:rgba(255,255,255,0.18)}
.encrypt-indicator{display:inline-flex;align-items:center;gap:4px;font-size:0.7rem;color:#fbbf24;margin-left:auto;opacity:0.8}
</style>
</head>
<body>
<div class="app">
<header class="header">
<div class="header-left">
<div class="logo">💬</div>
<div>
<h1>匿名聊天室</h1>
<p>
<span id="subtitle">私密房间聊天</span>
<span class="badge badge-secure" id="secureBadge" hidden>🔒 WSS</span>
<span class="badge badge-e2ee" id="e2eeBadge" hidden>🔐 E2EE</span>
<span class="badge badge-name" id="nameBadge" hidden></span>
</p>
</div>
</div>
<div class="status" id="status"><span class="status-dot"></span><span id="statusText">未连接</span></div>
</header>
<div class="lobby" id="lobby">
<div class="lobby-icon">🏠</div>
<h2>创建你的聊天房间</h2>
<p>点击按钮生成专属链接。可选择设置密码,密码会被分享给朋友以便进入同一房间。</p>
<button onclick="showCreatePasswordDialog()">创建房间</button>
</div>
<div class="chat-view hidden" id="chatView">
<div class="room-bar">
<label>邀请链接</label>
<input type="text" id="roomLink" readonly />
<button onclick="copyRoomLink()">复制</button>
<span class="encrypt-indicator hidden" id="e2eeIndicator">🔐 端到端加密</span>
</div>
<div class="chat" id="chat"></div>
<div class="composer">
<input type="text" id="msg" placeholder="输入消息,按 Enter 发送..." autocomplete="off" disabled />
<button id="sendBtn" onclick="sendMessage()" disabled>发送</button>
</div>
</div>
</div>
<div id="passwordModal" class="modal-overlay hidden">
<div class="modal-box">
<h3 id="pwTitle">设置房间密码</h3>
<p id="pwDesc">设置密码后,其他人需要密码才能进入房间</p>
<input type="password" id="passwordInput" placeholder="输入房间密码" autocomplete="off" />
<div class="error-msg" id="pwError"></div>
<div class="modal-actions">
<button id="pwSkipBtn" class="btn-secondary" onclick="skipPassword()">跳过(公开房间)</button>
<button id="pwConfirmBtn" class="btn-primary" onclick="confirmPassword()">确认</button>
</div>
</div>
</div>
<script>
const params=new URLSearchParams(location.search);
const roomId=params.get("room");
const lobby=document.getElementById("lobby");
const chatView=document.getElementById("chatView");
const chat=document.getElementById("chat");
const msgInput=document.getElementById("msg");
const sendBtn=document.getElementById("sendBtn");
const status=document.getElementById("status");
const statusText=document.getElementById("statusText");
const secureBadge=document.getElementById("secureBadge");
const e2eeBadge=document.getElementById("e2eeBadge");
const nameBadge=document.getElementById("nameBadge");
const e2eeIndicator=document.getElementById("e2eeIndicator");
const roomLinkInput=document.getElementById("roomLink");
const pwModal=document.getElementById("passwordModal");
const pwTitle=document.getElementById("pwTitle");
const pwDesc=document.getElementById("pwDesc");
const pwInput=document.getElementById("passwordInput");
const pwError=document.getElementById("pwError");
const pwSkipBtn=document.getElementById("pwSkipBtn");
const pwConfirmBtn=document.getElementById("pwConfirmBtn");
let ws=null;
let myName="";
let e2eeKey=null;
let hasE2EE=false;
let pendingAuthPassword=null;
let isCreatingRoom=false;
let reconnectCount=0;
secureBadge.hidden=false;
function generateRoomId(){return Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,"0")).join("")}
function nowTime(){return new Date().toLocaleTimeString("zh-CN",{hour:"2-digit",minute:"2-digit"})}
const E2EE_SALT=new TextEncoder().encode(roomId+":gochat-e2ee");
async function deriveKey(password){
const keyMaterial=await crypto.subtle.importKey("raw",new TextEncoder().encode(password),"PBKDF2",false,["deriveKey"]);
return crypto.subtle.deriveKey(
{name:"PBKDF2",salt:E2EE_SALT,iterations:100000,hash:"SHA-256"},
keyMaterial,
{name:"AES-GCM",length:256},
false,
["encrypt","decrypt"]
);
}
async function encryptMessage(key,plaintext){
const iv=crypto.getRandomValues(new Uint8Array(12));
const ciphertext=await crypto.subtle.encrypt(
{name:"AES-GCM",iv},key,new TextEncoder().encode(plaintext)
);
const combined=new Uint8Array(iv.length+ciphertext.byteLength);
combined.set(iv,0);
combined.set(new Uint8Array(ciphertext),iv.length);
return btoa(String.fromCharCode(...new Uint8Array(combined)));
}
async function decryptMessage(key,base64Data){
const combined=new Uint8Array(atob(base64Data).split("").map(c=>c.charCodeAt(0)));
const iv=combined.slice(0,12);
const ciphertext=combined.slice(12);
const plaintext=await crypto.subtle.decrypt({name:"AES-GCM",iv},key,ciphertext);
return new TextDecoder().decode(plaintext);
}
function showCreatePasswordDialog(){
isCreatingRoom=true;
pwTitle.textContent="设置房间密码";
pwDesc.textContent="设置密码后,其他人需要密码才能进入房间,消息将端到端加密。";
pwInput.value="";
pwError.textContent="";
pwSkipBtn.classList.remove("hidden");
pwSkipBtn.textContent="跳过(公开房间)";
pwConfirmBtn.textContent="确认";
pwModal.classList.remove("hidden");
setTimeout(()=>pwInput.focus(),100);
}
function showJoinPasswordDialog(){
isCreatingRoom=false;
pwTitle.textContent="输入房间密码";
pwDesc.textContent="该房间需要密码才能进入";
pwInput.value="";
pwError.textContent="";
pwSkipBtn.classList.add("hidden");
pwConfirmBtn.textContent="加入";
pwModal.classList.remove("hidden");
setTimeout(()=>pwInput.focus(),100);
}
function skipPassword(){
pwModal.classList.add("hidden");
const id=generateRoomId();
location.href="?room="+id;
}
function confirmPassword(){
const password=pwInput.value;
if(!password&&isCreatingRoom){
pwError.textContent="请输入密码或点击跳过";
return;
}
if(!password){
pwError.textContent="请输入密码";
return;
}
pwError.textContent="";
pwModal.classList.add("hidden");
if(isCreatingRoom){
const id=generateRoomId();
sessionStorage.setItem("gochat_pw_"+id,password);
sessionStorage.setItem("gochat_has_e2ee","1");
location.href="?room="+id;
}else{
pendingAuthPassword=password;
if(ws&&ws.readyState===WebSocket.OPEN){
ws.send(JSON.stringify({type:"auth",password}));
}
}
}
pwInput.addEventListener("keydown",function(e){
if(e.key==="Enter")confirmPassword();
});
function connectRoom(){
if(!roomId)return;
lobby.classList.add("hidden");
chatView.classList.remove("hidden");
const shareUrl=location.origin+location.pathname+"?room="+roomId;
roomLinkInput.value=shareUrl;
const storedPw=sessionStorage.getItem("gochat_pw_"+roomId);
if(storedPw){
pendingAuthPassword=storedPw;
sessionStorage.removeItem("gochat_pw_"+roomId);
}
if(sessionStorage.getItem("gochat_has_e2ee")){
hasE2EE=true;
sessionStorage.removeItem("gochat_has_e2ee");
}
const wsUrl=(location.protocol==="https:"?"wss://":"ws://")+location.host+"/ws?room="+encodeURIComponent(roomId);
ws=new WebSocket(wsUrl);
ws.onopen=function(){
setConnected(true);
if(pendingAuthPassword){
ws.send(JSON.stringify({type:"auth",password:pendingAuthPassword}));
}
};
ws.onmessage=msgHandler;
ws.onclose=function(){
setConnected(false);
if(pendingAuthPassword||!document.getElementById("passwordModal").classList.contains("hidden")){
setTimeout(reconnect,1000);
}else{
appendMessage({type:"system",name:"",content:"连接已断开,点击重新连接"},"system");
appendReconnectButton();
}
};
ws.onerror=function(){
setConnected(false);
};
}
async function msgHandler(event){
const msg=JSON.parse(event.data);
if(msg.type==="need_auth"){
if(!pendingAuthPassword){
showJoinPasswordDialog();
}
return;
}
if(msg.type==="auth_result"){
if(msg.ok){
if(pendingAuthPassword){
try{
e2eeKey=await deriveKey(pendingAuthPassword);
hasE2EE=true;
e2eeBadge.hidden=false;
e2eeIndicator.classList.remove("hidden");
}catch(e){
console.error("E2EE key derivation failed",e);
}
}
pendingAuthPassword=null;
}else{
pwError.textContent=msg.content||"密码错误,请重试";
showJoinPasswordDialog();
return;
}
return;
}
if(msg.type==="welcome"){
myName=msg.name;
nameBadge.hidden=false;
nameBadge.textContent="👤 "+myName;
appendMessage({type:"system",name:"",content:"你的昵称是「"+myName+"」,欢迎加入聊天室"},"system");
return;
}
if(msg.type==="system"){
appendMessage(msg,"system");
return;
}
const kind=msg.name===myName?"mine":"other";
try{
if(msg.encrypted&&e2eeKey&&msg.content){
const decrypted=await decryptMessage(e2eeKey,msg.content);
appendMessage({content:decrypted,image:msg.image,name:msg.name,type:"chat"},kind);
}else{
appendMessage({content:msg.content,image:msg.image,name:msg.name,type:"chat"},kind);
}
}catch(e){
console.error("Decrypt failed",e);
appendMessage({content:"[解密失败]",name:msg.name,type:"chat"},kind);
}
}
function reconnect(){
if(reconnectCount>3){
appendMessage({type:"system",name:"",content:"重连失败,请刷新页面重试"},"system");
return;
}
reconnectCount++;
if(ws)try{ws.close()}catch(e){}
const wsUrl=(location.protocol==="https:"?"wss://":"ws://")+location.host+"/ws?room="+encodeURIComponent(roomId);
ws=new WebSocket(wsUrl);
ws.onopen=function(){
setConnected(true);
reconnectCount=0;
if(pendingAuthPassword){
ws.send(JSON.stringify({type:"auth",password:pendingAuthPassword}));
}
};
ws.onmessage=msgHandler;
ws.onclose=function(){
setConnected(false);
if(pendingAuthPassword||!document.getElementById("passwordModal").classList.contains("hidden")){
setTimeout(reconnect,2000);
}
};
ws.onerror=function(){setConnected(false)};
}
function appendReconnectButton(){
const btn=document.createElement("button");
btn.textContent="重新连接";
btn.style="display:block;margin:8px auto;padding:8px 20px;border:none;border-radius:12px;background:var(--accent);color:#fff;font-size:0.85rem;font-weight:600;cursor:pointer";
btn.onclick=function(){btn.remove();reconnect()};
chat.appendChild(btn);
}
async function sendMessage(){
if(!ws||ws.readyState!==WebSocket.OPEN)return;
const text=msgInput.value.trim();
if(!text)return;
let payload={};
if(hasE2EE&&e2eeKey){
const encrypted=await encryptMessage(e2eeKey,text);
payload={content:encrypted,encrypted:true};
}else{
payload={content:text};
}
ws.send(JSON.stringify(payload));
msgInput.value="";
}
function sendImage(base64){
if(!ws||ws.readyState!==WebSocket.OPEN)return;
ws.send(JSON.stringify({content:"",image:base64}));
}
function compressImage(file,callback){
const img=new Image();
img.onload=function(){
const maxW=800;
let w=img.width,h=img.height;
if(w>maxW){h=h*maxW/w;w=maxW;}
const canvas=document.createElement("canvas");
canvas.width=w;canvas.height=h;
const ctx=canvas.getContext("2d");
ctx.drawImage(img,0,0,w,h);
callback(canvas.toDataURL("image/jpeg",0.8));
};
img.src=URL.createObjectURL(file);
}
function handlePaste(e){
const items=e.clipboardData.items;
for(let i=0;i<items.length;i++){
if(items[i].type.indexOf("image")!==-1){
e.preventDefault();
const file=items[i].getAsFile();
if(file.size>5*1024*1024){alert("图片太大,请选择小于5MB的图片");return;}
compressImage(file,function(base64){sendImage(base64);});
return;
}
}
}
msgInput.addEventListener("keydown",function(e){
if(e.key==="Enter"&&!e.shiftKey){
e.preventDefault();
sendMessage();
}
});
msgInput.addEventListener("paste",handlePaste);
function setConnected(connected){
status.classList.toggle("connected",connected);
status.classList.toggle("disconnected",!connected);
statusText.textContent=connected?"已连接":"已断开";
msgInput.disabled=!connected;
sendBtn.disabled=!connected;
if(connected)msgInput.focus();
}
function copyRoomLink(){
roomLinkInput.select();
navigator.clipboard.writeText(roomLinkInput.value).then(()=>{
const btn=roomLinkInput.nextElementSibling;
const orig=btn.textContent;
btn.textContent="已复制";
setTimeout(()=>btn.textContent=orig,1500);
});
}
function appendMessage(msg,kind){
const wrap=document.createElement("div");
wrap.className="message-wrap "+kind;
if(kind!=="system"&&msg.name){
const sender=document.createElement("div");
sender.className="sender";
sender.textContent=kind==="mine"?myName+"(我)":msg.name;
wrap.appendChild(sender);
}
const div=document.createElement("div");
div.className="message "+kind;
if(msg.type==="system"){
const text=document.createElement("span");
text.textContent=msg.name+" "+msg.content;
div.appendChild(text);
}else{
if(msg.content){
const text=document.createElement("span");
text.textContent=msg.content;
div.appendChild(text);
}
if(msg.image){
const img=document.createElement("img");
img.src=msg.image;
img.style="max-width:200px;max-height:200px;border-radius:8px;margin-top:4px;display:block;cursor:pointer";
img.onclick=function(){showImageModal(msg.image)};
div.appendChild(img);
}
}
const time=document.createElement("span");
time.className="time";
time.textContent=nowTime();
div.appendChild(time);
wrap.appendChild(div);
chat.appendChild(wrap);
chat.scrollTop=chat.scrollHeight;
}
function showImageModal(src){
const modal=document.createElement("div");
modal.className="img-modal";
modal.innerHTML='<span class="close-btn">×</span><img src="'+encodeURI(src)+'" />';
modal.querySelector(".close-btn").onclick=function(e){e.stopPropagation();modal.remove()};
modal.onclick=function(){modal.remove()};
document.body.appendChild(modal);
}
if(roomId){connectRoom()}
</script>
</body>
</html>`;

使用方法

  1. 打开页面,点击「创建房间」生成专属链接
  2. 将链接分享给朋友
  3. 朋友打开链接后自动进入同一房间
  4. 开始聊天

技术架构

  • Workers - 处理 HTTP 请求和 WebSocket 升级
  • Durable Objects - 按房间 ID 路由,确保同一房间的消息在同一实例中广播

功能列表

核心功能

  • 房间机制:创建房间后分享链接,只有持有链接的用户能进入同一房间
  • 随机昵称:服务端分配形如"快乐熊猫123"的昵称
  • 实时消息:WebSocket 实时收发消息
  • 图片消息:支持粘贴板图片发送(Ctrl+V),自动压缩

安全与限制

  • 消息限制:每条消息最多 500 字符
  • 单用户限流:每 500ms 最多发送 1 条消息
  • 房间限流:每秒最多 50 条消息
  • 人数限制:每个房间最多 200 人
  • 房间 ID:32 位随机 ID,不可预测

用户体验

  • 图片压缩:自动压缩到宽度 800px,质量 80%
  • 图片预览:点击图片放大查看
  • 连接状态:实时显示连接状态
  • 复制链接:一键复制房间邀请链接