用 WebRTC DataChannel 做个浏览器直传工具:SixDrop 的 6 位取件码与打洞实践
先说结论SixDrop 是一个打开网页就能用的传文件工具两边各开一个网页一边把 6 位取件码报给对方对方输码两个浏览器建立 WebRTC 直连文件从一台设备直接流到另一台。不装客户端、不注册、不加好友直连时文件字节不经过服务器服务器只交换 SDP/ICE 信令没有会员、没有排队、没有「先上传再下载」打不通会明确告诉你为什么默认不会偷偷把文件传到云上中转是可选项项目名是 SixDrop名字和站点同名前后端代码在一个仓库里文末写了怎么自己部署一份在线版入口放在最后一段。为什么做这个起因很具体给同事传一段 2.3 GB 的录屏。微信超过 1 GB 直接拒绝网盘先上传 40 分钟对方下载还要解压/会员分享链接对方打开先弹注册框最后拿移动硬盘拷的中间这些环节本质都是「把文件先搬到某个中间服务器再让对端去取」。可如果两个浏览器能直接对话这些环节全都能省掉——WebRTC 的 DataChannel 就是干这个的。怎么用两种入口按场景选场景操作两台设备在同一个 Wi-Fi大厅里会直接列出对方的设备点「申请加入」→ 对方点「同意」→ 进同一个房间不在同一个网络一方复制链接或报 6 位取件码另一方输码进房间双 WAN / 多出口的网络自动发现可能对不上出口 IP 每条连接都在变直接用取件码最稳进房间之后选文件可多选对方确认选保存目录传完房间还在可以再发一批也可以切到「消息」发文字走同一条连接技术栈层选型作用接入Cloudflare Workers路由、取件码格式校验、静态资源分发信令Durable Objects带 SQLite 存储 休眠每个房间一个 DO转发 SDP/ICE空闲自动休眠不烧额度同网发现另一个 Durable Object按出口 IP 分组大厅列表、一次性邀请 token传输WebRTC DataChannel真正的数据通道点对点NAT 穿透STUN4 个公共服务器拿 srflx 候选不配置 TURN兜底中转R2可选用户主动点打不通时把文件放 R2给对端下载链接校验DTLS 指纹 SHA-256两端显示同一个 8 位校验码防中间人前端原生 JS无框架一个app.js没有构建步骤成本Workers Free Durable Objects Free 额度内0 元。R2 只有主动用中转时才产生存储/流量。三个关键设计1为什么是 6 位码不是长链接链接有三个实际问题微信里点陌生链接会弹风险提示太长口头说不清群里发出去谁都能点。6 位码用的是去掉易混字符的 32 字符表2-9 A-H J-N P-Z没有0/1/I/O口头报、手输都不容易错。码只在房间存活期内有效房间用完可以换新码。2不装 TURN打不通就直说TURN 会让「连通率」好看但代价是所有流量都过你的服务器免费额度撑不住而且用户以为自己在直连。所以这里的选择是只做 STUN 打洞不做 TURN。打不通就明确失败把原因写清楚需要兜底时用户自己点「改用中转」文件才会上 R2——这是一个显式动作不是默认行为。3失败必须可见失败页不是一句「网络错误」而是给出候选统计host 2 / srflx 4 / relay 0以及其中几个是 IPv6失败类型超时、打洞失败、传输中断对端通知两边同时知道不会一边转圈一边干等三条后续换网络重试 / 同码重连 / 改用中转做了这套之后“为什么连不上” 从玄学变成了可以对着排查的东西。传输实现分块、背压、写盘数据面没什么魔法核心就是几个常量和一条发送循环// 分块默认 256 KiB?chunk64 可调小做对比真正发送时还会按平台 SCTP 上限收窄constCHUNK_LIMIT256*1024;// 背压水位缓冲超过 8 MB 就停降到 4 MB 再继续constHIGH_WATER8*1024*1024;constLOW_WATER4*1024*1024;发送循环简化for(constbufofchunks){awaitwaitForDrain(dc);// bufferedAmount HIGH_WATER 就等 bufferedamountlowawaitsendChunk(dc,buf);// 队列满send queue is full时等对方接住最长 30s}这两个地方都踩过坑只等背压不够dc.send()仍可能抛send queue is full手机、5G、双 WAN 抖动时很常见。早期实现 2 秒就放弃直接判整个传输失败现在改成等bufferedamountlow、最长 30 秒期间界面提示「网络卡了一下正在等对方接住」。失败要分清已经传过数据再断标题是「传输中断」而不是「打洞失败」诊断行也会说明「直连本来是通的」。接收端写盘分两种Chrome / EdgeFile System Access API边收边写内存占用恒定Firefox / Safari没有流式写盘先收进内存再落盘所以建议单文件 1 GB校验码两端对一下就知道有没有中间人连接建立后两端各自把 DTLS 指纹排序后做 SHA-256取前 4 字节转成 8 位十六进制constbytesnewTextEncoder().encode([localFingerprint,remoteFingerprint].sort().join(|));constdigestnewUint8Array(awaitcrypto.subtle.digest(SHA-256,bytes));constcode[...digest.slice(0,4)].map((b)b.toString(16).padStart(2,0)).join().toUpperCase();两端显示的是同一个码说明双方看到的是同一对证书没有中间人。这个码不是文件哈希是连接的指纹——文件完整性由 DTLS 自己保证。实测与测试速度这件事我不想吹同一个 Wi-Fi 的局域网直连、跨网络打洞、经 R2 中转这三档完全不是一个量级而且跨网络能不能打通还取决于两边 NAT。能说的是工程上的验证本地 e2ePlaywright 本地 Edge3 个文件共 4.4 MB端到端 1.8–2.5 s逐文件哈希一致房间模型 e2e同房间连续三批、接收中发消息不抢焦点、拒绝/返回、离开换码同网发现 e2e互相发现、申请/同意、拒绝、可见性开关单测 5 组房间状态机、大厅、信令协议、前端结构、R2其中专门注入过一次「发送队列满」让发送端前 3 秒所有send都抛queue is full验证传输能挺过去——这在真实手机上是会发生的。局限重要别踩坑限制说明对称 NAT / 公司网 / 校园网打洞大概率失败没有 TURN 就是打不通可以改用中转双 WAN、CGNAT、VPN出口 IP 每条连接都可能变自动发现会时有时无用取件码Firefox / Safari 大文件没有流式写盘建议单文件 1 GB没有断点续传中断要重传对端在线才能传不是离线分享不做账号没有历史记录关掉页面就散不适合同一场合给很多人发一个房间是 1 对 1多人分发请用网盘/CDN本地跑起来# 先把仓库克隆到本地并进入目录然后nodetools/dev-server.mjs# 本地垫片默认监听 127.0.0.1:8787nodetest/room.test.js# 单测nodetools/e2e.mjs# 端到端需要本地装 Edge/Chromium部署需要自己的 Cloudflare 账号npminstallnpx wrangler deploywrangler.jsonc里已经配好ROOMS、DROPS、LOBBY三个 Durable Object 绑定和 R2 bucket迁移标签是 v1/v2/v3。node tools/dev-server.mjs默认监听127.0.0.1:8787可用PORT覆盖它是个本地垫片不依赖 Cloudflare 账号就能调前端和信令。代码结构路径内容src/worker.js路由、房间创建、大厅/邀请、R2 中转src/room.js房间 DOWebSocket 信令、成员状态、knock/admit/rejectsrc/lobby.js同网发现 DO条目 TTL、一次性 invite tokensrc/drop.jsR2 中转与分享页public/app.js前端全部逻辑约 2500 行无框架public/index.html/style.css大厅 房间两个视图、两个标签页tools/*-e2e.mjs4 套 Playwright 验收脚本适合谁 / 不适合谁适合临时给同事/家人传个文件、手机照片挪电脑、跨网络发个安装包、不想注册任何账号的场景。不适合需要长期保存和分享链接、需要多人同时下载、需要断点续传、或者网络环境本身打不通 P2P 的场景。最后这个项目最初只是想解决「传个文件为什么这么麻烦」做完之后最大的收获其实是把「连不上」的原因讲清楚比把速度吹快更难也更有用。在线版就是一个网页sixdrop.dev两边打开就能用不装、不注册。如果你在自己的网络里也遇到过打洞失败欢迎在评论区贴一下失败页的候选统计host / srflx / relay 各几个、有没有 IPv6——不同的 NAT 组合处理方式确实不一样。