@dsh-remote/relay 3.1.3 → 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 +1 -1
- package/deploy/README.md +1 -1
- package/deploy/docker/README.md +4 -7
- package/dist/bundle/main.js +208 -253
- 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 配方见下),或从源码构建。
|
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"
|
|
@@ -23882,7 +23875,7 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
|
|
|
23882
23875
|
var import_websocket = __toESM(require_websocket(), 1);
|
|
23883
23876
|
var import_websocket_server = __toESM(require_websocket_server(), 1);
|
|
23884
23877
|
|
|
23885
|
-
// 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
|
|
23886
23879
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
23887
23880
|
var CONVERSATION_ID_PREFIX = "c_";
|
|
23888
23881
|
function newConversationId() {
|
|
@@ -23892,10 +23885,52 @@ function newHostId() {
|
|
|
23892
23885
|
return randomBytes(4).toString("hex");
|
|
23893
23886
|
}
|
|
23894
23887
|
|
|
23895
|
-
// 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
|
|
23896
23931
|
var SLEEP_BACKENDS = ["caffeinate", "systemd-inhibit", "unsupported"];
|
|
23897
23932
|
|
|
23898
|
-
// 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
|
|
23899
23934
|
var SESSION_STATES = [
|
|
23900
23935
|
"idle",
|
|
23901
23936
|
"running",
|
|
@@ -23965,9 +24000,18 @@ var evPermissionRequest = external_exports.object({
|
|
|
23965
24000
|
var evPermissionResolved = external_exports.object({
|
|
23966
24001
|
t: external_exports.literal("ev.permission_resolved"),
|
|
23967
24002
|
requestId: nonEmpty2,
|
|
23968
|
-
|
|
23969
|
-
|
|
23970
|
-
|
|
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"])
|
|
23971
24015
|
});
|
|
23972
24016
|
var questionItem = external_exports.object({
|
|
23973
24017
|
id: nonEmpty2,
|
|
@@ -23984,19 +24028,26 @@ var evQuestionRequest = external_exports.object({
|
|
|
23984
24028
|
/**
|
|
23985
24029
|
* 这张提问卡什么时候作废(ISO 字符串)。
|
|
23986
24030
|
*
|
|
23987
|
-
*
|
|
23988
|
-
*
|
|
23989
|
-
*
|
|
23990
|
-
*
|
|
24031
|
+
* ⚠️ **2026-10-11 起:这个字段由宿主决定,我们不自己算。**
|
|
24032
|
+
*
|
|
24033
|
+
* 主机侧追问题时**带不带期限,由那一代内核说了算**(实测它给的是"不带")。
|
|
24034
|
+
* 带来就填、不带就**省掉这个键** —— 手机据此决定要不要起倒计时,
|
|
24035
|
+
* 而它只在主机真的会到点结算时才有意义。
|
|
24036
|
+
*
|
|
24037
|
+
* 曾经这里写的是"主机侧的提问等待本来就带超时(默认 300s)",而那个上限是
|
|
24038
|
+
* **插件自己加的**(`approvalTimeoutSec` 那个旋钮同批删除)。
|
|
24039
|
+
* 它造成过真实故障:页面退后台 → 事件退订 → 卡帧到了 client 没到界面 →
|
|
24040
|
+
* 用户回到页面时主机早已判超时,于是"手机上点不动"。
|
|
23991
24041
|
*/
|
|
23992
24042
|
expiresAt: external_exports.string().optional()
|
|
23993
24043
|
});
|
|
23994
24044
|
var evQuestionResolved = external_exports.object({
|
|
23995
24045
|
t: external_exports.literal("ev.question_resolved"),
|
|
23996
24046
|
requestId: nonEmpty2,
|
|
23997
|
-
sessionId
|
|
23998
|
-
|
|
23999
|
-
|
|
24047
|
+
/** 必填,理由同 `evPermissionResolved.sessionId`(收场必须精确到会话)。 */
|
|
24048
|
+
sessionId: nonEmpty2,
|
|
24049
|
+
/** 必填,理由同 `evPermissionResolved.by`。 */
|
|
24050
|
+
by: external_exports.enum(["desktop", "cancelled"])
|
|
24000
24051
|
});
|
|
24001
24052
|
var evRunState = external_exports.object({
|
|
24002
24053
|
t: external_exports.literal("ev.run_state"),
|
|
@@ -24054,8 +24105,8 @@ var evHostInfo = external_exports.object({
|
|
|
24054
24105
|
t: external_exports.literal("ev.host_info"),
|
|
24055
24106
|
/**
|
|
24056
24107
|
* 主机自述。这里**没有** `sessionId`,将来也不许加(与 `ev.session_changed` /
|
|
24057
|
-
* `ev.keep_awake_state` 同一条 F11
|
|
24058
|
-
* "
|
|
24108
|
+
* `ev.keep_awake_state` 同一条 F11):限额是主机级的,按会话分装会把
|
|
24109
|
+
* "这台主机能收多少"变成"这条通道能收多少",而手机只想知道前者。
|
|
24059
24110
|
*/
|
|
24060
24111
|
host: external_exports.object({
|
|
24061
24112
|
platform: nonEmpty2,
|
|
@@ -24066,8 +24117,6 @@ var evHostInfo = external_exports.object({
|
|
|
24066
24117
|
carrier: external_exports.string().optional(),
|
|
24067
24118
|
dshVersion: external_exports.string().optional()
|
|
24068
24119
|
}),
|
|
24069
|
-
/** §17.4 的能力 id 列表。生产端取自 `CAPABILITY_IDS`(L1)。 */
|
|
24070
|
-
caps: capabilityList,
|
|
24071
24120
|
limits: hostLimits,
|
|
24072
24121
|
state: external_exports.object({
|
|
24073
24122
|
/** 防休眠走哪条系统后端;`unsupported` 是如实上报,不是错误。 */
|
|
@@ -24091,7 +24140,8 @@ var evModel = external_exports.object({
|
|
|
24091
24140
|
* 模型是**按会话**的,不带 sessionId 就无法过滤——任何一条别的会话的
|
|
24092
24141
|
* 模型帧都会覆盖本会话的显示。
|
|
24093
24142
|
*
|
|
24094
|
-
*
|
|
24143
|
+
* **必填**(2026-10-11 晚):不带会话归属的模型帧在 mp 端一律丢弃 ——
|
|
24144
|
+
* 宁可这一帧不算数,也不能把别的会话的模型名显示到本会话顶栏。
|
|
24095
24145
|
*/
|
|
24096
24146
|
sessionId: nonEmpty2,
|
|
24097
24147
|
provider: nonEmpty2.optional(),
|
|
@@ -24126,8 +24176,9 @@ var evSessionHistory = external_exports.object({
|
|
|
24126
24176
|
* 而空页没有任何 seq 可取——于是手机要么记一个陈旧的值(下次重复收到已看过的条目),
|
|
24127
24177
|
* 要么记 0(下次把整段历史重拉一遍)。这个字段让"你现在到哪了"始终有答案。
|
|
24128
24178
|
*
|
|
24129
|
-
* ⚠️
|
|
24130
|
-
*
|
|
24179
|
+
* ⚠️ 可选的含义是"**这一页报不出游标**"(内核这一代给不出来),不是"对端可能不支持":
|
|
24180
|
+
* 手机拿到 `undefined` 就按"已经到顶"收工(`client.js` 的 `next === null` 那一支),
|
|
24181
|
+
* 不记一个错的游标。(原来这里还提能力位 `drc.payload.resume` —— 那套机制已删除。)
|
|
24131
24182
|
*/
|
|
24132
24183
|
latestSeq: external_exports.number().int().nonnegative().optional()
|
|
24133
24184
|
});
|
|
@@ -24174,8 +24225,7 @@ var cmdSendPrompt = external_exports.object({
|
|
|
24174
24225
|
sessionId: nonEmpty2,
|
|
24175
24226
|
text: external_exports.string(),
|
|
24176
24227
|
/**
|
|
24177
|
-
*
|
|
24178
|
-
* (zod 默认 strip 未知键,而 text 照旧)——所以这是向后兼容的增补,不升协议版本。
|
|
24228
|
+
* 随消息的图片附件。**可选**:不带 = 这条消息没有图片(一个真实状态)。
|
|
24179
24229
|
* 上限取 `limits.ts` 的 `MAX_IMAGE_ATTACHMENTS`(4):再多对"看清楚这张图"没有帮助,
|
|
24180
24230
|
* 只是把帧撑爆。**同一个数**还在小程序(`MAX_ATTACH`)与主机侧(`uploads.ts`)各写一遍,
|
|
24181
24231
|
* 靠 `e2e/wire-surface.test.mjs` 逐处比对钉住——改动必须三处同时发生。
|
|
@@ -24306,145 +24356,7 @@ var PAYLOAD_TYPES = {
|
|
|
24306
24356
|
evSessionHistory: "ev.session_history"
|
|
24307
24357
|
};
|
|
24308
24358
|
|
|
24309
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.
|
|
24310
|
-
var ENDPOINT_FRAME_NAMES = [
|
|
24311
|
-
"hello",
|
|
24312
|
-
"pair-begin",
|
|
24313
|
-
"pair-begin-client",
|
|
24314
|
-
"resync",
|
|
24315
|
-
"enc",
|
|
24316
|
-
"enc-batch",
|
|
24317
|
-
"session-leave",
|
|
24318
|
-
"ping"
|
|
24319
|
-
];
|
|
24320
|
-
var RELAY_FRAME_NAMES = [
|
|
24321
|
-
"hello-ok",
|
|
24322
|
-
"pair-ready",
|
|
24323
|
-
"paired",
|
|
24324
|
-
"pair-fail",
|
|
24325
|
-
"peer-joined",
|
|
24326
|
-
"peer-left",
|
|
24327
|
-
"error",
|
|
24328
|
-
"pong",
|
|
24329
|
-
"enc",
|
|
24330
|
-
"enc-batch"
|
|
24331
|
-
];
|
|
24332
|
-
var FRAME_TYPES = [
|
|
24333
|
-
.../* @__PURE__ */ new Set([...ENDPOINT_FRAME_NAMES, ...RELAY_FRAME_NAMES])
|
|
24334
|
-
];
|
|
24335
|
-
var FRAME_TYPE_SET = new Set(FRAME_TYPES);
|
|
24336
|
-
function isKnownFrameType(name) {
|
|
24337
|
-
return typeof name === "string" && FRAME_TYPE_SET.has(name);
|
|
24338
|
-
}
|
|
24339
|
-
var CMD_TYPES = [
|
|
24340
|
-
PAYLOAD_TYPES.cmdSendPrompt,
|
|
24341
|
-
PAYLOAD_TYPES.cmdAnswer,
|
|
24342
|
-
PAYLOAD_TYPES.cmdResolvePermission,
|
|
24343
|
-
PAYLOAD_TYPES.cmdInterrupt,
|
|
24344
|
-
PAYLOAD_TYPES.cmdListSessions,
|
|
24345
|
-
PAYLOAD_TYPES.cmdKeepAwake,
|
|
24346
|
-
PAYLOAD_TYPES.cmdSessionHistory,
|
|
24347
|
-
PAYLOAD_TYPES.cmdNewSession,
|
|
24348
|
-
PAYLOAD_TYPES.cmdGetPending,
|
|
24349
|
-
PAYLOAD_TYPES.cmdArchiveSession
|
|
24350
|
-
];
|
|
24351
|
-
var EV_TYPES = [
|
|
24352
|
-
PAYLOAD_TYPES.evSessionChanged,
|
|
24353
|
-
PAYLOAD_TYPES.evMessageDelta,
|
|
24354
|
-
PAYLOAD_TYPES.evToolEvent,
|
|
24355
|
-
PAYLOAD_TYPES.evPermissionRequest,
|
|
24356
|
-
PAYLOAD_TYPES.evPermissionResolved,
|
|
24357
|
-
PAYLOAD_TYPES.evQuestionRequest,
|
|
24358
|
-
PAYLOAD_TYPES.evQuestionResolved,
|
|
24359
|
-
PAYLOAD_TYPES.evRunState,
|
|
24360
|
-
PAYLOAD_TYPES.evTodo,
|
|
24361
|
-
PAYLOAD_TYPES.evRetry,
|
|
24362
|
-
PAYLOAD_TYPES.evCompaction,
|
|
24363
|
-
PAYLOAD_TYPES.evKeepAwakeState,
|
|
24364
|
-
PAYLOAD_TYPES.evHostInfo,
|
|
24365
|
-
PAYLOAD_TYPES.evModel,
|
|
24366
|
-
PAYLOAD_TYPES.evResult,
|
|
24367
|
-
PAYLOAD_TYPES.evSessionHistory
|
|
24368
|
-
];
|
|
24369
|
-
var PAYLOAD_TYPE_SET = /* @__PURE__ */ new Set([...CMD_TYPES, ...EV_TYPES]);
|
|
24370
|
-
var CAPABILITY_IDS = [
|
|
24371
|
-
"drc.v1",
|
|
24372
|
-
"drc.pairing.qr",
|
|
24373
|
-
"drc.pairing.token",
|
|
24374
|
-
"drc.payload.attachments",
|
|
24375
|
-
"drc.payload.history",
|
|
24376
|
-
/**
|
|
24377
|
-
* 支持 `cmd.session_history.since` 向前补差量 + `ev.session_history.latestSeq`。
|
|
24378
|
-
*
|
|
24379
|
-
* **为什么是能力位而不是版本号**:协议 v1 的帧名与载荷名冻结(规范 §5.2 V1),
|
|
24380
|
-
* 加这个字段属"加性变更";而手机要知道**对端是否真的会回 `latestSeq`**,
|
|
24381
|
-
* 才能决定走补差量还是退回重拉一页。版本号表达不了"这一项有没有"。
|
|
24382
|
-
*
|
|
24383
|
-
* 老主机缺这一位时,手机**必须**退回重拉一页而不是发 `since`——
|
|
24384
|
-
* 那是可验证的降级路径(反向判据见 `tests/resume-capability.test.ts`)。
|
|
24385
|
-
*/
|
|
24386
|
-
"drc.payload.resume",
|
|
24387
|
-
"drc.payload.new-session.workspace",
|
|
24388
|
-
"drc.payload.get-pending",
|
|
24389
|
-
"drc.payload.keep-awake",
|
|
24390
|
-
"drc.payload.archive-session",
|
|
24391
|
-
"drc.payload.model",
|
|
24392
|
-
"drc.payload.retry-compaction",
|
|
24393
|
-
"drc.cmd.idempotency",
|
|
24394
|
-
"drc.data.enc-batch",
|
|
24395
|
-
"drc.host.resync",
|
|
24396
|
-
"drc.host.info",
|
|
24397
|
-
"drc.crypto.v2"
|
|
24398
|
-
];
|
|
24399
|
-
var CAPABILITY_SET = new Set(CAPABILITY_IDS);
|
|
24400
|
-
|
|
24401
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
|
|
24402
|
-
function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION, min = MIN_SUPPORTED_PROTOCOL) {
|
|
24403
|
-
if (peerProtocol === void 0 || peerProtocol === null) {
|
|
24404
|
-
return verdictFor(1, own2, min, true);
|
|
24405
|
-
}
|
|
24406
|
-
if (typeof peerProtocol !== "number" || !Number.isInteger(peerProtocol) || peerProtocol <= 0) {
|
|
24407
|
-
return {
|
|
24408
|
-
ok: false,
|
|
24409
|
-
reason: "invalid",
|
|
24410
|
-
peer: peerProtocol,
|
|
24411
|
-
message: `\u5BF9\u65B9\u62A5\u544A\u7684\u534F\u8BAE\u7248\u672C\u4E0D\u662F\u6B63\u6574\u6570\uFF08\u6536\u5230 ${JSON.stringify(peerProtocol) ?? "undefined"}\uFF09`
|
|
24412
|
-
};
|
|
24413
|
-
}
|
|
24414
|
-
return verdictFor(peerProtocol, own2, min, false);
|
|
24415
|
-
}
|
|
24416
|
-
function verdictFor(peer, own2, min, missing) {
|
|
24417
|
-
if (peer < min) {
|
|
24418
|
-
return {
|
|
24419
|
-
ok: false,
|
|
24420
|
-
reason: "too_old",
|
|
24421
|
-
peer,
|
|
24422
|
-
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`
|
|
24423
|
-
};
|
|
24424
|
-
}
|
|
24425
|
-
if (peer > own2) {
|
|
24426
|
-
return {
|
|
24427
|
-
ok: false,
|
|
24428
|
-
reason: "too_new",
|
|
24429
|
-
peer,
|
|
24430
|
-
// ⚠️ 这句话**会被原样弹到用户手机上**(中继 `acceptProtocol` 显式带 message,
|
|
24431
|
-
// 而客户端的处理是 `f.message || f.code`)。所以人称必须从**读它的人**出发:
|
|
24432
|
-
// 收这条错的一端是"对方",而被要求升级的是它自己 —— 写"请把本端升级"时,
|
|
24433
|
-
// 手机用户读到的是"请升级我的手机",而真正要升级的是中继/主机那一侧。
|
|
24434
|
-
// 同理"对方"在中继的日志里指的是手机。两个方向都试过,读起来都指错了人。
|
|
24435
|
-
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`
|
|
24436
|
-
};
|
|
24437
|
-
}
|
|
24438
|
-
return { ok: true, version: peer, missing };
|
|
24439
|
-
}
|
|
24440
|
-
|
|
24441
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
|
|
24442
|
-
var WAIT_HINTABLE = /* @__PURE__ */ new Set(["rate_limited", "pair_table_full"]);
|
|
24443
|
-
function wantsRetryAfter(code) {
|
|
24444
|
-
return typeof code === "string" && WAIT_HINTABLE.has(code);
|
|
24445
|
-
}
|
|
24446
|
-
|
|
24447
|
-
// node_modules/.pnpm/@dsh-remote+protocol@3.0.7/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
|
|
24448
24360
|
function helloOkForClient(clientId, protocol) {
|
|
24449
24361
|
return { t: "hello-ok", role: "client", clientId, ...protocol === void 0 ? {} : { protocol } };
|
|
24450
24362
|
}
|
|
@@ -24522,7 +24434,69 @@ function encBatchToRelay(sessionId, items) {
|
|
|
24522
24434
|
return { t: "enc-batch", sessionId, items: items.map((item) => ({ ...item })) };
|
|
24523
24435
|
}
|
|
24524
24436
|
|
|
24525
|
-
// 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
|
|
24526
24500
|
function frameTypeOf(value) {
|
|
24527
24501
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
24528
24502
|
return void 0;
|
|
@@ -24615,12 +24589,11 @@ import {
|
|
|
24615
24589
|
writeFileSync
|
|
24616
24590
|
} from "node:fs";
|
|
24617
24591
|
import { dirname } from "node:path";
|
|
24618
|
-
var STATE_FILE_VERSION =
|
|
24592
|
+
var STATE_FILE_VERSION = 2;
|
|
24619
24593
|
var CONVERSATION_ID_RE = new RegExp(`^${CONVERSATION_ID_PREFIX}[0-9a-f]{12}$`);
|
|
24620
24594
|
var HOST_TOKEN_RE = /^[0-9a-f]{48}$/;
|
|
24621
24595
|
var MAX_HOST_ID_LEN = 200;
|
|
24622
24596
|
function sanitizeHostTokens(raw) {
|
|
24623
|
-
if (!Array.isArray(raw)) return { hostTokens: [], dropped: 0 };
|
|
24624
24597
|
const hostTokens = [];
|
|
24625
24598
|
const seen = /* @__PURE__ */ new Set();
|
|
24626
24599
|
for (const item of raw) {
|
|
@@ -24628,7 +24601,10 @@ function sanitizeHostTokens(raw) {
|
|
|
24628
24601
|
continue;
|
|
24629
24602
|
}
|
|
24630
24603
|
const entry = item;
|
|
24631
|
-
if (typeof entry.hostId !== "string" ||
|
|
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)) {
|
|
24632
24608
|
continue;
|
|
24633
24609
|
}
|
|
24634
24610
|
seen.add(entry.hostId);
|
|
@@ -24703,6 +24679,10 @@ function readStateFile(path, log) {
|
|
|
24703
24679
|
}
|
|
24704
24680
|
const { conversations, dropped } = sanitize(file2.conversations);
|
|
24705
24681
|
if (dropped > 0) log.warn("state file entries dropped", { path, dropped, kept: conversations.length });
|
|
24682
|
+
if (!Array.isArray(file2.hostTokens)) {
|
|
24683
|
+
log.warn("state file has no hostTokens array, starting empty", { path });
|
|
24684
|
+
return void 0;
|
|
24685
|
+
}
|
|
24706
24686
|
const hostTokenResult = sanitizeHostTokens(file2.hostTokens);
|
|
24707
24687
|
if (hostTokenResult.dropped > 0) {
|
|
24708
24688
|
log.warn("state file host tokens dropped", {
|
|
@@ -24717,13 +24697,12 @@ function readStateFile(path, log) {
|
|
|
24717
24697
|
dropped,
|
|
24718
24698
|
hostTokens: hostTokenResult.hostTokens.length
|
|
24719
24699
|
});
|
|
24720
|
-
|
|
24700
|
+
return {
|
|
24721
24701
|
version: STATE_FILE_VERSION,
|
|
24722
24702
|
savedAt: isTimestamp(file2.savedAt) ? file2.savedAt : 0,
|
|
24723
|
-
conversations
|
|
24703
|
+
conversations,
|
|
24704
|
+
hostTokens: hostTokenResult.hostTokens
|
|
24724
24705
|
};
|
|
24725
|
-
if (hostTokenResult.hostTokens.length > 0) restored.hostTokens = hostTokenResult.hostTokens;
|
|
24726
|
-
return restored;
|
|
24727
24706
|
}
|
|
24728
24707
|
function snapshotState(state, now) {
|
|
24729
24708
|
const conversations = [];
|
|
@@ -24742,9 +24721,7 @@ function snapshotState(state, now) {
|
|
|
24742
24721
|
for (const [hostId, entry] of state.hostTokens) {
|
|
24743
24722
|
hostTokens.push({ hostId, token: entry.token, issuedAt: entry.issuedAt, lastUsedAt: entry.lastUsedAt });
|
|
24744
24723
|
}
|
|
24745
|
-
|
|
24746
|
-
if (hostTokens.length > 0) snapshot.hostTokens = hostTokens;
|
|
24747
|
-
return snapshot;
|
|
24724
|
+
return { version: STATE_FILE_VERSION, savedAt: now, conversations, hostTokens };
|
|
24748
24725
|
}
|
|
24749
24726
|
function restoreState(path, state, log, now) {
|
|
24750
24727
|
const file2 = readStateFile(path, log);
|
|
@@ -24766,7 +24743,7 @@ function restoreState(path, state, log, now) {
|
|
|
24766
24743
|
if (clamped > 0) {
|
|
24767
24744
|
log.warn("state file timestamps in the future, clamped to now", { path, clamped });
|
|
24768
24745
|
}
|
|
24769
|
-
for (const entry of file2.hostTokens
|
|
24746
|
+
for (const entry of file2.hostTokens) {
|
|
24770
24747
|
state.hostTokens.set(entry.hostId, {
|
|
24771
24748
|
token: entry.token,
|
|
24772
24749
|
issuedAt: entry.issuedAt,
|
|
@@ -24826,13 +24803,12 @@ function writeStateFile(path, snapshot, log) {
|
|
|
24826
24803
|
|
|
24827
24804
|
// src/state.ts
|
|
24828
24805
|
var WS_OPEN = 1;
|
|
24829
|
-
var UNBOUND_HOST_TOKEN_KEY = "";
|
|
24830
24806
|
var RelayState = class {
|
|
24831
24807
|
hosts = /* @__PURE__ */ new Map();
|
|
24832
24808
|
clients = /* @__PURE__ */ new Map();
|
|
24833
24809
|
pendingPairs = /* @__PURE__ */ new Map();
|
|
24834
24810
|
conversations = /* @__PURE__ */ new Map();
|
|
24835
|
-
/** 按 hostId
|
|
24811
|
+
/** 按 hostId 一机一枚(键一定是非空 hostId,见 `HostTokenEntry`)。 */
|
|
24836
24812
|
hostTokens = /* @__PURE__ */ new Map();
|
|
24837
24813
|
/** 测试可注入固定时钟;默认用真实时间。 */
|
|
24838
24814
|
clock;
|
|
@@ -25301,15 +25277,9 @@ function createRelay(config2) {
|
|
|
25301
25277
|
}
|
|
25302
25278
|
function hostTokenAccepted(hostId, got) {
|
|
25303
25279
|
if (!got) return false;
|
|
25304
|
-
const now = Date.now();
|
|
25305
25280
|
const perHost = state.hostTokens.get(hostId);
|
|
25306
25281
|
if (perHost && tokenMatches(perHost.token, got)) {
|
|
25307
|
-
state.markHostTokenUsed(hostId, now);
|
|
25308
|
-
return true;
|
|
25309
|
-
}
|
|
25310
|
-
const unbound = state.hostTokens.get(UNBOUND_HOST_TOKEN_KEY);
|
|
25311
|
-
if (unbound && tokenMatches(unbound.token, got)) {
|
|
25312
|
-
state.markHostTokenUsed(UNBOUND_HOST_TOKEN_KEY, now);
|
|
25282
|
+
state.markHostTokenUsed(hostId, Date.now());
|
|
25313
25283
|
return true;
|
|
25314
25284
|
}
|
|
25315
25285
|
return config2.hostToken !== "" && tokenMatches(config2.hostToken, got);
|
|
@@ -25423,21 +25393,6 @@ function createRelay(config2) {
|
|
|
25423
25393
|
peer.hostId = void 0;
|
|
25424
25394
|
}
|
|
25425
25395
|
function handleHello(peer, frame) {
|
|
25426
|
-
if (peer.protocol === void 0 && !peer.countedProtocol) {
|
|
25427
|
-
peer.countedProtocol = true;
|
|
25428
|
-
if (frame.protocol === void 0) peersWithoutProtocol++;
|
|
25429
|
-
else peerProtocols.set(frame.protocol, (peerProtocols.get(frame.protocol) ?? 0) + 1);
|
|
25430
|
-
} else if (peer.protocol !== frame.protocol) {
|
|
25431
|
-
if (peer.protocol === void 0) peersWithoutProtocol = Math.max(0, peersWithoutProtocol - 1);
|
|
25432
|
-
else {
|
|
25433
|
-
const left = (peerProtocols.get(peer.protocol) ?? 1) - 1;
|
|
25434
|
-
if (left > 0) peerProtocols.set(peer.protocol, left);
|
|
25435
|
-
else peerProtocols.delete(peer.protocol);
|
|
25436
|
-
}
|
|
25437
|
-
if (frame.protocol === void 0) peersWithoutProtocol++;
|
|
25438
|
-
else peerProtocols.set(frame.protocol, (peerProtocols.get(frame.protocol) ?? 0) + 1);
|
|
25439
|
-
}
|
|
25440
|
-
peer.protocol = frame.protocol;
|
|
25441
25396
|
if (frame.role === "host") {
|
|
25442
25397
|
const hostId = frame.hostId?.trim() || newHostId();
|
|
25443
25398
|
if (!config2.hostToken && state.hostTokens.size === 0) {
|
|
@@ -25607,8 +25562,6 @@ function createRelay(config2) {
|
|
|
25607
25562
|
for (const sock of clients) sendTo(sock, encBatchToClient(frame.sessionId, items));
|
|
25608
25563
|
}
|
|
25609
25564
|
let lastResyncPersistAt = 0;
|
|
25610
|
-
const peerProtocols = /* @__PURE__ */ new Map();
|
|
25611
|
-
let peersWithoutProtocol = 0;
|
|
25612
25565
|
function acceptProtocol(peer, peerProtocol) {
|
|
25613
25566
|
const verdict = negotiateProtocol(peerProtocol);
|
|
25614
25567
|
if (verdict.ok) return true;
|
|
@@ -25724,8 +25677,6 @@ function createRelay(config2) {
|
|
|
25724
25677
|
pairBudgetWarnedAt: 0,
|
|
25725
25678
|
backpressure: new BackpressureGate(),
|
|
25726
25679
|
alive: true,
|
|
25727
|
-
// 观测面计数的闩锁(见 Peer.countedProtocol):连接为单位,不是 hello 为单位。
|
|
25728
|
-
countedProtocol: false,
|
|
25729
25680
|
// 轮转序号取模分配:**均匀是刻意的**。哪怕重启风暴里 10k 条连接在几十秒内
|
|
25730
25681
|
// 全建起来,它们也会摊到所有桶上;若改成"分给下一个将要轮到的桶",
|
|
25731
25682
|
// 风暴会把它们全塞进同一两个桶,等于把刚拆掉的突发又造回来(C7 的场景)。
|
|
@@ -25800,12 +25751,6 @@ function createRelay(config2) {
|
|
|
25800
25751
|
});
|
|
25801
25752
|
ws.on("close", () => {
|
|
25802
25753
|
peers.delete(ws);
|
|
25803
|
-
if (peer.protocol === void 0) peersWithoutProtocol = Math.max(0, peersWithoutProtocol - 1);
|
|
25804
|
-
else {
|
|
25805
|
-
const left = (peerProtocols.get(peer.protocol) ?? 1) - 1;
|
|
25806
|
-
if (left > 0) peerProtocols.set(peer.protocol, left);
|
|
25807
|
-
else peerProtocols.delete(peer.protocol);
|
|
25808
|
-
}
|
|
25809
25754
|
bucketAt(peer.pingBucket).delete(peer);
|
|
25810
25755
|
if (peer.role === "client" && peer.clientId) {
|
|
25811
25756
|
for (const notice of state.clientGone(peer.clientId, ws)) {
|
|
@@ -25899,18 +25844,12 @@ function createRelay(config2) {
|
|
|
25899
25844
|
stateWriteFailures: persistence.failures,
|
|
25900
25845
|
lastPingAgo: lastPingAt === 0 ? -1 : Math.round((Date.now() - lastPingAt) / 1e3),
|
|
25901
25846
|
shuttingDown,
|
|
25902
|
-
//
|
|
25903
|
-
//
|
|
25904
|
-
|
|
25905
|
-
// 报的是**对端报的最小/最大版本**与"没报过版本"的连接数,两个方向缺一不可:
|
|
25906
|
-
// 只报最大值的话,一个全是老小程序的环境看起来与新版本一样正常。
|
|
25907
|
-
protocolSelf: PROTOCOL_VERSION,
|
|
25908
|
-
protocolMin: MIN_SUPPORTED_PROTOCOL,
|
|
25909
|
-
peerProtocolMin: peerProtocols.size === 0 ? -1 : Math.min(...peerProtocols.keys()),
|
|
25910
|
-
peerProtocolMax: peerProtocols.size === 0 ? -1 : Math.max(...peerProtocols.keys()),
|
|
25911
|
-
peersNoProtocol: peersWithoutProtocol
|
|
25847
|
+
// 本端说的协议版本(单版本制:它是一个常量,但排障时要能一眼看到"这一个中继
|
|
25848
|
+
// 说的是哪一版"——版本对不上的现场表现是"连上了、什么都不发生",没有任何一层会报错)。
|
|
25849
|
+
protocolSelf: PROTOCOL_VERSION
|
|
25912
25850
|
};
|
|
25913
25851
|
}
|
|
25852
|
+
const BODY_TOO_LARGE = "body_too_large";
|
|
25914
25853
|
function readBody(req, limit) {
|
|
25915
25854
|
return new Promise((resolve, reject) => {
|
|
25916
25855
|
const chunks = [];
|
|
@@ -25918,8 +25857,8 @@ function createRelay(config2) {
|
|
|
25918
25857
|
req.on("data", (chunk) => {
|
|
25919
25858
|
size += chunk.length;
|
|
25920
25859
|
if (size > limit) {
|
|
25921
|
-
|
|
25922
|
-
|
|
25860
|
+
req.pause();
|
|
25861
|
+
reject(Object.assign(new Error(BODY_TOO_LARGE), { code: BODY_TOO_LARGE }));
|
|
25923
25862
|
return;
|
|
25924
25863
|
}
|
|
25925
25864
|
chunks.push(chunk);
|
|
@@ -25950,7 +25889,18 @@ function createRelay(config2) {
|
|
|
25950
25889
|
}
|
|
25951
25890
|
rememberClaim(ip, now);
|
|
25952
25891
|
let body = {};
|
|
25953
|
-
|
|
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
|
+
}
|
|
25954
25904
|
if (raw.trim() !== "") {
|
|
25955
25905
|
try {
|
|
25956
25906
|
const parsed = JSON.parse(raw);
|
|
@@ -25961,24 +25911,29 @@ function createRelay(config2) {
|
|
|
25961
25911
|
}
|
|
25962
25912
|
}
|
|
25963
25913
|
const rawHostId = typeof body.hostId === "string" ? body.hostId.trim() : "";
|
|
25964
|
-
if (rawHostId
|
|
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)) {
|
|
25965
25925
|
res.statusCode = 400;
|
|
25966
25926
|
res.end(
|
|
25967
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 ._:-` })
|
|
25968
25928
|
);
|
|
25969
25929
|
return;
|
|
25970
25930
|
}
|
|
25971
|
-
const slot = rawHostId === "" ? UNBOUND_HOST_TOKEN_KEY : rawHostId;
|
|
25972
25931
|
const token = newHostToken();
|
|
25973
|
-
state.issueHostToken(
|
|
25932
|
+
state.issueHostToken(rawHostId, token, now);
|
|
25974
25933
|
const pruned = state.pruneHostTokens(config2.maxHostTokens);
|
|
25975
25934
|
if (pruned > 0) log.warn("host tokens pruned", { dropped: pruned, kept: state.hostTokens.size });
|
|
25976
25935
|
markStateChanged();
|
|
25977
|
-
log.info(
|
|
25978
|
-
ip,
|
|
25979
|
-
hostId: rawHostId,
|
|
25980
|
-
hostTokens: state.hostTokens.size
|
|
25981
|
-
});
|
|
25936
|
+
log.info("host token claimed", { ip, hostId: rawHostId, hostTokens: state.hostTokens.size });
|
|
25982
25937
|
res.end(JSON.stringify({ token, hostId: rawHostId, rotatedAt: new Date(now).toISOString() }));
|
|
25983
25938
|
}
|
|
25984
25939
|
function httpHandler(req, res) {
|
|
@@ -26186,7 +26141,7 @@ function registerSignalHandlers(shutdown) {
|
|
|
26186
26141
|
|
|
26187
26142
|
// src/main.ts
|
|
26188
26143
|
function readVersion() {
|
|
26189
|
-
if ("3.1.
|
|
26144
|
+
if ("3.1.4") return "3.1.4";
|
|
26190
26145
|
try {
|
|
26191
26146
|
const pkg = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8"));
|
|
26192
26147
|
if (typeof pkg.version === "string") return pkg.version;
|