零出站费用:我如何使用 Cloudflare 构建 P2P 文件共享

发布: (2026年1月9日 GMT+8 13:05)
7 分钟阅读
原文: Dev.to

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。文件直接在浏览器之间传输。服务器永远看不到它们。

技术栈

技术为什么
框架HonoTypeScript 优先,完美的 Cloudflare 集成
托管Cloudflare Workers边缘部署,成本低廉
状态Durable ObjectsWebSocket 连接 + 房间状态
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 = 无外部依赖,一条部署命令,即可运行。

最好的文件传输是从未触及你的服务器的那种。

Back to Blog

相关文章

阅读更多 »

你好,我是新人。

嗨!我又回到 STEM 的领域了。我也喜欢学习能源系统、科学、技术、工程和数学。其中一个项目是…