零出站费用:我如何使用 Cloudflare 构建 P2P 文件共享
Source: Dev.to
我构建了一个 P2P 文件共享工具,文件直接在浏览器之间传输。服务器仅处理 WebRTC 信令——实际文件从不经过服务器。传输 10 GB 的文件?仍然 零出口费用。 Stack: Hono + Cloudflare Workers + Durable Objects + STUN.
问题:出站费用快速累积
每个文件共享服务都会对带宽收费。S3、R2,或者其他——只要有字节离开服务器,你就得付费。
我算了一下一个简单的使用场景:与几位朋友共享大型视频文件。即使使用 Cloudflare R2 那个“慷慨”的免费层,每月几次 4 GB 的文件传输也已经产生费用。把这个规模放大到真实用户时,账单会变得相当惊人。
我想要的东西不一样:零传输费用,无论文件大小。
事后看来答案显而易见——根本不要让文件触碰服务器。
解决方案:WebRTC + Cloudflare
WebRTC 让浏览器直接相互通信。中间没有服务器。问题是?仍然需要一个服务器进行 信令 —— 交换连接信息,以便浏览器相互发现。
架构
┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐
│ Sender │◄───────►│ Durable Object │◄───────►│ Receiver │
│ │ WS │ (signaling only) │ WS │ │
└─────────────┘ └─────────────────────┘ └─────────────┘
│ │
│ │
└──────────────────── WebRTC P2P ───────────────────────┘
(files go here)
信令消息很小——只有几 KB。文件直接在浏览器之间传输。服务器永远看不到它们。
技术栈
| 层 | 技术 | 为什么 |
|---|---|---|
| 框架 | Hono | TypeScript 优先,完美的 Cloudflare 集成 |
| 托管 | Cloudflare Workers | 边缘部署,成本低廉 |
| 状态 | Durable Objects | WebSocket 连接 + 房间状态 |
| NAT 穿透 | Cloudflare STUN | 免费,同一供应商 |
所有内容都在 Cloudflare 内部。一次 wrangler deploy,即可上线。
Source: …
为什么使用 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 时,它会把它转发给正确的对等方。简单。
export class Room extends DurableObject {
async fetch(request: Request): Promise {
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) {
// 将信令消息转发给正确的对等方
}
private closeDuplicateClient(clientId: string) {
for (const socket of this.ctx.getWebSockets()) {
const attachment = socket.deserializeAttachment()
if (attachment?.cid === clientId) {
socket.close(1000, 'replaced')
}
}
}
}
硬核部分:重新连接
让初始连接正常工作花了一天时间。让重新连接可靠则花了一周。
问题 1 – 幽灵连接
当用户刷新页面时,浏览器会关闭 WebSocket,但 Durable Object 并不会立即得知——在 webSocketClose 触发之前会有延迟。随后新的连接到来,导致出现重复的 socket。
修复: 在 localStorage 中存储一个持久化的客户端 ID。
function getClientId() {
const stored = localStorage.getItem('client-id')
if (stored) return stored
const id = crypto.randomUUID()
localStorage.setItem('client-id', id)
return id
}
当新的连接携带相同的客户端 ID 时,强制关闭旧的连接(参见上文的 closeDuplicateClient)。
问题 2 – 过期的信令消息
来自上一次会话的旧 offer/answer 消息可能在重新连接后才到达,和新会话的消息混在一起,导致全部失效。
修复: 为每条信令消息附加一个 会话 ID。
// Sender
const sendOffer = async (peer: OffererPeer) => {
const sid = ++peer.signalSid // Increment on every new 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 })
}
// Receiver
if (msg.sid !== peer.activeSid) return // Ignore stale messages
客户端 ID 用于处理重复连接;会话 ID 用于过滤过期消息。两者结合即可实现稳定的重新连接。
无 TURN 权衡
我刻意省略了 TURN 服务器。当点对点失败时(例如严格的企业防火墙、对称 NAT),TURN 会通过服务器中继流量。使用 TURN 将违背初衷——文件会经过我的服务器,从而产生出站流量费用。
如果不使用 TURN,某些企业网络将无法使用。这就是权衡。对于我的使用场景——在普通家庭/办公网络上与朋友和同事共享文件——仅使用 STUN 已足够。
如果需要支持更严格的环境,我会将 TURN 作为可选项并收取费用。但免费层仍然是 仅 P2P。
附加:端到端加密
使用 URL 片段的可选端到端加密:
https://example.com/room/ABC123#k=Base64EncodedKey
进入全屏模式退出全屏模式
# 片段永远不会发送到服务器。Cloudflare Workers 永远看不到密钥;只有共享链接的浏览器才能解密。
我学到的
- Durable Objects 被低估了。 大家都在谈 Workers,但正是 Durable Objects 让有状态的边缘应用成为可能——WebSocket 管理、房间状态、连接排队——全部集成在一个原语中。
- WebRTC 重连很痛苦。 正常路径执行快速。重连的边缘情况会慢 10 倍。需要预留预算。
- TURN 是商业决策,而非技术决策。 你可以随时以后再加。先不使用 TURN 可以保持成本为零,并迫使你验证仅 P2P 是否足够。
- Cloudflare 组合在实时应用中被低估。 Workers + Durable Objects + STUN = 无外部依赖,一条部署命令,即可运行。
最好的文件传输是从未触及你的服务器的那种。