mc-tunnel 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +37 -3
  2. package/dist/cjs/chinese-code.js +148 -0
  3. package/dist/cjs/chinese-code.js.map +1 -0
  4. package/dist/cjs/constants.js +1 -1
  5. package/dist/cjs/data/bip39-zh.js +16 -0
  6. package/dist/cjs/data/bip39-zh.js.map +1 -0
  7. package/dist/cjs/data/zh-variants.js +16 -0
  8. package/dist/cjs/data/zh-variants.js.map +1 -0
  9. package/dist/cjs/errors.js.map +1 -1
  10. package/dist/cjs/guest.js +11 -1
  11. package/dist/cjs/guest.js.map +1 -1
  12. package/dist/cjs/host.js +10 -3
  13. package/dist/cjs/host.js.map +1 -1
  14. package/dist/cjs/index.js +6 -1
  15. package/dist/cjs/index.js.map +1 -1
  16. package/dist/cjs/passphrase.js +89 -0
  17. package/dist/cjs/passphrase.js.map +1 -0
  18. package/dist/cjs/room-code.js +47 -0
  19. package/dist/cjs/room-code.js.map +1 -1
  20. package/dist/esm/chinese-code.js +140 -0
  21. package/dist/esm/chinese-code.js.map +1 -0
  22. package/dist/esm/constants.js +1 -1
  23. package/dist/esm/data/bip39-zh.js +13 -0
  24. package/dist/esm/data/bip39-zh.js.map +1 -0
  25. package/dist/esm/data/zh-variants.js +13 -0
  26. package/dist/esm/data/zh-variants.js.map +1 -0
  27. package/dist/esm/errors.js.map +1 -1
  28. package/dist/esm/guest.js +11 -1
  29. package/dist/esm/guest.js.map +1 -1
  30. package/dist/esm/host.js +10 -3
  31. package/dist/esm/host.js.map +1 -1
  32. package/dist/esm/index.js +1 -0
  33. package/dist/esm/index.js.map +1 -1
  34. package/dist/esm/passphrase.js +83 -0
  35. package/dist/esm/passphrase.js.map +1 -0
  36. package/dist/esm/room-code.js +41 -0
  37. package/dist/esm/room-code.js.map +1 -1
  38. package/dist/types/chinese-code.d.ts +27 -0
  39. package/dist/types/constants.d.ts +1 -1
  40. package/dist/types/data/bip39-zh.d.ts +12 -0
  41. package/dist/types/data/zh-variants.d.ts +12 -0
  42. package/dist/types/errors.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -0
  44. package/dist/types/passphrase.d.ts +26 -0
  45. package/dist/types/room-code.d.ts +12 -0
  46. package/dist/types/types.d.ts +12 -2
  47. package/docs/USAGE.md +1220 -0
  48. package/package.json +12 -3
package/docs/USAGE.md ADDED
@@ -0,0 +1,1220 @@
1
+ # mc-tunnel 使用文档
2
+
3
+ 面向 **Electron / Node.js** 的 Minecraft 局域网联机模块。房主在游戏里「对局域网开放」,把**房间码**发给朋友;朋友输入房间码后,游戏「多人游戏 → 局域网世界」里就会出现这条房间,双击即可进入。
4
+
5
+ 本文档按「摸清概念 → 跑起来 → 查 API → 排错」的顺序组织,**所有参数默认值、端口、超时、事件时机都来自当前源码**(v0.1.1),可直接对照排查。
6
+
7
+ ---
8
+
9
+ ## 目录
10
+
11
+ 1. [它能做什么 / 不能做什么](#1-它能做什么--不能做什么)
12
+ 2. [工作原理](#2-工作原理)
13
+ 3. [环境与依赖](#3-环境与依赖)
14
+ 4. [安装与 EasyTier 准备](#4-安装与-easytier-准备)
15
+ 5. [五分钟上手](#5-五分钟上手)
16
+ 6. [房主 API(createHost)](#6-房主-apicreatehost)
17
+ 7. [客人 API(createGuest)](#7-客人-apicreateguest)
18
+ 8. [事件与状态机](#8-事件与状态机)
19
+ 9. [房间码与凭据](#9-房间码与凭据)
20
+ 10. [协调协议(HTTP 接口)](#10-协调协议http-接口)
21
+ 11. [工具函数参考](#11-工具函数参考)
22
+ 12. [Electron 集成](#12-electron-集成)
23
+ 13. [网络部署模式](#13-网络部署模式)
24
+ 14. [错误码与故障排查](#14-错误码与故障排查)
25
+ 15. [生命周期与进程管理](#15-生命周期与进程管理)
26
+ 16. [安全模型](#16-安全模型)
27
+ 17. [兼容性 / 已知限制](#17-兼容性--已知限制)
28
+ 18. [性能与资源占用](#18-性能与资源占用)
29
+ 19. [附录:类型与默认值速查](#19-附录类型与默认值速查)
30
+
31
+ ---
32
+
33
+ ## 1. 它能做什么 / 不能做什么
34
+
35
+ ### 能做
36
+
37
+ | 场景 | 说明 |
38
+ |---|---|
39
+ | 公网联机 | 双方各自在任意网络(家庭宽带、4G/5G、公司网),凭一个 19 字符房间码互联 |
40
+ | 局域网直连 | 同一局域网内用 `peers` 直连,完全绕开公共节点,无需外网 |
41
+ | 自建节点 | 用 `listeners` + `peers` 指向自建 EasyTier 节点,`p2pOnly: false` 可走中继 |
42
+ | 免管理员权限 | EasyTier 以 `--no-tun` 运行,不创建虚拟网卡,普通用户即可 |
43
+ | 无 mod | 通过伪造 Minecraft 局域网多播,原版客户端直接可见 |
44
+ | Electron 桌面应用 | 提供 `mc-tunnel/electron` 子入口,自动处理 userData 路径与退出清理 |
45
+
46
+ ### 不能做
47
+
48
+ - **不是 Minecraft 服务端**:它只做网络打通 + 广播伪造,Minecraft 本身要房主自己开。
49
+ - **不打包 EasyTier 二进制**:体积与许可原因,需要你自己放置 `easytier-core` / `easytier-cli`。
50
+ - **不支持 IPv6 多播**:扫描与广播均只实现 `udp4`(IPv4)。
51
+ - **不支持同一台机器开多个房主**:协调端口默认固定 `25991`,第二个房主会失败。
52
+ - **不提供中继服务器**:默认 `p2pOnly: true`,对称 NAT 双端可能连不上(见 §13)。
53
+ - **不修改游戏、不需要客户端装 mod**,但要求 MC 版本支持「对局域网开放」(1.3+ 全部支持)。
54
+
55
+ ---
56
+
57
+ ## 2. 工作原理
58
+
59
+ ```
60
+ 房主(Host) 客人(Guest)
61
+ ──────────── ────────────
62
+ ① 监听 UDP 4445 多播,发现本机 MC 端口 ① 房间码 → 派生网络名 / 网络密钥
63
+ (或直接传 mcPort 跳过) ↓
64
+ ↓ ② easytier-core 以 DHCP 入网
65
+ ② easytier-core 固定虚拟 IP 10.144.144.1 ↓
66
+ ↓ ③ 轮询 peer 列表,等 10.144.144.1 出现
67
+ ③ 本地 HTTP 协调服务 0.0.0.0:25991 ↓
68
+ (/v1/hello /v1/session /v1/players) ④ 端口映射 tcp://0.0.0.0:<随机> → 10.144.144.1:25991
69
+ ↓ ↓
70
+ ④ 提供 session:返回 MC 端口与玩家列表 ⑤ GET /v1/hello 校验 probe(确认对端真是房主)
71
+ ↓
72
+ ⑥ GET /v1/session(带 x-mct-token)拿到 MC 端口
73
+ ↓
74
+ ⑦ 端口映射 tcp/udp 0.0.0.0:<localPort> → 10.144.144.1:<mcPort>
75
+ ↓
76
+ ⑧ 本地 ping 127.0.0.1:<localPort> 确认可用
77
+ ↓
78
+ ⑨ 每 1.5s 伪造多播「局域网世界」,端口写 localPort
79
+ ↓
80
+ ⑩ 每 5s POST /v1/players 心跳 + 刷新玩家列表
81
+ ```
82
+
83
+ 关键设计取舍:
84
+
85
+ 1. **房主虚拟 IP 固定** `10.144.144.1`(EasyTier 默认网段 `10.144.144.0/24`)。这样客人不需要任何发现协议,直接按地址连协调服务。
86
+ 2. **协调协议用 HTTP/JSON**,跑在隧道内部,`curl` 就能调试,不引入任何 npm 依赖。
87
+ 3. **房间码自包含凭据**:网络名、网络密钥、握手探针、访问令牌全部由房间码里的 64 bit 种子经 HMAC-SHA256 派生,房间码本身不含明文。
88
+ 4. **端口白名单**:房主 `--tcp-whitelist` 只放行协调端口 + MC 端口,客人 `--tcp-whitelist=0`(不提供任何服务)。
89
+
90
+ ---
91
+
92
+ ## 3. 环境与依赖
93
+
94
+ | 项 | 要求 | 说明 |
95
+ |---|---|---|
96
+ | Node.js | **>= 18.17** | 需要内置 `fetch`(18.0+ 实验性)与 `AbortSignal.timeout`(17.3+);`node --test` 跑测试需要 18+ |
97
+ | 操作系统 | Windows / macOS / Linux | Windows 下二进制名自动补 `.exe` |
98
+ | 运行时 npm 依赖 | **零** | 只用 `node:http` `node:dgram` `node:net` `node:child_process` `node:crypto` `node:os` `node:fs` `node:path` |
99
+ | 权限 | 普通用户 | 不开 TUN 网卡,不需要管理员 / root |
100
+ | 网络 | 出站 UDP 可用 | EasyTier 需要 UDP 打洞;公司网封 UDP 时需 `p2pOnly: false` + 自建 TCP 节点 |
101
+ | EasyTier | 自备二进制 | `easytier-core` + `easytier-cli`,两者放在同一目录 |
102
+
103
+ ### 运行时端口一览
104
+
105
+ | 端口 | 协议 | 谁监听 | 用途 | 是否可改 |
106
+ |---|---|---|---|---|
107
+ | `4445` | UDP 多播 `224.0.2.60` | 读:房主扫描 / 写:客人广播 | Minecraft 局域网发现 | 不可改(MC 固定) |
108
+ | `25991` | TCP | 房主 | 协调服务 | `HostOptions.coordPort` 可改,但**客人侧硬编码 25991**,见 §17 |
109
+ | 随机高位端口 | TCP | 房主 / 客人 | EasyTier RPC(`-r`) | 内部自动分配 |
110
+ | `11010` 等 | TCP/UDP | 双方 | EasyTier 监听(默认 `0.0.0.0:0` 随机,可加 `listeners`) | 可配置 |
111
+ | `25565` 或房主实际端口 | TCP+UDP | 客人 | 本地映射端口 `localPort` | `preferredLocalPort` |
112
+ | 随机高位端口 | TCP | 客人 | 协调服务临时映射 | 内部自动分配 |
113
+
114
+ ---
115
+
116
+ ## 4. 安装与 EasyTier 准备
117
+
118
+ ### 4.1 安装本包
119
+
120
+ ```bash
121
+ npm install mc-tunnel
122
+ # 国内镜像
123
+ npm install mc-tunnel --registry=https://registry.npmmirror.com
124
+ ```
125
+
126
+ 包同时提供 ESM 与 CJS:
127
+
128
+ ```ts
129
+ // ESM / TypeScript
130
+ import { createHost, createGuest } from 'mc-tunnel';
131
+ ```
132
+
133
+ ```js
134
+ // CommonJS
135
+ const { createHost, createGuest } = require('mc-tunnel');
136
+ ```
137
+
138
+ 子路径:
139
+
140
+ ```ts
141
+ import { createHostInElectron, manageSessions } from 'mc-tunnel/electron'; // Electron 适配
142
+ import { toChineseRoomCode } from 'mc-tunnel/zh'; // 中文房间码(彩蛋,可选)
143
+ ```
144
+
145
+ ### 4.2 放置 EasyTier 二进制
146
+
147
+ 需要两个文件(Windows 带 `.exe`):
148
+
149
+ ```
150
+ easytier-core(.exe)
151
+ easytier-cli(.exe)
152
+ ```
153
+
154
+ **查找顺序**(`resolveBinaries()` 实现,命中即用):
155
+
156
+ 1. `easyTier.binDir` —— 可以是**目录**,也可以是 `easytier-core` 的**完整文件路径**(会自动取父目录)
157
+ 2. 环境变量 `MC_TUNNEL_EASYTIER` —— 同上,目录或文件路径
158
+ 3. `<process.cwd()>/easytier`
159
+ 4. `<process.resourcesPath>/easytier` —— 仅在 Electron 打包后存在
160
+
161
+ 四个位置都没找到会抛:
162
+
163
+ ```
164
+ TunnelError: EASYTIER_NOT_FOUND
165
+ Cannot find easytier-core.exe / easytier-cli.exe. Tried:
166
+ <逐个列出的候选路径>
167
+ 请通过 easyTier.binDir 或环境变量 MC_TUNNEL_EASYTIER 指定目录。
168
+ ```
169
+
170
+ > 提示:只需保证 `core` 与 `cli` 在**同一目录**;`core` 子进程的 `cwd` 会被设为该目录。
171
+
172
+ ### 4.3 三种放置方式示例
173
+
174
+ ```bash
175
+ # 方式 1:环境变量(开发时最方便)
176
+ # Windows PowerShell
177
+ $env:MC_TUNNEL_EASYTIER = "D:\tools\easytier"
178
+ # macOS / Linux
179
+ export MC_TUNNEL_EASYTIER=/opt/easytier
180
+ ```
181
+
182
+ ```ts
183
+ // 方式 2:代码里显式指定
184
+ const host = await createHost({ mcPort: 25565, easyTier: { binDir: 'D:/tools/easytier' } });
185
+ ```
186
+
187
+ ```jsonc
188
+ // 方式 3:Electron 打包时放进 resources
189
+ // electron-builder 配置
190
+ {
191
+ "extraResources": [
192
+ { "from": "vendor/easytier", "to": "easytier" }
193
+ ]
194
+ }
195
+ ```
196
+
197
+ > ⚠️ 若使用 ASAR:**不要**把 EasyTier 打进 `asar`,ASAR 内的可执行文件无法 `spawn`。必须走 `extraResources`。
198
+
199
+ ---
200
+
201
+ ## 5. 五分钟上手
202
+
203
+ ### 5.1 房主
204
+
205
+ ```ts
206
+ import { createHost } from 'mc-tunnel';
207
+
208
+ const host = await createHost({
209
+ playerName: 'Alice',
210
+ mcPort: 25565, // 不传则自动扫描局域网广播(最多 30s)
211
+ });
212
+
213
+ console.log('房间码:', host.roomCode); // 2GJY-DYSK-8825-DZJY
214
+ console.log('MC 端口:', host.mcPort);
215
+ console.log('虚拟 IP:', host.virtualIp); // 10.144.144.1
216
+
217
+ host.on('player-join', (p) => console.log(`${p.name} 加入了`));
218
+ host.on('player-leave', (p) => console.log(`${p.name} 离开了`));
219
+ host.on('players', (list) => console.log('当前玩家:', list.map((x) => x.name).join(', ')));
220
+ host.on('mc-closed', ({ port }) => console.log(`端口 ${port} 上的存档已关闭`));
221
+ host.on('error', (err) => console.error('房主异常:', err.code, err.message));
222
+
223
+ console.log('状态:', host.phase); // ready
224
+ ```
225
+
226
+ 房主要做的事:**别关程序**。玩家退出存档时 `mc-closed` 会触发,但会话不会自动销毁(方便玩家重新开放)。
227
+
228
+ ### 5.2 客人
229
+
230
+ ```ts
231
+ import { createGuest } from 'mc-tunnel';
232
+
233
+ const guest = await createGuest({
234
+ roomCode: '2GJY-DYSK-8825-DZJY',
235
+ playerName: 'Bob',
236
+ });
237
+
238
+ console.log('在游戏里连接:', guest.address); // 127.0.0.1:25565
239
+ console.log('NAT 难度:', guest.natDifficulty); // easiest | simple | medium | tough | unknown
240
+
241
+ guest.on('server-status', (st) => console.log('房主存档:', st.version, st.players));
242
+ guest.on('players', (list) => console.log('房间里:', list.map((x) => x.name).join(', ')));
243
+ guest.on('error', (err) => console.error('客人异常:', err.code, err.message));
244
+ ```
245
+
246
+ 此时打开 Minecraft → 多人游戏 → **局域网世界**,就会出现一条房间(MOTD 默认 `§b§lMC Tunnel 联机房间(请保持程序运行)`),双击进入。
247
+
248
+ ### 5.3 一定要关闭
249
+
250
+ ```ts
251
+ await guest.close();
252
+ await host.close();
253
+ ```
254
+
255
+ 不调用 `close()` 会残留 `easytier-core` 子进程,端口也被占着。库里有兜底(宿主进程 `exit` 时 `kill`),但只对正常退出有效,`SIGKILL` / 任务管理器强杀无效。
256
+
257
+ ### 5.4 最小可运行脚本(两个文件、两台机器)
258
+
259
+ `host.mjs`:
260
+
261
+ ```js
262
+ import { createHost } from 'mc-tunnel';
263
+
264
+ const host = await createHost({ playerName: 'Alice' });
265
+ console.log('\n房间码(发给朋友):\n ' + host.roomCode + '\n');
266
+
267
+ process.on('SIGINT', async () => {
268
+ await host.close();
269
+ process.exit(0);
270
+ });
271
+ ```
272
+
273
+ `guest.mjs`:
274
+
275
+ ```js
276
+ import { createGuest } from 'mc-tunnel';
277
+
278
+ const guest = await createGuest({
279
+ roomCode: process.argv[2],
280
+ playerName: 'Bob',
281
+ });
282
+ console.log('\n在 Minecraft 里连接:\n ' + guest.address + '\n');
283
+
284
+ process.on('SIGINT', async () => {
285
+ await guest.close();
286
+ process.exit(0);
287
+ });
288
+ ```
289
+
290
+ ```bash
291
+ node host.mjs
292
+ node guest.mjs 2GJY-DYSK-8825-DZJY
293
+ ```
294
+
295
+ ---
296
+
297
+ ## 6. 房主 API(createHost)
298
+
299
+ ```ts
300
+ function createHost(options?: HostOptions): Promise<TunnelHost>;
301
+ ```
302
+
303
+ 返回 `TunnelHost`(`extends EventEmitter`,同时实现 `HostSession` 接口)。
304
+
305
+ ### 6.1 参数
306
+
307
+ | 参数 | 类型 | 默认 | 说明 |
308
+ |---|---|---|---|
309
+ | `roomCode` | `string` | 随机生成 | 指定房间码;非法时抛 `INVALID_ROOM_CODE`。与 `passphrase` 二选一 |
310
+ | `passphrase` | `string` | `undefined` | **口令模式**:用一段短语代替房间码(§9.5)。与 `roomCode` 二选一,同时给会抛 `INVALID_OPTIONS` |
311
+ | `mcPort` | `number` | 自动扫描 | 指定 MC 端口;**不传时监听 UDP 4445 多播自动发现** |
312
+ | `playerName` | `string` | `'Host'` | 昵称,会出现在玩家列表 `/v1/session` 里 |
313
+ | `motd` | `string` | `'§b§lMC Tunnel 联机房间(请保持程序运行)'` | 局域网列表显示名;**同时用于排除自己发出的广播** |
314
+ | `coordPort` | `number` | `25991` | 协调服务端口。⚠️ 客人侧硬编码 25991,改了客人连不上(§17) |
315
+ | `listeners` | `string[]` | `undefined` | 额外 EasyTier 监听,如 `["tcp://0.0.0.0:11010"]` |
316
+ | `peers` | `string[]` | `undefined` | 直连对端,如 `["tcp://192.168.1.7:11010"]`。**不能写 127.0.0.1** |
317
+ | `scanTimeoutMs` | `number` | `30000` | 自动发现 MC 的超时 |
318
+ | `dataDir` | `string` | `<tmp>/mc-tunnel` | 设备标识存储目录 |
319
+ | `easyTier.binDir` | `string` | 见 §4.2 | EasyTier 目录或 core 路径 |
320
+ | `easyTier.publicNodes` | `string[]` | `undefined` | 额外公共节点,如 `["tcp://public.easytier.top:11010"]` |
321
+ | `easyTier.p2pOnly` | `boolean` | `true`(未传即视为 true) | 是否只用 P2P、禁止中继 |
322
+
323
+ ### 6.2 只读属性
324
+
325
+ | 属性 | 类型 | 说明 |
326
+ |---|---|---|
327
+ | `roomCode` | `string` | 规范化后的房间码(含 `-` 分组、大写) |
328
+ | `mcPort` | `number` | 房主 MC 端口 |
329
+ | `coordPort` | `number` | 协调服务端口(实际生效值) |
330
+ | `virtualIp` | `string` | 恒为 `'10.144.144.1'` |
331
+ | `phase` | `Phase` | 当前阶段 |
332
+
333
+ ### 6.3 方法
334
+
335
+ | 方法 | 说明 |
336
+ |---|---|
337
+ | `players(): Player[]` | 当前玩家列表,**房主自己在第一位**,其余按 `id` 字典序。`close()` 后返回 `[]` |
338
+ | `close(): Promise<void>` | 幂等。停 watchdog → 关协调服务 → 停 EasyTier → 置 `closed` |
339
+
340
+ ### 6.4 事件
341
+
342
+ | 事件 | 载荷 | 触发时机 |
343
+ |---|---|---|
344
+ | `state` | `Phase` | 每次阶段变化(含 `closed`) |
345
+ | `players` | `Player[]` | 玩家加入 / 被剔除 |
346
+ | `player-join` | `Player` | **首次**收到某 `id` 的心跳(`{ id, name }`,注意不含 `role`) |
347
+ | `player-leave` | `Player` | 12 秒无心跳被剔除(`{ id, name }`) |
348
+ | `mc-closed` | `{ port: number }` | 连续 3 次(约 15s)ping 不通本机 MC 端口 |
349
+ | `error` | `TunnelError` | EasyTier 进程异常退出(**仅此一种**,见下) |
350
+
351
+ > ⚠️ 非常关键:`error` 事件**只有房主 watchdog 检测到 EasyTier 退出时才发**,而且**没有监听者时不会 emit**(代码里做了 `listenerCount('error') > 0` 判断)。初始化阶段的失败是**直接 reject Promise**,不会走事件。
352
+
353
+ ### 6.5 初始化流程(`createHost` 内部顺序)
354
+
355
+ ```
356
+ getDeviceId(dataDir) ← 读/生成设备标识(24 位 hex)
357
+ parseRoomCode(roomCode ?? generate) ← 得到网络名 / 网络密钥
358
+ deriveRoomSecrets(code) ← 得到 probe / token
359
+ emit('state', 'scanning')
360
+ ├─ 有 mcPort:直接用
361
+ └─ 无 mcPort:McLanScanner 监听 4445,filter: motd !== 自己的 motd
362
+ 命中第一条广播即取端口;超时抛 MC_NOT_FOUND
363
+ emit('state', 'starting-tunnel')
364
+ resolveBinaries() → pickTcpPort()(内部 RPC 端口) → coordPort = options.coordPort ?? 25991
365
+ EasyTierNode.start({ ipv4: '10.144.144.1', tcpWhitelist: [coordPort, mcPort], udpWhitelist: [mcPort] })
366
+ CoordServer.start({ port: coordPort, ... })
367
+ startWatchdog() ← 每 5s:EasyTier 存活?pingMc(mcPort) 通吗?
368
+ emit('state', 'ready')
369
+ ```
370
+
371
+ ### 6.6 watchdog 的精确行为
372
+
373
+ - 间隔 `5000ms`(定时器 `unref()`,不阻止进程退出)。
374
+ - 先判 EasyTier 是否存活:不存活 → `state = error` + `emit('error', EASYTIER_EXITED)`,**但不自动 close**。
375
+ - 再 `pingMc(mcPort)`:
376
+ - 成功 → 失败计数清零
377
+ - 失败 → 计数 +1,累计到 3 才 `emit('mc-closed', { port })`,然后计数清零
378
+ - 也就是说:**Minecraft 关掉后大约 15 秒**才会通知,且不会重复轰炸(再次失败要再攒 3 次)。
379
+
380
+ ---
381
+
382
+ ## 7. 客人 API(createGuest)
383
+
384
+ ```ts
385
+ function createGuest(options: GuestOptions): Promise<TunnelGuest>;
386
+ ```
387
+
388
+ `roomCode` 必填。失败时 Promise **reject 为 `TunnelError`**,并且内部已经 `close()` 过,不会残留进程。
389
+
390
+ ### 7.1 参数
391
+
392
+ | 参数 | 类型 | 默认 | 说明 |
393
+ |---|---|---|---|
394
+ | `roomCode` | `string` | 二选一必填 | 支持容错输入(§9.3)。与 `passphrase` 二选一 |
395
+ | `passphrase` | `string` | 二选一必填 | 口令模式,需与房主同口令(大小写、空白、全角不敏感);两者都不给或都给都抛 `INVALID_OPTIONS` |
396
+ | `playerName` | `string` | `'Guest'` | 昵称,最长 32 字符(服务端截断) |
397
+ | `motd` | `string` | 同房主默认值 | 局域网列表显示名 |
398
+ | `preferredLocalPort` | `number` | 房主 MC 端口 | 期望的本地端口;TCP/UDP 都被占则随机 |
399
+ | `peers` | `string[]` | `undefined` | 直连对端;**不能写 127.0.0.1** |
400
+ | `timeoutMs` | `number` | `90000` | 整个建链的**总预算**,不是单步超时 |
401
+ | `dataDir` | `string` | `<tmp>/mc-tunnel` | 设备标识目录 |
402
+ | `easyTier.binDir` / `publicNodes` / `p2pOnly` | 同上 | | 客人未暴露 `listeners` |
403
+
404
+ ### 7.2 只读属性
405
+
406
+ | 属性 | 类型 | 说明 |
407
+ |---|---|---|
408
+ | `roomCode` | `string` | 规范化房间码 |
409
+ | `mcPort` | `number` | **房主**(远端)的 MC 端口 |
410
+ | `localPort` | `number` | 本地映射端口 |
411
+ | `address` | `string` | `127.0.0.1:<localPort>`,直接喂给 Minecraft |
412
+ | `natDifficulty` | `NatDifficulty` | 基于双方 NAT 类型的展示用难度 |
413
+ | `phase` | `Phase` | 当前阶段 |
414
+
415
+ ### 7.3 方法
416
+
417
+ | 方法 | 说明 |
418
+ |---|---|
419
+ | `hostPlayers(): Player[]` | 缓存的上一次玩家列表(含房主) |
420
+ | `close(): Promise<void>` | 幂等。停心跳 → 停广播 → 停 EasyTier → 置 `closed` |
421
+
422
+ ### 7.4 事件
423
+
424
+ | 事件 | 载荷 | 触发时机 |
425
+ |---|---|---|
426
+ | `state` | `Phase` | 每次阶段变化 |
427
+ | `players` | `Player[]` | 握手拿到初始列表 + 每次心跳返回(**每 5s 一次**) |
428
+ | `server-status` | `McStatus` | 本地映射首次 ping 通房主 MC 时(**只发一次**;被插件屏蔽状态查询时不发) |
429
+ | `error` | `TunnelError` | ⚠️ **库本身不会 emit 这个事件**,初始化失败是 reject;保留给使用者自行 emit 的约定 |
430
+
431
+ > 实用提示:`players` 事件每 5 秒发一次,UI 上做 diff 再渲染,避免无谓重绘。
432
+
433
+ ### 7.5 初始化流程
434
+
435
+ ```
436
+ parseRoomCode(roomCode) ← 非法 → INVALID_ROOM_CODE
437
+ deriveRoomSecrets(code)
438
+ emit('state', 'starting-tunnel')
439
+ EasyTierNode.start({ dhcp: true, tcpWhitelist: [0], udpWhitelist: [0] })
440
+ emit('state', 'waiting-peer')
441
+ 每 1s 查一次 peer 列表,找 ipv4 === '10.144.144.1' 且 !isLocal 的节点
442
+ 期间 EasyTier 退出 → EASYTIER_EXITED
443
+ 直到 deadline(timeoutMs)→ PEER_NOT_FOUND
444
+ emit('state', 'connecting')
445
+ pickTcpPort() → 端口映射 tcp 0.0.0.0:<tmp> → 10.144.144.1:25991
446
+ CoordClient('http://127.0.0.1:<tmp>', token)
447
+ 每 1s 重试 GET /v1/hello,probe 匹不上就继续重试
448
+ 超过 deadline → HANDSHAKE_FAILED
449
+ GET /v1/session(x-mct-token)→ mcPort、players
450
+ mcPort 非 1..65535 → HANDSHAKE_FAILED
451
+ localPort = preferredLocalPort ?? mcPort → pickDualPort(优先同号,TCP+UDP 同时空闲)
452
+ 端口映射 tcp 0.0.0.0:<localPort> → 10.144.144.1:<mcPort>
453
+ 端口映射 udp 0.0.0.0:<localPort> → 10.144.144.1:<mcPort>
454
+ 最多 10 次(每次间隔 1s):pingMc(localPort)
455
+ 成功 → emit('server-status')
456
+ 失败 → isPortOpen(localPort) 兜底(有服务端插件屏蔽状态查询时)
457
+ 都失败 → TIMEOUT('端口映射已建立,但无法连到房主的 Minecraft 服务端。')
458
+ McLanAnnouncer.start() ← 每 1500ms 发 [MOTD]...[AD]localPort[/AD]
459
+ natDifficulty(本地 NAT, 房主 NAT)
460
+ startHeartbeat() ← 立即一次 + 每 5s POST /v1/players
461
+ emit('state', 'ready')
462
+ ```
463
+
464
+ 任一环节抛错 → 内部 `close()` → 原样抛 `TunnelError`(非 `TunnelError` 会被包成 `UNKNOWN`)。
465
+
466
+ ### 7.6 NAT 难度判定规则
467
+
468
+ ```ts
469
+ open = ['OpenInternet']
470
+ simple = ['NoPat', 'FullCone']
471
+ medium = ['Restricted', 'PortRestricted']
472
+
473
+ 任一方在 open → 'easiest'
474
+ 任一方在 simple → 'simple'
475
+ 任一方在 medium → 'medium'
476
+ 双方都是 Unknown → 'unknown'
477
+ 其余(含 Symmetric 等) → 'tough'
478
+ ```
479
+
480
+ ---
481
+
482
+ ## 8. 事件与状态机
483
+
484
+ ### 8.1 Phase 枚举
485
+
486
+ ```ts
487
+ type Phase =
488
+ | 'idle' // 刚创建,尚未初始化(外部几乎观察不到)
489
+ | 'scanning' // 房主:正在扫局域网广播
490
+ | 'starting-tunnel' // 双方:EasyTier 启动中(含等待 RPC 就绪,最长 20s)
491
+ | 'waiting-peer' // 客人:等房主 10.144.144.1 出现
492
+ | 'connecting' // 客人:协调握手 + 建端口映射
493
+ | 'ready' // 可用
494
+ | 'closed' // 已关闭
495
+ | 'error' // 房主:EasyTier 异常退出后
496
+ ```
497
+
498
+ > 注:`'error'` 只在房主 watchdog 检测到 EasyTier 退出时出现;客人出错是抛异常 + 直接进 `closed`。
499
+
500
+ ### 8.2 状态流转
501
+
502
+ 房主:
503
+
504
+ ```
505
+ idle → scanning → starting-tunnel → ready → closed
506
+ └→ error (EasyTier 崩溃)
507
+ ```
508
+
509
+ 客人:
510
+
511
+ ```
512
+ idle → starting-tunnel → waiting-peer → connecting → ready → closed
513
+ └────────────── 任何一步失败:内部 close() → closed,同时 reject
514
+ ```
515
+
516
+ ### 8.3 订阅建议
517
+
518
+ ```ts
519
+ // 事件监听要在 await 之前? 不行——createHost 返回后已经 ready 了。
520
+ // 若要观察 scanning 等早期阶段,请改用类静态方法并自行 hook(见下)
521
+ const host = await createHost({ mcPort: 25565 }); // 此时 phase 已是 'ready'
522
+ host.on('state', (p) => console.log('phase:', p)); // 只能看到后续变化
523
+ ```
524
+
525
+ 如果你需要 UI 上显示「正在扫描 / 正在启动」:`createHost` 是原子的(内部完成全部初始化才 resolve),因此**UI 层应在调用前先渲染一个「初始化中」的占位状态**,不要指望中间 phase 事件。
526
+
527
+ ---
528
+
529
+ ## 9. 房间码与凭据
530
+
531
+ ### 9.1 格式
532
+
533
+ ```
534
+ [4 bit 版本][64 bit 随机种子][12 bit CRC] = 80 bit = 16 字符 × 5 bit
535
+ ```
536
+
537
+ - 字符集:**Crockford Base32**(`0-9A-Z` 去掉 `I` `L` `O` `U`)
538
+ - 展示:`XXXX-XXXX-XXXX-XXXX`,长度 **19**(`ROOM_CODE_LENGTH`)
539
+ - 版本号当前为 `1`;其他版本一律视为非法
540
+ - CRC-12,多项式 `0x80F`
541
+
542
+ 示例:`2GJY-DYSK-8825-DZJY`
543
+
544
+ ### 9.2 派生关系
545
+
546
+ ```
547
+ seed(64 bit)
548
+ ├─ HMAC-SHA256(seed, "mc-tunnel/v1/network-name")[0..8] → mct-<hex> EasyTier --network-name
549
+ ├─ HMAC-SHA256(seed, "mc-tunnel/v1/network-secret")[0..16]→ <hex> EasyTier --network-secret
550
+ ├─ HMAC-SHA256(seed, "mc-tunnel/v1/probe")[0..8] → <hex> /v1/hello 的 probe
551
+ └─ HMAC-SHA256(seed, "mc-tunnel/v1/token")[0..16] → <hex> x-mct-token
552
+ ```
553
+
554
+ **安全语义**:拿到房间码 = 拿到入网凭据。房间码本身不含明文密码,但 64 bit 种子等价于密钥。
555
+ 熵为 2^64,配合 12 bit CRC 的误输入拦截,暴力猜码不现实;但**房间码泄露即等于房间被加入**,不要公开发布在公共频道。
556
+
557
+ ### 9.3 输入容错(`normalizeRoomCode`)
558
+
559
+ | 输入 | 处理 |
560
+ |---|---|
561
+ | 小写 | 转大写 |
562
+ | 空格、`-`、任意非 `0-9A-Z` 字符 | 全部剔除 |
563
+ | `MT-` 前缀 | 剔除后若正好 16 位才剥(避免误伤以 `MT` 开头的合法码) |
564
+ | `I` / `L` | → `1` |
565
+ | `O` | → `0` |
566
+ | `U` | → `V` |
567
+ | 结果不是 16 位 / 含非法字符 | 返回 `null` |
568
+
569
+ ```ts
570
+ normalizeRoomCode('2gjy dysk-8825 dzjy'); // '2GJYDYSK8825DZJY'
571
+ normalizeRoomCode('MT-2GJY-DYSK-8825-DZJY'); // '2GJYDYSK8825DZJY'
572
+ normalizeRoomCode('hello'); // null
573
+ ```
574
+
575
+ ### 9.4 校验
576
+
577
+ ```ts
578
+ import { verifyRoomCode, parseRoomCode } from 'mc-tunnel';
579
+
580
+ verifyRoomCode('2GJY-DYSK-8825-DZJY'); // true/false,含版本与 CRC 校验
581
+ parseRoomCode('2GJY-DYSK-8825-DZJY');
582
+ // { code: '2GJY-DYSK-8825-DZJY',
583
+ // networkName: 'mct-3f9a...',
584
+ // networkSecret: 'c1d2...' }
585
+ ```
586
+
587
+ 非法输入 `parseRoomCode` 抛 `TunnelError('INVALID_ROOM_CODE', 'Invalid room code')`。
588
+
589
+ ### 9.5 口令模式(用一段短语代替房间码)
590
+
591
+ ```ts
592
+ import { createHost, createGuest, roomCodeFromPassphrase } from 'mc-tunnel';
593
+
594
+ // 房主
595
+ const host = await createHost({ mcPort: 25565, passphrase: '彩蛋口令 演示' });
596
+ console.log(host.roomCode); // 等价房间码,UI 可以二选一展示
597
+
598
+ // 客人:口令相同即可加入(大小写、首尾/连续空白、全角都不敏感)
599
+ const guest = await createGuest({ passphrase: ' 彩蛋口令 演示 ' });
600
+ ```
601
+
602
+ 原理:
603
+
604
+ ```
605
+ 口令 → NFKC 归一化 → 小写 / 折叠空白
606
+ → HMAC-SHA256('mc-tunnel/v1/passphrase', 口令) 前 8 字节 = 64 bit 种子
607
+ → 与随机房间码走**完全相同**的派生链(网络名 / 网络密钥 / probe / token)
608
+ ```
609
+
610
+ 因此「口令」和「房间码」是同一个种子的两种表达方式,两者可互换:
611
+
612
+ ```ts
613
+ roomCodeFromPassphrase('彩蛋口令 演示'); // => '2GJY-DYSK-8825-DZJY' 这样的标准码
614
+ ```
615
+
616
+ | 工具 | 说明 |
617
+ |---|---|
618
+ | `normalizePassphrase(text)` | NFKC → trim → 折叠空白 → 小写;返回归一化后的口令 |
619
+ | `seedFromPassphrase(text)` | 口令 → `bigint` 64 bit 种子;空口令抛 `INVALID_PASSPHRASE` |
620
+ | `roomCodeFromPassphrase(text)` | 口令 → 标准房间码 |
621
+ | `passphraseStrength(text)` | `'weak' \| 'fair' \| 'good'`,纯中文按更短阈值评估(<4 弱、≥6 好) |
622
+
623
+ > ⚠️ 安全边界:口令的强度**完全由口令本身决定**。`1234`、`芝麻开门` 这类弱口令等于把房间公开——64 bit 种子并不等于 64 bit 安全性。需要严格安全时请用随机房间码(默认行为)。
624
+ > 另外,口令一旦泄露,攻击者不仅能进房间,还能反推出同一个房间码;反之从房间码也推不出口令(HMAC 单向)。
625
+
626
+ ### 9.6 中文房间码(彩蛋,需显式引入)
627
+
628
+ 中文码是**可选模块**,放在子入口 `mc-tunnel/zh`,普通用户不会为它付体积代价(数据约 12 KB 源码)。
629
+
630
+ ```ts
631
+ import { toChineseRoomCode, parseChineseRoomCode, formatChineseRoomCode } from 'mc-tunnel/zh';
632
+
633
+ const zh = toChineseRoomCode(host.roomCode); // 例如 '朱上岸雪存违户'
634
+ formatChineseRoomCode(zh); // '朱 上 岸 雪 存 违 户'(便于抄写/朗读)
635
+
636
+ // 客人:把中文码解析回标准码再传进去
637
+ const guest = await createGuest({ roomCode: parseChineseRoomCode('朱 上 岸 雪 存 违 户') });
638
+ ```
639
+
640
+ 编码关系(无损双向):
641
+
642
+ ```
643
+ 16 个 Base32 字符 = 5 bit × 16 = 80 bit(版本 1 时高 3 bit 恒为 0)
644
+ ⇅
645
+ 7 个汉字 = 11 bit × 7 = 77 bit(2048⁷ = 2⁷⁷)
646
+ ```
647
+
648
+ 字符集是 **BIP39 简体中文词表**的 2048 个去重单字(标准、MIT、永不改动),因此不需要自己维护词库。
649
+
650
+ | 工具 | 说明 |
651
+ |---|---|
652
+ | `toChineseRoomCode(code)` | 标准码 → 7 个字;版本号无法表示时抛 `INVALID_ROOM_CODE` |
653
+ | `parseChineseRoomCode(input)` | 7 个字 → 标准码;含字数/表外字/CRC 校验,报错会指出是第几个字 |
654
+ | `normalizeChineseRoomCode(input)` | 归一化(去标点空白、繁转简),非法返回 `null` |
655
+ | `isChineseRoomCode(input)` | 布尔判断 |
656
+ | `formatChineseRoomCode(code)` | 按字加空格便于展示 |
657
+ | `ZH_ALPHABET` / `ZH_CODE_LENGTH` | 字表(2048 字)/ 字数(7) |
658
+
659
+ 容错范围:
660
+
661
+ - 空格、换行、中英文标点、全角标点 → 忽略
662
+ - **繁体字形自动折简**(`這→这`、`為→为`…共 773 对,由简/繁两张 BIP39 表逐位对齐自动生成)
663
+ - 全角字符经 NFKC 折叠
664
+
665
+ ```text
666
+ parseChineseRoomCode('的一是') // 抛:中文房间码应为 7 个字,当前是 3 个字
667
+ parseChineseRoomCode('朱上岸雪存违龘') // 抛:第 7 个字「龘」不在房间码字符集中
668
+ parseChineseRoomCode('朱上岸雪存违這') // 抛:第 7 个字「這」不在字符集中,是不是想输「这」?
669
+ ```
670
+
671
+ 已知限制:
672
+
673
+ 1. **只支持编码版本 ≤ 1**。`2048⁷ = 2⁷⁷`,而版本 2 以上会用到第 78/79 位,7 个字装不下(届时需要改成 8 个字)。
674
+ 2. 词表是**单字**而非词,2048 个字摊在约 400 个拼音音节上,同音字很多。**适合复制/扫码/点选,不适合电话口述**。
675
+ 3. 继承房间码的 CRC-12 校验,而它并没有想象中强(实测数据,`test/room-code.test.mjs` 里有对应用例):
676
+ - **随机抄错一个字**:拒绝率约 `99.7%`(漏检 ≈ `0.3%`,比理论值 `1/4096` 高,因为进位链会构成多项式倍式)
677
+ - **固定偏移式错误**(把某个字换成字符表里相邻的字):在最敏感的位数上漏检率可达 `25%`
678
+ - 结论:CRC 只能挡住绝大多数误输,不能当强校验和用;抄错的极端情况会表现成「房间码合法但连不上房主」(`PEER_NOT_FOUND`),而不是「房间码错误」。
679
+
680
+ ---
681
+
682
+ ## 10. 协调协议(HTTP 接口)
683
+
684
+ 跑在隧道内,房主监听 `0.0.0.0:<coordPort>`,客人通过本地端口映射访问。
685
+ 协议常量:`COORD_PROTOCOL_VERSION = 1`、`COORD_TOKEN_HEADER = 'x-mct-token'`。
686
+
687
+ ### 10.1 `GET /v1/hello`(无需令牌)
688
+
689
+ ```json
690
+ { "app": "mc-tunnel", "protocol": 1, "probe": "a1b2c3d4e5f60718" }
691
+ ```
692
+
693
+ 客人用**恒定时间比较**(`timingSafeEqual`)核对 `probe`,确认对端确实是持相同房间码的房主。
694
+
695
+ ### 10.2 `GET /v1/session`(需 `x-mct-token`)
696
+
697
+ ```json
698
+ {
699
+ "room": "2GJY-DYSK-8825-DZJY",
700
+ "mcPort": 25565,
701
+ "host": { "id": "6f1c...", "name": "Alice" },
702
+ "players": [
703
+ { "id": "6f1c...", "name": "Alice", "role": "host" },
704
+ { "id": "9ab3...", "name": "Bob", "role": "guest" }
705
+ ]
706
+ }
707
+ ```
708
+
709
+ ### 10.3 `POST /v1/players`(需 `x-mct-token`)
710
+
711
+ 请求体(≤ 4096 字节):
712
+
713
+ ```json
714
+ { "id": "9ab3...", "name": "Bob" }
715
+ ```
716
+
717
+ - `id` 必须匹配 `^[0-9a-z]{8,64}$/i`,`name` 非空且截断到 32 字符,否则 `400 { error: 'bad-player' }`
718
+ - `id` 与房主冲突 → `409 { error: 'id-conflict' }`
719
+ - 成功 → `200 { players: [...] }`(返回**全量**列表,客人据此刷新)
720
+
721
+ ### 10.4 其他
722
+
723
+ | 情况 | 响应 |
724
+ |---|---|
725
+ | 缺令牌 / 令牌错误 | `401 { error: 'unauthorized' }` |
726
+ | 未知路径 | `404 { error: 'not-found' }` |
727
+ | 服务端内部异常 | `500 { error: 'internal' }` |
728
+
729
+ ### 10.5 手工调试
730
+
731
+ 房主本机(不经过隧道):
732
+
733
+ ```bash
734
+ curl -s http://127.0.0.1:25991/v1/hello
735
+ curl -s -H "x-mct-token: <由房间码派生>" http://127.0.0.1:25991/v1/session
736
+ ```
737
+
738
+ 令牌由 `deriveRoomSecrets()` 生成,但它**没有从包入口导出**(`package.json` 的 `exports` 也只放开了 `.` 与 `./electron`,不能深引 `dist/...`)。手工调试时按下面的等价算法自行复算即可:
739
+
740
+ ```js
741
+ // 复算 probe / token:房间码 → 80bit → 取 64bit 种子 → HMAC-SHA256 域分离
742
+ import { createHmac } from 'node:crypto';
743
+
744
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
745
+ const code = '2GJYDYSK8825DZJY'; // 去掉分隔符、转大写
746
+
747
+ let value = 0n;
748
+ for (const c of code) value = (value << 5n) | BigInt(ALPHABET.indexOf(c));
749
+
750
+ const seed = Buffer.alloc(8);
751
+ seed.writeBigUInt64BE((value >> 12n) & ((1n << 64n) - 1n), 0); // 低 12 bit 是 CRC
752
+
753
+ const derive = (label, n) =>
754
+ createHmac('sha256', seed).update(label, 'utf8').digest().subarray(0, n).toString('hex');
755
+
756
+ console.log('probe:', derive('mc-tunnel/v1/probe', 8));
757
+ console.log('token:', derive('mc-tunnel/v1/token', 16));
758
+ ```
759
+
760
+ ---
761
+
762
+ ## 11. 工具函数参考
763
+
764
+ ```ts
765
+ import {
766
+ generateRoomCode, normalizeRoomCode, verifyRoomCode, parseRoomCode, ROOM_CODE_LENGTH,
767
+ normalizePassphrase, seedFromPassphrase, roomCodeFromPassphrase, passphraseStrength,
768
+ pingMc, isPortOpen,
769
+ resolveBinaries,
770
+ getDeviceId, defaultDataDir,
771
+ McLanScanner, McLanAnnouncer, MC_MULTICAST_GROUP, MC_MULTICAST_PORT,
772
+ natDifficulty, EasyTierNode,
773
+ CoordServer, CoordClient, COORD_PROTOCOL_VERSION, COORD_TOKEN_HEADER,
774
+ TunnelError, isTunnelError, VERSION,
775
+ TunnelHost, TunnelGuest,
776
+ } from 'mc-tunnel';
777
+ ```
778
+
779
+ | 函数 | 签名 | 说明 |
780
+ |---|---|---|
781
+ | `generateRoomCode` | `() => string` | CSPRNG 生成,格式 `XXXX-XXXX-XXXX-XXXX` |
782
+ | `normalizeRoomCode` | `(input) => string \| null` | 见 §9.3 |
783
+ | `verifyRoomCode` | `(input) => boolean` | 长度 + 字符集 + 版本 + CRC |
784
+ | `parseRoomCode` | `(input) => RoomInfo` | 非法抛 `INVALID_ROOM_CODE` |
785
+ | `pingMc` | `(port, host='127.0.0.1', timeoutMs=3000) => Promise<McStatus \| null>` | 自实现 1.7+ 状态查询(协议版本号 769),失败返回 `null` |
786
+ | `isPortOpen` | `(port, host='127.0.0.1', timeoutMs=2000) => Promise<boolean>` | 仅 TCP 连通性 |
787
+ | `resolveBinaries` | `(binDir?) => { core, cli }` | 找不到抛 `EASYTIER_NOT_FOUND` |
788
+ | `getDeviceId` | `(dataDir?) => string` | 24 位 hex,落盘 `device-id`,按目录缓存 |
789
+ | `defaultDataDir` | `() => string` | `<os.tmpdir()>/mc-tunnel` |
790
+ | `natDifficulty` | `(local, remote) => NatDifficulty` | 纯展示 |
791
+ | `normalizePassphrase` | `(text) => string` | NFKC + trim + 折叠空白 + 小写 |
792
+ | `seedFromPassphrase` | `(text) => bigint` | 口令 → 64 bit 种子;空口令抛 `INVALID_PASSPHRASE` |
793
+ | `roomCodeFromPassphrase` | `(text) => string` | 口令 → 标准房间码 |
794
+ | `passphraseStrength` | `(text) => 'weak' \| 'fair' \| 'good'` | 粗略强度分档(供 UI 提示) |
795
+ | `isTunnelError` | `(e, code?) => boolean` | 类型守卫 |
796
+ | `VERSION` | `string` | 与 `package.json` 同步 |
797
+
798
+ ### 11.1 `McStatus`
799
+
800
+ ```ts
801
+ interface McStatus {
802
+ version?: string; // 如 '1.20.4'
803
+ players?: { online: number; max: number };
804
+ description?: string; // 仅 string 形式的 description
805
+ latency: number; // 毫秒
806
+ }
807
+ ```
808
+
809
+ ### 11.2 底层类(一般不用,但可用)
810
+
811
+ `McLanScanner`:`new McLanScanner({ group?, port?, ttlMs=5000, filter? })`,`start()` / `stop()` / `list(): LanServer[]`,事件 `update`(**新增端口或被判定过期时**才发)、`error`。
812
+ `ttlMs` 默认 5s:MC 客户端退出广播后,5 秒内从列表消失。
813
+
814
+ `McLanAnnouncer`:`new McLanAnnouncer({ port, motd, group?, multicastPort?, intervalMs=1500, interfaces? })`,`start()` / `stop()`,`payload` getter 返回实际报文(排查用)。
815
+
816
+ `EasyTierNode`:`EasyTierNode.start(config, binaries, readyTimeoutMs=20000)`,属性 `alive`,方法 `peers()` / `addPortForward('tcp'|'udp', local, remote)` / `logTail(n=20)` / `stop()`。
817
+
818
+ `CoordServer` / `CoordClient`:协调服务与客户端,通常在测试里单独用。
819
+
820
+ ---
821
+
822
+ ## 12. Electron 集成
823
+
824
+ ### 12.1 为什么有单独的 `mc-tunnel/electron`
825
+
826
+ 主包不 `import electron`(避免硬依赖),`electron.ts` 只用 duck typing 访问 `app.getPath` / `app.on` / `app.quit`,所以在纯 Node、测试里也能 import。
827
+
828
+ ### 12.2 三个工具
829
+
830
+ ```ts
831
+ import { electronDefaults, createHostInElectron, createGuestInElectron, manageSessions }
832
+ from 'mc-tunnel/electron';
833
+
834
+ electronDefaults(app);
835
+ // { dataDir: app.getPath('userData'),
836
+ // easyTier: { binDir: '<resourcesPath>/easytier' } }
837
+
838
+ createHostInElectron(app, options); // = createHost(合并默认 dataDir / binDir)
839
+ createGuestInElectron(app, options); // = createGuest(同上)
840
+ manageSessions(app, host, guest); // 绑到 before-quit,返回解绑函数
841
+ ```
842
+
843
+ `manageSessions` 的行为:`before-quit` 时 `preventDefault()` → `Promise.allSettled` 关闭所有会话 → 再 `app.quit()`;重复触发有 `closing` 闸门保护。
844
+
845
+ ### 12.3 主进程完整示例
846
+
847
+ ```ts
848
+ // src/main/index.ts
849
+ import { app, BrowserWindow, ipcMain } from 'electron';
850
+ import path from 'node:path';
851
+ import {
852
+ createGuestInElectron,
853
+ createHostInElectron,
854
+ manageSessions,
855
+ } from 'mc-tunnel/electron';
856
+ import type { TunnelHost, TunnelGuest } from 'mc-tunnel';
857
+
858
+ let host: TunnelHost | undefined;
859
+ let guest: TunnelGuest | undefined;
860
+
861
+ function send(channel: string, payload: unknown) {
862
+ for (const win of BrowserWindow.getAllWindows()) {
863
+ win.webContents.send(channel, payload);
864
+ }
865
+ }
866
+
867
+ ipcMain.handle('tunnel:host-start', async (_e, opts: { mcPort?: number }) => {
868
+ if (host) throw new Error('已在房间中');
869
+ host = await createHostInElectron(app, opts);
870
+ manageSessions(app, host); // 应用退出时自动 close
871
+ host.on('state', (p) => send('tunnel:state', p));
872
+ host.on('players', (list) => send('tunnel:players', list));
873
+ host.on('mc-closed', () => send('tunnel:mc-closed', null));
874
+ host.on('error', (err) => send('tunnel:error', { code: err.code, message: err.message }));
875
+ return { roomCode: host.roomCode, mcPort: host.mcPort };
876
+ });
877
+
878
+ ipcMain.handle('tunnel:guest-join', async (_e, opts: { roomCode: string; playerName?: string }) => {
879
+ if (guest) throw new Error('已在房间中');
880
+ guest = await createGuestInElectron(app, opts);
881
+ manageSessions(app, guest);
882
+ guest.on('players', (list) => send('tunnel:players', list));
883
+ guest.on('server-status', (st) => send('tunnel:server-status', st));
884
+ return { address: guest.address, localPort: guest.localPort };
885
+ });
886
+
887
+ ipcMain.handle('tunnel:close', async () => {
888
+ await Promise.allSettled([host?.close(), guest?.close()]);
889
+ host = undefined;
890
+ guest = undefined;
891
+ });
892
+ ```
893
+
894
+ ### 12.4 electron-builder 配置
895
+
896
+ ```jsonc
897
+ {
898
+ "build": {
899
+ "asar": true,
900
+ "files": ["dist/**", "node_modules/**"],
901
+ "extraResources": [
902
+ { "from": "vendor/easytier", "to": "easytier" }
903
+ ]
904
+ }
905
+ }
906
+ ```
907
+
908
+ 打包后实际路径:`<安装目录>/resources/easytier/easytier-core.exe`,正是 `electronDefaults()` 推导出来的 `binDir`。
909
+
910
+ ### 12.5 常见坑
911
+
912
+ | 现象 | 原因 / 处理 |
913
+ |---|---|
914
+ | 打包后报 `EASYTIER_NOT_FOUND` | 二进制被打进 asar,或 `extraResources` 的 `to` 不是 `easytier` |
915
+ | 开发时能跑、打包后不行 | 开发下 `process.cwd()` 可能命中 `easytier/`,打包后只有 `resourcesPath` 那条有效 |
916
+ | 关窗后残留 `easytier-core.exe` | 没调用 `manageSessions`,或用了 `app.exit()` 强退 |
917
+ | 房间码在渲染进程丢失 | 通过 IPC 存到主进程;`roomCode` 只是内存值 |
918
+
919
+ ---
920
+
921
+ ## 13. 网络部署模式
922
+
923
+ ### 13.1 默认:公网 + 公共节点 + 只走 P2P
924
+
925
+ 不需要任何配置,双方能打洞即通。代价是依赖公共节点做信令(`-p` 未显式指定时由 EasyTier 默认节点承担)。
926
+
927
+ ### 13.2 局域网直连(完全离线,无需外网)
928
+
929
+ ```ts
930
+ const IP = '192.168.1.7'; // 房机真实网卡 IP,不能用 127.0.0.1
931
+
932
+ // 房主
933
+ await createHost({ mcPort: 25565, listeners: ['tcp://0.0.0.0:11010'], peers: [`tcp://${IP}:11010`] });
934
+
935
+ // 客人
936
+ await createGuest({ roomCode, peers: [`tcp://${IP}:11010`] });
937
+ ```
938
+
939
+ > `127.0.0.1` 会触发 EasyTier 的 `10049 AddrNotAvailable`(绑定本地地址时失败)。
940
+ > 拿真实 IP 的办法:`os.networkInterfaces()` 里挑一个非 `internal` 的 IPv4;参考仓库里 `test/helpers.mjs` 的 `primaryIpv4()`。
941
+
942
+ ### 13.3 自建节点 / 需要中继(对称 NAT)
943
+
944
+ ```ts
945
+ await createHost({
946
+ mcPort: 25565,
947
+ easyTier: {
948
+ p2pOnly: false, // 允许中继
949
+ publicNodes: ['tcp://your-node.example.com:11010'],
950
+ },
951
+ });
952
+
953
+ await createGuest({
954
+ roomCode,
955
+ easyTier: { p2pOnly: false, publicNodes: ['tcp://your-node.example.com:11010'] },
956
+ });
957
+ ```
958
+
959
+ 自建节点用 EasyTier 官方方式起(例如 `easytier-core -d --network-name <任意> --network-secret <任意> -l tcp://0.0.0.0:11010`),中继流量会走它。
960
+
961
+ ### 13.4 公司网 / 弱网
962
+
963
+ - 只允许 TCP 出站:给双方都加 `publicNodes: ['tcp://...:11010']`,并考虑 `p2pOnly: false`。
964
+ - UDP 被限速:优先考虑自建 TCP 节点。
965
+ - MTU 异常(能握手但大包丢):EasyTier 侧可加 `--mtu` 参数——本库未暴露,需要自行 `EasyTierNode` 或改 `buildArgs`。
966
+
967
+ ---
968
+
969
+ ## 14. 错误码与故障排查
970
+
971
+ ### 14.1 `TunnelErrorCode` 全表
972
+
973
+ | code | 抛出位置 | 典型原因 | 处置 |
974
+ |---|---|---|---|
975
+ | `EASYTIER_NOT_FOUND` | `resolveBinaries` | 四个候选路径都没有 `core+cli` | 设 `binDir` / `MC_TUNNEL_EASYTIER`;检查文件名(Windows 要 `.exe`) |
976
+ | `EASYTIER_EXITED` | `EasyTierNode.start` / 客人 `waitForHost` / 房主 watchdog(事件) | 二进制不完整、端口/参数不兼容、被杀 | 看 `error.message` 里附的 `logTail()`;手工跑一次 `easytier-core` 复现 |
977
+ | `INVALID_ROOM_CODE` | `parseRoomCode` / `parseChineseRoomCode` | 房间码长度/字符/CRC/版本错;中文码还会指出第几个字有问题 | 先 `normalizeRoomCode` + `verifyRoomCode` 再传;中文码用 `isChineseRoomCode` 预检 |
978
+ | `INVALID_PASSPHRASE` | `seedFromPassphrase` | 口令为空(或归一化后为空) | UI 侧要求非空,并配合 `passphraseStrength` 提示 |
979
+ | `INVALID_OPTIONS` | `createHost` / `createGuest` | `roomCode` 与 `passphrase` 同时给(或客人都没给) | 二选一;客人必须提供其中之一 |
980
+ | `MC_NOT_FOUND` | 房主 `scanMinecraft` | 30s 内没扫到广播 | 先开 MC 的「对局域网开放」;或直接传 `mcPort` |
981
+ | `PEER_NOT_FOUND` | 客人 `waitForHost` | 90s 内没见到 `10.144.144.1` | 房间码是否一致;UDP 是否被墙;试 `p2pOnly:false` + 自建节点 |
982
+ | `HANDSHAKE_FAILED` | 客人 `connectCoord` | probe 不匹配 / 返回端口非法 | 双方版本是否一致(协议版本 1);房间码是否抄错 |
983
+ | `PORT_FORWARD_FAILED` | `addPortForward` | CLI 3 次重试仍失败 | 端口被占用、EasyTier 权限不足;查 `logTail()` |
984
+ | `TIMEOUT` | 客人 `waitForLocalMinecraft` / `EasyTierNode.start` | 映射建好但连不到 MC;或 EasyTier 20s 未就绪 | 确认房主 MC 真的在跑且未被防火墙拦 |
985
+ | `CLOSED` | `delay()` | 会话已被 `close()`,正在等待的步骤被唤醒 | 正常收尾,不必上报用户 |
986
+ | `UNKNOWN` | 客人初始化兜底 | 非 `TunnelError` 的异常 | 看 `error.cause` |
987
+ | `EASYTIER_CLI_FAILED` | —(当前未使用) | 预留 | — |
988
+ | `COORD_PORT_BUSY` | —(当前未使用) | 实际抛的是普通 `Error('Coord port 25991 is already in use')` | 见 §14.3 |
989
+
990
+ ### 14.2 `isTunnelError` 用法
991
+
992
+ ```ts
993
+ import { isTunnelError } from 'mc-tunnel';
994
+
995
+ try {
996
+ guest = await createGuest({ roomCode });
997
+ } catch (error) {
998
+ if (isTunnelError(error, 'PEER_NOT_FOUND')) {
999
+ ui.hint('对方还没开房间,或网络打洞失败');
1000
+ } else if (isTunnelError(error)) {
1001
+ ui.hint(`联机失败(${error.code}): ${error.message}`);
1002
+ } else {
1003
+ throw error;
1004
+ }
1005
+ }
1006
+ ```
1007
+
1008
+ ### 14.3 故障排查对照表
1009
+
1010
+ | 症状 | 排查步骤 |
1011
+ |---|---|
1012
+ | `Coord port 25991 is already in use` | 本机已有房主在跑,或别的程序占了 25991。`netstat -ano \| findstr 25991`(Windows)/ `lsof -i:25991` |
1013
+ | 房主卡在 `scanning` 直到 `MC_NOT_FOUND` | ①MC 是否已「对局域网开放」②是否开了 VPN/虚拟网卡导致多播走错网卡(关掉)③直接传 `mcPort` 绕过 |
1014
+ | 客人 `PEER_NOT_FOUND` | ①房间码两端一致?②双方网络能否出 UDP?③临时 `p2pOnly:false` 验证是否对称 NAT |
1015
+ | 客人 `HANDSHAKE_FAILED` | 房间码抄错(CRC 会拦住大部分)、双方包版本不同、隧道没通(probe 一直不匹配) |
1016
+ | `ready` 但游戏里看不到世界 | ①`localPort` 是否被防火墙拦(UDP 广播是出站,一般不用放行)②MC 版本与「局域网世界」是否同一网段 ③某些杀软拦多播 |
1017
+ | 游戏里能看到但进不去 | 房主 MC 端口之外的服务(如 SimpleVoiceChat 的 UDP)没映射;用 `preferredLocalPort` 固定同号端口 |
1018
+ | 玩家列表不更新 | 客人是否 `ready`(心跳只在 ready 后每 5s 一次);`players` 事件是否被节流 |
1019
+ | `mc-closed` 误报 | 房主 MC 端口被别的程序短暂占用或 MC 卡顿;3 次(15s)连续失败才报,可据此放宽 |
1020
+ | 关闭很慢(最多 3s) | `EasyTierNode.stop()` 会等进程退出最多 3s,属正常 |
1021
+ | 进程退出后仍有 `easytier-core` | 宿主被强杀(SIGKILL / 任务管理器)时兜底钩子无效;下次启动可先杀同名进程 |
1022
+
1023
+ ### 14.4 打开 EasyTier 日志
1024
+
1025
+ ```ts
1026
+ try {
1027
+ await createGuest({ roomCode });
1028
+ } catch (e) {
1029
+ // EASYTIER_EXITED / TIMEOUT 的 message 已附最后 20 行日志
1030
+ console.error(e.message);
1031
+ }
1032
+ ```
1033
+
1034
+ 若需更多,临时用 `EasyTierNode` 自己起一个节点,观察 `logTail(200)`。
1035
+
1036
+ ---
1037
+
1038
+ ## 15. 生命周期与进程管理
1039
+
1040
+ ### 15.1 子进程归属
1041
+
1042
+ - 每个会话(房主/客人)**各起一个** `easytier-core`。
1043
+ - `easytier-cli` 是**一次性**调用(`execFile`,8s / 10s 超时),不是常驻进程。
1044
+ - `EasyTierNode` 注册了 `process.once('exit', killer)`,宿主正常退出时杀子进程;子进程自己退出时移除该钩子。
1045
+
1046
+ ### 15.2 `close()` 做了什么
1047
+
1048
+ 房主:置 `closed` → `abort` 唤醒所有 `delay` → 清 watchdog → 停扫描器 → `CoordServer.stop()`(含 `closeAllConnections`)→ `EasyTierNode.stop()`(最多等 3s)→ `state='closed'`。
1049
+
1050
+ 客人:置 `closed` → `abort` → 清心跳 → 停广播 → `EasyTierNode.stop()` → `state='closed'`。
1051
+
1052
+ 两者都**幂等**,重复调用立即返回。
1053
+
1054
+ ### 15.3 推荐的生命周期写法
1055
+
1056
+ ```ts
1057
+ const host = await createHost({ mcPort: 25565 });
1058
+ try {
1059
+ // ... 业务
1060
+ } finally {
1061
+ await host.close();
1062
+ }
1063
+ ```
1064
+
1065
+ Electron 里用 `manageSessions(app, host)`;CLI 里监听 `SIGINT` / `SIGTERM`:
1066
+
1067
+ ```ts
1068
+ for (const sig of ['SIGINT', 'SIGTERM'] as const) {
1069
+ process.once(sig, () => { void session.close().finally(() => process.exit(0)); });
1070
+ }
1071
+ ```
1072
+
1073
+ ### 15.4 定时器不拖住进程
1074
+
1075
+ 所有内部定时器(watchdog、心跳、广播、prune、`delay`)都调了 `unref()`,因此**即使忘了 close,Node 进程也能正常退出**;但子进程仍需 close 才干净。
1076
+
1077
+ ---
1078
+
1079
+ ## 16. 安全模型
1080
+
1081
+ | 面 | 做法 |
1082
+ |---|---|
1083
+ | 房间码 | 64 bit CSPRNG 种子 + 12 bit CRC;版本字段预留升级 |
1084
+ | 网络凭据 | 网络名/密钥由种子 HMAC 派生,房间码不含明文 |
1085
+ | 协调鉴权 | `x-mct-token`,`timingSafeEqual` 恒定时间比较,防时序侧信道 |
1086
+ | 对端确认 | `/v1/hello` 的 probe,同样恒定时间比较 |
1087
+ | 最小暴露 | 房主 TCP 白名单只放行 `coordPort` + `mcPort`,UDP 白名单只放行 `mcPort`;客人白名单为 `0` |
1088
+ | 输入校验 | `/v1/players`:`id` 正则、`name` 截断 32、body ≤ 4KB;`id` 与房主冲突返回 409 |
1089
+ | 设备标识 | 本地随机 24 位 hex,落盘 `device-id`,无硬件指纹 |
1090
+ | 无 TUN | `--no-tun`,不创建虚拟网卡,不改变系统路由 |
1091
+
1092
+ **注意**:
1093
+
1094
+ - 房间码 = 入网凭据,泄露即可被加入;不要贴在公开群组。
1095
+ - 协调服务监听 `0.0.0.0`,但在 `--no-tun` 下只有虚拟网段内的节点可达;同时受 EasyTier 白名单限制。
1096
+ - 本包不做内容审计,Minecraft 服务端自身的防护(正版验证、白名单)请自行开启。
1097
+
1098
+ ---
1099
+
1100
+ ## 17. 兼容性 / 已知限制
1101
+
1102
+ | # | 限制 | 影响 | 规避 |
1103
+ |---|---|---|---|
1104
+ | 1 | **客人协调端口硬编码 `25991`**(`guest.ts` 里直接用 `DEFAULT_COORD_PORT`,不读选项) | 房主改 `coordPort` 后客人必然 `HANDSHAKE_FAILED` | 不要改 `coordPort`,或 fork 后让客人也读该值 |
1105
+ | 2 | 同一台机器只能开一个房主 | 第二实例报 `Coord port 25991 is already in use`(普通 `Error`,非 `TunnelError`) | 虚拟机 / 容器隔离 |
1106
+ | 3 | `peers` 不能写 `127.0.0.1` | EasyTier 报 `10049 AddrNotAvailable` | 用真实网卡 IP |
1107
+ | 4 | 只支持 IPv4 多播 | IPv6-only 环境扫描/广播失效 | 传 `mcPort` 绕过扫描;广播仍需 IPv4 网卡 |
1108
+ | 5 | 房主自动发现依赖同机多播 | MC 在另一台机器上时扫不到 | 直接传 `mcPort` |
1109
+ | 6 | 单机同时跑房主+客人(测试)时靠 `motd` 过滤自身广播 | 若把客人 `motd` 改成与 MC 真实 MOTD 相同,可能误判 | 保持默认 MOTD 或自定义独特字符串 |
1110
+ | 7 | 对称 NAT 双端 | 默认 `p2pOnly: true` 下打不通 | `p2pOnly: false` + 自建节点 |
1111
+ | 8 | 状态查询可能被插件屏蔽 | 客人拿不到 `server-status` | 已内置 `isPortOpen` 兜底,仅影响展示 |
1112
+ | 9 | MC 端口变化(重开存档换端口) | watchdog 会一直报 `mc-closed` | 重新 `createHost` |
1113
+ | 10 | 无 TLS | 协调协议是明文 HTTP,但跑在加密隧道(EasyTier)内 | 无需处理 |
1114
+ | 11 | `HostOptions.coordPort` 等少数选项未在文档化的 `GuestOptions` 对齐 | 见 #1 | — |
1115
+ | 12 | `error` 事件仅房主 EasyTier 退出时触发 | 别指望靠 `on('error')` 捕获初始化失败 | 用 `try/catch` 包 `createHost` / `createGuest` |
1116
+ | 13 | CRC-12(`0x80F`)对进位链式错误存在系统性漏检 | 随机单字符误输拒绝率约 99.7%;**固定偏移**在最敏感位数上漏检可达 25% | 只当「大概率」发现误输;关键流程可在 UI 上做二次确认(如要求重复输入) |
1117
+ | 14 | 中文码只支持编码版本 ≤ 1 | 将来升版本后 7 个字装不下 | 届时改 8 个字;现在遇到会明确抛 `INVALID_ROOM_CODE` |
1118
+ | 15 | 口令模式强度取决于口令 | 弱口令等于公开房间 | UI 用 `passphraseStrength` 提示,或直接用随机房间码 |
1119
+
1120
+ ---
1121
+
1122
+ ## 18. 性能与资源占用
1123
+
1124
+ | 指标 | 实测/设计值 |
1125
+ |---|---|
1126
+ | 常驻进程 | 每会话 1 个 `easytier-core`(空载内存约 20–40 MB,视版本) |
1127
+ | CPU | 空载接近 0;`easytier-cli` 轮询只在启动阶段(每 500ms / 1s) |
1128
+ | 启动耗时 | 房主:扫描决定(立即传 `mcPort` 时约 1–3s);客人:通常 3–10s,最坏 `timeoutMs` |
1129
+ | 心跳/广播流量 | 客人:HTTP 心跳每 5s(<200B)+ 多播广播每 1.5s(~60B/网卡) |
1130
+ | 定时器 | 全部 `unref()`,不阻止进程退出 |
1131
+ | 端口映射 | 房主 1 个协调服务;客人 2 个(协调临时 + MC 双协议) |
1132
+
1133
+ ---
1134
+
1135
+ ## 19. 附录:类型与默认值速查
1136
+
1137
+ ### 19.1 默认值总表
1138
+
1139
+ | 常量 | 值 |
1140
+ |---|---|
1141
+ | `HOST_VIRTUAL_IP` | `10.144.144.1` |
1142
+ | `DEFAULT_COORD_PORT` | `25991` |
1143
+ | `DEFAULT_MOTD` | `§b§lMC Tunnel 联机房间(请保持程序运行)` |
1144
+ | `HEARTBEAT_INTERVAL_MS` | `5000` |
1145
+ | `MC_WATCH_INTERVAL_MS` | `5000` |
1146
+ | `MC_MULTICAST_GROUP` | `224.0.2.60` |
1147
+ | `MC_MULTICAST_PORT` | `4445` |
1148
+ | 玩家超时 | `12000`(`PLAYER_TIMEOUT_MS`,内部) |
1149
+ | EasyTier 就绪超时 | `20000` |
1150
+ | 端口映射重试 | 3 次,间隔 400/800/1200ms |
1151
+ | 停止等待 | 3000ms |
1152
+ | `ROOM_CODE_LENGTH` | `19` |
1153
+ | `ZH_CODE_LENGTH`(`mc-tunnel/zh`) | `7` |
1154
+ | `ZH_ALPHABET`(`mc-tunnel/zh`) | BIP39 简体中文 2048 字 |
1155
+
1156
+ ### 19.2 类型定义
1157
+
1158
+ ```ts
1159
+ type Phase = 'idle' | 'scanning' | 'starting-tunnel' | 'waiting-peer'
1160
+ | 'connecting' | 'ready' | 'closed' | 'error';
1161
+
1162
+ type NatDifficulty = 'unknown' | 'easiest' | 'simple' | 'medium' | 'tough';
1163
+
1164
+ interface Player { id: string; name: string; role: 'host' | 'guest'; }
1165
+
1166
+ interface RoomInfo { code: string; networkName: string; networkSecret: string; }
1167
+
1168
+ interface McStatus { version?: string; players?: { online: number; max: number }; description?: string; latency: number; }
1169
+
1170
+ interface EasyTierOptions { binDir?: string; publicNodes?: string[]; p2pOnly?: boolean; }
1171
+
1172
+ interface HostOptions {
1173
+ roomCode?: string; passphrase?: string; // 二选一
1174
+ mcPort?: number; playerName?: string; motd?: string;
1175
+ coordPort?: number; listeners?: string[]; peers?: string[];
1176
+ scanTimeoutMs?: number; dataDir?: string; easyTier?: EasyTierOptions;
1177
+ }
1178
+
1179
+ interface GuestOptions {
1180
+ roomCode?: string; passphrase?: string; // 二选一,必须提供一个
1181
+ playerName?: string; motd?: string;
1182
+ preferredLocalPort?: number; peers?: string[]; timeoutMs?: number;
1183
+ dataDir?: string; easyTier?: EasyTierOptions;
1184
+ }
1185
+ ```
1186
+
1187
+ ### 19.3 返回值一览
1188
+
1189
+ ```ts
1190
+ createHost(opts?) → Promise<TunnelHost>
1191
+ .roomCode .mcPort .coordPort .virtualIp .phase
1192
+ .players() .close()
1193
+ on: 'state' | 'players' | 'player-join' | 'player-leave' | 'mc-closed' | 'error'
1194
+
1195
+ createGuest(opts) → Promise<TunnelGuest>
1196
+ .roomCode .mcPort .localPort .address .natDifficulty .phase
1197
+ .hostPlayers() .close()
1198
+ on: 'state' | 'players' | 'server-status' | 'error'
1199
+ ```
1200
+
1201
+ ### 19.4 一页速记
1202
+
1203
+ ```ts
1204
+ // 房主
1205
+ const host = await createHost({ mcPort: 25565, playerName: 'Alice' });
1206
+ share(host.roomCode);
1207
+
1208
+ // 客人
1209
+ const guest = await createGuest({ roomCode: code, playerName: 'Bob' });
1210
+ connectMc(guest.address); // 127.0.0.1:xxxxx
1211
+
1212
+ // 收尾
1213
+ await guest.close();
1214
+ await host.close();
1215
+ ```
1216
+
1217
+ ---
1218
+
1219
+ **版本**:本文档对应 `mc-tunnel@0.2.0`(`VERSION` 常量同步)。
1220
+ 若你改了 `src/constants.ts` 里的默认端口/间隔,请同步更新本文档对应的表格。