ARTICLE · 1266875
不想拉群也不想装 App?0 元用 Cloudflare Durable Objects 自建多人实时聊天室,刷新断网消息都不丢

先看成品。这是我自己域名下的一个聊天室:小明、小红、小刚三台手机打开同一个链接,各自起个昵称就能聊,一边发出去,另外两台几乎同时出现。刷新页面、网络断了再连回来,最近的消息会自动补回来;消息只存 7 天,到期自己删掉。不用注册,不用装 App,也不存访客 IP。
为什么要做这个?我经常遇到这种场景:几个同学临时约个复习,或者家里人商量个事,专门拉个群吧,事情完了群还在那儿占地方;用别人的在线聊天工具,又要注册,聊天记录也不在自己手里。我就想要一个「打开链接就能聊、用完就散」的小房间,而且 0 元。
这是《Cloudflare 从零到上线》第 13 期。前面几期用的都是 Workers + D1,这期换一个新东西:Durable Objects。全部代码就一个 worker.js,不依赖任何库,完整源码我放在博客的「项目开源」页了。

首页:点「创建房间」生成一个随机房间号,把链接发给朋友就行
用法很简单:首页点「创建房间」,会生成一个随机房间号,比如 qmgq-vf5z,把链接发给朋友,对方打开起个昵称就进来了;也可以手动输入朋友给的房间号。

原理:Worker 按房间号找到同一个 Durable Object,它管连接、广播、存 SQLite
原理是这样的:Worker 收到请求,按房间号用 idFromName 找到一个 Durable Object。关键在于,同一个房间号永远对应同一个对象,全世界只有这一个,所以所有人的 WebSocket 连接都落在它手里,它收到一条消息,直接发给其他人就行,不需要再搭什么消息队列。每个对象还自带一个 SQLite 数据库,消息就存在里面。
可能有朋友会问:前面几期一直用的 D1,这次为什么不用?因为聊天室最难的不是「存」,而是「推」:小明发一条消息,要马上推到小红和小刚的手机上。普通 Worker 每个请求都是各跑各的,彼此不知道对方的连接在哪,D1 也只能存不能推。Durable Object 正好补上这一块:同一个房间的所有连接都在一个对象里,推送就是一个 for 循环。
开始前准备
就三样东西:
1. 一个已经接入 Cloudflare 的域名(前面几期讲过怎么接入);
2. 电脑装好 Node.js,运行一次 npx wrangler login 登录;
3. 建一个文件夹 tangent-chat,里面只需要 wrangler.toml 和 worker.js 两个文件。
第 1 步:写 wrangler.toml

先确认环境:Node 20、wrangler 4.86
我的环境是 Node 20、wrangler 4.86。然后写配置:
name = "tangent-chat"main = "worker.js"compatibility_date = "2026-10-01"workers_dev = false[[routes]]pattern = "chat.你的域名"custom_domain = true[[durable_objects.bindings]]name = "ROOM"class_name = "Room"[[migrations]]tag = "v1"new_sqlite_classes = ["Room"][vars]TITLE = "我的聊天室"HISTORY = "50" # 进房间时补发最近多少条KEEP_DAYS = "7" # 消息保留几天MAX_PEOPLE = "30" # 每个房间最多同时在线人数MAX_LEN = "500" # 单条消息最多多少字
wrangler.toml:自定义域名、ROOM 绑定、new_sqlite_classes 迁移、5 个变量
三块要看清楚:routes 把 Worker 绑到你的子域名;durable_objects.bindings 声明一个叫 ROOM 的绑定,对应代码里的 Room 类;migrations 告诉 Cloudflare 新建这个类。下面 5 个变量都可以按自己需要改。

踩坑:写成 new_classes,免费版部署直接报错 code 10097(账号 ID 已打码)
这里是第一个坑。网上很多老教程写的是 new_classes,我专门试了一下,免费版部署直接失败,报错 code: 10097,意思是免费版只能用 SQLite 存储的 Durable Object,必须写 new_sqlite_classes。改回来就好了。
第 2 步:写房间类 Room
房间类继承 DurableObject,构造函数里建表,再设置一个自动回复:
import { DurableObject } from "cloudflare:workers";export class Room extends DurableObject { constructor(ctx, env) { super(ctx, env); this.env = env; this.sql = ctx.storage.sql; this.sql.exec(`CREATE TABLE IF NOT EXISTS messages( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, name TEXT NOT NULL, text TEXT NOT NULL)`); // 客户端发 "ping",运行时直接回 "pong":不唤醒对象 ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")); }有人进房间时:先看人数,满了回 429;然后接住连接,把昵称挂在连接上,从 SQLite 取最近 50 条发给他,再广播「谁进来了」:
async fetch(req) { const url = new URL(req.url); const name = cleanName(url.searchParams.get("name")) || "游客"; if (this.ctx.getWebSockets().length >= Number(this.env.MAX_PEOPLE || 30)) return new Response("房间已满", { status: 429 }); const [client, server] = Object.values(new WebSocketPair()); this.ctx.acceptWebSocket(server); // 休眠 API server.serializeAttachment({ name, joined: Date.now(), hits: [] }); const n = Number(this.env.HISTORY || 50); const rows = this.sql.exec("SELECT ts,name,text FROM messages ORDER BY id DESC LIMIT ?", n).toArray().reverse(); server.send(JSON.stringify({ type: "history", items: rows })); this.broadcast({ type: "join", name, ts: Date.now() }); this.sendPeople(); return new Response(null, { status: 101, webSocket: client }); }第二个坑就在这里:一定要用 this.ctx.acceptWebSocket(server),不要用常见的 server.accept()。前者是官方的「WebSocket 休眠 API」,连接挂着、没人说话的时候,对象可以休眠,休眠期间不计时长费用;后者会让对象一直醒着。昵称为什么要用 serializeAttachment 存?因为对象休眠后内存里的变量会丢,挂在连接上的附件不会丢。
收到消息时做三件事:检查、存库、广播。
async webSocketMessage(ws, raw) { const me = ws.deserializeAttachment(); let m; try { m = JSON.parse(raw); } catch { return; } if (m.type !== "msg") return; const text = String(m.text || "").trim(); const max = Number(this.env.MAX_LEN || 500); if (!text) return; if (text.length > max) return ws.send(JSON.stringify({ type: "error", text: `消息太长,最多 ${max} 字` })); // 简单限流:同一个连接 10 秒内最多 5 条 const now = Date.now(); me.hits = (me.hits || []).filter((t) => now - t < 10000); if (me.hits.length >= 5) return ws.send(JSON.stringify({ type: "error", text: "发太快了,歇几秒" })); me.hits.push(now); ws.serializeAttachment(me); this.sql.exec("INSERT INTO messages(ts,name,text) VALUES(?,?,?)", now, me.name, text); if ((await this.ctx.storage.getAlarm()) == null) await this.ctx.storage.setAlarm(now + 86400000); this.broadcast({ type: "msg", name: me.name, text, ts: now }); }最后是清理:用 Durable Object 自带的闹钟 alarm(),每天触发一次,删掉 7 天前的消息,房间里还有消息就续上,空了就不续:
async alarm() { const keep = Number(this.env.KEEP_DAYS || 7) * 86400000; this.sql.exec("DELETE FROM messages WHERE ts < ?", Date.now() - keep); const left = this.sql.exec("SELECT COUNT(*) AS n FROM messages").one().n; if (left > 0) await this.ctx.storage.setAlarm(Date.now() + 86400000); }断开连接的 webSocketClose、广播的 broadcast 都只有几行,完整代码看源码就行。
第 3 步:Worker 入口按房间号路由
export default { async fetch(req, env) { const url = new URL(req.url); const p = url.pathname.split("/").filter(Boolean); // ["r", 房间号, "ws"] if (p.length === 0) return html(homePage(env)); if (p[0] === "r" && p[1] && ROOM_RE.test(p[1])) { const stub = env.ROOM.get(env.ROOM.idFromName(p[1])); // 同一个房间号永远是同一个对象 if (p[2] === "ws") { if (req.headers.get("Upgrade") !== "websocket") return new Response("需要 WebSocket", { status: 426 }); return stub.fetch(req); } if (p.length === 2) return html(roomPage(env, p[1])); } // ……统计接口和 404 }};页面里的 JS 很短:连上 /r/房间号/ws,每 30 秒发一次 ping 当心跳;先收历史,再收新消息和在线名单;连接断了就按 1、2、4 秒……最多 30 秒自动重连。房间号我限制成 4~32 位小写字母、数字和短横线,乱写的直接 404。
第 4 步:部署
npx wrangler deploy
部署成功:ROOM 是 Durable Object,自定义域名绑上了(Version ID 已打码)
能看到 env.ROOM (Room) 是 Durable Object,最后自定义域名也绑上了。
第 5 步:三台手机实测

第一次进房间要起个昵称,下次自动记住
第一次进房间会弹窗让你起个昵称,存在浏览器本地,下次直接进。

房间页:右上角连接状态,中间是补发的历史,底部输入框回车就发
自己发的消息在右边,别人的在左边,顶部能看到在线名单。实测几个细节:

小刚连发 6 条:前面刚发过 1 条,再过 4 条就到上限,后 2 条被拦
小刚连着发了 6 条,只过了 4 条,后两条提示「发太快了,歇几秒」。为什么不是 5 条?因为他几秒前刚发过一条,10 秒内加起来正好 5 条,限流是按连接算的。

小红刷新页面重新进来:最近的聊天记录自动补回
小红刷新页面重新进来,前面的聊天记录全都补回来了,底下有一行「以上是最近 6 条消息」。

在页面里手动断开连接(模拟切网络):顶部提示「1 秒后重连」

自己连回来了:状态变回「已连接」,消息照常发
断网这个我是在页面里手动把连接断掉来模拟的:顶部马上变成「1 秒后重连」,然后自己连回来,再发消息照常收到。
第 6 步:自检和看日志
我写了个 test.mjs,用两三个 WebSocket 连接把功能全测一遍。Node 20 要加个参数:
node --experimental-websocket test.mjs chat.你的域名
node test.mjs 自检:页面、426、404、延迟、超长、限流、ping、历史全部通过
全部打勾:页面 200,不是 WebSocket 的请求回 426,乱写的房间号 404;小红收到小明的消息用时 65 毫秒;501 个字被拦;10 秒连发 6 条对方只收到 4 条;ping 自动回 pong;后进来的人补到了历史。

wrangler tail:WebSocket 连接是 GET,房间里的每条消息显示为 Unknown Event
npx wrangler tail tangent-chat --format pretty 能看实时日志,每次 WebSocket 连接是一条 GET,房间里的每条消息会显示成 Unknown Event,第一次看到别慌,哈哈。

统计接口:12 条消息,SQLite 才 20480 字节,清理闹钟已挂上
我还留了一个统计接口 /api/stats/房间号:演示房间存了 12 条消息,SQLite 才 20480 字节,清理闹钟也挂上了。
免费额度够不够
我对照了 Cloudflare 官方的 Durable Objects 价格页:免费版每天 10 万次请求,收到的 WebSocket 消息按 20 条算 1 次,发出去的不算;时长每天 13,000 GB-s,可以休眠的空闲对象不计;SQLite 每天 500 万行读、10 万行写,总共 5 GB。超了当天相关操作会报错,UTC 0 点也就是北京时间早上 8 点重置。几个人的小房间,一天几百条消息,离上限还远。
踩坑汇总
1. 迁移写成 new_classes,免费版部署报错 10097,要用 new_sqlite_classes;
2. 用 server.accept() 不会休眠,要用 ctx.acceptWebSocket();
3. 昵称这类状态别只放内存变量,用 serializeAttachment 挂在连接上;
4. 我做演示时把三个房间页嵌在一个页面里,结果 iframe 里 localStorage 被浏览器拦了,页面直接报错,后来加了 try/catch 才好;
5. Node 20 自带的 WebSocket 要加 --experimental-websocket 参数。
小结
这期用 Durable Objects 做了一个真正能用的多人聊天室:一个房间一个对象,WebSocket 休眠 API 省时长,自带 SQLite 存消息,闹钟每天清理,Worker 按房间号路由。整个就一个文件、一条部署命令。同样的套路换一下消息格式,就能做多人白板、协同清单、投票这类实时小工具。

博客「项目开源」页:源码打包下载,文件逐个能在线看
完整源码(worker.js、wrangler.toml、test.mjs)放在我博客的项目开源页:https://blog.tangentlab.kdns.fr/projects/cf-chat/ ,改个域名就能部署。这个系列我会一直更新下去,想看下一期的朋友可以关注一下。
好啦,就写到这里,喜欢的朋友点个赞,谢谢。















































































