用 TypeScript 通过 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件流式传输

朴素的 WebRTC 文件传输实现都有一个共同问题:它们遍历文件并在紧密循环中调用 dataChannel.send(),把无上限的数据倾倒进 SCTP 发送缓冲区。这对小载荷还行,但对于超出可用系统内存余量的文件(在没有背压的情况下,通常观察到约 100 MB 及以上就会出问题),RTCDataChannel.bufferedAmount 会不受控制地攀升,内存压力可能导致标签页崩溃。用 TypeScript 通过 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件的流式传输,需要一套根本不同的架构——它围绕背压流控、逐块 AES-GCM 加密,以及 SHA-256 完整性校验来构建。

本教程将构建一个模块化、无服务端的点对点流式文件传输引擎,它完全运行在浏览器中,具备端到端加密,且服务器完全不中继文件数据。技术栈包括 TypeScript 5.x、WebRTC DataChannel、WebCrypto API(AES-GCM 用于加密,SHA-256 用于完整性)、用于分块处理的 Streams API,以及用于开发工具的 Vite。

用 TypeScript 通过 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件流式传输

如何用 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件流式传输

  1. 搭建脚手架:创建一个 Vite + TypeScript 项目,并把 tsconfig.json 配置为严格模式,采用 ES2022 和 DOM 库目标。
  2. 建立 WebRTC 对等连接,使用 BroadcastChannel(本地开发)或 WebSocket 信令来完成 offer/answer/ICE 交换。
  3. 创建一个有序 DataChannel,设置 binaryType = 'arraybuffer',并定义一套带类型字节前缀的帧协议。
  4. 切片:用 TransformStream 重新切分原生 File.stream() 的输出,把源文件切成固定的 64 KB 块。
  5. 加密:通过 WebCrypto 用 AES-GCM 加密每个块,在每个密文前加上唯一的 12 字节 IV(4 字节索引 + 8 个随机字节)。
  6. 用 bufferedAmountLowThreshold 实现背压,当 SCTP 缓冲超过 256 KB 时暂停,在 bufferedamountlow 事件上恢复。
  7. 校验:接收端把每个解密后的块与预先共享的 SHA-256 摘要清单比对,一旦不匹配立即中止。
  8. 重组:把校验通过的块重新组装成 Blob,并通过 URL.createObjectURL 触发下载。

为什么标准 WebRTC 文件传输在规模上会崩

朴素的 WebRTC 文件传输实现都有一个共同问题:它们遍历文件并在紧密循环中调用 dataChannel.send(),把无上限的数据倾倒进 SCTP 发送缓冲区。这对小载荷还行,但对于超出可用系统内存余量的文件(在没有背压的情况下,通常观察到约 100 MB 及以上就会出问题),RTCDataChannel.bufferedAmount 会不受控制地攀升,内存压力可能导致标签页崩溃。用 TypeScript 通过 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件的流式传输,需要一套根本不同的架构——它围绕背压流控、逐块 AES-GCM 加密,以及 SHA-256 完整性校验来构建。

本教程将构建一个模块化、无服务端的点对点流式文件传输引擎,它完全运行在浏览器中,具备端到端加密,且服务器完全不中继文件数据。技术栈包括 TypeScript 5.x、WebRTC DataChannel、WebCrypto API(AES-GCM 用于加密,SHA-256 用于完整性)、用于分块处理的 Streams API,以及用于开发工具的 Vite。

架构遵循这样一条流水线:信令建立对等连接 → 交换对称加密密钥 → 文件被切成固定大小的块并通过加密变换流式处理 → 背压门控发送速率 → 接收端解密、对照预先共享的清单校验完整性、重组并触发下载。

架构概览与项目搭建

高层数据流图

数据流在发送端与接收端之间清晰分离:

发送端: File → 切片(64 KB 块)→ 加密(AES-GCM)→ DataChannel.send()
                          ↕ 信令(offer/answer/ICE) ↕
接收端: DataChannel.onmessage → 解密(AES-GCM)→ 校验(SHA-256,逐块内联)→ 重组 → 下载

四个模块负责这一切:SignalingBroker 管理 offer/answer/ICE 交换,TransferCoordinator 编排传输生命周期。加密方面,CryptoStream 负责加密与解密的变换,IntegrityVerifier 计算并检查 SHA-256 摘要。

注意:在当前实现中,接收端是逐块内联校验的,而不是作为独立的 Streams API 流水线阶段。

前置条件

  • Node.js 18.0.0 或更高版本(用 node --version 确认)
  • npm ≥9.x 或等价工具
  • TypeScript ≥5.0 与 Vite ≥4.0(moduleResolution: "bundler" 所需)
  • 现代浏览器:Chrome ≥96、Edge ≥96 或 Firefox ≥96
  • 本地开发时在同一台机器上开两个浏览器标签页(见下文 BroadcastChannel 的限制)

用 Vite 和 TypeScript 搭建脚手架

npm create vite@latest p2p-transfer -- --template vanilla-ts
cd p2p-transfer && npm install

项目结构:

src/
  signaling.ts       # SignalingBroker
  coordinator.ts     # TransferCoordinator
  crypto.ts          # CryptoStream(加密/解密变换、密钥工具)
  integrity.ts       # IntegrityVerifier(SHA-256 摘要)
  main.ts            # UI 接线
tsconfig.json

tsconfig.json 必须把目标库设为一组包含 WebCrypto 与 Streams API 类型定义的集合:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["src"]
}

在这里设置 strict: true 至关重要。它会启用 strictNullChecks 和完整的类型安全,捕获在处理 ArrayBuffer 与 Uint8Array、跨 WebCrypto 和 DataChannel API 时常见的可空性错误及其他类型不匹配问题。

注意:moduleResolution: "bundler" 需要 TypeScript 5.0+ 和 Vite 4.0+。如果你用的是更早的版本,请改用 "moduleResolution": "node16"。

建立 WebRTC 对等连接与 DataChannel

通过 BroadcastChannel(本地开发)或 WebSocket 实现最小信令

信令与文件传输这一课本身是正交的。对于本地双标签页开发,BroadcastChannel 提供一条零依赖的信令通路。在生产环境中,替换为 WebSocket 服务器或 Firebase Realtime Database。

重要:BroadcastChannel 仅限同源、同一浏览器。它无法跨不同机器或浏览器连接对等方,只适用于本地开发测试。

// src/signaling.ts
type SignalMessage = { type: string; payload: unknown };

export class SignalingBroker {
  private bc: BroadcastChannel;
  private handler: ((msg: SignalMessage) => void) | null = null;

  constructor(channel: string) {
    this.bc = new BroadcastChannel(channel);
    this.bc.onmessage = (ev: MessageEvent<SignalMessage>) => {
      this.handler?.(ev.data);
    };
  }

  send(type: string, payload: unknown): void {
    this.bc.postMessage({ type, payload });
  }

  onMessage(cb: (msg: SignalMessage) => void): void {
    this.handler = cb;
  }

  close(): void {
    this.bc.close();
  }
}

创建 RTCPeerConnection 并配置 DataChannel

DataChannel 必须使用 ordered: true 模式。由于每个块都用基于顺序索引派生出的 IV 加密,并且要对照有序清单校验,乱序投递会导致解密失败和完整性不匹配。两端都设置 binaryType = 'arraybuffer',可以避免 Blob 转换带来的开销。

注意:ordered: true 会引入队头阻塞。对于有丢包的网络路径,可以考虑使用无序通道配合应用层排序。

// src/coordinator.ts(片段 —— 连接建立)
const ICE_CONFIG: RTCConfiguration = {
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
};

export function createPeerConnection(
  signaling: SignalingBroker,
  onChannel: (ch: RTCDataChannel) => void
): RTCPeerConnection {
  const pc = new RTCPeerConnection(ICE_CONFIG);

  pc.onicecandidate = (ev) => {
    if (ev.candidate) {
      signaling.send('ice-candidate', ev.candidate.toJSON());
    }
  };

  pc.ondatachannel = (ev) => {
    const ch = ev.channel;
    ch.binaryType = 'arraybuffer';
    onChannel(ch);
  };

  signaling.onMessage(async (msg) => {
    if (msg.type === 'offer') {
      await pc.setRemoteDescription(msg.payload as RTCSessionDescriptionInit);
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      signaling.send('answer', answer);
    } else if (msg.type === 'answer') {
      await pc.setRemoteDescription(msg.payload as RTCSessionDescriptionInit);
    } else if (msg.type === 'ice-candidate') {
      await pc.addIceCandidate(msg.payload as RTCIceCandidateInit);
    }
  });

  return pc;
}

export async function initiateSenderConnection(
  pc: RTCPeerConnection,
  signaling: SignalingBroker
): Promise<RTCDataChannel> {
  const channel = pc.createDataChannel('filetransfer', { ordered: true });
  channel.binaryType = 'arraybuffer';
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  signaling.send('offer', offer);
  return channel;
}

用 Streams API 做分块切片

为什么是 64 KB 的块

按 RFC 8831,SCTP 消息大小的互操作性安全下限是 64 KB;浏览器会协商出更大的值,但 64 KB 块是安全且可移植的默认选择。更小的 64 KB 块能提供更细粒度的背压响应,而 AES-GCM 每 64 KB 块只增加 16 字节(0.02%)的认证标签开销。它们还能降低峰值内存占用,因为任意时刻在途的字节更少。

从 File Blob 构建 ReadableStream

File.stream() 返回原生 ReadableStream<Uint8Array>,但其内部分块大小因浏览器而异。TransformStream 把流重新切分成精确的 64 KB 片段,在内部缓冲区中累积不完整的读取,并把剩余部分作为最后一块较小的块冲刷出去。

// src/coordinator.ts(分块工具)
export function chunkStream(
  file: File,
  chunkSize: number = 65536
): ReadableStream<Uint8Array> {
  const accumulator: Uint8Array[] = [];
  let accumulatedLength = 0;

  const ts = new TransformStream<Uint8Array, Uint8Array>({
    transform(chunk, controller) {
      accumulator.push(chunk);
      accumulatedLength += chunk.length;

      while (accumulatedLength >= chunkSize) {
        const out = new Uint8Array(chunkSize);
        let written = 0;

        while (written < chunkSize) {
          const head = accumulator[0];
          if (head === undefined) break;
          const need = chunkSize - written;

          if (head.length <= need) {
            out.set(head, written);
            written += head.length;
            accumulatedLength -= head.length;
            accumulator.shift();
          } else {
            out.set(head.subarray(0, need), written);
            accumulator[0] = head.subarray(need);
            accumulatedLength -= need;
            written += need;
          }
        }

        controller.enqueue(out);
      }
    },

    flush(controller) {
      if (accumulatedLength > 0) {
        const out = new Uint8Array(accumulatedLength);
        let written = 0;

        for (const piece of accumulator) {
          out.set(piece, written);
          written += piece.length;
        }

        controller.enqueue(out);
      }
    },
  });

  return file.stream().pipeThrough(ts);
}

用 bufferedAmountLowThreshold 做背压流控

问题所在:无界发送排队

RTCDataChannel.bufferedAmount 报告的是已排入 SCTP 发送缓冲区、但尚未传给对端的数据字节数。没有任何门控机制时,读取一个 1 GB 的文件并在循环中调用 send(),会在网络来得及排空之前把整 GB 数据推进缓冲区,导致内存耗尽并让标签页崩溃。

实现背压感知的发送循环

解决办法是一个基于拉取的发送循环:当 bufferedAmount 超过阈值时暂停,缓冲区排空后恢复。设置 dataChannel.bufferedAmountLowThreshold = 256 * 1024(256 KB),意味着一旦缓冲区降到该水平以下,bufferedamountlow 事件就会触发。配合 64 KB 的块,这大约允许任意时刻有四个块在途。

这是全文最关键的代码示例:

解决办法是一个基于拉取的发送循环:当 bufferedAmount 超过阈值时暂停,缓冲区排空后恢复。

// src/coordinator.ts(带背压的发送器)
export async function sendWithBackpressure(
  channel: RTCDataChannel,
  stream: ReadableStream<Uint8Array>,
  onProgress?: (bytesSent: number) => void
): Promise<void> {
  const THRESHOLD = 256 * 1024;
  channel.bufferedAmountLowThreshold = THRESHOLD;

  const reader = stream.getReader();
  let bytesSent = 0;

  async function waitForDrain(): Promise<void> {
    if (channel.readyState !== 'open') {
      throw new Error(`DataChannel 未打开(状态:${channel.readyState})`);
    }
    if (channel.bufferedAmount <= THRESHOLD) {
      return;
    }

    return new Promise<void>((resolve, reject) => {
      const onLow = () => {
        cleanup();
        resolve();
      };
      const onClose = () => {
        cleanup();
        reject(new Error('等待排空期间 DataChannel 被关闭'));
      };
      const cleanup = () => {
        channel.removeEventListener('bufferedamountlow', onLow);
        channel.removeEventListener('close', onClose);
      };

      channel.addEventListener('bufferedamountlow', onLow, { once: true });
      channel.addEventListener('close', onClose, { once: true });
    });
  }

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    // 前置消息类型字节:0x01 = 加密块
    const frame = new Uint8Array(1 + value.length);
    frame[0] = 0x01;
    frame.set(value, 1);

    channel.send(frame);
    bytesSent += value.length;
    onProgress?.(bytesSent);

    if (channel.bufferedAmount > THRESHOLD) {
      await waitForDrain();
    }
  }

  // 发送传输结束信号
  channel.send(new Uint8Array([0x02]));
}

为吞吐量调优阈值与块大小

这里存在直接权衡:阈值设得太低会导致频繁暂停,饿死网络、降低吞吐量;阈值设得太高又会让内存占用攀升,重新引入最初的问题。建议的起点是 64 KB 块配 256 KB 阈值。对于高带宽局域网传输,把两者都翻倍(128 KB 块、512 KB 阈值)可以提升吞吐量,但超过这一点后,瓶颈就变成 SCTP 拥塞控制,而不再是缓冲策略。

用 WebCrypto 做端到端 AES-GCM 加密

密钥生成与安全交换

WebCrypto API 完全在浏览器内生成 AES-GCM 密钥。密钥以原始 ArrayBuffer 导出,并作为控制消息(首字节 0x00)通过 DataChannel 传给对端。

警告:DataChannel 的 DTLS 加密能保护密钥免受被动网络窃听,但被攻陷的信令服务器仍可替换密钥(中间人攻击)。生产环境不要使用原始密钥交换。请把原始密钥传输替换为 ECDH 密钥协商(见生产清单)。

// src/crypto.ts(密钥工具)
export async function generateKey(): Promise<CryptoKey> {
  return crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );
}

export async function exportKey(key: CryptoKey): Promise<ArrayBuffer> {
  return crypto.subtle.exportKey('raw', key);
}

export async function importKey(raw: ArrayBuffer): Promise<CryptoKey> {
  return crypto.subtle.importKey(
    'raw',
    raw,
    { name: 'AES-GCM', length: 256 },
    false,
    ['decrypt']
  );
}

逐块加密与唯一 IV

AES-GCM 要求在同一密钥下的每一次加密操作都使用唯一的 12 字节初始化向量(IV)。在同一密钥下复用 IV 是灾难性的,会导致明文可被恢复。这里的策略是把 4 字节大端块索引与 8 个随机字节拼接起来,降低跨传输的碰撞概率;有了 8 个随机字节,生日界约为每密钥 2^32 个块(按 64 KB 块算约 274 TB)。每次传输后请轮换密钥。IV 会被前置到密文上,以便接收端在解密前取出。

AES-GCM 要求在同一密钥下的每一次加密操作都使用唯一的 12 字节初始化向量(IV)。在同一密钥下复用 IV 是灾难性的,会导致明文可被恢复。

// src/crypto.ts(逐块加密/解密)
function buildIV(chunkIndex: number): Uint8Array {
  const iv = new Uint8Array(12);
  const view = new DataView(iv.buffer);
  view.setUint32(0, chunkIndex, false); // 大端索引
  crypto.getRandomValues(iv.subarray(4)); // 8 个随机字节
  return iv;
}

export async function encryptChunk(
  key: CryptoKey, chunkIndex: number, data: Uint8Array
): Promise<Uint8Array> {
  const iv = buildIV(chunkIndex);
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    data
  );

  const result = new Uint8Array(12 + ciphertext.byteLength);
  result.set(iv, 0);
  result.set(new Uint8Array(ciphertext), 12);
  return result;
}

export async function decryptChunk(
  key: CryptoKey, payload: Uint8Array
): Promise<Uint8Array> {
  const iv = payload.slice(0, 12);
  const ciphertext = payload.slice(12);
  const plaintext = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv },
    key,
    ciphertext
  );
  return new Uint8Array(plaintext);
}

把加密整合进流式流水线

加密与解密以 TransformStream 工厂的形式接入 Streams API。每个阶段(分块切片、加密、背压发送、解密、完整性校验)都可以被替换或单独测试,而无需修改相邻阶段。

// src/crypto.ts(流式集成)
export function createEncryptTransform(key: CryptoKey): TransformStream<Uint8Array, Uint8Array> {
  let index = 0;

  return new TransformStream({
    async transform(chunk, controller) {
      if (index > 0xFFFF_FFFF) {
        controller.error(new Error('块索引溢出:请轮换加密密钥'));
        return;
      }
      const encrypted = await encryptChunk(key, index++, chunk);
      controller.enqueue(encrypted);
    },
  });
}

export function createDecryptTransform(key: CryptoKey): TransformStream<Uint8Array, Uint8Array> {
  return new TransformStream({
    async transform(chunk, controller) {
      const decrypted = await decryptChunk(key, chunk);
      controller.enqueue(decrypted);
    },
  });
}

SHA-256 分块完整性校验

计算与验证块摘要

接收端用 crypto.subtle.digest('SHA-256', decryptedChunk) 计算每个块的 SHA-256 摘要。在传输开始之前,发送端会计算一份清单,其中包含每个明文块的十六进制 SHA-256 摘要,并作为控制消息传输出去。接收端把每个解密后的块与清单中对应条目比对,一旦不匹配立即中止。

// src/integrity.ts
export async function computeDigest(data: Uint8Array): Promise<string> {
  const hash = await crypto.subtle.digest('SHA-256', data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

export function createVerifyTransform(
  manifest: string[]
): TransformStream<Uint8Array, Uint8Array> {
  let index = 0;

  return new TransformStream({
    async transform(chunk, controller) {
      const expected: string | undefined = manifest[index];

      if (expected === undefined) {
        controller.error(
          new Error(`块 ${index} 超出清单长度(${manifest.length})`)
        );
        return;
      }

      const digest = await computeDigest(chunk);

      if (digest !== expected) {
        controller.error(
          new Error(`块 ${index} 完整性校验失败:期望 ${expected},实际 ${digest}`)
        );
        return;
      }

      index++;
      controller.enqueue(chunk);
    },
  });
}

传输协调器:把所有部分串起来

协议消息类型

协议使用不同的类型字节在 DataChannel 上对消息进行分帧:添加到对话

类型字节消息格式
0x00原始 AES-256 密钥1 个类型字节 + 32 字节密钥(共 33 字节)
0x03元数据 + 清单(JSON)1 个类型字节 + UTF-8 编码的 JSON
0x01加密块1 个类型字节 + IV(12 字节)+ 密文
0x02传输结束信号1 字节

发送端流程

TransferCoordinator 把所有模块串联在一起。发送端一侧:生成密钥 → 计算完整的分块清单 → 打开 DataChannel → 发送控制消息(密钥、清单、文件元数据)→ 然后把文件依次通过分块切片、加密和背压发送器进行管道传输。

注意:这种清单方式会把文件读两遍:一遍用于计算块摘要,一遍用于流式加密传输。对于内存受限的环境,可以考虑在传输过程中计算摘要、并在传输后通过最终清单校验的流式方案。

接收端流程

接收端等待控制消息、导入密钥、保存清单,并把进来的加密块依次通过解密和完整性校验处理。一旦收到传输结束信号,累积起来的块就组成一个 Blob,通过 URL.createObjectURL 触发下载。

警告:这个实现会把所有解密后的块缓存在内存中。对于超出可用内存的文件,请使用生产清单中描述的 File System Access API 或 StreamSaver.js 方案。

这个实现会把所有解密后的块缓存在内存中。对于超出可用内存的文件,请使用生产清单中描述的 File System Access API 或 StreamSaver.js 方案。

// src/coordinator.ts(TransferCoordinator —— 完整编排器)
//
// chunkStream 和 sendWithBackpressure 在本文件(coordinator.ts)前面已定义

import { SignalingBroker } from './signaling';
import {
  generateKey, exportKey, importKey,
  encryptChunk, decryptChunk,
  createEncryptTransform,
} from './crypto';
import { computeDigest, createVerifyTransform } from './integrity';

export class TransferCoordinator {
  private signaling: SignalingBroker;

  constructor(channelName: string) {
    this.signaling = new SignalingBroker(channelName);
  }

  async send(
    file: File,
    channel: RTCDataChannel,
    onProgress?: (pct: number) => void
  ): Promise<void> {
    const key = await generateKey();
    const rawKey = await exportKey(key);

    if (rawKey.byteLength !== 32) {
      throw new Error(`密钥导出的长度异常:${rawKey.byteLength}`);
    }

    // 从明文块计算清单
    const manifest: string[] = [];
    const preReader = chunkStream(file, 65536).getReader();

    try {
      while (true) {
        const { done, value } = await preReader.read();
        if (done) break;
        manifest.push(await computeDigest(value));
      }
    } finally {
      preReader.releaseLock();
    }

    // 发送控制消息:原始密钥(类型 0x00)
    const keyFrame = new Uint8Array(1 + rawKey.byteLength);
    keyFrame[0] = 0x00;
    keyFrame.set(new Uint8Array(rawKey), 1);
    channel.send(keyFrame);

    // 发送控制消息:元数据 + 清单(类型 0x03)
    const metaPayload = JSON.stringify({
      name: file.name,
      size: file.size,
      totalChunks: manifest.length,
      manifest,
    });
    const encoded = new TextEncoder().encode(metaPayload);
    const metaFrame = new Uint8Array(1 + encoded.length);
    metaFrame[0] = 0x03;
    metaFrame.set(encoded, 1);
    channel.send(metaFrame);

    // 流式处理:切片 → 加密 → 带背压发送
    const encrypted = chunkStream(file, 65536).pipeThrough(createEncryptTransform(key));

    await sendWithBackpressure(channel, encrypted, (bytes) => {
      onProgress?.(Math.round((bytes / file.size) * 100));
    });
  }

  receive(channel: RTCDataChannel): Promise<void> {
    return new Promise((resolve, reject) => {
      let key: CryptoKey | null = null;
      let meta: { name: string; manifest: string[] } | null = null;
      const chunks: Uint8Array[] = [];

      channel.onmessage = (ev) => {
        (async () => {
          const data = new Uint8Array(ev.data as ArrayBuffer);
          const msgType = data[0];

          if (msgType === 0x00) {
            if (data.byteLength !== 33) {
              throw new Error(`密钥消息无效:期望 33 字节,实际 ${data.byteLength}`);
            }
            // .slice() 会生成一个恰好 32 字节的新 ArrayBuffer
            key = await importKey(data.slice(1, 33).buffer);

          } else if (msgType === 0x03) {
            let parsed: unknown;

            try {
              parsed = JSON.parse(new TextDecoder().decode(data.slice(1)));
            } catch {
              throw new Error('元数据格式错误:JSON 解析失败');
            }

            if (
              typeof parsed !== 'object' || parsed === null ||
              typeof (parsed as Record<string, unknown>)['name'] !== 'string' ||
              !Array.isArray((parsed as Record<string, unknown>)['manifest'])
            ) {
              throw new Error('元数据格式错误:缺少必需字段');
            }

            meta = parsed as { name: string; manifest: string[] };

          } else if (msgType === 0x01 && key && meta) {
            const plain = await decryptChunk(key, data.slice(1));
            const digest = await computeDigest(plain);
            const expected: string | undefined = meta.manifest[chunks.length];

            if (expected === undefined) {
              throw new Error(`意外的块 ${chunks.length}:超出清单长度`);
            }

            if (digest !== expected) {
              throw new Error(`块 ${chunks.length} 完整性失败`);
            }

            chunks.push(plain);

          } else if (msgType === 0x02) {
            const blob = new Blob(chunks);
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = meta?.name ?? 'download';
            a.click();
            setTimeout(() => URL.revokeObjectURL(url), 300_000);
            resolve();
          }
        })().catch(reject);
      };

      channel.onerror = (e) => {
        reject(new Error((e as RTCErrorEvent).error?.message ?? 'DataChannel 错误'));
      };
    });
  }
}

进度报告与错误处理

发送端通过把 chunksSent 除以清单中的 totalChunks 来跟踪进度。DataChannel 的 onerror 与 onclose 事件必须上报到 UI 层。接收端必须在解密失败(认证标签不匹配时由 WebCrypto 抛出)或完整性不匹配时立即终止传输,并通知用户。

运行与测试应用

运行 npx vite,打开两个指向 localhost:5173 的标签页,指定一个为发送端、一个为接收端。用不同大小的文件测试:1 MB 验证基本功能,100 MB 压测背压,1 GB 确认内存保持有界。在浏览器 DevTools 中运行 setInterval(() => console.log(channel.bufferedAmount), 200),应当看到数值徘徊在 256 KB 阈值附近或之下,而不是不受控制地攀升。Chrome、Edge 和 Firefox 都支持所需的 API。Safari 15.4 加入了可靠的 ArrayBuffer DataChannel 消息支持,解决了早先的二进制传输问题;但请针对你的具体用例测试,因为在较老的 WebKit 构建上,超过 256 KB 的二进制消息大小限制可能仍然适用。

性能考量与生产环境加固

吞吐量基准

在 localhost 回环连接上,吞吐量随调优而变化。以下数字是非正式观察所得,会随硬件、操作系统和浏览器版本而变化。把它们当作方向性参考,而非保证:添加到对话

块大小阈值观察到的吞吐量
16 KB64 KB约 30 MB/s
64 KB256 KB约 80 MB/s
128 KB512 KB约 95 MB/s
256 KB1 MB约 100 MB/s(收益递减)

在真实网络路径上,吞吐量会降到大约 10–50 MB/s,具体取决于路径 RTT 和丢包率,因为 SCTP 拥塞控制会限制有效发送速率。

生产清单

把 BroadcastChannel 信令替换为基于 WebSocket 或 SSE 的信令服务器。为处于对称 NAT 之后的对等方配置 TURN 服务器回退,否则直连会失败。使用临时 ECDH(ECDHE):每次会话用 crypto.subtle.generateKey 生成一对全新的 P-256 密钥。静态 ECDH 不提供前向保密,只有临时密钥对才有。若要在接收端实现真正流式下载而不完整缓冲 Blob,在 Chromium 浏览器中优先使用 File System Access API(showSaveFilePicker)。StreamSaver.js 是一个兼容性更广的替代方案,但已不再活跃维护。设置 CSP 头以允许用于触发下载的 blob: URL。

下一步

在这个基础之上,开发者可以进一步探索多对等方扇出(同时把块发送给多个接收端)、通过持久化块索引实现可续传传输,以及把加密工作卸载到 Web Worker 以在数百兆字节的传输中保持主线程响应。注意 Web Worker 无法直接访问 RTCDataChannel;请用 postMessage 配合可转移的 ArrayBuffer 在 Worker 加密上下文与主线程的 DataChannel 之间搬运数据。

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

赞 (0)

相关推荐