# wsbase 接入说明

> 房间化 WebSocket 基础服务：配对、1-1 / 1-N 消息、文件分片、CDP 透明管道。
> 业务只决定「谁进哪个房间、帧里是什么」；连接、路由、鉴权、心跳由本服务承担。

- 人类：读本页，或打开 [`/`](/) 的渲染版。
- 机器 / AI：优先读 **本文件** [`/index.md`](/index.md)（`Content-Type: text/markdown`），目录见 [`/llms.txt`](/llms.txt)。
- 纯前端默认 **无 SDK**：浏览器 `fetch` + `WebSocket`。演示 [`/pairing/`](/pairing/) 也是无 SDK。

## 基址

令 `{origin}` = 当前站点 origin（本文件若从本服务取出，就是你正在请求的 host）。

生产：

| 项 | 值 |
| --- | --- |
| HTTP | `https://wsbase.yangyongan.com` |
| Hub WebSocket | `wss://wsbase.yangyongan.com/v1/ws` |
| 默认 `app` | `demo`（`auth=open`，除非服务端改了配置） |

规则：`http` → `ws`，`https` → `wss`。纯前端自己替换；若用 SDK，传入 `https://…` 也会自动改成 `wss://…`。

探活：

```http
GET {origin}/v1/health
```

```json
{"ok":true,"service":"wsbase"}
```

## 两种房间（不要混用）

| | Hub | Pipe |
| --- | --- | --- |
| 路径 | `GET /v1/ws` | `GET /v1/pipe/{room}` |
| 帧 | JSON 信封 + 可选带头二进制 | **原样转发**，服务端不解析 |
| 路由 | `to`: `*` / peer id / `@role` | 房间内其它连接各收一份 |
| 场景 | IM、扫码配对、指令、文件 | Chrome DevTools Protocol、任意已有 WebSocket 协议 |
| 创建 | `POST /v1/rooms` 默认 `kind=hub` | `{"kind":"pipe"}` |

同一房间 `kind` 创建时定死。Hub 房间不能走 pipe URL，反之亦然。房间按 `app_id` 隔离：两个 app 可以用相同房间名，互不可见。

## 5 分钟（纯前端，无 SDK）

**不必安装 npm 包。** 任意静态页 / Vue / React 用浏览器自带的 `fetch` + `WebSocket` 即可。完整可运行示例：[`/pairing/`](/pairing/)（源码 `examples/pairing/index.html`）。

约束：页面必须是 `https`（才能连 `wss`）；跨域可以（CORS `*`，WebSocket 不校验 Origin）。

```html
<script type="module">
const origin = 'https://wsbase.yangyongan.com'
const app = 'demo'

const created = await fetch(origin + '/v1/rooms', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-App-Id': app },
  body: JSON.stringify({ kind: 'hub', capacity: 2, ttl: '1h' }),
}).then((r) => r.json())
// created.id / created.ws / created.pipe / created.join_url

const ws = new WebSocket(
  origin.replace(/^http/, 'ws') + '/v1/ws?app=' + encodeURIComponent(app)
)
ws.binaryType = 'arraybuffer'

ws.onmessage = (ev) => {
  if (typeof ev.data !== 'string') return // 二进制见下文「二进制帧」
  const msg = JSON.parse(ev.data)
  if (msg.op === 'hello') {
    ws.send(JSON.stringify({
      v: 1, op: 'join', room: created.id, meta: { role: 'pc' },
    }))
    return
  }
  if (msg.op === 'ok') console.log('已进房', msg.peer_id, msg.peers)
  if (msg.op === 'msg') console.log(msg.from, msg.data)
  if (msg.op === 'peer.join') console.log('上线', msg.peer)
  if (msg.op === 'error') console.error(msg.code, msg.message)
}

function send(data, to = '*') {
  ws.send(JSON.stringify({ v: 1, op: 'msg', room: created.id, to, data }))
}
// send({ cmd: 'shot' }, '@mobile')
</script>
```

顺序不能反：连上先收到 `hello`，再 `join`，看到 `ok` 后再 `msg`。对方用同一个 `created.id` `join` 即可。

`to`：`*` 广播（不含自己）、peer id 单播、`@mobile` 匹配对方 `meta.role`。

鉴权：当前默认 `app=demo` 为 open，不传 token。若服务端改成 token/hmac，在 URL 加 `&token=…`，或头 `Authorization: Bearer` / `X-App-Secret`。

Pipe 同样无 SDK：`POST /v1/rooms` 带 `{"kind":"pipe"}`，两端都 `new WebSocket(created.pipe)`，帧原样收发，没有 JSON 信封。

可选封装：`npm install @wsbase/sdk`，见下文「TypeScript SDK」。

## HTTP API

凭证见「鉴权」。未注明的 JSON 字段可省略。

| 方法 | 路径 | 作用 |
| --- | --- | --- |
| `GET` | `/health` | 探活（同 `/v1/health`） |
| `GET` | `/v1/health` | 探活 |
| `GET` | `/v1/stats` | 房间数 / 连接数 |
| `POST` | `/v1/rooms` | 建房 |
| `GET` | `/v1/rooms/{id}` | 在场 peer |
| `GET` | `/v1/ws` | Hub WebSocket 升级 |
| `GET` | `/v1/pipe/{id}` | Pipe WebSocket 升级 |
| `GET` | `/v1/cdp/{id}/json` | Chrome `/json` 列表（同 list） |
| `GET` | `/v1/cdp/{id}/json/list` | Chrome target 列表 |
| `GET` | `/v1/cdp/{id}/json/version` | Chrome `/json/version` |
| `GET` | `/` | 本说明的 HTML |
| `GET` | `/index.md` | 本说明的 Markdown |
| `GET` | `/llms.txt` | 给 LLM 的目录 |
| `GET` | `/pairing/` | 扫码配对演示 |

CORS：允许 `*`，预检 `OPTIONS`。允许头：`Authorization, Content-Type, X-App-Id, X-App-Secret`。

### `POST /v1/rooms`

请求：

```json
{
  "id": "",
  "kind": "hub",
  "capacity": 2,
  "ttl": "1h",
  "meta": { "purpose": "pairing" }
}
```

| 字段 | 说明 |
| --- | --- |
| `id` | 省略则服务端生成（12 字节随机，约 16 字符）。open 模式下知道 id ≈ 门票 |
| `kind` | `hub`（默认）或 `pipe` |
| `capacity` | `2` = 一对一。`0` / 省略 = 不限制 |
| `ttl` | Go duration，空房保留时间，默认 `1h` |
| `meta` | 任意 JSON，原样出现在响应里 |

响应：

```json
{
  "id": "sorEuJfNIC77L3H5",
  "app": "demo",
  "kind": "hub",
  "ws": "wss://wsbase.yangyongan.com/v1/ws?app=demo",
  "pipe": "wss://wsbase.yangyongan.com/v1/pipe/sorEuJfNIC77L3H5?app=demo",
  "join_url": "https://wsbase.yangyongan.com/pairing/?room=sorEuJfNIC77L3H5&app=demo",
  "meta": { "purpose": "pairing" }
}
```

`kind=pipe` 时多一个 `cdp`：`{origin}/v1/cdp/{id}/json/list`。响应里的 `ws` / `pipe` / `join_url` 会看 `X-Forwarded-Proto` 和 `Host`，反代后面应是 `https` / `wss`。

### `GET /v1/rooms/{id}`

返回该 app 下房间的在场 peer。没有则 `404`。

## 鉴权

每个业务一个 `app` id。凭证放在下列任一处即可（优先级：`X-App-Secret` → `Authorization: Bearer` → query `token`）：

- Query：`?app=demo&token=…`
- 头：`X-App-Id`、`X-App-Secret`、`Authorization: Bearer …`

`app` 省略时用服务端 `default_app`（本部署默认 `demo`）。

服务端按 app 配置四种模式：

| `auth` | 何时用 | 客户端做什么 |
| --- | --- | --- |
| `open` | 开发、内网、扫码配对 | 不传 token。房间号即门票 |
| `token` | 静态口令 | `token` = 配置里的字符串 |
| `hmac` | 生产 | `wsbase token --app prod --secret S --ttl 24h` 签发，形如 `wsb1.<payload>.<mac>`；可 `--room` 绑死房间 |
| `webhook` | 接自己的登录 | `token` 交给你的系统。服务端 POST `{app,token,room?}`，要 `{ok:true, room?, meta?}` |

HMAC 签发：

```bash
wsbase token --app prod --secret 'app-secret' --ttl 24h
# 可选：--room R   只允许进这个房间
```

未知 app → `401 unknown app`。坏 token / 过期 → `401`。

## Hub 协议（`/v1/ws`）

升级成功后服务端先发 `hello`：

```json
{ "v": 1, "op": "hello", "peer_id": "p_xxxx" }
```

之后文本帧均为 JSON 信封。`v` 可省略，服务端当 `1`。

### 客户端 → 服务端

```json
{ "v": 1, "op": "join", "room": "r1", "id": "m_1", "meta": { "role": "pc" } }
{ "v": 1, "op": "msg",  "room": "r1", "to": "*", "data": { "cmd": "shot" } }
{ "v": 1, "op": "msg",  "to": "p_xxxx", "data": { "text": "hi" } }
{ "v": 1, "op": "msg",  "to": "@mobile", "data": { "cmd": "shot" } }
{ "v": 1, "op": "leave", "room": "r1" }
{ "v": 1, "op": "ping", "id": "m_2" }
```

| 字段 | 说明 |
| --- | --- |
| `op` | `join` / `leave` / `msg` / `ping` |
| `id` | 可选，服务端 `ok` / `error` 原样带回 |
| `room` | `join` 必填；`msg` 已进房后也可靠连接上下文 |
| `to` | `*` 广播（**不含自己**）；peer id 单播；`@role` 匹配 `meta.role` |
| `meta` | `join` 时成为自己的 peer 元数据。约定 `role`：`pc` / `mobile` / `debugger` … |
| `data` | 任意 JSON。服务端不解释 |

一条连接可 `join` 多个房间。

### 服务端 → 客户端

| `op` | 含义 |
| --- | --- |
| `hello` | 刚连上，带 `peer_id` |
| `ok` | `join` 成功：`room`、`peer_id`、`peers`（已在场的其它人，不含自己） |
| `error` | `code` + `message` |
| `msg` | 别人发来的消息。`from` 由服务端填写，客户端改不了 |
| `peer.join` / `peer.leave` | `peer`: `{id, meta?}` |
| `pong` | 对应 `ping` 的 `id` |

### 二进制帧

WebSocket **binary** message，不是 JSON：

```
[u16be 头长度][头 JSON，op 必为 bin][payload]
```

头 JSON 与文本信封相同字段（`room` / `to` / `name` / `id` / `seq` / `last` / `size`）。服务端只改 `from`、`room`，payload 原样转发。

文件约定（SDK `sendFile`）：按 ≤64KiB 切片，同一 `id`，`seq` 从 0，最后一片 `last: true`。接收方按到达顺序拼接，`last` 时得到完整 Blob。服务端不拆包、不落盘。

## Pipe / CDP（`/v1/pipe/{id}`）

两端都连同一个 `pipe` URL。帧不做信封。建议 **target 先上线，再让 debugger 连**——不缓存离线帧。无 SDK：

```js
const info = await fetch(origin + '/v1/rooms', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ kind: 'pipe', capacity: 2 }),
}).then((r) => r.json())

const ws = new WebSocket(info.pipe) // 已带 app query
ws.binaryType = 'arraybuffer'
ws.onmessage = (ev) => { /* string | ArrayBuffer，原样 */ }
ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression: '1+1' } }))
```

Chrome 发现：

```http
GET {origin}/v1/cdp/{id}/json/list
```

返回项里的 `webSocketDebuggerUrl` 就是 pipe 地址。可填进 DevTools / `chrome-devtools-frontend`。

自建 `WebSocket` 时设 `binaryType = 'arraybuffer'`，否则二进制会变成 Blob。

## TypeScript SDK（可选）

纯前端不必装。若希望少手写信封 / 文件分片：

```bash
npm install @wsbase/sdk
```

```ts
import { Hub, Pipe } from '@wsbase/sdk'

const hub = new Hub('https://wsbase.yangyongan.com', { app: 'demo' })
const created = await hub.createRoom({ capacity: 2, ttl: '1h' })
const room = await hub.join(created.id, { role: 'pc' })
room.send({ cmd: 'shot' }, '@mobile')
room.on('message', (m) => console.log(m.from, m.data))
room.on('file', (f) => { /* f.name, f.blob */ })
```

ESM，Node ≥ 18 或现代浏览器。完整 API：仓库 [`sdk/js/README.md`](https://gitee.com/YangYongAn/wsbase/blob/main/sdk/js/README.md)。

SDK 里的 `encodeText` / `decodeBinary` 只是同一套信封的编解码；无 SDK 时按「Hub 协议」自己 `JSON.stringify` 即可。

## Go 客户端 / 嵌进现有进程

Go 客户端：同一仓库 `pkg/client`（`DialHub` / `Join` / `Send` / `SendBinary`）。

嵌进已有 Go 进程：

```go
srv := wsbase.New(wsbase.Config{
    Listen: ":8787",
    Apps: []wsbase.App{
        {ID: "my-app", Auth: wsbase.AuthHMAC, Secret: os.Getenv("WSBASE_SECRET")},
    },
})
http.ListenAndServe(":8787", srv.Handler())
```

## 限制（v1，不要假设有这些能力）

- 不自动重连，不保证断线补发。断线后重新 `connect` + `join`。
- 消息不落盘、无离线漫游。
- 单进程内存路由，没有跨节点 Redis 广播。
- Hub 写缓冲满时会丢聊天帧；管道满则断开慢消费者。文件请控制并发。
- 不是 Socket.IO，没有默认 ACK / 压缩。
- `join_url` 指向 `/pairing/?room=…`。旧链接 `/?room=…` 会 302 到演示页。

## 源码

- 服务端 / 协议：[gitee.com/YangYongAn/wsbase](https://gitee.com/YangYongAn/wsbase)
- TS SDK：[sdk/js](https://gitee.com/YangYongAn/wsbase/blob/main/sdk/js)
