零流量费用:用 Cloudflare 构建 P2P 文件共享工具
TL;DR
我做了一个 P2P 文件共享工具,文件直接在浏览器之间传输。服务器只负责 WebRTC 信令——文件本身从不经过服务器。传 10GB 的文件?流量费用依然是零。技术栈:Hono + Cloudflare Workers + Durable Objects + STUN。
演示: https://share-files.karakuri-maker.com/
源码: https://github.com/kiyo-e/p2p-share-files
问题:流量费用很快就会失控
每个文件共享服务都要收带宽费。S3、R2,随便哪个——每传出一个字节都要付钱。
我算了一下一个简单的场景:和几个朋友分享大视频文件。即使用 Cloudflare R2 "慷慨的" 免费额度,每月传几个 4GB 的文件就要开始付费了。如果有真正的用户呢?账单会很难看。
我想要的是:无论文件多大,传输成本都是零。
答案事后看来很明显——让文件根本不经过服务器。
解决方案:WebRTC + Cloudflare
WebRTC 让浏览器可以直接通信,中间不需要服务器。但有个前提:你还是需要一个服务器做"信令"——交换连接信息,让浏览器能找到彼此。
架构是这样的:
┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐
│ 发送方 │◄───────►│ Durable Object │◄───────►│ 接收方 │
│ │ WS │ (仅做信令) │ WS │ │
└─────────────┘ └─────────────────────┘ └─────────────┘
│ │
│ │
└──────────────────── WebRTC P2P ───────────────────────┘
(文件走这里)
信令消息很小——几 KB。文件直接在浏览器之间流动,服务器根本看不到。
技术栈
| 层 | 技术 | 原因 |
|---|---|---|
| 框架 | Hono | TypeScript 优先,Cloudflare 集成完美 |
| 托管 | Cloudflare Workers | 边缘部署,便宜 |
| 状态 | Durable Objects | WebSocket 连接 + 房间状态 |
| NAT 穿透 | Cloudflare STUN | 免费,同一供应商 |
一切都在 Cloudflare 内部。一条 wrangler deploy 命令就上线了。
为什么用 Durable Objects?
Workers 是无状态的。这通常没问题,但信令需要状态——你需要跟踪谁在哪个房间,并在他们之间转发消息。
Durable Objects 完美解决了这个问题。每个房间都有自己的实例:
app.get('/ws/:roomId', (c) => {
const roomId = c.req.param('roomId')
const id = c.env.ROOM.idFromName(roomId)
const stub = c.env.ROOM.get(id)
return stub.fetch(c.req.raw)
})
Durable Object 处理该房间的所有 WebSocket 连接。当有人发送 offer 时,它转发给对应的 peer。就这么简单。
export class Room extends DurableObject {
async fetch(request: Request): Promise<Response> {
const clientId = new URL(request.url).searchParams.get('cid')
?? crypto.randomUUID()
this.closeDuplicateClient(clientId) // 处理重连
const pair = new WebSocketPair()
this.ctx.acceptWebSocket(pair[1])
return new Response(null, { status: 101, webSocket: pair[0] })
}
webSocketMessage(ws: WebSocket, message: string) {
// 将信令消息转发给正确的 peer
}
}
最难的部分:重连处理
让初始连接工作花了一天。让重连可靠花了一周。
问题 1:幽灵连接
用户刷新页面。浏览器关闭 WebSocket。但 Durable Object 不会立即知道——webSocketClose 触发有延迟。新连接进来了,现在你有重复连接。
解决方案:在 localStorage 中存储 Client ID。
function getClientId() {
const stored = localStorage.getItem('client-id')
if (stored) return stored
const id = crypto.randomUUID()
localStorage.setItem('client-id', id)
return id
}
当同一个 Client ID 的新连接到达时,强制关闭旧的:
private closeDuplicateClient(clientId: string) {
for (const socket of this.ctx.getWebSockets()) {
const attachment = socket.deserializeAttachment()
if (attachment?.cid === clientId) {
socket.close(1000, 'replaced')
}
}
}
问题 2:过时的信令消息
重连后,上一个会话的旧 offer/answer 消息会到达。它们和新会话的消息混在一起。一切都崩了。
解决方案:在每条信令消息上加 Session ID。
const sendOffer = async (peer: OffererPeer) => {
const sid = ++peer.signalSid // 每次新 offer 都递增
peer.activeSid = sid
const offer = await peer.pc.createOffer({ iceRestart: true })
await peer.pc.setLocalDescription(offer)
send({ type: 'offer', to: peer.peerId, sid, sdp: offer })
}
// 接收方:忽略不匹配的 session ID
if (msg.sid !== peer.activeSid) return
Client ID 处理重复连接。Session ID 处理过时消息。两者结合才终于稳定了。
不用 TURN 的取舍
我故意没用 TURN 服务器。
当 P2P 失败时(严格的企业防火墙、对称 NAT),TURN 会通过服务器中继流量。但这违背了初衷——文件会经过我的服务器,我就要付流量费。
没有 TURN,一些企业网络可能无法使用。这是取舍。对于我的场景——在普通网络上和朋友同事分享文件——只用 STUN 就够了。
如果需要支持更严格的环境,我会把 TURN 作为付费选项加进去。但免费版只走 P2P。
附加功能:端到端加密
可选的端到端加密,使用 URL fragment:
https://example.com/room/ABC123#k=Base64EncodedKey
# fragment 不会发送到服务器。Cloudflare Workers 永远看不到密钥。只有分享链接的浏览器才能解密。
我学到的
Durable Objects 被低估了。 大家都在谈 Workers,但 Durable Objects 才是让有状态边缘应用成为可能的东西。WebSocket 管理、房间状态、连接队列——一个原语搞定。
WebRTC 重连很痛苦。 正常路径很快就能工作。重连的边界情况要花 10 倍的时间。要预留时间。
TURN 是商业决策,不是技术决策。 你总可以之后再加。一开始不用它可以保持成本为零,并迫使你验证只用 P2P 是否足够好。
Cloudflare 技术栈在实时应用上被低估了。 Workers + Durable Objects + STUN。没有外部依赖。一条部署命令。就是能用。
最好的文件传输,是那些永远不经过你服务器的传输。
演示: https://share-files.karakuri-maker.com/
源码: https://github.com/kiyo-e/p2p-share-files

浙公网安备 33010602011771号