用 WebRTC 与 Cloudflare Durable Objects 构建一个零知识 P2P 同步引擎

Local-first(本地优先)软件运动代表着现代应用设计中最令人兴奋的转变之一。通过把数据以纯文本或 SQLite 数据库的形式存放在用户自己的设备上,local-first 应用带来了无可比拟的速度、离线韧性与数据所有权保障。没有转圈的加载动画,没有离线报错,也不必担心某家第三方创业公司倒闭、把你毕生的成果一并带走。

然而,构建 local-first 软件会引入许多人眼中的应用架构终极 Boss:多设备同步。

在设计 H.A.N.(Hierarchical Adaptive Notebook,分层自适应笔记本)​——一款 local-first、注重隐私的 Markdown 笔记本时,我们希望用户能在桌面电脑上编辑笔记,然后在手机或平板上无缝继续写作。但我们拒绝在几项根本信条上做出妥协:

  1. 无用户账号:没有登录、邮箱验证、密码,也没有 OAuth 提供商。
  2. 零知识隐私:用户的笔记、标题、标签和任务,绝不能以未加密的形式接触服务器。
  3. 无云存储中间商:不使用任何托管数据库(PostgreSQL、Supabase、CouchDB)存储用户数据保险库的副本。
  4. 0 美元基础设施账单:同步架构必须能永久在免费额度的无服务器限制内运行。

为了实现这一点,我们设计并构建了一套去中心化的点对点(P2P)同步引擎,它由 WebRTC DataChannelWeb 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 生命周期:

  1. 主机端在我们的 Cloudflare Worker 上创建一个短暂房间,并通过 WebSocket 连接。
  2. 对端加入该房间,通过 WebSocket 交换 SDP/ICE 消息。
  3. 在 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)​:

  1. 用户在桌面端创建了 Project.md
  2. 用户同步桌面与手机,两边都有了 Project.md
  3. 用户在手机上删除了 Project.md
  4. 一周后,用户再次同步桌面与手机。
  5. 一个天真的同步系统观察到:桌面有 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);
  }
}

这套架构为何出彩

  1. WebSocket 休眠(Hibernation)​:Cloudflare 不对空闲的 WebSocket 连接计费。如果某个对端正等待用户扫描二维码,这个 Durable Object 会在内存中休眠,消耗零 CPU 周期。
  2. 全球低延迟:Cloudflare 的 Anycast 网络在最近的数据中心边缘节点终止 TLS 连接,确保全球范围内的信令延迟通常低于 30 毫秒。
  3. 绝对的短暂性:房间只在客户端连接期间存在。没有数据库清理定时任务,没有需要刷新的 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 DataChannelWeb Crypto API 与无服务器边缘 Worker 已经成熟到这样一种程度:去中心化、零知识的软件不仅可行,而且明显优于集中式架构。

通过把数据传输直接转移到用户设备之间,我们实现了即时同步、完整的用户主权,以及一个能在数十年内持续可用、而不产生一美元服务器账单的系统。

H.A.N. 完全开源且 local-first。欢迎查看代码库、研究它的同步实现,或者直接在浏览器中运行这个应用:GitHub

作者:Barış Oku

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/72180.html

(0)

相关推荐