零流量费用:用 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

posted @ 2026-01-09 13:25  kiyo-e  阅读(5)  评论(0)    收藏  举报