@dsh-remote/relay 3.1.2 → 3.1.4
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.
- package/README.md +7 -7
- package/deploy/README.md +1 -1
- package/deploy/docker/README.md +4 -7
- package/dist/bundle/main.js +417 -268
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @dsh-remote/relay
|
|
2
2
|
|
|
3
3
|
DSH Remote Control 的**零知识 WebSocket 中继**:在宿主插件与已配对的手机之间转发**密封记录**,
|
|
4
|
-
只认帧名 / 会话 id /
|
|
4
|
+
只认帧名 / 会话 id / 协议版本闸,**从不解析载荷**——密文对它就是一个 base64 字符串。
|
|
5
5
|
|
|
6
6
|
生产形态是**单文件产物** `dist/bundle/main.js`(`ws` / `zod` / 协议层全部内联,运行时零依赖);
|
|
7
7
|
部署只有两种:拷这个单文件 + 起进程(systemd / docker / nginx 配方见下),或从源码构建。
|
|
@@ -34,12 +34,12 @@ curl -s http://127.0.0.1:8787/healthz
|
|
|
34
34
|
|
|
35
35
|
### 端点
|
|
36
36
|
|
|
37
|
-
| 路径 | 说明
|
|
38
|
-
| ---------------------- |
|
|
39
|
-
| `GET /healthz` |
|
|
40
|
-
| `GET /api/info` | `{"publicUrl","protocol"}`
|
|
41
|
-
| `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉
|
|
42
|
-
| WebSocket | 路径不设限,任意路径都能升级
|
|
37
|
+
| 路径 | 说明 |
|
|
38
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| `GET /healthz` | 运维契约(**二十四个字段**):`ok` / `version` / `uptimeSec` / `hosts` / `clients` / `conversations` / `pendingPairs` / `hostTokens` / `droppedFrames` / `slowConsumers` / `rejectedPairs` / `shutdownForced` / `persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` / `stateWriteFailures` / `lastPingAgo` / `shuttingDown` / `protocolSelf` / `protocolMin` / `peerProtocolMin` / `peerProtocolMax` / `peersNoProtocol` |
|
|
40
|
+
| `GET /api/info` | `{"publicUrl","protocol"}` |
|
|
41
|
+
| `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉 |
|
|
42
|
+
| WebSocket | 路径不设限,任意路径都能升级 |
|
|
43
43
|
|
|
44
44
|
落盘五字段(`persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` /
|
|
45
45
|
`stateWriteFailures`)是"还在落盘"的唯一读数——换机器先看 `stateWriteFailures`,不是 0 就是没在写。
|
package/deploy/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
于是生产只有"拷一个文件 + 一个 env 文件",没有 `npm install`,也就没有
|
|
21
21
|
"服务器上的依赖树和 CI 不一样"这一类故障。
|
|
22
22
|
|
|
23
|
-
>
|
|
23
|
+
> 完整的环境变量表、日志契约、重启语义、升级见 [`../docs/SELF-HOSTING.md`](../docs/SELF-HOSTING.md)。
|
|
24
24
|
|
|
25
25
|
## 从零复现
|
|
26
26
|
|
package/deploy/docker/README.md
CHANGED
|
@@ -57,20 +57,17 @@ docker compose … logs relay | jq 'select(.msg=="counters")'
|
|
|
57
57
|
json-file 轮转(`max-size: 10m` / `max-file: 5`)。要长期留存就装一个日志驱动转发出去,
|
|
58
58
|
别依赖 `docker logs`。
|
|
59
59
|
|
|
60
|
-
##
|
|
60
|
+
## 升级
|
|
61
61
|
|
|
62
62
|
```sh
|
|
63
63
|
# 升级:构建 → 重启 → 看版本号变了没有
|
|
64
64
|
docker compose -f deploy/docker/compose.yaml up -d --build
|
|
65
65
|
curl -s http://127.0.0.1:8787/healthz | grep -o '"version":"[^"]*"'
|
|
66
|
-
|
|
67
|
-
# 回滚:把标签切回去再 up(镜像还在本地)
|
|
68
|
-
docker tag dsh-remote-relay:2.0.14 dsh-remote-relay:2.0.15
|
|
69
|
-
docker compose -f deploy/docker/compose.yaml up -d
|
|
70
66
|
```
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
三端(中继 / 宿主插件 / 小程序)必须升到**同一协议版本**:落后的一端会在 `hello` 那一刻
|
|
69
|
+
被明确拒掉(`unsupported_protocol`)。⚠️ 2026-10-11 晚:原来这里还写着一套"成对回滚 +
|
|
70
|
+
两代兼容矩阵",随能力位与版本协商机制一起删掉了 —— 没有老用户,不需要回滚路径。
|
|
74
71
|
|
|
75
72
|
## 拉不到 Docker Hub 怎么办
|
|
76
73
|
|
package/dist/bundle/main.js
CHANGED
|
@@ -23394,7 +23394,7 @@ function date4(params) {
|
|
|
23394
23394
|
return _coercedDate(ZodDate, params);
|
|
23395
23395
|
}
|
|
23396
23396
|
|
|
23397
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.
|
|
23397
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
|
|
23398
23398
|
var MAX_RELAY_MESSAGE_BYTES = 1024 * 1024;
|
|
23399
23399
|
var MAX_CIPHERTEXT_BYTES = MAX_RELAY_MESSAGE_BYTES - 8 * 1024;
|
|
23400
23400
|
var MAX_IMAGE_ATTACHMENTS = 4;
|
|
@@ -23403,13 +23403,11 @@ var MAX_ATTACHMENT_BYTES = 512 * 1024;
|
|
|
23403
23403
|
var MAX_ATTACHMENT_TOTAL_BYTES = 512 * 1024;
|
|
23404
23404
|
var IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
|
|
23405
23405
|
|
|
23406
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.
|
|
23406
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
|
|
23407
23407
|
var PROTOCOL_VERSION = 1;
|
|
23408
|
-
var MIN_SUPPORTED_PROTOCOL = 1;
|
|
23409
23408
|
var PAIRING_TOKEN_RE = /^\d{6}$/;
|
|
23410
23409
|
var nonEmpty = external_exports.string().min(1);
|
|
23411
23410
|
var pairingToken = external_exports.string().regex(PAIRING_TOKEN_RE);
|
|
23412
|
-
var capabilityList = external_exports.array(external_exports.string().min(1).max(64)).max(64);
|
|
23413
23411
|
var base64Text = external_exports.string().min(1).regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/);
|
|
23414
23412
|
var clientMeta = external_exports.object({
|
|
23415
23413
|
/**
|
|
@@ -23424,12 +23422,8 @@ var clientMeta = external_exports.object({
|
|
|
23424
23422
|
var helloFrame = external_exports.object({
|
|
23425
23423
|
t: external_exports.literal("hello"),
|
|
23426
23424
|
role: external_exports.enum(["host", "client"]),
|
|
23427
|
-
|
|
23428
|
-
|
|
23429
|
-
* 本端支持的能力(规范 §5.3)。**可选**:老对端不带,老中继也照旧工作——
|
|
23430
|
-
* 缺省按"只支持基线"处理,而不是按"什么都不支持"拒绝。
|
|
23431
|
-
*/
|
|
23432
|
-
capabilities: capabilityList.optional(),
|
|
23425
|
+
/** **必填**:两端必须同版本(单版本制,见 `negotiate.ts`)。 */
|
|
23426
|
+
protocol: external_exports.number().int().positive(),
|
|
23433
23427
|
/** host 的出站认证凭据,与中继的 `DRC_HOST_TOKEN` 定时安全比较。上限防超长串白占内存。 */
|
|
23434
23428
|
token: external_exports.string().min(1).max(512).optional(),
|
|
23435
23429
|
hostId: nonEmpty.optional(),
|
|
@@ -23494,9 +23488,7 @@ var helloOkFrame = external_exports.object({
|
|
|
23494
23488
|
/** 权威 clientId:小程序会采纳它并覆盖本地安装 id(F2/§3.6)。 */
|
|
23495
23489
|
clientId: external_exports.string().min(1).max(128).optional(),
|
|
23496
23490
|
hostId: nonEmpty.optional(),
|
|
23497
|
-
protocol: external_exports.number().int().positive()
|
|
23498
|
-
/** 中继自己支持的能力(规范 §5.3)。端点据此知道自己能向它要什么。 */
|
|
23499
|
-
capabilities: capabilityList.optional()
|
|
23491
|
+
protocol: external_exports.number().int().positive()
|
|
23500
23492
|
});
|
|
23501
23493
|
var pairReadyFrame = external_exports.object({
|
|
23502
23494
|
t: external_exports.literal("pair-ready"),
|
|
@@ -23541,11 +23533,11 @@ var pairFailFrame = external_exports.object({
|
|
|
23541
23533
|
* 症状不是"多扫一次码":B 的 PSK 被丢弃意味着那条配对通道作废,
|
|
23542
23534
|
* 而这正是文件头引用的那起「取错 PSK 全线解不开」事故的前置条件。
|
|
23543
23535
|
*
|
|
23544
|
-
* ##
|
|
23536
|
+
* ## 为什么可选
|
|
23545
23537
|
*
|
|
23546
|
-
*
|
|
23547
|
-
*
|
|
23548
|
-
*
|
|
23538
|
+
* **中继不总能知道是哪一张**:帧本身坏掉那条分支(`server.ts` 里 `bad_frame` 那条)
|
|
23539
|
+
* 手上没有 token 可点名。缺省时主机按"当前展示的那张"兜底
|
|
23540
|
+
* (`onPairFail` 的注释里写着这条兜底的原因)。
|
|
23549
23541
|
*/
|
|
23550
23542
|
pairingToken: external_exports.string().min(1).max(16).optional()
|
|
23551
23543
|
});
|
|
@@ -23568,11 +23560,12 @@ var peerLeftFrame = external_exports.object({
|
|
|
23568
23560
|
* **再也不会带这个 convId 回来**。主机留着这条会话就是一条永远清不掉的幽灵。
|
|
23569
23561
|
* - 手机 socket 断了(切后台、断网):D3 要求会话留着,回前台还要用它。
|
|
23570
23562
|
*
|
|
23571
|
-
* 不带这个标记 =
|
|
23572
|
-
*
|
|
23573
|
-
*
|
|
23563
|
+
* 不带这个标记 = **断线**(D3:会话留着,回前台还要用);带 true = 主动解配,
|
|
23564
|
+
* 主机应当把会话一并作废,于是 pill 从「手机离线」回到「未配对」
|
|
23565
|
+
* (2026-10-05 用户报:「mp 端解除配对,dsh 端执行的是手机离线」)。
|
|
23574
23566
|
*
|
|
23575
|
-
*
|
|
23567
|
+
* 可选是**语义**上的:它表达"这次离开是断线还是解配",缺省必须是断线
|
|
23568
|
+
* (主机不许假定是解配 —— 那会把用户的一条会话凭空作废)。
|
|
23576
23569
|
*/
|
|
23577
23570
|
unpaired: external_exports.boolean().optional()
|
|
23578
23571
|
});
|
|
@@ -23609,12 +23602,12 @@ var errorCodes = [
|
|
|
23609
23602
|
*/
|
|
23610
23603
|
"history_window_exceeded",
|
|
23611
23604
|
/**
|
|
23612
|
-
*
|
|
23605
|
+
* 对端报的协议版本与本端**不相等**(含没报,规范 §5.2 V3)。
|
|
23613
23606
|
*
|
|
23614
23607
|
* 为什么不复用 `bad_frame`:它是**版本问题**而不是形状问题,而它唯一该触发的
|
|
23615
23608
|
* 动作是"把版本说清楚"——一句"帧形状非法"会让用户去查一个根本不存在的问题。
|
|
23616
|
-
*
|
|
23617
|
-
*
|
|
23609
|
+
* 不认识的码在客户端会走兜底展示(`message || code`),所以中继 MUST 同时带上一句
|
|
23610
|
+
* 中文 message(规范 §12.3 E1)—— 反过来,"没带 message"就是把英文码弹给用户。
|
|
23618
23611
|
*/
|
|
23619
23612
|
"unsupported_protocol",
|
|
23620
23613
|
"internal"
|
|
@@ -23725,7 +23718,19 @@ var BackpressureGate = class {
|
|
|
23725
23718
|
var LOG_LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
|
|
23726
23719
|
var levelWeight = (level) => LOG_LEVELS[level];
|
|
23727
23720
|
var DECIMAL_INTEGER_RE = /^[0-9]+$/;
|
|
23728
|
-
function
|
|
23721
|
+
function claimMinIntervalMs(env, problems) {
|
|
23722
|
+
const value = integer2(env.DRC_CLAIM_MIN_INTERVAL_MS, 4e3, "DRC_CLAIM_MIN_INTERVAL_MS", problems, {
|
|
23723
|
+
allowZero: true
|
|
23724
|
+
});
|
|
23725
|
+
if (value === 0) {
|
|
23726
|
+
problems.push({
|
|
23727
|
+
level: "warn",
|
|
23728
|
+
message: "DRC_CLAIM_MIN_INTERVAL_MS=0\uFF1A\u8BA4\u9886\u63A5\u53E3\u7684\u9650\u9891**\u5DF2\u5173\u95ED**\uFF08\u540C IP \u53EF\u4EE5\u65E0\u9650\u8FDE\u6253\uFF09\u3002\u53EA\u5728\u6D4B\u8BD5\u6216\u5B8C\u5168\u5185\u7F51\u81EA\u7528\u65F6\u8FD9\u4E48\u8BBE\uFF1B\u516C\u7F51\u4E0A\u7684\u8FD9\u53F0\u4E2D\u7EE7\u8BF7\u7559 4000\u3002"
|
|
23729
|
+
});
|
|
23730
|
+
}
|
|
23731
|
+
return value;
|
|
23732
|
+
}
|
|
23733
|
+
function integer2(raw, fallback, name, problems, options = {}) {
|
|
23729
23734
|
if (raw === void 0) return fallback;
|
|
23730
23735
|
if (raw === "") {
|
|
23731
23736
|
problems.push({
|
|
@@ -23739,8 +23744,9 @@ function integer2(raw, fallback, name, problems) {
|
|
|
23739
23744
|
return fallback;
|
|
23740
23745
|
}
|
|
23741
23746
|
const n = Number(raw.trim());
|
|
23742
|
-
if (!Number.isSafeInteger(n) || n
|
|
23743
|
-
|
|
23747
|
+
if (!Number.isSafeInteger(n) || n < 0 || n === 0 && options.allowZero !== true) {
|
|
23748
|
+
const shape = options.allowZero === true ? "\u975E\u8D1F\u6574\u6570" : "\u6B63\u6574\u6570";
|
|
23749
|
+
problems.push({ level: "error", message: `${name} \u5FC5\u987B\u662F${shape}\uFF0C\u6536\u5230 ${JSON.stringify(raw)}` });
|
|
23744
23750
|
return fallback;
|
|
23745
23751
|
}
|
|
23746
23752
|
return n;
|
|
@@ -23804,6 +23810,8 @@ function loadConfig(env, version2) {
|
|
|
23804
23810
|
pairGlobalBudgetPerSec: integer2(env.DRC_PAIR_GLOBAL_PER_SEC, 20, "DRC_PAIR_GLOBAL_PER_SEC", problems),
|
|
23805
23811
|
pairStatusBudgetPerSec: integer2(env.DRC_PAIR_STATUS_PER_SEC, 5, "DRC_PAIR_STATUS_PER_SEC", problems),
|
|
23806
23812
|
maxPendingPairs: integer2(env.DRC_MAX_PENDING_PAIRS, 1e3, "DRC_MAX_PENDING_PAIRS", problems),
|
|
23813
|
+
maxHostTokens: integer2(env.DRC_MAX_HOST_TOKENS, 500, "DRC_MAX_HOST_TOKENS", problems),
|
|
23814
|
+
claimMinIntervalMs: claimMinIntervalMs(env, problems),
|
|
23807
23815
|
conversationIdleTtlMs: integer2(env.DRC_CONV_IDLE_TTL_MS, 7 * 24 * 3600 * 1e3, "DRC_CONV_IDLE_TTL_MS", problems),
|
|
23808
23816
|
// P2-⑤(2026-10-04 用户拍板 30 分钟):远小于 7 天的空闲 TTL。
|
|
23809
23817
|
// 删的代价是手机再扫一次码;不删的代价是 conversations 计数虚高、排障对不上。
|
|
@@ -23867,7 +23875,7 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
|
|
|
23867
23875
|
var import_websocket = __toESM(require_websocket(), 1);
|
|
23868
23876
|
var import_websocket_server = __toESM(require_websocket_server(), 1);
|
|
23869
23877
|
|
|
23870
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.
|
|
23878
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
|
|
23871
23879
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
23872
23880
|
var CONVERSATION_ID_PREFIX = "c_";
|
|
23873
23881
|
function newConversationId() {
|
|
@@ -23877,10 +23885,52 @@ function newHostId() {
|
|
|
23877
23885
|
return randomBytes(4).toString("hex");
|
|
23878
23886
|
}
|
|
23879
23887
|
|
|
23880
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.
|
|
23888
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
|
|
23889
|
+
function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION) {
|
|
23890
|
+
if (peerProtocol === void 0 || peerProtocol === null) {
|
|
23891
|
+
return {
|
|
23892
|
+
ok: false,
|
|
23893
|
+
reason: "missing",
|
|
23894
|
+
peer: peerProtocol,
|
|
23895
|
+
message: `\u5BF9\u65B9\u6CA1\u6709\u62A5\u534F\u8BAE\u7248\u672C\uFF08\u672C\u7AEF\u4E3A ${own2}\uFF09\uFF1A\u628A\u5BF9\u7AEF\u5347\u5230\u540C\u4E00\u7248\u672C\u518D\u8BD5`
|
|
23896
|
+
};
|
|
23897
|
+
}
|
|
23898
|
+
if (typeof peerProtocol !== "number" || !Number.isInteger(peerProtocol) || peerProtocol <= 0) {
|
|
23899
|
+
return {
|
|
23900
|
+
ok: false,
|
|
23901
|
+
reason: "invalid",
|
|
23902
|
+
peer: peerProtocol,
|
|
23903
|
+
message: `\u5BF9\u65B9\u62A5\u544A\u7684\u534F\u8BAE\u7248\u672C\u4E0D\u662F\u6B63\u6574\u6570\uFF08\u6536\u5230 ${JSON.stringify(peerProtocol) ?? "undefined"}\uFF09`
|
|
23904
|
+
};
|
|
23905
|
+
}
|
|
23906
|
+
if (peerProtocol !== own2) {
|
|
23907
|
+
return {
|
|
23908
|
+
ok: false,
|
|
23909
|
+
reason: "mismatch",
|
|
23910
|
+
peer: peerProtocol,
|
|
23911
|
+
/**
|
|
23912
|
+
* ⚠️ 这句话**会被原样弹到用户手机上**(中继 `acceptProtocol` 显式带 message,
|
|
23913
|
+
* 而客户端的处理是 `f.message || f.code`)。所以人称必须从**读它的人**出发:
|
|
23914
|
+
* 收这条错的那端才是要被升级的那端。写"请把本端升级"时,手机用户读到的是
|
|
23915
|
+
* "请升级我的手机",而真正落后的是中继/主机那一侧 —— 两个方向都试过,
|
|
23916
|
+
* 都会指错人。⇒ 只陈述事实(本端 X、对方 Y),由人来判断升哪一端。
|
|
23917
|
+
*/
|
|
23918
|
+
message: `\u534F\u8BAE\u7248\u672C\u5BF9\u4E0D\u4E0A\uFF1A\u8FD9\u4E00\u7AEF\u662F ${own2}\uFF0C\u5BF9\u65B9\u662F ${peerProtocol}\u3002\u4E24\u7AEF\u5FC5\u987B\u662F\u540C\u4E00\u7248\u672C`
|
|
23919
|
+
};
|
|
23920
|
+
}
|
|
23921
|
+
return { ok: true, version: peerProtocol };
|
|
23922
|
+
}
|
|
23923
|
+
|
|
23924
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
|
|
23925
|
+
var WAIT_HINTABLE = /* @__PURE__ */ new Set(["rate_limited", "pair_table_full"]);
|
|
23926
|
+
function wantsRetryAfter(code) {
|
|
23927
|
+
return typeof code === "string" && WAIT_HINTABLE.has(code);
|
|
23928
|
+
}
|
|
23929
|
+
|
|
23930
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
|
|
23881
23931
|
var SLEEP_BACKENDS = ["caffeinate", "systemd-inhibit", "unsupported"];
|
|
23882
23932
|
|
|
23883
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.
|
|
23933
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
|
|
23884
23934
|
var SESSION_STATES = [
|
|
23885
23935
|
"idle",
|
|
23886
23936
|
"running",
|
|
@@ -23950,9 +24000,18 @@ var evPermissionRequest = external_exports.object({
|
|
|
23950
24000
|
var evPermissionResolved = external_exports.object({
|
|
23951
24001
|
t: external_exports.literal("ev.permission_resolved"),
|
|
23952
24002
|
requestId: nonEmpty2,
|
|
23953
|
-
|
|
23954
|
-
|
|
23955
|
-
|
|
24003
|
+
/**
|
|
24004
|
+
* ⚠️ **必填**(2026-10-11 晚):句柄在生产端(`runtime.ts` 的 `voidStaleCard`)
|
|
24005
|
+
* 从来都是给全的,而"可选"在这里只有一个后果 —— 一次串台的收场会把别的会话的卡收掉。
|
|
24006
|
+
*/
|
|
24007
|
+
sessionId: nonEmpty2,
|
|
24008
|
+
/**
|
|
24009
|
+
* 谁收的场。
|
|
24010
|
+
*
|
|
24011
|
+
* ⚠️ **必填**(2026-10-11 晚):原来写的是"手机只按'要不要收掉这张卡'读它,所以不做必填",
|
|
24012
|
+
* 而生产端一直都给 —— 形状只有一种,那就不留"缺了也算合法"的第二条路。
|
|
24013
|
+
*/
|
|
24014
|
+
by: external_exports.enum(["desktop", "cancelled"])
|
|
23956
24015
|
});
|
|
23957
24016
|
var questionItem = external_exports.object({
|
|
23958
24017
|
id: nonEmpty2,
|
|
@@ -23969,19 +24028,26 @@ var evQuestionRequest = external_exports.object({
|
|
|
23969
24028
|
/**
|
|
23970
24029
|
* 这张提问卡什么时候作废(ISO 字符串)。
|
|
23971
24030
|
*
|
|
23972
|
-
*
|
|
23973
|
-
*
|
|
23974
|
-
*
|
|
23975
|
-
*
|
|
24031
|
+
* ⚠️ **2026-10-11 起:这个字段由宿主决定,我们不自己算。**
|
|
24032
|
+
*
|
|
24033
|
+
* 主机侧追问题时**带不带期限,由那一代内核说了算**(实测它给的是"不带")。
|
|
24034
|
+
* 带来就填、不带就**省掉这个键** —— 手机据此决定要不要起倒计时,
|
|
24035
|
+
* 而它只在主机真的会到点结算时才有意义。
|
|
24036
|
+
*
|
|
24037
|
+
* 曾经这里写的是"主机侧的提问等待本来就带超时(默认 300s)",而那个上限是
|
|
24038
|
+
* **插件自己加的**(`approvalTimeoutSec` 那个旋钮同批删除)。
|
|
24039
|
+
* 它造成过真实故障:页面退后台 → 事件退订 → 卡帧到了 client 没到界面 →
|
|
24040
|
+
* 用户回到页面时主机早已判超时,于是"手机上点不动"。
|
|
23976
24041
|
*/
|
|
23977
24042
|
expiresAt: external_exports.string().optional()
|
|
23978
24043
|
});
|
|
23979
24044
|
var evQuestionResolved = external_exports.object({
|
|
23980
24045
|
t: external_exports.literal("ev.question_resolved"),
|
|
23981
24046
|
requestId: nonEmpty2,
|
|
23982
|
-
sessionId
|
|
23983
|
-
|
|
23984
|
-
|
|
24047
|
+
/** 必填,理由同 `evPermissionResolved.sessionId`(收场必须精确到会话)。 */
|
|
24048
|
+
sessionId: nonEmpty2,
|
|
24049
|
+
/** 必填,理由同 `evPermissionResolved.by`。 */
|
|
24050
|
+
by: external_exports.enum(["desktop", "cancelled"])
|
|
23985
24051
|
});
|
|
23986
24052
|
var evRunState = external_exports.object({
|
|
23987
24053
|
t: external_exports.literal("ev.run_state"),
|
|
@@ -24039,8 +24105,8 @@ var evHostInfo = external_exports.object({
|
|
|
24039
24105
|
t: external_exports.literal("ev.host_info"),
|
|
24040
24106
|
/**
|
|
24041
24107
|
* 主机自述。这里**没有** `sessionId`,将来也不许加(与 `ev.session_changed` /
|
|
24042
|
-
* `ev.keep_awake_state` 同一条 F11
|
|
24043
|
-
* "
|
|
24108
|
+
* `ev.keep_awake_state` 同一条 F11):限额是主机级的,按会话分装会把
|
|
24109
|
+
* "这台主机能收多少"变成"这条通道能收多少",而手机只想知道前者。
|
|
24044
24110
|
*/
|
|
24045
24111
|
host: external_exports.object({
|
|
24046
24112
|
platform: nonEmpty2,
|
|
@@ -24051,8 +24117,6 @@ var evHostInfo = external_exports.object({
|
|
|
24051
24117
|
carrier: external_exports.string().optional(),
|
|
24052
24118
|
dshVersion: external_exports.string().optional()
|
|
24053
24119
|
}),
|
|
24054
|
-
/** §17.4 的能力 id 列表。生产端取自 `CAPABILITY_IDS`(L1)。 */
|
|
24055
|
-
caps: capabilityList,
|
|
24056
24120
|
limits: hostLimits,
|
|
24057
24121
|
state: external_exports.object({
|
|
24058
24122
|
/** 防休眠走哪条系统后端;`unsupported` 是如实上报,不是错误。 */
|
|
@@ -24076,7 +24140,8 @@ var evModel = external_exports.object({
|
|
|
24076
24140
|
* 模型是**按会话**的,不带 sessionId 就无法过滤——任何一条别的会话的
|
|
24077
24141
|
* 模型帧都会覆盖本会话的显示。
|
|
24078
24142
|
*
|
|
24079
|
-
*
|
|
24143
|
+
* **必填**(2026-10-11 晚):不带会话归属的模型帧在 mp 端一律丢弃 ——
|
|
24144
|
+
* 宁可这一帧不算数,也不能把别的会话的模型名显示到本会话顶栏。
|
|
24080
24145
|
*/
|
|
24081
24146
|
sessionId: nonEmpty2,
|
|
24082
24147
|
provider: nonEmpty2.optional(),
|
|
@@ -24111,8 +24176,9 @@ var evSessionHistory = external_exports.object({
|
|
|
24111
24176
|
* 而空页没有任何 seq 可取——于是手机要么记一个陈旧的值(下次重复收到已看过的条目),
|
|
24112
24177
|
* 要么记 0(下次把整段历史重拉一遍)。这个字段让"你现在到哪了"始终有答案。
|
|
24113
24178
|
*
|
|
24114
|
-
* ⚠️
|
|
24115
|
-
*
|
|
24179
|
+
* ⚠️ 可选的含义是"**这一页报不出游标**"(内核这一代给不出来),不是"对端可能不支持":
|
|
24180
|
+
* 手机拿到 `undefined` 就按"已经到顶"收工(`client.js` 的 `next === null` 那一支),
|
|
24181
|
+
* 不记一个错的游标。(原来这里还提能力位 `drc.payload.resume` —— 那套机制已删除。)
|
|
24116
24182
|
*/
|
|
24117
24183
|
latestSeq: external_exports.number().int().nonnegative().optional()
|
|
24118
24184
|
});
|
|
@@ -24159,8 +24225,7 @@ var cmdSendPrompt = external_exports.object({
|
|
|
24159
24225
|
sessionId: nonEmpty2,
|
|
24160
24226
|
text: external_exports.string(),
|
|
24161
24227
|
/**
|
|
24162
|
-
*
|
|
24163
|
-
* (zod 默认 strip 未知键,而 text 照旧)——所以这是向后兼容的增补,不升协议版本。
|
|
24228
|
+
* 随消息的图片附件。**可选**:不带 = 这条消息没有图片(一个真实状态)。
|
|
24164
24229
|
* 上限取 `limits.ts` 的 `MAX_IMAGE_ATTACHMENTS`(4):再多对"看清楚这张图"没有帮助,
|
|
24165
24230
|
* 只是把帧撑爆。**同一个数**还在小程序(`MAX_ATTACH`)与主机侧(`uploads.ts`)各写一遍,
|
|
24166
24231
|
* 靠 `e2e/wire-surface.test.mjs` 逐处比对钉住——改动必须三处同时发生。
|
|
@@ -24291,145 +24356,7 @@ var PAYLOAD_TYPES = {
|
|
|
24291
24356
|
evSessionHistory: "ev.session_history"
|
|
24292
24357
|
};
|
|
24293
24358
|
|
|
24294
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.
|
|
24295
|
-
var ENDPOINT_FRAME_NAMES = [
|
|
24296
|
-
"hello",
|
|
24297
|
-
"pair-begin",
|
|
24298
|
-
"pair-begin-client",
|
|
24299
|
-
"resync",
|
|
24300
|
-
"enc",
|
|
24301
|
-
"enc-batch",
|
|
24302
|
-
"session-leave",
|
|
24303
|
-
"ping"
|
|
24304
|
-
];
|
|
24305
|
-
var RELAY_FRAME_NAMES = [
|
|
24306
|
-
"hello-ok",
|
|
24307
|
-
"pair-ready",
|
|
24308
|
-
"paired",
|
|
24309
|
-
"pair-fail",
|
|
24310
|
-
"peer-joined",
|
|
24311
|
-
"peer-left",
|
|
24312
|
-
"error",
|
|
24313
|
-
"pong",
|
|
24314
|
-
"enc",
|
|
24315
|
-
"enc-batch"
|
|
24316
|
-
];
|
|
24317
|
-
var FRAME_TYPES = [
|
|
24318
|
-
.../* @__PURE__ */ new Set([...ENDPOINT_FRAME_NAMES, ...RELAY_FRAME_NAMES])
|
|
24319
|
-
];
|
|
24320
|
-
var FRAME_TYPE_SET = new Set(FRAME_TYPES);
|
|
24321
|
-
function isKnownFrameType(name) {
|
|
24322
|
-
return typeof name === "string" && FRAME_TYPE_SET.has(name);
|
|
24323
|
-
}
|
|
24324
|
-
var CMD_TYPES = [
|
|
24325
|
-
PAYLOAD_TYPES.cmdSendPrompt,
|
|
24326
|
-
PAYLOAD_TYPES.cmdAnswer,
|
|
24327
|
-
PAYLOAD_TYPES.cmdResolvePermission,
|
|
24328
|
-
PAYLOAD_TYPES.cmdInterrupt,
|
|
24329
|
-
PAYLOAD_TYPES.cmdListSessions,
|
|
24330
|
-
PAYLOAD_TYPES.cmdKeepAwake,
|
|
24331
|
-
PAYLOAD_TYPES.cmdSessionHistory,
|
|
24332
|
-
PAYLOAD_TYPES.cmdNewSession,
|
|
24333
|
-
PAYLOAD_TYPES.cmdGetPending,
|
|
24334
|
-
PAYLOAD_TYPES.cmdArchiveSession
|
|
24335
|
-
];
|
|
24336
|
-
var EV_TYPES = [
|
|
24337
|
-
PAYLOAD_TYPES.evSessionChanged,
|
|
24338
|
-
PAYLOAD_TYPES.evMessageDelta,
|
|
24339
|
-
PAYLOAD_TYPES.evToolEvent,
|
|
24340
|
-
PAYLOAD_TYPES.evPermissionRequest,
|
|
24341
|
-
PAYLOAD_TYPES.evPermissionResolved,
|
|
24342
|
-
PAYLOAD_TYPES.evQuestionRequest,
|
|
24343
|
-
PAYLOAD_TYPES.evQuestionResolved,
|
|
24344
|
-
PAYLOAD_TYPES.evRunState,
|
|
24345
|
-
PAYLOAD_TYPES.evTodo,
|
|
24346
|
-
PAYLOAD_TYPES.evRetry,
|
|
24347
|
-
PAYLOAD_TYPES.evCompaction,
|
|
24348
|
-
PAYLOAD_TYPES.evKeepAwakeState,
|
|
24349
|
-
PAYLOAD_TYPES.evHostInfo,
|
|
24350
|
-
PAYLOAD_TYPES.evModel,
|
|
24351
|
-
PAYLOAD_TYPES.evResult,
|
|
24352
|
-
PAYLOAD_TYPES.evSessionHistory
|
|
24353
|
-
];
|
|
24354
|
-
var PAYLOAD_TYPE_SET = /* @__PURE__ */ new Set([...CMD_TYPES, ...EV_TYPES]);
|
|
24355
|
-
var CAPABILITY_IDS = [
|
|
24356
|
-
"drc.v1",
|
|
24357
|
-
"drc.pairing.qr",
|
|
24358
|
-
"drc.pairing.token",
|
|
24359
|
-
"drc.payload.attachments",
|
|
24360
|
-
"drc.payload.history",
|
|
24361
|
-
/**
|
|
24362
|
-
* 支持 `cmd.session_history.since` 向前补差量 + `ev.session_history.latestSeq`。
|
|
24363
|
-
*
|
|
24364
|
-
* **为什么是能力位而不是版本号**:协议 v1 的帧名与载荷名冻结(规范 §5.2 V1),
|
|
24365
|
-
* 加这个字段属"加性变更";而手机要知道**对端是否真的会回 `latestSeq`**,
|
|
24366
|
-
* 才能决定走补差量还是退回重拉一页。版本号表达不了"这一项有没有"。
|
|
24367
|
-
*
|
|
24368
|
-
* 老主机缺这一位时,手机**必须**退回重拉一页而不是发 `since`——
|
|
24369
|
-
* 那是可验证的降级路径(反向判据见 `tests/resume-capability.test.ts`)。
|
|
24370
|
-
*/
|
|
24371
|
-
"drc.payload.resume",
|
|
24372
|
-
"drc.payload.new-session.workspace",
|
|
24373
|
-
"drc.payload.get-pending",
|
|
24374
|
-
"drc.payload.keep-awake",
|
|
24375
|
-
"drc.payload.archive-session",
|
|
24376
|
-
"drc.payload.model",
|
|
24377
|
-
"drc.payload.retry-compaction",
|
|
24378
|
-
"drc.cmd.idempotency",
|
|
24379
|
-
"drc.data.enc-batch",
|
|
24380
|
-
"drc.host.resync",
|
|
24381
|
-
"drc.host.info",
|
|
24382
|
-
"drc.crypto.v2"
|
|
24383
|
-
];
|
|
24384
|
-
var CAPABILITY_SET = new Set(CAPABILITY_IDS);
|
|
24385
|
-
|
|
24386
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
|
|
24387
|
-
function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION, min = MIN_SUPPORTED_PROTOCOL) {
|
|
24388
|
-
if (peerProtocol === void 0 || peerProtocol === null) {
|
|
24389
|
-
return verdictFor(1, own2, min, true);
|
|
24390
|
-
}
|
|
24391
|
-
if (typeof peerProtocol !== "number" || !Number.isInteger(peerProtocol) || peerProtocol <= 0) {
|
|
24392
|
-
return {
|
|
24393
|
-
ok: false,
|
|
24394
|
-
reason: "invalid",
|
|
24395
|
-
peer: peerProtocol,
|
|
24396
|
-
message: `\u5BF9\u65B9\u62A5\u544A\u7684\u534F\u8BAE\u7248\u672C\u4E0D\u662F\u6B63\u6574\u6570\uFF08\u6536\u5230 ${JSON.stringify(peerProtocol) ?? "undefined"}\uFF09`
|
|
24397
|
-
};
|
|
24398
|
-
}
|
|
24399
|
-
return verdictFor(peerProtocol, own2, min, false);
|
|
24400
|
-
}
|
|
24401
|
-
function verdictFor(peer, own2, min, missing) {
|
|
24402
|
-
if (peer < min) {
|
|
24403
|
-
return {
|
|
24404
|
-
ok: false,
|
|
24405
|
-
reason: "too_old",
|
|
24406
|
-
peer,
|
|
24407
|
-
message: `\u5BF9\u65B9\u534F\u8BAE\u7248\u672C ${peer} \u592A\u65E7\uFF0C\u672C\u7AEF\u6700\u4F4E\u652F\u6301 ${min}\uFF1B\u8BF7\u628A\u5BF9\u7AEF\u5347\u7EA7\u540E\u518D\u8BD5`
|
|
24408
|
-
};
|
|
24409
|
-
}
|
|
24410
|
-
if (peer > own2) {
|
|
24411
|
-
return {
|
|
24412
|
-
ok: false,
|
|
24413
|
-
reason: "too_new",
|
|
24414
|
-
peer,
|
|
24415
|
-
// ⚠️ 这句话**会被原样弹到用户手机上**(中继 `acceptProtocol` 显式带 message,
|
|
24416
|
-
// 而客户端的处理是 `f.message || f.code`)。所以人称必须从**读它的人**出发:
|
|
24417
|
-
// 收这条错的一端是"对方",而被要求升级的是它自己 —— 写"请把本端升级"时,
|
|
24418
|
-
// 手机用户读到的是"请升级我的手机",而真正要升级的是中继/主机那一侧。
|
|
24419
|
-
// 同理"对方"在中继的日志里指的是手机。两个方向都试过,读起来都指错了人。
|
|
24420
|
-
message: `\u672C\u7AEF\u534F\u8BAE\u7248\u672C ${own2} \u6BD4\u5BF9\u65B9\u65E7\uFF08\u5BF9\u65B9\u4E3A ${peer}\uFF09\uFF1B\u8BF7\u628A\u672C\u7AEF\u5347\u7EA7\u5230 ${peer} \u6216\u66F4\u9AD8\u540E\u518D\u8BD5`
|
|
24421
|
-
};
|
|
24422
|
-
}
|
|
24423
|
-
return { ok: true, version: peer, missing };
|
|
24424
|
-
}
|
|
24425
|
-
|
|
24426
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
|
|
24427
|
-
var WAIT_HINTABLE = /* @__PURE__ */ new Set(["rate_limited", "pair_table_full"]);
|
|
24428
|
-
function wantsRetryAfter(code) {
|
|
24429
|
-
return typeof code === "string" && WAIT_HINTABLE.has(code);
|
|
24430
|
-
}
|
|
24431
|
-
|
|
24432
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
|
|
24359
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
|
|
24433
24360
|
function helloOkForClient(clientId, protocol) {
|
|
24434
24361
|
return { t: "hello-ok", role: "client", clientId, ...protocol === void 0 ? {} : { protocol } };
|
|
24435
24362
|
}
|
|
@@ -24507,7 +24434,69 @@ function encBatchToRelay(sessionId, items) {
|
|
|
24507
24434
|
return { t: "enc-batch", sessionId, items: items.map((item) => ({ ...item })) };
|
|
24508
24435
|
}
|
|
24509
24436
|
|
|
24510
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.
|
|
24437
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
|
|
24438
|
+
var ENDPOINT_FRAME_NAMES = [
|
|
24439
|
+
"hello",
|
|
24440
|
+
"pair-begin",
|
|
24441
|
+
"pair-begin-client",
|
|
24442
|
+
"resync",
|
|
24443
|
+
"enc",
|
|
24444
|
+
"enc-batch",
|
|
24445
|
+
"session-leave",
|
|
24446
|
+
"ping"
|
|
24447
|
+
];
|
|
24448
|
+
var RELAY_FRAME_NAMES = [
|
|
24449
|
+
"hello-ok",
|
|
24450
|
+
"pair-ready",
|
|
24451
|
+
"paired",
|
|
24452
|
+
"pair-fail",
|
|
24453
|
+
"peer-joined",
|
|
24454
|
+
"peer-left",
|
|
24455
|
+
"error",
|
|
24456
|
+
"pong",
|
|
24457
|
+
"enc",
|
|
24458
|
+
"enc-batch"
|
|
24459
|
+
];
|
|
24460
|
+
var FRAME_TYPES = [
|
|
24461
|
+
.../* @__PURE__ */ new Set([...ENDPOINT_FRAME_NAMES, ...RELAY_FRAME_NAMES])
|
|
24462
|
+
];
|
|
24463
|
+
var FRAME_TYPE_SET = new Set(FRAME_TYPES);
|
|
24464
|
+
function isKnownFrameType(name) {
|
|
24465
|
+
return typeof name === "string" && FRAME_TYPE_SET.has(name);
|
|
24466
|
+
}
|
|
24467
|
+
var CMD_TYPES = [
|
|
24468
|
+
PAYLOAD_TYPES.cmdSendPrompt,
|
|
24469
|
+
PAYLOAD_TYPES.cmdAnswer,
|
|
24470
|
+
PAYLOAD_TYPES.cmdResolvePermission,
|
|
24471
|
+
PAYLOAD_TYPES.cmdInterrupt,
|
|
24472
|
+
PAYLOAD_TYPES.cmdListSessions,
|
|
24473
|
+
PAYLOAD_TYPES.cmdKeepAwake,
|
|
24474
|
+
PAYLOAD_TYPES.cmdSessionHistory,
|
|
24475
|
+
PAYLOAD_TYPES.cmdNewSession,
|
|
24476
|
+
PAYLOAD_TYPES.cmdGetPending,
|
|
24477
|
+
PAYLOAD_TYPES.cmdArchiveSession
|
|
24478
|
+
];
|
|
24479
|
+
var EV_TYPES = [
|
|
24480
|
+
PAYLOAD_TYPES.evSessionChanged,
|
|
24481
|
+
PAYLOAD_TYPES.evMessageDelta,
|
|
24482
|
+
PAYLOAD_TYPES.evToolEvent,
|
|
24483
|
+
PAYLOAD_TYPES.evPermissionRequest,
|
|
24484
|
+
PAYLOAD_TYPES.evPermissionResolved,
|
|
24485
|
+
PAYLOAD_TYPES.evQuestionRequest,
|
|
24486
|
+
PAYLOAD_TYPES.evQuestionResolved,
|
|
24487
|
+
PAYLOAD_TYPES.evRunState,
|
|
24488
|
+
PAYLOAD_TYPES.evTodo,
|
|
24489
|
+
PAYLOAD_TYPES.evRetry,
|
|
24490
|
+
PAYLOAD_TYPES.evCompaction,
|
|
24491
|
+
PAYLOAD_TYPES.evKeepAwakeState,
|
|
24492
|
+
PAYLOAD_TYPES.evHostInfo,
|
|
24493
|
+
PAYLOAD_TYPES.evModel,
|
|
24494
|
+
PAYLOAD_TYPES.evResult,
|
|
24495
|
+
PAYLOAD_TYPES.evSessionHistory
|
|
24496
|
+
];
|
|
24497
|
+
var PAYLOAD_TYPE_SET = /* @__PURE__ */ new Set([...CMD_TYPES, ...EV_TYPES]);
|
|
24498
|
+
|
|
24499
|
+
// node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
|
|
24511
24500
|
function frameTypeOf(value) {
|
|
24512
24501
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
24513
24502
|
return void 0;
|
|
@@ -24600,8 +24589,34 @@ import {
|
|
|
24600
24589
|
writeFileSync
|
|
24601
24590
|
} from "node:fs";
|
|
24602
24591
|
import { dirname } from "node:path";
|
|
24603
|
-
var STATE_FILE_VERSION =
|
|
24592
|
+
var STATE_FILE_VERSION = 2;
|
|
24604
24593
|
var CONVERSATION_ID_RE = new RegExp(`^${CONVERSATION_ID_PREFIX}[0-9a-f]{12}$`);
|
|
24594
|
+
var HOST_TOKEN_RE = /^[0-9a-f]{48}$/;
|
|
24595
|
+
var MAX_HOST_ID_LEN = 200;
|
|
24596
|
+
function sanitizeHostTokens(raw) {
|
|
24597
|
+
const hostTokens = [];
|
|
24598
|
+
const seen = /* @__PURE__ */ new Set();
|
|
24599
|
+
for (const item of raw) {
|
|
24600
|
+
if (typeof item !== "object" || item === null) {
|
|
24601
|
+
continue;
|
|
24602
|
+
}
|
|
24603
|
+
const entry = item;
|
|
24604
|
+
if (typeof entry.hostId !== "string" || // ⚠️ **空 hostId 一律丢**(2026-10-11 晚):那曾是"未绑定槽位"的键,而它的令牌
|
|
24605
|
+
// 对任意主机都算数。旧状态文件里可能真有一条这样的记录 —— 读进来就等于把
|
|
24606
|
+
// 那把万能钥匙又装回内存,所以在这一层直接筛掉(不是靠 `hostTokenAccepted` 单独兜)。
|
|
24607
|
+
entry.hostId === "" || entry.hostId.length > MAX_HOST_ID_LEN || entry.hostId.trim() !== entry.hostId || !isNonEmptyString(entry.token) || !HOST_TOKEN_RE.test(entry.token) || !isTimestamp(entry.issuedAt) || !isTimestamp(entry.lastUsedAt) || seen.has(entry.hostId)) {
|
|
24608
|
+
continue;
|
|
24609
|
+
}
|
|
24610
|
+
seen.add(entry.hostId);
|
|
24611
|
+
hostTokens.push({
|
|
24612
|
+
hostId: entry.hostId,
|
|
24613
|
+
token: entry.token,
|
|
24614
|
+
issuedAt: entry.issuedAt,
|
|
24615
|
+
lastUsedAt: entry.lastUsedAt
|
|
24616
|
+
});
|
|
24617
|
+
}
|
|
24618
|
+
return { hostTokens, dropped: raw.length - hostTokens.length };
|
|
24619
|
+
}
|
|
24605
24620
|
function isNonEmptyString(value) {
|
|
24606
24621
|
return typeof value === "string" && value.length > 0;
|
|
24607
24622
|
}
|
|
@@ -24664,8 +24679,30 @@ function readStateFile(path, log) {
|
|
|
24664
24679
|
}
|
|
24665
24680
|
const { conversations, dropped } = sanitize(file2.conversations);
|
|
24666
24681
|
if (dropped > 0) log.warn("state file entries dropped", { path, dropped, kept: conversations.length });
|
|
24667
|
-
|
|
24668
|
-
|
|
24682
|
+
if (!Array.isArray(file2.hostTokens)) {
|
|
24683
|
+
log.warn("state file has no hostTokens array, starting empty", { path });
|
|
24684
|
+
return void 0;
|
|
24685
|
+
}
|
|
24686
|
+
const hostTokenResult = sanitizeHostTokens(file2.hostTokens);
|
|
24687
|
+
if (hostTokenResult.dropped > 0) {
|
|
24688
|
+
log.warn("state file host tokens dropped", {
|
|
24689
|
+
path,
|
|
24690
|
+
dropped: hostTokenResult.dropped,
|
|
24691
|
+
kept: hostTokenResult.hostTokens.length
|
|
24692
|
+
});
|
|
24693
|
+
}
|
|
24694
|
+
log.info("state file loaded", {
|
|
24695
|
+
path,
|
|
24696
|
+
conversations: conversations.length,
|
|
24697
|
+
dropped,
|
|
24698
|
+
hostTokens: hostTokenResult.hostTokens.length
|
|
24699
|
+
});
|
|
24700
|
+
return {
|
|
24701
|
+
version: STATE_FILE_VERSION,
|
|
24702
|
+
savedAt: isTimestamp(file2.savedAt) ? file2.savedAt : 0,
|
|
24703
|
+
conversations,
|
|
24704
|
+
hostTokens: hostTokenResult.hostTokens
|
|
24705
|
+
};
|
|
24669
24706
|
}
|
|
24670
24707
|
function snapshotState(state, now) {
|
|
24671
24708
|
const conversations = [];
|
|
@@ -24680,7 +24717,11 @@ function snapshotState(state, now) {
|
|
|
24680
24717
|
if (conv.emptySince !== void 0) conversation.emptySince = conv.emptySince;
|
|
24681
24718
|
conversations.push(conversation);
|
|
24682
24719
|
}
|
|
24683
|
-
|
|
24720
|
+
const hostTokens = [];
|
|
24721
|
+
for (const [hostId, entry] of state.hostTokens) {
|
|
24722
|
+
hostTokens.push({ hostId, token: entry.token, issuedAt: entry.issuedAt, lastUsedAt: entry.lastUsedAt });
|
|
24723
|
+
}
|
|
24724
|
+
return { version: STATE_FILE_VERSION, savedAt: now, conversations, hostTokens };
|
|
24684
24725
|
}
|
|
24685
24726
|
function restoreState(path, state, log, now) {
|
|
24686
24727
|
const file2 = readStateFile(path, log);
|
|
@@ -24702,7 +24743,18 @@ function restoreState(path, state, log, now) {
|
|
|
24702
24743
|
if (clamped > 0) {
|
|
24703
24744
|
log.warn("state file timestamps in the future, clamped to now", { path, clamped });
|
|
24704
24745
|
}
|
|
24705
|
-
|
|
24746
|
+
for (const entry of file2.hostTokens) {
|
|
24747
|
+
state.hostTokens.set(entry.hostId, {
|
|
24748
|
+
token: entry.token,
|
|
24749
|
+
issuedAt: entry.issuedAt,
|
|
24750
|
+
lastUsedAt: entry.lastUsedAt
|
|
24751
|
+
});
|
|
24752
|
+
}
|
|
24753
|
+
log.info("state restored", {
|
|
24754
|
+
path,
|
|
24755
|
+
conversations: state.conversations.size,
|
|
24756
|
+
hostTokens: state.hostTokens.size
|
|
24757
|
+
});
|
|
24706
24758
|
return state.conversations.size;
|
|
24707
24759
|
}
|
|
24708
24760
|
function syncPath(target, log, strict = true) {
|
|
@@ -24756,6 +24808,8 @@ var RelayState = class {
|
|
|
24756
24808
|
clients = /* @__PURE__ */ new Map();
|
|
24757
24809
|
pendingPairs = /* @__PURE__ */ new Map();
|
|
24758
24810
|
conversations = /* @__PURE__ */ new Map();
|
|
24811
|
+
/** 按 hostId 一机一枚(键一定是非空 hostId,见 `HostTokenEntry`)。 */
|
|
24812
|
+
hostTokens = /* @__PURE__ */ new Map();
|
|
24759
24813
|
/** 测试可注入固定时钟;默认用真实时间。 */
|
|
24760
24814
|
clock;
|
|
24761
24815
|
maxPendingPairsValue;
|
|
@@ -25120,12 +25174,56 @@ var RelayState = class {
|
|
|
25120
25174
|
return dropped;
|
|
25121
25175
|
}
|
|
25122
25176
|
/** 测试与诊断:把内部计数摊平成 `/healthz` 需要的形状。 */
|
|
25177
|
+
// ── host token(按 hostId 一机一枚,2026-10-11 起)────────────────────
|
|
25178
|
+
/**
|
|
25179
|
+
* 签发/覆盖 `hostId` 的令牌。**只动这一条**,别人那枚不受影响。
|
|
25180
|
+
*
|
|
25181
|
+
* `lastUsedAt` 归零:这一枚刚签出来还没被用来鉴权过。它只影响诊断与裁剪顺序
|
|
25182
|
+
* (见 `pruneHostTokens`),不参与鉴权判定——鉴权只看 `token` 本身。
|
|
25183
|
+
*/
|
|
25184
|
+
issueHostToken(hostId, token, now) {
|
|
25185
|
+
const entry = { token, issuedAt: now, lastUsedAt: 0 };
|
|
25186
|
+
this.hostTokens.set(hostId, entry);
|
|
25187
|
+
return entry;
|
|
25188
|
+
}
|
|
25189
|
+
/** 记下"这枚令牌刚被用来**成功**鉴权"。没这一条时裁剪顺序就退化成"签得早的先丢"。 */
|
|
25190
|
+
markHostTokenUsed(hostId, now) {
|
|
25191
|
+
const entry = this.hostTokens.get(hostId);
|
|
25192
|
+
if (entry) entry.lastUsedAt = now;
|
|
25193
|
+
}
|
|
25194
|
+
/**
|
|
25195
|
+
* 令牌表有界:超出 `max` 时,按 `lastUsedAt || issuedAt` **从旧到新**丢。
|
|
25196
|
+
*
|
|
25197
|
+
* 为什么不是"按签发时刻丢最老":一台一直在用的机器(签得早、但一直在用)会被
|
|
25198
|
+
* 优先丢掉,而它**不会自愈**——下次它连上来只会拿到 `bad_token`,而它盘上有令牌
|
|
25199
|
+
* 就不会重新认领。所以分两档:**没用过的先丢**,同一档里再按最近活动从旧到新。
|
|
25200
|
+
*
|
|
25201
|
+
* 返回丢掉几条(调用方去记一条 warn)。
|
|
25202
|
+
*/
|
|
25203
|
+
pruneHostTokens(max) {
|
|
25204
|
+
if (!Number.isFinite(max) || max <= 0) return 0;
|
|
25205
|
+
if (this.hostTokens.size <= max) return 0;
|
|
25206
|
+
const order = [...this.hostTokens.entries()].sort((a, b) => {
|
|
25207
|
+
const usedA = a[1].lastUsedAt > 0 ? 1 : 0;
|
|
25208
|
+
const usedB = b[1].lastUsedAt > 0 ? 1 : 0;
|
|
25209
|
+
if (usedA !== usedB) return usedA - usedB;
|
|
25210
|
+
return (a[1].lastUsedAt || a[1].issuedAt) - (b[1].lastUsedAt || b[1].issuedAt);
|
|
25211
|
+
});
|
|
25212
|
+
let dropped = 0;
|
|
25213
|
+
for (const [key] of order) {
|
|
25214
|
+
if (this.hostTokens.size <= max) break;
|
|
25215
|
+
this.hostTokens.delete(key);
|
|
25216
|
+
dropped += 1;
|
|
25217
|
+
}
|
|
25218
|
+
return dropped;
|
|
25219
|
+
}
|
|
25123
25220
|
counts() {
|
|
25124
25221
|
return {
|
|
25125
25222
|
hosts: this.hosts.size,
|
|
25126
25223
|
clients: this.clients.size,
|
|
25127
25224
|
conversations: this.conversations.size,
|
|
25128
|
-
pendingPairs: this.pendingPairs.size
|
|
25225
|
+
pendingPairs: this.pendingPairs.size,
|
|
25226
|
+
hostTokens: this.hostTokens.size
|
|
25129
25227
|
};
|
|
25130
25228
|
}
|
|
25131
25229
|
/** 全量清空(优雅停机或测试重置)。 */
|
|
@@ -25134,6 +25232,7 @@ var RelayState = class {
|
|
|
25134
25232
|
this.clients.clear();
|
|
25135
25233
|
this.pendingPairs.clear();
|
|
25136
25234
|
this.conversations.clear();
|
|
25235
|
+
this.hostTokens.clear();
|
|
25137
25236
|
}
|
|
25138
25237
|
};
|
|
25139
25238
|
|
|
@@ -25147,8 +25246,9 @@ function tokenMatches(expected, got) {
|
|
|
25147
25246
|
if (a.length !== b.length) return false;
|
|
25148
25247
|
return timingSafeEqual(a, b);
|
|
25149
25248
|
}
|
|
25150
|
-
var CLAIM_MIN_INTERVAL_MS = 4e3;
|
|
25151
25249
|
var CLAIM_TRACK_MAX = 1e4;
|
|
25250
|
+
var MAX_HOST_ID_LEN2 = 200;
|
|
25251
|
+
var HOST_ID_RE = /^[A-Za-z0-9._:-]+$/;
|
|
25152
25252
|
function createRelay(config2) {
|
|
25153
25253
|
const log = new Log(config2.logLevel);
|
|
25154
25254
|
const state = new RelayState({ maxPendingPairs: config2.maxPendingPairs });
|
|
@@ -25157,23 +25257,32 @@ function createRelay(config2) {
|
|
|
25157
25257
|
const pairStatusBudget = new Budget(config2.pairStatusBudgetPerSec);
|
|
25158
25258
|
const peers = /* @__PURE__ */ new WeakMap();
|
|
25159
25259
|
let shuttingDown = false;
|
|
25160
|
-
let currentHostToken = config2.hostToken;
|
|
25161
25260
|
const lastClaimAt = /* @__PURE__ */ new Map();
|
|
25162
25261
|
let sweepTimer;
|
|
25163
25262
|
let pingTimer;
|
|
25164
25263
|
let lastPingAt = 0;
|
|
25165
25264
|
let lastCountersAt = 0;
|
|
25166
|
-
function
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
|
|
25174
|
-
|
|
25265
|
+
function newHostToken() {
|
|
25266
|
+
return randomBytes2(24).toString("hex");
|
|
25267
|
+
}
|
|
25268
|
+
function rememberClaim(ip, now) {
|
|
25269
|
+
lastClaimAt.set(ip, now);
|
|
25270
|
+
if (lastClaimAt.size <= CLAIM_TRACK_MAX) return;
|
|
25271
|
+
const drop = lastClaimAt.size - CLAIM_TRACK_MAX;
|
|
25272
|
+
let i = 0;
|
|
25273
|
+
for (const key of lastClaimAt.keys()) {
|
|
25274
|
+
lastClaimAt.delete(key);
|
|
25275
|
+
if (++i >= drop) break;
|
|
25276
|
+
}
|
|
25277
|
+
}
|
|
25278
|
+
function hostTokenAccepted(hostId, got) {
|
|
25279
|
+
if (!got) return false;
|
|
25280
|
+
const perHost = state.hostTokens.get(hostId);
|
|
25281
|
+
if (perHost && tokenMatches(perHost.token, got)) {
|
|
25282
|
+
state.markHostTokenUsed(hostId, Date.now());
|
|
25283
|
+
return true;
|
|
25175
25284
|
}
|
|
25176
|
-
return
|
|
25285
|
+
return config2.hostToken !== "" && tokenMatches(config2.hostToken, got);
|
|
25177
25286
|
}
|
|
25178
25287
|
const persistence = { enabled: config2.stateFile !== "", restored: 0, savedAt: 0, writes: 0, failures: 0 };
|
|
25179
25288
|
if (persistence.enabled) {
|
|
@@ -25284,35 +25393,20 @@ function createRelay(config2) {
|
|
|
25284
25393
|
peer.hostId = void 0;
|
|
25285
25394
|
}
|
|
25286
25395
|
function handleHello(peer, frame) {
|
|
25287
|
-
if (peer.protocol === void 0 && !peer.countedProtocol) {
|
|
25288
|
-
peer.countedProtocol = true;
|
|
25289
|
-
if (frame.protocol === void 0) peersWithoutProtocol++;
|
|
25290
|
-
else peerProtocols.set(frame.protocol, (peerProtocols.get(frame.protocol) ?? 0) + 1);
|
|
25291
|
-
} else if (peer.protocol !== frame.protocol) {
|
|
25292
|
-
if (peer.protocol === void 0) peersWithoutProtocol = Math.max(0, peersWithoutProtocol - 1);
|
|
25293
|
-
else {
|
|
25294
|
-
const left = (peerProtocols.get(peer.protocol) ?? 1) - 1;
|
|
25295
|
-
if (left > 0) peerProtocols.set(peer.protocol, left);
|
|
25296
|
-
else peerProtocols.delete(peer.protocol);
|
|
25297
|
-
}
|
|
25298
|
-
if (frame.protocol === void 0) peersWithoutProtocol++;
|
|
25299
|
-
else peerProtocols.set(frame.protocol, (peerProtocols.get(frame.protocol) ?? 0) + 1);
|
|
25300
|
-
}
|
|
25301
|
-
peer.protocol = frame.protocol;
|
|
25302
25396
|
if (frame.role === "host") {
|
|
25303
|
-
|
|
25397
|
+
const hostId = frame.hostId?.trim() || newHostId();
|
|
25398
|
+
if (!config2.hostToken && state.hostTokens.size === 0) {
|
|
25304
25399
|
sendError(peer, "internal", "relay has no host token configured");
|
|
25305
25400
|
peer.ws.close(1011);
|
|
25306
25401
|
return;
|
|
25307
25402
|
}
|
|
25308
|
-
if (!
|
|
25403
|
+
if (!hostTokenAccepted(hostId, frame.token)) {
|
|
25309
25404
|
peer.authAttempts += 1;
|
|
25310
25405
|
log.warn("host auth failed", { attempts: peer.authAttempts });
|
|
25311
25406
|
sendError(peer, "bad_token");
|
|
25312
25407
|
if (peer.authAttempts >= config2.maxHostAuthAttempts) peer.ws.close(4001, "too_many_auth_attempts");
|
|
25313
25408
|
return;
|
|
25314
25409
|
}
|
|
25315
|
-
const hostId = frame.hostId?.trim() || newHostId();
|
|
25316
25410
|
releaseIdentityFor(peer, { role: "host", id: hostId });
|
|
25317
25411
|
const { replaced } = state.attachHost(hostId, peer.ws, frame.label ?? "");
|
|
25318
25412
|
peer.role = "host";
|
|
@@ -25468,8 +25562,6 @@ function createRelay(config2) {
|
|
|
25468
25562
|
for (const sock of clients) sendTo(sock, encBatchToClient(frame.sessionId, items));
|
|
25469
25563
|
}
|
|
25470
25564
|
let lastResyncPersistAt = 0;
|
|
25471
|
-
const peerProtocols = /* @__PURE__ */ new Map();
|
|
25472
|
-
let peersWithoutProtocol = 0;
|
|
25473
25565
|
function acceptProtocol(peer, peerProtocol) {
|
|
25474
25566
|
const verdict = negotiateProtocol(peerProtocol);
|
|
25475
25567
|
if (verdict.ok) return true;
|
|
@@ -25585,8 +25677,6 @@ function createRelay(config2) {
|
|
|
25585
25677
|
pairBudgetWarnedAt: 0,
|
|
25586
25678
|
backpressure: new BackpressureGate(),
|
|
25587
25679
|
alive: true,
|
|
25588
|
-
// 观测面计数的闩锁(见 Peer.countedProtocol):连接为单位,不是 hello 为单位。
|
|
25589
|
-
countedProtocol: false,
|
|
25590
25680
|
// 轮转序号取模分配:**均匀是刻意的**。哪怕重启风暴里 10k 条连接在几十秒内
|
|
25591
25681
|
// 全建起来,它们也会摊到所有桶上;若改成"分给下一个将要轮到的桶",
|
|
25592
25682
|
// 风暴会把它们全塞进同一两个桶,等于把刚拆掉的突发又造回来(C7 的场景)。
|
|
@@ -25661,12 +25751,6 @@ function createRelay(config2) {
|
|
|
25661
25751
|
});
|
|
25662
25752
|
ws.on("close", () => {
|
|
25663
25753
|
peers.delete(ws);
|
|
25664
|
-
if (peer.protocol === void 0) peersWithoutProtocol = Math.max(0, peersWithoutProtocol - 1);
|
|
25665
|
-
else {
|
|
25666
|
-
const left = (peerProtocols.get(peer.protocol) ?? 1) - 1;
|
|
25667
|
-
if (left > 0) peerProtocols.set(peer.protocol, left);
|
|
25668
|
-
else peerProtocols.delete(peer.protocol);
|
|
25669
|
-
}
|
|
25670
25754
|
bucketAt(peer.pingBucket).delete(peer);
|
|
25671
25755
|
if (peer.role === "client" && peer.clientId) {
|
|
25672
25756
|
for (const notice of state.clientGone(peer.clientId, ws)) {
|
|
@@ -25760,18 +25844,98 @@ function createRelay(config2) {
|
|
|
25760
25844
|
stateWriteFailures: persistence.failures,
|
|
25761
25845
|
lastPingAgo: lastPingAt === 0 ? -1 : Math.round((Date.now() - lastPingAt) / 1e3),
|
|
25762
25846
|
shuttingDown,
|
|
25763
|
-
//
|
|
25764
|
-
//
|
|
25765
|
-
|
|
25766
|
-
// 报的是**对端报的最小/最大版本**与"没报过版本"的连接数,两个方向缺一不可:
|
|
25767
|
-
// 只报最大值的话,一个全是老小程序的环境看起来与新版本一样正常。
|
|
25768
|
-
protocolSelf: PROTOCOL_VERSION,
|
|
25769
|
-
protocolMin: MIN_SUPPORTED_PROTOCOL,
|
|
25770
|
-
peerProtocolMin: peerProtocols.size === 0 ? -1 : Math.min(...peerProtocols.keys()),
|
|
25771
|
-
peerProtocolMax: peerProtocols.size === 0 ? -1 : Math.max(...peerProtocols.keys()),
|
|
25772
|
-
peersNoProtocol: peersWithoutProtocol
|
|
25847
|
+
// 本端说的协议版本(单版本制:它是一个常量,但排障时要能一眼看到"这一个中继
|
|
25848
|
+
// 说的是哪一版"——版本对不上的现场表现是"连上了、什么都不发生",没有任何一层会报错)。
|
|
25849
|
+
protocolSelf: PROTOCOL_VERSION
|
|
25773
25850
|
};
|
|
25774
25851
|
}
|
|
25852
|
+
const BODY_TOO_LARGE = "body_too_large";
|
|
25853
|
+
function readBody(req, limit) {
|
|
25854
|
+
return new Promise((resolve, reject) => {
|
|
25855
|
+
const chunks = [];
|
|
25856
|
+
let size = 0;
|
|
25857
|
+
req.on("data", (chunk) => {
|
|
25858
|
+
size += chunk.length;
|
|
25859
|
+
if (size > limit) {
|
|
25860
|
+
req.pause();
|
|
25861
|
+
reject(Object.assign(new Error(BODY_TOO_LARGE), { code: BODY_TOO_LARGE }));
|
|
25862
|
+
return;
|
|
25863
|
+
}
|
|
25864
|
+
chunks.push(chunk);
|
|
25865
|
+
});
|
|
25866
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
25867
|
+
req.on("error", reject);
|
|
25868
|
+
});
|
|
25869
|
+
}
|
|
25870
|
+
const CLAIM_BODY_MAX_BYTES = 1024;
|
|
25871
|
+
async function handleClaimToken(req, res) {
|
|
25872
|
+
if (req.method !== "POST") {
|
|
25873
|
+
res.statusCode = 405;
|
|
25874
|
+
res.end(JSON.stringify({ error: "use POST" }));
|
|
25875
|
+
return;
|
|
25876
|
+
}
|
|
25877
|
+
if (shuttingDown) {
|
|
25878
|
+
res.statusCode = 503;
|
|
25879
|
+
res.end(JSON.stringify({ error: "shutting_down" }));
|
|
25880
|
+
return;
|
|
25881
|
+
}
|
|
25882
|
+
const now = Date.now();
|
|
25883
|
+
const ip = req.socket.remoteAddress ?? "unknown";
|
|
25884
|
+
const last = lastClaimAt.get(ip) ?? 0;
|
|
25885
|
+
if (now - last < config2.claimMinIntervalMs) {
|
|
25886
|
+
res.statusCode = 429;
|
|
25887
|
+
res.end(JSON.stringify({ error: "too_soon", retryAfterMs: config2.claimMinIntervalMs - (now - last) }));
|
|
25888
|
+
return;
|
|
25889
|
+
}
|
|
25890
|
+
rememberClaim(ip, now);
|
|
25891
|
+
let body = {};
|
|
25892
|
+
let raw;
|
|
25893
|
+
try {
|
|
25894
|
+
raw = await readBody(req, CLAIM_BODY_MAX_BYTES);
|
|
25895
|
+
} catch (error62) {
|
|
25896
|
+
if (error62?.code === BODY_TOO_LARGE) {
|
|
25897
|
+
res.statusCode = 413;
|
|
25898
|
+
res.setHeader("connection", "close");
|
|
25899
|
+
res.end(JSON.stringify({ error: "body_too_large", limitBytes: CLAIM_BODY_MAX_BYTES }));
|
|
25900
|
+
return;
|
|
25901
|
+
}
|
|
25902
|
+
throw error62;
|
|
25903
|
+
}
|
|
25904
|
+
if (raw.trim() !== "") {
|
|
25905
|
+
try {
|
|
25906
|
+
const parsed = JSON.parse(raw);
|
|
25907
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
25908
|
+
body = parsed;
|
|
25909
|
+
}
|
|
25910
|
+
} catch {
|
|
25911
|
+
}
|
|
25912
|
+
}
|
|
25913
|
+
const rawHostId = typeof body.hostId === "string" ? body.hostId.trim() : "";
|
|
25914
|
+
if (rawHostId === "") {
|
|
25915
|
+
res.statusCode = 400;
|
|
25916
|
+
res.end(
|
|
25917
|
+
JSON.stringify({
|
|
25918
|
+
error: "host_id_required",
|
|
25919
|
+
hint: 'POST {"hostId":"<\u672C\u673A\u8EAB\u4EFD>"} \u2014\u2014 2026-10-11 \u8D77\u4EE4\u724C\u6309 hostId \u4E00\u673A\u4E00\u679A\uFF0C\u6CA1\u6709\u8EAB\u4EFD\u5C31\u4E0D\u7B7E'
|
|
25920
|
+
})
|
|
25921
|
+
);
|
|
25922
|
+
return;
|
|
25923
|
+
}
|
|
25924
|
+
if (rawHostId.length > MAX_HOST_ID_LEN2 || !HOST_ID_RE.test(rawHostId)) {
|
|
25925
|
+
res.statusCode = 400;
|
|
25926
|
+
res.end(
|
|
25927
|
+
JSON.stringify({ error: "bad_host_id", hint: `hostId \u6700\u591A ${MAX_HOST_ID_LEN2} \u5B57\u7B26\uFF0C\u53EA\u5141\u8BB8\u5B57\u6BCD\u6570\u5B57\u4E0E ._:-` })
|
|
25928
|
+
);
|
|
25929
|
+
return;
|
|
25930
|
+
}
|
|
25931
|
+
const token = newHostToken();
|
|
25932
|
+
state.issueHostToken(rawHostId, token, now);
|
|
25933
|
+
const pruned = state.pruneHostTokens(config2.maxHostTokens);
|
|
25934
|
+
if (pruned > 0) log.warn("host tokens pruned", { dropped: pruned, kept: state.hostTokens.size });
|
|
25935
|
+
markStateChanged();
|
|
25936
|
+
log.info("host token claimed", { ip, hostId: rawHostId, hostTokens: state.hostTokens.size });
|
|
25937
|
+
res.end(JSON.stringify({ token, hostId: rawHostId, rotatedAt: new Date(now).toISOString() }));
|
|
25938
|
+
}
|
|
25775
25939
|
function httpHandler(req, res) {
|
|
25776
25940
|
try {
|
|
25777
25941
|
const url2 = new URL(req.url ?? "/", "http://relay.invalid");
|
|
@@ -25782,28 +25946,13 @@ function createRelay(config2) {
|
|
|
25782
25946
|
return;
|
|
25783
25947
|
}
|
|
25784
25948
|
if (url2.pathname === "/api/claim-token") {
|
|
25785
|
-
|
|
25786
|
-
|
|
25787
|
-
res.
|
|
25788
|
-
|
|
25789
|
-
|
|
25790
|
-
|
|
25791
|
-
|
|
25792
|
-
res.end(JSON.stringify({ error: "shutting_down" }));
|
|
25793
|
-
return;
|
|
25794
|
-
}
|
|
25795
|
-
const now = Date.now();
|
|
25796
|
-
const ip = req.socket.remoteAddress ?? "unknown";
|
|
25797
|
-
const last = lastClaimAt.get(ip) ?? 0;
|
|
25798
|
-
if (now - last < CLAIM_MIN_INTERVAL_MS) {
|
|
25799
|
-
res.statusCode = 429;
|
|
25800
|
-
res.end(JSON.stringify({ error: "too_soon", retryAfterMs: CLAIM_MIN_INTERVAL_MS - (now - last) }));
|
|
25801
|
-
return;
|
|
25802
|
-
}
|
|
25803
|
-
lastClaimAt.set(ip, now);
|
|
25804
|
-
const token = rotateHostToken();
|
|
25805
|
-
log.info("host token claimed", { ip });
|
|
25806
|
-
res.end(JSON.stringify({ token, rotatedAt: new Date(now).toISOString() }));
|
|
25949
|
+
void handleClaimToken(req, res).catch((e) => {
|
|
25950
|
+
log.warn("claim handler failed", { error: String(e) });
|
|
25951
|
+
if (!res.headersSent) {
|
|
25952
|
+
res.statusCode = 500;
|
|
25953
|
+
res.end(JSON.stringify({ error: "internal" }));
|
|
25954
|
+
}
|
|
25955
|
+
});
|
|
25807
25956
|
return;
|
|
25808
25957
|
}
|
|
25809
25958
|
if (url2.pathname === "/api/info") {
|
|
@@ -25992,7 +26141,7 @@ function registerSignalHandlers(shutdown) {
|
|
|
25992
26141
|
|
|
25993
26142
|
// src/main.ts
|
|
25994
26143
|
function readVersion() {
|
|
25995
|
-
if ("3.1.
|
|
26144
|
+
if ("3.1.4") return "3.1.4";
|
|
25996
26145
|
try {
|
|
25997
26146
|
const pkg = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8"));
|
|
25998
26147
|
if (typeof pkg.version === "string") return pkg.version;
|