Direct Sockets

Demián Renzulli
Demián Renzulli
Andrew Rayskiy
Andrew Rayskiy
Vlad Krot
Vlad Krot

标准 Web 应用通常仅限于特定的通信协议(如 HTTP)和 API(如 WebSocketWebRTC)。虽然这些功能很强大,但设计上受到严格限制,以防止滥用。它们无法建立原始 TCP 或 UDP 连接,这限制了 Web 应用与使用自己的非 Web 协议的旧版系统或硬件设备进行通信的能力。例如,您可能想要构建基于网络的 SSH 客户端、连接到本地打印机或管理一组 IoT 设备。过去,这需要浏览器插件或原生辅助应用。

Direct Sockets API 通过使独立式 Web 应用 (IWA) 能够建立直接 TCP 和 UDP 连接(无需中继服务器)来解决此限制。借助 IWA,由于采取了额外的安全措施(例如严格的内容安全政策 [CSP] 和跨源隔离),此 API 可以安全地公开。

使用场景

在什么情况下应使用 Direct Sockets 而不是标准 WebSockets?

  • IoT 和智能设备:与使用原始 TCP/UDP 而不是 HTTP 的硬件通信。
  • 旧版系统:连接到旧版邮件服务器 (SMTP/IMAP)、IRC 聊天服务器或打印机。
  • 远程桌面和终端:实现 SSH、Telnet 或 RDP 客户端。
  • P2P 系统:实现分布式哈希表 (DHT) 或弹性协作工具(例如 IPFS)。
  • 媒体广播:利用 UDP 同时将内容流式传输到多个端点(多播),从而实现各种使用情形,例如在零售信息亭网络中协调视频播放。
  • 服务器和监听器功能:将 IWA 配置为使用 TCPServerSocket 或绑定 UDPSocket 作为传入 TCP 连接或 UDP 数据报的接收端点。

Direct Sockets 的前提条件

在使用直接套接字之前,您需要设置一个可正常运行的 IWA。然后,您可以将 Direct Sockets 集成到网页中。

添加权限政策

如需使用 Direct Sockets,您必须在 IWA 清单中配置 permissions_policy 对象。您需要添加 direct-sockets 键来明确启用该 API。此外,您还必须添加 cross-origin-isolated 键。此键并非特定于 Direct Sockets,但对于所有 IWA 都是必需的,并且用于确定文档是否可以访问需要跨源隔离的 API。

{
  "permissions_policy": {
    "direct-sockets": ["self"],
    "cross-origin-isolated": ["self"]
  }
}

direct-sockets 键用于确定是否允许调用 new TCPSocket(...)new TCPServerSocket(...)new UDPSocket(...)。如果未设置此政策,这些构造函数将立即拒绝并返回 NotAllowedError

实现 TCPSocket

应用可以通过创建 TCPSocket 实例来请求 TCP 连接。

打开连接

如需打开连接,请使用 new 运算符和 await 已打开的 promise。

TCPSocket 构造函数使用指定的 remoteAddressremotePort 初始化连接。

const remoteAddress = 'example.com';
const remotePort = 7;

// Configure options like keepAlive or buffering
const options = {
  keepAlive: true,
  keepAliveDelay: 720000
};

let tcpSocket = new TCPSocket(remoteAddress, remotePort, options);

// Wait for the connection to be established
let { readable, writable } = await tcpSocket.opened;

可选的配置对象可实现精细的网络控制;在此特定情况下,keepAliveDelay 设置为 720000 毫秒,以便在不活动期间保持连接。开发者还可以在此处配置其他属性,例如 noDelay,该属性可停用 Nagle 算法以阻止系统批量处理小数据包,从而可能缩短延迟时间;或者使用 sendBufferSizereceiveBufferSize 来管理吞吐量。

在上述代码段的最后一部分中,代码会等待已打开的 promise,该 promise 仅在握手完成后才会解析,并返回一个 TCPSocketOpenInfo 对象,其中包含数据传输所需的只读和可写流。

读写

套接字打开后,使用标准 Streams API 接口与其互动。

  • 写入:可写流接受 BufferSource(例如 ArrayBuffer)。
  • 读取:可读数据流会生成 Uint8Array 数据。
// Writing data
const writer = writable.getWriter();
const encoder = new TextEncoder();
await writer.write(encoder.encode("Hello Server"));

// Call when done
writer.releaseLock();

// Reading data
const reader = readable.getReader();
const { value, done } = await reader.read();
if (!done) {
    const decoder = new TextDecoder();
    console.log("Received:", decoder.decode(value));
}

// Call when done
reader.releaseLock();

通过 BYOB 优化读取

对于管理内存分配至关重要的高性能应用,该 API 支持“自带缓冲区”(BYOB) 读取。您可以将预分配的缓冲区传递给读取器,而不是让浏览器为接收到的每个数据块分配新的缓冲区。这样一来,系统便可将数据直接写入现有内存,从而减少垃圾回收开销。

// 1. Get a BYOB reader explicitly
const reader = readable.getReader({ mode: 'byob' });

// 2. Allocate a reusable buffer (e.g., 4KB)
let buffer = new Uint8Array(4096);

// 3. Read directly into the existing buffer
const { value, done } = await reader.read(buffer);

if (!done) {
  // 'value' is a view of the data written directly into your buffer
  console.log("Bytes received:", value.byteLength);
}

reader.releaseLock();

实现 UDPSocket

UDPSocket 类支持 UDP 通信。它会以两种不同的模式运行,具体取决于您如何配置选项。

已连接模式

在此模式下,套接字与单个特定目的地进行通信。这对于标准客户端-服务器任务非常有用。

// Connect to a specific remote host
let udpSocket = new UDPSocket({
    remoteAddress: 'example.com',
    remotePort: 7 });

let { readable, writable } = await udpSocket.opened;

绑定模式

在此模式下,套接字绑定到本地 IP 端点。它可以接收来自任意来源的数据报,并将其发送到任意目的地。这通常用于本地发现协议或类似服务器的行为。

// Bind to all interfaces (IPv6)
let udpSocket = new UDPSocket({
    localAddress: '::'
    // omitting localPort lets the OS pick one
});

// localPort will tell you the OS-selected port.
let { readable, writable, localPort } = await udpSocket.opened;

处理 UDP 消息

与 TCP 字节流不同,UDP 流处理的是 UDPMessage 对象,其中包含数据和远程地址信息。以下代码演示了在“绑定模式”下使用 UDPSocket 时如何处理输入/输出操作。

// Writing (Bound Mode requires specifying destination)
const writer = writable.getWriter();
await writer.write({
    data: new TextEncoder().encode("Ping"),
    remoteAddress: '192.168.1.50',
    remotePort: 8080
});

// Reading
const reader = readable.getReader();
const { value } = await reader.read();
// value contains: { data, remoteAddress, remotePort }
console.log(`Received from ${value.remoteAddress}:`, value.data);

与套接字锁定到特定对等方的“连接模式”不同,绑定模式允许套接字与任意目的地通信。因此,在将数据写入可写数据流时,您必须传递一个 UDPMessage 对象,该对象明确指定每个数据包的 remoteAddressremotePort,从而准确指示套接字将特定数据报路由到何处。同样,从可读数据流读取数据时,返回值不仅包含数据载荷,还包含发送者的 remoteAddressremotePort,从而使您的应用能够识别每个传入数据包的来源。

注意:在“连接模式”下使用 UDPSocket 时,套接字实际上会锁定到特定对等方,从而简化 I/O 流程。在此模式下,写入时 remoteAddressremotePort 属性实际上是空操作,因为目标位置已固定。同样,在读取消息时,这些属性将返回 null,因为来源保证是已连接的对等方。

多播支持

对于在多个信息亭之间同步视频播放或实现本地设备发现(例如 mDNS)等使用情形,Direct Sockets 支持多播 UDP。这样,消息就可以发送到“群组”地址,并由网络上的所有订阅者接收,而不是由单个特定对等方接收。

多播权限

如需使用多播功能,您必须向 IWA 清单添加特定的 direct-sockets-multicast 权限。这与标准直接套接字权限不同,之所以需要此权限,是因为多播仅在专用网络中使用。

{
  "permissions_policy": {
    "direct-sockets": ["self"],
    "direct-sockets-multicast": ["self"],
    "direct-sockets-private": ["self"],
    "cross-origin-isolated": ["self"]
  }
}

发送多播数据报

向多播群组发送数据包与标准 UDP“连接模式”非常相似,只是添加了用于控制数据包行为的特定选项。

const MULTICAST_GROUP = '239.0.0.1';
const PORT = 12345;

const socket = new UDPSocket({
  remoteAddress: MULTICAST_GROUP,
  remotePort: PORT,
  // Time To Live: How many router hops the packet can survive (default: 1)
  multicastTimeToLive: 5,
  // Loopback: Whether to receive your own packets (default: true)
  multicastLoopback: true
});

const { writable } = await socket.opened;
// Write to the stream as usual...

接收多播数据报

如需接收多播流量,您必须以“绑定模式”(通常绑定到 0.0.0.0::)打开 UDPSocket,然后使用 MulticastController 加入特定群组。您还可以使用 multicastAllowAddressSharing 选项(类似于 Unix 上的 SO_REUSEADDR),这对于同一设备上的多个应用需要监听同一端口的设备发现协议至关重要。

const socket = new UDPSocket({
  localAddress: '0.0.0.0', // Listen on all interfaces
  localPort: 12345,
  multicastAllowAddressSharing: true // Allow multiple applications to bind to the same address / port pair.
});

// The open info contains the MulticastController
const { readable, multicastController } = await socket.opened;

// Join the group to start receiving packets
await multicastController.joinGroup('239.0.0.1');

const reader = readable.getReader();

// Read the stream...
const { value } = await reader.read();
console.log(`Received multicast from ${value.remoteAddress}`);

// When finished, you can leave the group (this is an optional, but recommended practice)
await multicastController.leaveGroup('239.0.0.1');

创建服务器

该 API 还支持 TCPServerSocket,用于接受传入的 TCP 连接,从而使 IWA 能够充当本地服务器。以下代码展示了如何使用 TCPServerSocket 接口建立 TCP 服务器。

// Listen on all interfaces (IPv6)
let tcpServerSocket = new TCPServerSocket('::');

// Accept connections via the readable stream
let { readable } = await tcpServerSocket.opened;
let reader = readable.getReader();

// Wait for a client to connect
let { value: clientSocket } = await reader.read();

// 'clientSocket' is a standard TCPSocket you can now read/write to

通过使用 '::' 地址实例化该类,服务器会绑定到所有可用的 IPv6 网络接口,以侦听传入的尝试。与基于回调的传统服务器 API 不同,此 API 采用 Web 的 Streams API 模式:传入连接以 ReadableStream 的形式传递。当您调用 reader.read() 时,应用会等待并接受队列中的下一个连接,解析为一个功能齐全的 TCPSocket 实例,该实例已准备好与特定客户端进行双向通信。

使用 Chrome 开发者工具调试直接套接字

从 Chrome 138 开始,您可以在 Chrome DevTools 的网络面板中直接调试 Direct Sockets 流量,而无需使用外部数据包嗅探器。借助此工具,您可以监控 TCPSocket 连接以及 UDPSocket 流量(在绑定模式和连接模式下),同时监控标准 HTTP 请求。

如需检查应用的网络活动,请执行以下操作:

  1. 在 Chrome 开发者工具中打开网络面板。
  2. 在请求表格中找到并选择套接字连接。
  3. 打开消息标签页,查看所有已传输和接收的数据的日志。

开发者工具中“消息”标签页内的数据。

此视图提供了一个十六进制查看器,可让您检查 TCP 和 UDP 消息的原始二进制载荷,确保您的协议实现完美无缺。

演示

IWA Kitchen Sink 包含一个具有多个标签页的应用,每个标签页都展示了不同的 IWA API,例如 Direct Sockets、Controlled Frame 等。

或者,telnet 客户端演示包含一个隔离的 Web 应用,该应用允许用户通过交互式终端连接到 TCP/IP 服务器。换句话说,就是 Telnet 客户端。

总结

直接套接字 API 通过使 Web 应用能够处理以前无法在没有原生封装器的情况下支持的原始网络协议,弥合了关键功能差距。它不仅提供简单的客户端连接;借助 TCPServerSocket,应用可以监听传入的连接,而 UDPSocket 则提供灵活的模式,用于对等通信和本地网络发现。

通过现代 Streams API 公开这些原始 TCP 和 UDP 功能,您现在可以直接在 JavaScript 中构建旧版协议(如 SSH、RDP 或自定义 IoT 标准)的完整功能实现。由于此 API 授予低级网络访问权限,因此会带来严重的安全隐患。因此,此功能仅限于独立式 Web 应用 (IWA),以确保此类功能仅授予受信任的、明确安装的且强制执行严格安全政策的应用。这种平衡可让您构建强大的以设备为中心的应用程序,同时保持用户对 Web 平台的安全预期。

资源