在 Cloudflare Workers 上部署 PeerJS 信令服务器

自建 PeerJS 信令服务器(Cloudflare Workers)

给 Hexo 博客加了联机小游戏(德州扑克)后,遇到了最头疼的一环:WebRTC 建立连接前必须先经过信令服务器交换 SDP 与 ICE 候选。官方 PeerJS Cloud Server 是实验性的、延迟高、还不稳定,于是决定自己部署一个,正好博客本身跑在 Cloudflare 上,直接用一个 Worker 搞定。

下面的代码与步骤都经过双客户端实测通过(两个浏览器窗口正常互通握手)。

为什么需要信令服务器

WebRTC 的数据通道(DataChannel)虽然是点对点的,但建立对等连接的过程需要第三方帮忙:

  1. 双方首先要交换各自的连接信息(SDP + ICE Candidate)
  2. 这个”撮合”过程必须走一个双方都能访问的中转服务器
  3. WebRTC 本身不提供这个中转,PeerJS 引用了它

PeerJS 的工作流程大致是:

1
2
3
[A] --WS--> 信令服务器 --WS--> [B]
<--OFFER/ANSWER/ICE--转发-->
连接建立后:A 与 B 改为 P2P 直连,信令服务器退出数据路径

所以信令服务器只负责”介绍”,游戏数据本身不经过它,这也是它可以用非常轻量的 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' },
});
}

// 解析信令路径:/peerjs/id/<peerId>?key=peerjs
const peerId = url.pathname.split('/peerjs/id/')[1] || url.pathname.split('/id/')[1];
if (!peerId) {
return new Response('Not found', { status: 404 });
}
// URL 中的 id 参数优先,其次取路径中的 peerId(PeerJS 会带 ?id=<id>&key=peerjs)
const finalId = url.searchParams.get('id') || peerId;

// 用 peerId 生成一个稳定 socket,保证同一个 id 落到同一个 DO 实例
const id = env.PEER_SERVER.idFromName(finalId);
const stub = env.PEER_SERVER.get(id);
return stub.fetch(request);
},
};

// ── Durable Object:真正处理 WebSocket ──────────────────
export class PeerServer extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.ws = null; // 本实例持有的玩家 WebSocket
this.peerId = null; // 当前连接的玩家 id
}

async fetch(request) {
const url = new URL(request.url);
this.peerId = url.searchParams.get('id') || '';

// PeerJS 会发送 WebSocket 升级请求
const upgradeHeader = request.headers.get('Upgrade') || '';
if (upgradeHeader.toLowerCase().includes('websocket')) {
const [client, server] = Object.values(new WebSocketPair());
server.acceptWith(this);

// 已有一个 id 持有连接 → 提示冲突,拒绝第二个
if (this.ws) {
server.send(JSON.stringify({
type: 'ALERT',
payload: { type: 'unavailable' },
src: 'server',
dst: this.peerId,
}));
}
this.ws = server;

// 回发 OPEN 确认(PeerJS 注册成功的关键)
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; }

// DELETE/LEAVE 类型 → 掉线清理
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;
}

// 是发给对端玩家的信令(OFFER/ANSWER/ICE 等),通过另一个握手接线的对方转发
// 这里只是单房间实现,多玩家房间需要维护 this.clients 映射按 dst 转发
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 当作了经典脚本。很多人首传都栽在这。

坑 3:绑定配置(metadata)要与非绑定一起提交

用 API 部署时,绑定属于 metadata 的一部分。要用 HTTP 上传(新版本 API)同时提交 metadata(内含有 main_modulebindings)。如果不带 bindings,页面看着部署了,实际 WebSocket 会返回 500(代码 1101),因为 Worker 里 env.PEER_SERVER 是 undefined。

部署步骤

方式 A:Dashboard 手动(适合首次尝试)

  1. 进入 Cloudflare Dashboard → Workers & Pages创建 Worker
  2. 粘贴上面的 worker.js 代码 → 保存并部署
  3. 进入 Worker 的 设置 → 绑定 → 添加绑定
  4. 绑定类型选择 Durable Object(注意别选成 KV)
  5. 变量名填 PEER_SERVER,绑定类名选代码里的 PeerServer
  6. 部署完成后,浏览器访问 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'; // 房间名 / 玩家 id

var peer = new Peer(myId, {
host: '你的-worker子域.workers.dev',
port: 443, // 部署在 HTTPS,443
secure: true,
debug: 1, // 1 输出关键日志,便于排查
// 默认 path 就是 /peerjs,无需改
});

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
  • 信令中转很「透明」,真正的防作弊/逻辑在应用层

在 Cloudflare Workers 上部署 PeerJS 信令服务器
https://neoisconstantine-github-io.pages.dev/2026/08/06/在Cloudflare上部署PeerJS信令服务器/
作者
constantine
发布于
2026年8月7日
许可协议