# 套接字

用 SDK 套接字，让你应用的 TCP 或 UDP 客户端经由一台 URnetwork Device 建立连接。它具有熟悉的读、写、截止时间和关闭行为，而它的流量走的是该 Device 所选的连接路径。你可以只为一个客户端使用它，而不必让机器上的其余流量都经过操作系统级 VPN。

先按照 [SDK 入门](/docs/getting-started-sdk)准备好一台已初始化的 Device。套接字方法存在于更新后的 SDK 源码中，并要求远程 Device 具备与之匹配的套接字支持。在 RPC 连接的两端都使用包含这些 API 的 SDK 构建。

原生包管理器命令见[安装](/docs/install-sdk)；十二种语言的可运行套接字集成和 HTTP 库集成见[示例](/docs/examples)，其中 JavaScript 有彼此独立的 Node 程序和浏览器程序。

## 它与内核套接字有何不同

内核套接字属于操作系统的网络栈。SDK 套接字属于 Device，使用一个进程内的 TCP/IP 栈。Device 经它配置好的路由发送数据包；经由 URnetwork 连接时，这条路由也包括它所选的提供者。

| | 内核套接字 | SDK 套接字 |
| --- | --- | --- |
| 网络路径 | 操作系统路由表与接口选择 | Device 路由、提供者选择与策略 |
| 应用接口 | 文件描述符或平台套接字对象 | Go `net.Conn`、JS `Conn`、C 句柄或移动端 `Socket` |
| 范围 | 宿主网络上的应用连接 | 归某一台 Device 所有的应用连接 |
| 这条连接是否需要系统 VPN | 取决于操作系统路由如何配置 | 仅为调用 SDK 套接字，不需要任何操作系统 TUN/VPN 接口 |
| 本地地址 | 宿主接口/地址空间 | Device 用户态栈中的虚拟地址 |
| 关闭 Device | 没有关系 | 关闭它的 SDK 连接 |
| 套接字选项与监听 | 依平台而定的操作系统 API | 仅限出站连接；没有文件描述符、任意套接字选项或监听 API |

虚拟的 `localAddr` 不是提供者的公网出口 IP，绑定到它也不会让你的应用能从互联网访问。底层的 URnetwork 连接和提供者仍然可以使用操作系统的网络功能。SDK 套接字本身不会改变 Device 已配置的路由模式，也不会自行建立提供者连接。

TLS 和 DTLS 对应用到其目的地的连接进行加密。普通 TCP 和 UDP 保留应用的明文协议；URnetwork 到提供者的传输加密，并不会把那个协议变成到目的地的 TLS。

## 网络类型与主机名

用以下网络类型之一拨号 `host:port`：

| 网络类型 | 行为 |
| --- | --- |
| `tcp` | TCP；对主机名使用 IPv4/IPv6 Happy Eyeballs |
| `tcp4`、`tcp6` | 限定于该地址族的 TCP |
| `udp` | UDP；双地址族的主机名通过让第一个数据报/回复竞速来选定对端 |
| `udp4`、`udp6` | 限定于该地址族的 UDP |

IPv6 字面地址要加方括号，例如 `[2001:db8::10]:443`。主机名解析使用 Device 的套接字解析器和数据包路径。没有可用的提供者时，它不会回落到经由宿主机直接解析或打开目的地。

TCP 的 Happy Eyeballs 让多个连接尝试竞速。TLS/DTLS 拨号器在某个地址族的安全握手成功之后才选定该地址族。显式的地址族名称和字面地址会绕过双地址族竞速。

### UDP：可能的重复投递

UDP 没有连接握手。对同时有 A 和 AAAA 记录的 `udp`，第一次写入先发往 IPv6，若 250 ms 内没有回复，再发往 IPv4。IPv6 写入立即失败会让回落更早开始。**最初的那个应用数据报可能同时到达两个地址。**第一个回复选定对端，之后的写入只发往那个对端。

当重复投递会造成影响时，使用请求 ID 或其他应用层的去重机制。第一次写入成功，既不能证明已经送达，也不会选定对端。如果服务器从不回复，之后的写入会一直等待对端选定，直到它们的截止时间到达或连接关闭。对只发不收的协议，使用 `udp4`、`udp6` 或字面 IP。零字节的回复也是有效回复。

每次 UDP 写入就是一个数据报。每次读取消费一个数据报；读缓冲区太小，剩余的字节会被丢弃。空数据报是数据，不是 EOF。让消息保持在目的地协议和路径的限制之内。

## Go

`DeviceLocal` 和 `DeviceRemote` 提供 `Dial`、`DialContext`、`DialTls` 和 `DialTlsContext`。普通的拨号方法与 Go 的 `net.Dialer` 签名相同，并返回一个与 `net.Conn` 兼容的连接。

```go
// device is an initialized sdk.Device.
conn, err := device.DialContext(ctx, "tcp", "example.com:80")
if err != nil {
    return err
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
    return err
}
_, err = conn.Write([]byte("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n"))
```

现有的 HTTP 客户端可以直接使用 Device：

```go
transport := &http.Transport{DialContext: device.DialContext}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 20 * time.Second}
// net/http performs normal HTTPS certificate verification above this dialer.
```

要直接加密，调用 `device.DialTlsContext(ctx, "tcp", "example.com:443", nil)`。TCP 使用 TLS；UDP 使用 DTLS 1.2。TLS 配置为 nil 时，使用常规的证书校验。原始主机名提供默认的服务器名称。DTLS 支持的配置面比 Go TLS 小，并会拒绝不支持的安全选项。

读和写可能在返回错误的同时返回部分数据；要处理已返回的字节。拨号的 context 控制的是连接的建立，而不是一条已成功建立的连接的生命周期。为后续 I/O 设置绝对截止时间，并用 `time.Time{}` 清除它们。用完后关闭连接。建立连接/安全握手的默认时限是 30 秒，调用方给出更早的截止时间时会相应缩短。

## JavaScript 与 TypeScript

SDK 的 Device 封装提供基于 Promise 的 `dial` 和 `dialTls` 方法。浏览器页面使用一台已配置、支持套接字的远程 Device。SDK 通过 WASM 暴露这条远程连接；它不需要浏览器原生的 Direct Sockets 支持，也不需要打包成 Isolated Web App。

```js
const conn = await device.dialTls("tcp", "example.com:443", undefined, {
  timeoutMillis: 5000,
});
try {
  await conn.setDeadline(Date.now() + 5000);
  await conn.write(new TextEncoder().encode(
    "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n"
  ));
  for (let chunk; (chunk = await conn.read()) !== null;) {
    console.log(new TextDecoder().decode(chunk));
  }
} finally {
  await conn.close();
}
```

`read()` 返回一个 `Uint8Array`，遇到 EOF 时返回 `null`。空数组是一个有效的 UDP 数据报。`readable` 和 `writable` 提供 Web Streams 适配器；每个方向要么用直接方法，要么用适配器，二者择一。在支持的情况下，关闭 TCP 流会发送写端 FIN。显式调用 `close()` 会关闭整条连接。

用 `AbortSignal` 取消打开过程。已建立的套接字使用截止时间和关闭。截止时间方法接受纪元毫秒数、一个 `Date`，或用 `null` 清除。部分写入的错误包含 `bytesWritten`；部分读取的数据会先返回，与之相伴的错误则在下一次直接读取时返回。拥有套接字的远程服务断开时，套接字流量随之停止；重连之后不会重放。

### Direct Sockets API

对 UR Device 使用 Direct Sockets 的构造函数签名：

```js
const {TCPSocket, UDPSocket} = device.directSockets;
const socket = new TCPSocket("echo.example", 9000);
const {readable, writable} = await socket.opened;
const reader = readable.getReader({mode: "byob"});
const writer = writable.getWriter();
try {
  await writer.write(new TextEncoder().encode("hello"));
  await writer.close();
  console.log(await reader.read(new Uint8Array(1024)));
} finally {
  await Promise.allSettled([reader.cancel(), writer.abort()]);
  reader.releaseLock(); writer.releaseLock();
  await socket.close();
  await socket.closed;
}
```

对于 UDP，构造 `new UDPSocket({remoteAddress: "echo.example", remotePort: 9001})`。先等待 `opened`，然后写入 `{data: new Uint8Array([1, 2, 3])}`，并读取 `value.data`。每个对象就是一个数据报，零字节的数据报也不例外。已连接 UDP 的消息省略远程地址字段，并拒绝逐条消息的目的地覆盖。

导出的 `createDirectSockets(device)` 工厂函数也返回这些构造函数。两种接口都有 `opened` 和 `closed` 两个 promise，以及异步的 `close()`。TCP 支持默认读取器和 BYOB 读取器，以及 BufferSource 写入。关闭它的可写流会发送 FIN，而读取仍然可用。关闭套接字之前，先取消/中止挂起的操作，并释放读取器/写入器的锁；否则 `close()` 会以 `InvalidStateError` 拒绝。网络故障以 `NetworkError` 拒绝。

Chrome 的原生 API 可供 Isolated Web App 使用。SDK 的实现经由已配置的 Device，在普通浏览器和 Node 中都能工作，不需要 IWA 包，也不需要原生 Direct Sockets 权限。它不会替换浏览器的全局对象。参见 [Chrome 文档](https://developer.chrome.com/docs/iwa/direct-sockets)和 [Direct Sockets 提案](https://wicg.github.io/direct-sockets/)。

本发布版实现了出站 TCP 和已连接 UDP。用 `dnsQueryType: "ipv4"` 或 `"ipv6"` 限定解析；省略时保留 Device 的 Happy Eyeballs。绑定 UDP、多播、监听，以及逐套接字的缓冲区/no-delay/keep-alive 调优都不可用，对这些功能的有效请求会以 `NotSupportedError` 失败。这是一个客户端兼容性子集，不是完整的浏览器 API。

对双地址族的 UDP 名称，`opened` 在发送之前就会兑现。它的端点字段起初描述的是第一个候选地址，在消费第一个回复之后更新。**最初的数据报可能同时到达两个地址。**如果在打开时就需要一个固定的端点，请选择字面 IP 或某个 DNS 地址族。示例用一个计时器来取消停滞的流 I/O；另外的 `Conn` API 也提供截止时间。

Direct Sockets 构造函数打开的是普通 TCP/UDP 连接。TLS/DTLS 请用 `dialTls`。可运行的 [JavaScript Node/浏览器示例](/docs/examples/javascript)和 [TypeScript 示例](/docs/examples/typescript)包括 TCP/UDP 回显、超时、清理以及 HTTP 库集成。

## 原生绑定与支持的平台

| 绑定 | 套接字接口与平台 |
| --- | --- |
| Go | 在 SDK 支持的 Go 目标平台上提供 `net.Conn` 行为 |
| Android Kotlin/Java | AAR 中可移植的 `OpenSocket`/`Socket`；Android API 24+，适用于随附的各 ABI |
| Apple Swift | XCFramework 中可移植的 `OpenSocket`/`Socket`；iOS 16+ 和 macOS 13.5+，适用于随附的真机/模拟器切片 |
| C/C++ 及其他 FFI 客户端 | 面向 amd64/arm64 上 Windows 10+ 和 Linux glibc 2.35+ 的 C ABI；另有供开发使用的 macOS 宿主构建 |
| 浏览器与 Node 的 JS/TS | WASM、Conn 和 Direct Sockets Web Streams，外加一个可用的、支持套接字的 Device RPC 端点 |

移动端调用方使用 `OpenSocket(network, address, timeoutMillis, tlsOptions)`。TLS 选项为 nil 时请求普通连接；非 nil 的配置请求 TLS/DTLS。`Socket.Read` 返回一个带有 `Data` 和 `Eof` 的结果；截止时间方法接受纪元毫秒数，传零表示清除。阻塞操作请放到工作线程或合适的协程调度器上执行。

C 调用方使用 `urnet_device_dial` / `urnet_device_dial_tls`，随后是 `urnet_conn_read`、`urnet_conn_write` 以及截止时间/关闭函数。读取返回一个字节数和一个单独的 EOF 标志。它会立即消费数据；它不是对输出缓冲区大小的查询。即使返回了错误字符串，也要处理部分字节数。用 `urnet_free_string` 释放错误/地址字符串，用 `urnet_release` 释放连接句柄，这一步也会关闭该连接。

SDK 目前提供客户端连接。服务器/监听套接字将作为单独的新增功能推出，并带有明确的可达性和所有权语义。绑定/构建模型见 [SDK 导览](/docs/tour-sdk)，Device 设置见[入门指南](/docs/getting-started-sdk)。
