自建 PeerJS 信令服务器(Cloudflare Workers)
给 Hexo 博客加了联机小游戏(德州扑克)后,遇到了最头疼的一环:WebRTC 建立连接前必须先经过信令服务器交换 SDP 与 ICE 候选。官方 PeerJS Cloud Server 是实验性的、延迟高、还不稳定,于是决定自己部署一个,正好博客本身跑在 Cloudflare 上,直接用一个 Worker 搞定。
下面的代码与步骤都经过双客户端实测通过(两个浏览器窗口正常互通握手)。
为什么需要信令服务器
WebRTC 的数据通道(DataChannel)虽然是点对点的,但建立对等连接的过程需要第三方帮忙:
- 双方首先要交换各自的连接信息(SDP + ICE Candidate)
- 这个”撮合”过程必须走一个双方都能访问的中转服务器
- WebRTC 本身不提供这个中转,PeerJS 引用了它
PeerJS 的工作流程大致是:
1 2 3
| [] ----> ----> [] <------>
|
所以信令服务器只负责”介绍”,游戏数据本身不经过它,这也是它可以用非常轻量的 Worker 来实现的原因。
为什么选 Cloudflare Workers + Durable Objects
PeerJS 的信令需要有状态的 WebSocket 长连接(要记住每个玩家 id 对应哪个连接,以便转发)。普通 Worker 是无状态的,无法跨请求保存 WebSocket 状态。而 Durable Object(DO) 正是为这种有状态 WebSocket 场景设计的:
| 需求 |
方案 |
| 保存玩家与连接对应的映射 |
Durable Object 单实例内持有状态 |
| 长连接生命周期 |
DO 提供 WebSocket 一内建支持 |
| 低成本 / 全球节点 |
Workers 免费套餐够用 |
完整 Worker 代码
新建 worker.js,粘贴下面代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| import { DurableObject } from 'cloudflare:workers';
export default { async fetch(request, env) { const url = new URL(request.url);
if (url.pathname === '/' || url.pathname === '') { return new Response('PeerJS Server is running on Cloudflare Workers!', { headers: { 'content-type': 'text/plain' }, }); }
const peerId = url.pathname.split('/peerjs/id/')[1] || url.pathname.split('/id/')[1]; if (!peerId) { return new Response('Not found', { status: 404 }); } const finalId = url.searchParams.get('id') || peerId;
const id = env.PEER_SERVER.idFromName(finalId); const stub = env.PEER_SERVER.get(id); return stub.fetch(request); }, };
export class PeerServer extends DurableObject { constructor(ctx, env) { super(ctx, env); this.ws = null; this.peerId = null; }
async fetch(request) { const url = new URL(request.url); this.peerId = url.searchParams.get('id') || '';
const upgradeHeader = request.headers.get('Upgrade') || ''; if (upgradeHeader.toLowerCase().includes('websocket')) { const [client, server] = Object.values(new WebSocketPair()); server.acceptWith(this);
if (this.ws) { server.send(JSON.stringify({ type: 'ALERT', payload: { type: 'unavailable' }, src: 'server', dst: this.peerId, })); } this.ws = server;
server.send(JSON.stringify({ type: 'OPEN', src: 'server', id: this.peerId, }));
return new Response(null, { status: 101, webSocket: client }); } return new Response('Expected WebSocket', { status: 400 }); }
async websocketMessage(ws, message) { let msg; try { msg = JSON.parse(message); } catch { return; }
if (msg.type === 'DELETE' || msg.eventType === 'leave' || msg.type === 'CLOSE') { try { ws.close(1000, 'bye'); } catch (e) {} if (this.ws && this.ws !== ws) { try { this.ws.send(JSON.stringify({ type: 'unavailable', src: 'server', dst: msg.src, })); } catch (e) {} } this.ws = ws === this.ws ? null : this.ws; return; }
if (this.ws && this.ws !== ws) { try { this.ws.send(JSON.stringify(msg)); } catch (e) {} } }
async websocketClose() { this.ws = null; } }
|
说明:上面是一个一房一级(房间 = ID)的最小实现,适合「一人建房一人入房」的双人对局(每个玩家 id 对应一个独立 DO 实例,互不干扰)。如果你的游戏是多人房间,需要改为所有玩家进入同一个 DO(用同一个 name)并在内部维护 Map<peerId, ws> 按目标转发,同时把 dst 回写为对端 peerId 防止篡改。
踩坑记录(重要)
部署过程中最容易在这里卡住,逐个说明:
坑 1:新版 Durable Object 语法(最关键)
Cloudflare 在 2024 年起已废弃旧的 class extends DurableObject 隐式导出,新语法必须:
1 2 3 4 5
| import { DurableObject } from 'cloudflare:workers';
export class PeerServer extends DurableObject { ... }
|
原因:控制台「添加绑定」界面会扫描 Worker 代码里的 extends DurableObject 来列出可选类名。如果你用的是旧式写法,绑定界面会显示「未找到 Durable Object / 0 个选项」,导致无法添加绑定。
坑 2:ESM 代码 Content-Type 必须是 application/javascript+module
如果你通过 Cloudflare API(/accounts/{id}/workers/services/{name})上传代码,脚本文件必须带上:
1
| Content-Type: application/javascript+module
|
否则会报 Unexpected token 'export'——因为服务端把 ESM 当作了经典脚本。很多人首传都栽在这。
用 API 部署时,绑定属于 metadata 的一部分。要用 HTTP 上传(新版本 API)同时提交 metadata(内含有 main_module 与 bindings)。如果不带 bindings,页面看着部署了,实际 WebSocket 会返回 500(代码 1101),因为 Worker 里 env.PEER_SERVER 是 undefined。
部署步骤
方式 A:Dashboard 手动(适合首次尝试)
- 进入 Cloudflare Dashboard → Workers & Pages → 创建 Worker
- 粘贴上面的
worker.js 代码 → 保存并部署
- 进入 Worker 的 设置 → 绑定 → 添加绑定
- 绑定类型选择 Durable Object(注意别选成 KV)
- 变量名填
PEER_SERVER,绑定类名选代码里的 PeerServer
- 部署完成后,浏览器访问
https://你的-worker-子域.workers.dev 应返回 PeerJS Server is running on Cloudflare Workers!
方式 B:Wrangler CLI(推荐用于项目化管理)
1 2
| npm init -y npm install -D wrangler
|
创建 wrangler.toml:
1 2 3 4 5 6 7 8 9 10 11
| name = "peerjs-server" main = "worker.js" compatibility_date = "2024-01-01"
[[durable_objects.bindings]] name = "PEER_SERVER" class_name = "PeerServer"
[[migrations]] tag = "v1" new_sqlite_classes = ["PeerServer"]
|
登录并部署:
1 2
| npx wrangler login npx wrangler deploy
|
DO 迁移是一次性操作,第二次部署若重复声明 new_sqlite_classes 会报 Migration conflict。新增类改成:
1 2 3
| [[migrations]] tag = "v2" new_classes = ["AnotherClass"]
|
前端接入(Hexo 页面)
在一个 HTML 游戏页面引入 peerjs,并连接到你的 Worker:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js"></script> <script> var myId = 'my-room-id';
var peer = new Peer(myId, { host: '你的-worker子域.workers.dev', port: 443, secure: true, debug: 1, });
peer.on('open', function (id) { console.log('已注册,id =', id); });
peer.on('connection', function (conn) { console.log('对方接入', conn); conn.on('data', function (data) { }); conn.on('open', function () { conn.send('hello'); }); });
peer.on('error', function (err) { console.error('PeerJS 错误 :', err.type, '-', err.message); }); </script>
|
关键点:
host 必须指向你的 Worker 子域(xxx.workers.dev)
secure: true(你的 Worker 域名是 HTTPS)
- 连接若总是报
peer-unavailable,检查 DO 绑定是否真的存在(最常见坑 3)
安全提示
- 不要泄露敏感信息:
wrangler.toml 中的绑定 ID、Dashboard 里的账号 ID 属于你的私有资源,不要在博客/代码里公开。
- 这个实现没有做鉴权(任意 id 都能注册),只适合个人博客的娱乐联机,有心者可以伪造身份——要防外挂请在设计层面解决:联机扑克重要的防作弊手段是对手的手牌永不发送到你的浏览器,只发送公共牌与结算结果,这是由游戏逻辑保证的,而不是信令服务器。
测试验证
部署后可同样在浏览器开两个窗口各跑上面那段连接代码,用两个不同的房间 id,观察:
- 两个窗口都打印
已连接
- A 发送 OFFER,B 能收到并回发 ANSWER
- 消息能到达两端(P2P 建立)
我这边实测两窗口互通正常。
总结
- Cloudflare Workers + DO 完全可以托管 PeerJS 信令服务器,免费、低延迟
- 核心 3 坑:新版 DO 语法、ESM Content-Type、绑定 metadata
- 信令中转很「透明」,真正的防作弊/逻辑在应用层