前端也能搞传输层协议啦 - TCP/UDP Socket APIs 体验

666 阅读6分钟

1. Chrome 131 enables Direct Socket API by default

在WebRTC大佬群潜水看到一个讨论极其热烈的话题,Chrome 131 要开放裸TCP/UDP能力,之前没有注意见这个API,这次就研究了一下。

转发的图片:

Diect Socket API说明:

由说明中可以看到:

  1. 运行环境是 IWAs Isolated Web Apps ,并非普通的浏览器网页,

  2. 有一个体验Demo中的README,Chrome 110(2023 年 2 月)就集成了该功能,只是需要加flags打开

2. IWA vs PWA

由于之前没有接触过IWA,有请豆包回答一下二者的区别

IWA(Isolated Web App,隔离型网络应用)和 PWA(Progressive Web App,渐进式网络应用)有以下一些区别:

一、概念与目标

IWA:
主要强调应用程序的隔离性,旨在为用户提供更安全、独立的网络应用体验。
通常通过限制应用对系统资源的访问以及与其他应用的交互,来降低安全风险。
例如,IWA 可以建立直接的 TCP 和 UDP 通信,同时保证这种通信不会对其他应用或系统造成干扰。
PWA:
目标是提供类似原生应用的体验,同时利用网络的优势,无需用户从应用商店下载安装。
强调离线可用性、快速加载、推送通知等功能,以提高用户的便利性和参与度。

二、功能特性

网络连接:
IWA:可以建立直接的 TCP 和 UDP 通信,并监听和接受传入的连接。这种能力使其适用于一些特定的网络应用场景,如需要直接与网络设备或系统进行通信的应用。
PWA:主要依赖于浏览器的网络连接能力,虽然可以通过缓存和离线存储来提供一定程度的离线可用性,但在直接的网络通信方面没有 IWA 那么强大的功能。
安全性:
IWA:由于强调隔离性,通常具有更高的安全级别。它们运行在相对独立的环境中,减少了与其他应用或系统组件的交互,从而降低了被攻击的风险。
PWA:安全性主要依赖于浏览器的安全机制。虽然浏览器也提供了一定的安全保障,但由于 PWA 与浏览器的紧密集成,可能面临一些与浏览器相关的安全风险。
安装与使用:
IWA:安装和使用方式可能因操作系统和具体实现而异。一般来说,用户可能需要通过特定的渠道或方式来安装和启动 IWA。
PWA:可以通过浏览器访问网页,然后将其添加到主屏幕,就像安装原生应用一样。用户可以直接从主屏幕启动 PWA,无需经过传统的应用商店安装过程。

三、应用场景

IWA:
适用于对安全性要求较高的网络应用,如企业级应用、金融交易应用等。
对于需要直接与网络设备进行通信的应用,如物联网应用、网络管理工具等,IWA 的直接 TCP 和 UDP 通信能力非常有用。
PWA:
适合各种类型的网络应用,尤其是那些需要在不同设备上提供一致体验的应用,如新闻网站、博客、电子商务网站等。
对于希望提高用户参与度和留存率的应用,PWA 的推送通知、离线可用性等功能可以提供很大的帮助。

3. 体验TCP/UDP Socket

3.1 TCPSocket

启动项目:

直接按telnet-client README说明运行该项目:

  1. npm install

  2. npm start -> 启动localhost:4321端口

这个时候打开是会报错的,因为不是IWAs环境

使用命令行启动浏览器,注意端口号是4321,README有问题,在 chrome://apps 里找到 telnet App 打开

google-chrome-unstable --enable-features=IsolatedWebApps,IsolatedWebAppDevMode \
                       --install-isolated-web-app-from-url=http://localhost:4321

打开后是这样的独立窗口,点击[Launch echo server],会出现右边的echo窗口

client 窗口

echo 窗口

看了下代码,这个应用有2个页面

  • index.html 代表客户端

  • echo.html TCPServer 模拟服务端,只有一个简单的回显功能

Demo体验:

  1. Host填 localhost
  2. Port填 echo 窗口那个port
  3. 点击[connect]
  4. 连接成功后,就可以在client窗口黑框里打字,显示的内容其实是从tcp流里读到的内容,如果打开[local echo],你会看到输入1个字母,显示2个字母

API介绍:

MDN还没有收录该API,不过可以在Direct Sockets API查看,和TCP相关的类有2个:

  • TCPSocket

  • TCPServerSocket

用法说明

const socket = new TCPSocket(host, port);
const { readable, writable } = await socket.opened;
const reader = readable.getReader();
const writer = writable.getWriter();

读数据,需要写一个死循环,一直从reader里面读,如果read返回done,就break跳出循环

for (;;) {
  const {value, done} = await reader.read();
  if (value) {
    // do sth..
  }
  if (done) {
    break;
  }
}

// 死循环的另一写法是
while (true) {
    // 同上
}

写数据,写比较简单,只是注意读写的数据都是 Uint8Array 类型的

writer.write(message);

TCPServerSocket的用法和TCPSocket非常类似,在创建实例后就能拿要TCP监听的port,读写流程就和client一致了。

3.2 UDPSocket

这个 telnet App 只提供了TCP的使用(可能是找不到UDP Server做测试,注意没有UDPServerSocket),此时我们使用一个开放的STUN服务器stun.l.google.com(STUN是基于UDP传输协议的应用层协议)来测试一下。

TS类型准备:

ts还没有收录该API的定义,所以需要在项目direct-sockets.d.ts里手动添加一下,这些内容是gemini识别IDL定义直接生成的,如果有需要可以直接拿走:

declare enum SocketDnsQueryType {
  "ipv4",
  "ipv6"
}

interface UDPSocketOptions {
  remoteAddress?: string;
  remotePort?: number;
  localAddress?: string;
  localPort?: number;
  sendBufferSize?: number;
  receiveBufferSize?: number;
  dnsQueryType?: SocketDnsQueryType;
  ipv6Only?: boolean;
}

interface UDPMessage {
  data: BufferSource;
  remoteAddress: string;
  remotePort: number;
  dnsQueryType: SocketDnsQueryType;
}

interface UDPSocketOpenInfo {
  readable: ReadableStream;
  writable: WritableStream;
  remoteAddress: string;
  remotePort: number;
  localAddress: string;
  localPort: number;
}

declare class UDPSocket {
  constructor(options?: UDPSocketOptions);
  readonly opened: Promise<UDPSocketOpenInfo>;
  readonly closed: Promise<undefined>;
  close(): Promise<undefined>;
}

发送与接收STUN协议:

STUN协议 的一个功能是拿到我们本机NAT外的公网地址,更具体的用法可以看STUN的RFC datatracker.ietf.org/doc/html/rf…

读写流程上其实和TCPSocket一样,这里就是验证下有无生效,UDP发送的内容是从wireshark上爪的一个STUN包,你也可以自己按照RFC自己构造。


async function testUdpSocket() {
  const udpSocket = new UDPSocket({
    remoteAddress: 'stun.l.google.com',
    remotePort: 19302,
  });
  const {readable, writable} = await udpSocket.opened;
  const reader = readable.getReader();
  const writer = writable.getWriter();
  receiveUdpMessage(reader);
  sendUdpMessage(writer);
}

async function sendUdpMessage(writer: WritableStreamDefaultWriter<any>) {
  const data = new Uint8Array([
    0x00,
    0x01,
    0x00,
    0x00,
    0x21,
    0x12,
    0xa4,
    0x42,
    0x61,
    0x74,
    0x64,
    0x44,
    0x6c,
    0x45,
    0x42,
    0x36,
    0x58,
    0x6a,
    0x63,
    0x4f]);

  const message = {
    data,
  };

  await writer.ready;
  await writer.write(message);

  writer.releaseLock();
}

async function receiveUdpMessage(reader: ReadableStreamDefaultReader<any>) {
  try {
    for (;;) {
      const {value, done} = await reader.read();
      if (value) {
        console.log('receive value: ', value);
      }
      if (done) {
        break;
      }
    }
    reader.releaseLock();
  } catch (e) {
    console.error(e);
    term.writeln(`<ERROR: ${e.message}>`);
  }
}

同样我们打开wireshark抓包,看看google服务器有没有给我们正常返回,发送,可以看到STUN的内容和我们代码内容一致

接收wireshark的STUN包内容和我们代码打印的内容一致(数据1和数据2)