Local-first(本地优先)软件运动代表着现代应用设计中最令人兴奋的转变之一。通过把数据以纯文本或 SQLite 数据库的形式存放在用户自己的设备上,local-first 应用带来了无可比拟的速度、离线韧性与数据所有权保障。没有转圈的加载动画,没有离线报错,也不必担心某家第三方创业公司倒闭、把你毕生的成果一并带走。
然而,构建 local-first 软件会引入许多人眼中的应用架构终极 Boss:多设备同步。
在设计 H.A.N.(Hierarchical Adaptive Notebook,分层自适应笔记本)——一款 local-first、注重隐私的 Markdown 笔记本时,我们希望用户能在桌面电脑上编辑笔记,然后在手机或平板上无缝继续写作。但我们拒绝在几项根本信条上做出妥协:
- 无用户账号:没有登录、邮箱验证、密码,也没有 OAuth 提供商。
- 零知识隐私:用户的笔记、标题、标签和任务,绝不能以未加密的形式接触服务器。
- 无云存储中间商:不使用任何托管数据库(PostgreSQL、Supabase、CouchDB)存储用户数据保险库的副本。
- 0 美元基础设施账单:同步架构必须能永久在免费额度的无服务器限制内运行。
为了实现这一点,我们设计并构建了一套去中心化的点对点(P2P)同步引擎,它由 WebRTC DataChannel、Web Crypto API(AES-GCM-256),以及一个运行在 Cloudflare Workers 与 Durable Objects 上的短暂信令中继所驱动。
下面是对这套架构、密码学协议、数据包分片算法,以及我们一路走来所学到的经验的完整深入解析。
1. 高层架构:把数据与信令解耦
许多开发者设计同步系统时犯的根本性错误,是把同步当作一个数据库问题。而实际上,同步是一个传输问题。
如果设备 A 有一份更新版本的笔记,设备 B 想要它,你并不需要一个中间数据库把这份笔记永久存起来。你只需要两台设备之间有一条安全的直连管道,用来流式传输那个差异量(delta)。
WebRTC(Web Real-Time Communication)通过它的 RTCDataChannel API 恰好提供了这一点:一个加密的点对点传输协议,运行在包裹于 DTLS(Datagram Transport Layer Security)之内的 SCTP(Stream Control Transmission Protocol)之上。
但 WebRTC 无法凭空建立连接。在两个浏览器能够直接对话之前,它们必须先交换会话描述(SDP offer 与 answer)以及网络路由候选(ICE candidate)。这个协商阶段被称为信令(signaling)。
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 1. Ephemeral Signaling Phase │
└─────────────────────────────────────────────────────────────────────────────────┘
Desktop (Host) Mobile (Peer)
│ │
│───────► WSS Connect (/room/xyz) ◄───────────────────│
│ Cloudflare Durable Object │
│ (SQLite Relay) │
│ │
│─── Encrypted SDP Offer ────────────────────────────►│
│◄── Encrypted SDP Answer ────────────────────────────│
│─── ICE Candidates ◄────────────────────────────────►│
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 2. WebRTC DataChannel Opened (Signaling WebSocket Dropped) │
└─────────────────────────────────────────────────────────────────────────────────┘
Desktop (Host) Mobile (Peer)
│ │
│ ◄=================================================► │
│ Direct P2P WebRTC DataChannel │
│ (16 KiB Framed Packets, E2EE) │
│ │
│─── Note Manifest Exchange (SHA-256 Diffing) ───────►│
│─── Encrypted Delta Streaming (AES-GCM-256) ────────►│
│◄── Soft-Delete Tombstones & Conflict Sync ──────────│
Drop on Open 原则
传统的协作类应用会在用户的整个会话期间,保持与自家应用服务器之间的长连接 WebSocket。这种模式会消耗大量服务器内存,并需要复杂的自动扩容基础设施。
H.A.N. 采用严格的 Drop on Open 生命周期:
- 主机端在我们的 Cloudflare Worker 上创建一个短暂房间,并通过 WebSocket 连接。
- 对端加入该房间,通过 WebSocket 交换 SDP/ICE 消息。
- 在 WebRTC 的
RTCDataChannel.onopen事件触发的那一刻,两台设备立即切断与 Cloudflare 的 WebSocket 连接。
从那一毫秒起,所有数据传输、笔记差异计算和密码学校验,都 100% 点对点地在本地 Wi-Fi 或公网上流动。信令服务器的内存被立即释放,让我们的云计算成本精确保持在 0.00 美元。
2. 零知识密钥交换:URL 哈希片段技巧
在没有账号系统、也没有集中式密钥管理服务(KMS)的情况下,两台设备如何建立共享的加密密钥?
如果信令服务器能看到加密密钥,那它就不是零知识系统。我们需要一种机制,让解密密钥严格地从屏幕传到摄像头,而绝不出现在任何 HTTP 请求头、查询参数或服务器日志中。
答案就在 RFC 3986(统一资源标识符规范)以及 URL 哈希片段(#)的机制之中。
第一步:客户端生成密钥
当用户在桌面电脑上打开 P2P 同步弹窗时,H.A.N. 会利用浏览器原生的 Web Crypto API 生成一个加密学随机、256 位的 AES-GCM 对称密钥:
// Generate a non-extractable, high-entropy 256-bit symmetric key
export async function generateSyncKey(): Promise<CryptoKey> {
return await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // Extractable so we can export to base64url for QR
['encrypt', 'decrypt']
);
}
// Export the key as URL-safe base64
export async function exportKeyToBase64(key: CryptoKey): Promise<string> {
const rawKey = await crypto.subtle.exportKey('raw', key);
return bytesToBase64Url(new Uint8Array(rawKey));
}
第二步:构造哈希 URL
接下来,H.A.N. 生成一个唯一的短暂房间标识(例如 sync-8f92-4d1a),并构造一条配对链接:
https://bishoku.github.io/han-notes-app/#sync=sync-8f92-4d1a&key=dGhpcy1pcy1hLXNhbXBsZS0yNTYtYml0LWtleQ&role=peer
请注意这些参数所在的位置:在 # 符号之后。
根据 HTTP 规范,浏览器在抓取页面时永远不会把 URI 片段发送给 Web 服务器。当移动设备打开这个 URL 时,GitHub Pages(或任何托管这套静态资源的服务器)只会看到一个针对 GET /han-notes-app/ 的请求。房间 ID 和那个 256 位加密密钥,只存在于移动设备客户端的浏览器运行时里。
┌─────────────────────────────────────────────────────────────┐
│ Desktop Screen │
│ │
│ ┌──────────────────────┐ │
│ │ ████████ ████████ │ QR Code encodes: │
│ │ █ ▄▄▄ █ █ ▄▄▄ █ │ https://app/#sync=... │
│ │ █▄▄▄█ █ █▄▄▄█ █ │ │
│ │ ████████ ████████ │ │
│ └──────────────────────┘ │
└───────────────────▲─────────────────────────────────────────┘
│ Optical Transfer (Air-Gapped)
│ Key never touches any network interface!
┌───────────────────▼─────────────────────────────────────────┐
│ Mobile Camera │
│ │
│ 1. Camera reads QR URL │
│ 2. JavaScript parses window.location.hash │
│ 3. Key imported into crypto.subtle in memory │
│ 4. Room ID sent to Signaling Server (Key stays private) │
└─────────────────────────────────────────────────────────────┘
第三步:链路上的端到端加密
通过 DataChannel 传输的每一个载荷,都使用带认证的 AES-GCM 加密,且每条消息都会新生成一个 12 字节的初始化向量(IV):
export async function encryptPayload(data: unknown, key: CryptoKey): Promise<Uint8Array> {
const jsonStr = JSON.stringify(data);
const encoded = new TextEncoder().encode(jsonStr);
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit unique IV
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
);
// Prepend IV to ciphertext: [ 12B IV ][ Ciphertext + 16B Auth Tag ]
const result = new Uint8Array(iv.byteLength + ciphertext.byteLength);
result.set(iv, 0);
result.set(new Uint8Array(ciphertext), iv.byteLength);
return result;
}
由于 AES-GCM 自带认证标签,任何在网络链路上的篡改、比特翻转或数据损坏,都会在数据被反序列化之前,被密码学机制立即拒绝。
3. 64 KiB 陷阱:数据包分片与背压
密码学握手就位之后,我们在两台 MacBook 之间的初始同步测试顺利得像魔法。但当我们测试一个贴近真实场景的数据保险库,里面有大体量的 Markdown 文档、内嵌的矢量草图(Excalidraw/YADA),以及整个保险库的清单(manifest),传输就以下面这个臭名昭著的运行时错误崩掉了:
Uncaught DOMException: Failed to execute 'send' on 'RTCDataChannel':
Trying to send message larger than max-message-size
WebRTC 消息上限的解剖
WebRTC DataChannel 运行在 SCTP 协议之上。虽然 WebRTC 规范在理论上支持大消息,但浏览器实现(尤其是 Chromium 和 Safari)对单次 channel.send() 调用能传入的最大载荷尺寸施加了严格限制。
- 在较老的 Safari 版本中,这个上限严格为 64 KiB(65,536 字节)。
- 在 Chromium 中,上限通常是 262,144 字节。
- 一旦超过平台限制,DataChannel 会立即中止,并抛出一个无法恢复的异常。
如果用户有一条包含 500 KB 内嵌图表的笔记,或者一份列出 2,000 个文件的清单,那么单次 send() 调用就会让整个同步会话崩溃。
解决方案:8 字节二进制分帧协议
为了永久消除这个限制,我们构建了一个应用层的数据包分片与重组引擎(src/services/sync/chunking.ts)。
每一个加密后的二进制载荷,都会被切成安全的 16 KiB(16,384 字节)分片。每个分片前面都带有一个 8 字节的二进制分帧头:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Message ID (uint32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Chunk Index (uint16) | Total Chunks (uint16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Chunk Data (0..16 KiB) |
| ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
下面是分片切分的实现:
const CHUNK_SIZE = 16 * 1024; // 16 KiB safe transmission unit
const HEADER_SIZE = 8; // 4B msgId + 2B index + 2B total
export function chunkPayload(payload: Uint8Array, messageId: number): Uint8Array[] {
const totalChunks = Math.ceil(payload.byteLength / CHUNK_SIZE);
const chunks: Uint8Array[] = [];
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, payload.byteLength);
const slice = payload.subarray(start, end);
const packet = new Uint8Array(HEADER_SIZE + slice.byteLength);
const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength);
view.setUint32(0, messageId, false); // 4 bytes: Message ID
view.setUint16(4, i, false); // 2 bytes: Chunk Index
view.setUint16(6, totalChunks, false);// 2 bytes: Total Chunks
packet.set(slice, HEADER_SIZE);
chunks.push(packet);
}
return chunks;
}
乱序解交织与异步重组
在接收端,数据包可能乱序到达,也可能出现小控制消息的包插在巨型文件传输的分片之间的情况。
我们的 MessageReassembler 使用一个以 messageId 为键的有状态映射。随着分片到达,它会校验包头、把它们存入各自的索引槽位,并只在全部分片到齐后才产出组装完成的载荷:
export class MessageReassembler {
private inFlight = new Map<number, { chunks: (Uint8Array | null)[]; received: number; total: number }>();
public processPacket(packet: Uint8Array): Uint8Array | null {
if (packet.byteLength < HEADER_SIZE) return null;
const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength);
const messageId = view.getUint32(0, false);
const index = view.getUint16(4, false);
const total = view.getUint16(6, false);
const chunkData = packet.subarray(HEADER_SIZE);
let entry = this.inFlight.get(messageId);
if (!entry) {
entry = { chunks: new Array(total).fill(null), received: 0, total };
this.inFlight.set(messageId, entry);
}
if (!entry.chunks[index]) {
entry.chunks[index] = chunkData;
entry.received++;
}
// All chunks received? Reassemble in proper sequence!
if (entry.received === entry.total) {
this.inFlight.delete(messageId);
const totalBytes = entry.chunks.reduce((acc, c) => acc + (c?.byteLength || 0), 0);
const fullPayload = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of entry.chunks) {
if (chunk) {
fullPayload.set(chunk, offset);
offset += chunk.byteLength;
}
}
return fullPayload;
}
return null;
}
}
流控与背压管理
把消息切成 16 KB 的帧解决了包体过大的异常,但另一个障碍很快浮现:缓冲区溢出(Buffer Flooding)。
在同步 50 MB 笔记时,在一个紧凑的同步循环里调用 channel.send(),会迅速填满浏览器底层的 SCTP 发送缓冲区。当 channel.bufferedAmount 超过浏览器内部上限时,消息会被丢弃,或者在极端内存压力下整个标签页直接卡死。
我们通过实现背压流控(Backpressure Flow Control)解决了这个问题:
export async function sendWithBackpressure(
channel: RTCDataChannel,
chunks: Uint8Array[]
): Promise<void> {
const HIGH_WATERMARK = 128 * 1024; // Pause at 128 KiB buffer
const LOW_WATERMARK = 64 * 1024; // Resume when buffer drains to 64 KiB
channel.bufferedAmountLowThreshold = LOW_WATERMARK;
for (const chunk of chunks) {
// If the outbound buffer is congested, wait for the low watermark event
if (channel.bufferedAmount > HIGH_WATERMARK) {
await new Promise<void>((resolve) => {
const onLow = () => {
channel.removeEventListener('bufferedamountlow', onLow);
resolve();
};
channel.addEventListener('bufferedamountlow', onLow);
});
}
channel.send(chunk);
}
}
分片与背压协同工作后,H.A.N. 可以在移动设备之间平滑传输数 GB 的笔记、PDF 和内嵌草图,且内存占用极低。
4. 状态同步:冲突解决与墓碑(Tombstone)模式
在一条可靠、加密的二进制传输通道跑起来之后,我们要如何在两台异步设备之间同步笔记内容,而不丢失任何编辑?
像 Yjs 或 Automerge 这类完整的 CRDT(无冲突复制数据类型),对于逐字符协作的文本编辑器来说非常出色,但把 CRDT 模型应用到一整个 Markdown 文件系统上,会引入显著的元数据膨胀。对于个人笔记场景,一套确定性的、Delta-Manifest + 墓碑系统能提供最佳的性能与简洁性。
第一步:清单交换
两台设备不把每一条笔记都发到网络上,而是先交换一份紧凑的、加密的清单(Manifest):
interface NoteManifestEntry {
id: string; // Relative path, e.g. "Work/Architecture.md"
updatedAt: number; // Epoch millisecond timestamp
contentHash: string; // SHA-256 hash of plaintext content
deletedAt?: number; // Deletion timestamp (if deleted)
}
通过计算并比对笔记内容的 SHA-256 哈希:
- 如果
local.contentHash === remote.contentHash,说明笔记完全相同,零字节传输。 - 如果
local.updatedAt < remote.updatedAt,本地设备向对端请求更新的版本。 - 如果
local.updatedAt > remote.updatedAt,本地设备把自己的更新版本发给对端。
第二步:复活 Bug 与软删除墓碑
分布式笔记应用中一个臭名昭著的坑是复活 Bug(Resurrection Bug):
- 用户在桌面端创建了
Project.md。 - 用户同步桌面与手机,两边都有了
Project.md。 - 用户在手机上删除了
Project.md。 - 一周后,用户再次同步桌面与手机。
- 一个天真的同步系统观察到:桌面有
Project.md,而手机没有。它由此推断手机缺少这条笔记,于是把它又传回了手机!
被删除的笔记就这样从坟墓里复活了。
H.A.N. 用软删除墓碑彻底解决了这个问题:
- 当用户删除一条笔记时,H.A.N. 并不会简单地把文件从磁盘上抹掉,而是在本地元数据存储(
.han_sync_metadata.json)中记录一条墓碑条目:
{ "Work/Project.md": { "deleted": true, "deletedAt": 1725451200000 }
- 在同步过程中,设备 A 会把自己的墓碑与其仍然活跃的笔记一起传输。
- 如果设备 B 拥有的
Work/Project.md的updatedAt时间戳早于墓碑的deletedAt,设备 B 就确认该文件是在其最后一次编辑之后被删除的,于是清除自己的本地副本。 - 反之,如果设备 B 在删除时间戳之后修改过
Work/Project.md,则以更新的编辑为准,保留这条笔记。
第三步:非破坏性冲突分叉
那么,如果用户在没有网络的飞机上,同时在笔记本电脑和手机上编辑了同一条笔记,会发生什么?
当时间戳分叉、内容不一致时,静默覆盖是不可接受的。H.A.N. 会执行一次非破坏性的冲突分叉:
- 把来自对端的笔记写盘,并附加冲突时间戳后缀:
Work/Project (Conflict 2026-09-04-154500).md - 本地版本保持不动。
- 弹出通知,提示用户使用 H.A.N. 内置的 Git 可视化 diff 查看器,并排审阅两个版本。
用户的任何想法、任何段落,都不会被覆盖或丢失。
5. 存储不对称:连通桌面磁盘与移动端 IndexedDB
H.A.N. 运行在多种宿主环境中:
- 桌面端:通过 Tauri & Rust 作为轻量的 macOS/Windows 应用运行,直接把原始
.md文件写入文件系统;或者在 Chromium 中通过 File System Access API(showDirectoryPicker)运行。 - 移动端 / 平板:作为离线优先的渐进式 Web 应用(PWA)运行在 iOS Safari 或 Android Chrome 中,而浏览器沙箱禁止任意访问文件系统。
为了让同步引擎在所有环境中行为一致,我们实现了一层同构抽象,名为 SyncStorageAdapter。
export interface SyncStorageAdapter {
getManifest(): Promise<NoteManifestEntry[]>;
readNote(id: string): Promise<CanonicalNote | null>;
writeNote(note: CanonicalNote): Promise<void>;
deleteNote(id: string): Promise<void>;
applyTombstone(id: string, deletedAt: number): Promise<void>;
}
在桌面端,SyncStorageAdapter 直接操作磁盘上的真实目录。在移动端,它把调用透明地映射到一个大容量的 IndexedDB 数据库(han_notes_db)。当一条笔记通过 WebRTC 到达时,适配器把底层存储介质抽象掉,从而保证同步协议完全与平台无关。
6. UI 响应式的陷阱:EventBus 与跨标签页失效通知
构建网络层只是战斗的一半。在初期测试中,我们遇到了一个微妙的前端 Bug:
当 20 条笔记通过 WebRTC 后台 worker 完成更新后,文件被正确地保存进了 IndexedDB,但手机屏幕上打开的笔记编辑器仍显示着过期内容,直到用户手动刷新浏览器标签页!
在 React 和 Zustand 应用中,更新底层数据库(如 IndexedDB 或磁盘)并不会自动触发那些订阅了内存中笔记缓冲区的组件重新渲染。
为了解决这个问题,我们把同步引擎接入了一个全局、解耦的 EventBus 与浏览器 BroadcastChannel:
// When a remote note is written to local storage via WebRTC:
await storage.writeNote(note.id, note.content);
// 1. Emit an application-wide event
eventBus.emit('note:reloaded', { noteId: note.id });
// 2. Broadcast to any other open browser tabs
if (typeof BroadcastChannel !== 'undefined') {
const channel = new BroadcastChannel('han_notes_sync');
channel.postMessage({ type: 'NOTE_UPDATED', noteId: note.id });
}
在活跃的笔记编辑器(MainEditor.tsx)中,我们注册了一个事件监听器:
useEffect(() => {
const unsubscribe = eventBus.on('note:reloaded', ({ noteId }) => {
// If the currently open note was modified by peer sync, refresh immediately
if (noteId === currentNoteId) {
loadNoteContent(noteId);
}
});
return () => unsubscribe();
}, [currentNoteId]);
对端一传输完一次编辑,另一台设备上打开的文档就会实时更新,零闪烁,也无需手动刷新页面。
7. 信令服务器:Cloudflare Workers 与 Durable Objects
我们的信令服务器无需任何维护,完全运行在 Cloudflare 慷慨的免费额度之内。
借助 Cloudflare Workers 与 Durable Objects,我们可以为 WebRTC 对端建立一个短暂的中转协调点。当客户端请求 /room/:roomId 时,Cloudflare 会把请求路由到一台运行在用户附近、唯一的 Durable Object actor 实例上:
export class SyncRoomDurableObject implements DurableObject {
private sessions = new Set<WebSocket>();
async fetch(request: Request): Promise<Response> {
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// Accept WebSocket with Hibernation API
this.ctx.acceptWebSocket(server);
this.sessions.add(server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
// Broadcast SDP / ICE payloads strictly to other peers in this room
for (const session of this.sessions) {
if (session !== ws) {
session.send(message);
}
}
}
async webSocketClose(ws: WebSocket): Promise<void> {
this.sessions.delete(ws);
}
}
这套架构为何出彩
- WebSocket 休眠(Hibernation):Cloudflare 不对空闲的 WebSocket 连接计费。如果某个对端正等待用户扫描二维码,这个 Durable Object 会在内存中休眠,消耗零 CPU 周期。
- 全球低延迟:Cloudflare 的 Anycast 网络在最近的数据中心边缘节点终止 TLS 连接,确保全球范围内的信令延迟通常低于 30 毫秒。
- 绝对的短暂性:房间只在客户端连接期间存在。没有数据库清理定时任务,没有需要刷新的 Redis 缓存,也没有留下任何残留状态。
8. 结果汇总与关键指标
在 H.A.N. 上全面铺开这套 P2P 同步架构之后,我们的真实环境基准测试给出了相当亮眼的结果:
| 指标 | 实测值 |
|---|---|
| 初始连接时间(本地 Wi-Fi) | 180ms — 350ms |
| 初始连接时间(跨地区 5G) | 450ms — 850ms |
| 传输吞吐 | 12 MB/s — 28 MB/s(仅受设备加解密/磁盘性能限制) |
| 保险库差异计算耗时(1,000 条笔记) | 约 40ms(通过 SHA-256 哈希比对) |
| 服务器基础设施成本 | $0.00 / 月 |
| 所需的用户账号数量 | 零 |
| 加密标准 | AES-GCM-256 认证的端到端加密(E2EE) |
结语:隐私优先的软件,并不意味着用户体验的妥协
多年来,开发者一直假定:要提供无缝的跨设备同步,就必须构建庞大的云端后端、管理用户数据库,并承担存储未加密用户数据的沉重责任。
我们构建 H.A.N. 的经验证明,现代 Web 标准 WebRTC DataChannel、Web Crypto API 与无服务器边缘 Worker 已经成熟到这样一种程度:去中心化、零知识的软件不仅可行,而且明显优于集中式架构。
通过把数据传输直接转移到用户设备之间,我们实现了即时同步、完整的用户主权,以及一个能在数十年内持续可用、而不产生一美元服务器账单的系统。
H.A.N. 完全开源且 local-first。欢迎查看代码库、研究它的同步实现,或者直接在浏览器中运行这个应用:GitHub。
作者:Barış Oku
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/72180.html