@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # @dsh-remote/relay
2
2
 
3
3
  DSH Remote Control 的**零知识 WebSocket 中继**:在宿主插件与已配对的手机之间转发**密封记录**,
4
- 只认帧名 / 会话 id / 能力协商,**从不解析载荷**——密文对它就是一个 base64 字符串。
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
- > 完整的环境变量表、日志契约、重启语义、升级回滚见 [`../docs/SELF-HOSTING.md`](../docs/SELF-HOSTING.md)。
23
+ > 完整的环境变量表、日志契约、重启语义、升级见 [`../docs/SELF-HOSTING.md`](../docs/SELF-HOSTING.md)。
24
24
 
25
25
  ## 从零复现
26
26
 
@@ -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
- "永远配不上对"或"静默不上线"。见 `../SELF-HOSTING.md` §10.2 的对照表。
68
+ 三端(中继 / 宿主插件 / 小程序)必须升到**同一协议版本**:落后的一端会在 `hello` 那一刻
69
+ 被明确拒掉(`unsupported_protocol`)。⚠️ 2026-10-11 晚:原来这里还写着一套"成对回滚 +
70
+ 两代兼容矩阵",随能力位与版本协商机制一起删掉了 —— 没有老用户,不需要回滚路径。
74
71
 
75
72
  ## 拉不到 Docker Hub 怎么办
76
73
 
@@ -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.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
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.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
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
- protocol: external_exports.number().int().positive().optional(),
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().optional(),
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
- * (`onPairFail` 的注释里写着这条兜底的原因)。发它的是中继、读它的是主机,
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
- * 不带这个标记 = 断线(老中继行为,保持 D3);带 true = 主动解配,主机应当把
23572
- * 会话一并作废,于是 pill 从「手机离线」回到「未配对」(2026-10-05 用户报:
23573
- * 「mp 端解除配对,dsh 端执行的是手机离线」)。
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
- * 对端的对端版本落在 `[MIN_SUPPORTED_PROTOCOL, 本端]` 之外(规范 §5.2 V3)。
23605
+ * 对端报的协议版本与本端**不相等**(含没报,规范 §5.2 V3)。
23613
23606
  *
23614
23607
  * 为什么不复用 `bad_frame`:它是**版本问题**而不是形状问题,而它唯一该触发的
23615
23608
  * 动作是"把版本说清楚"——一句"帧形状非法"会让用户去查一个根本不存在的问题。
23616
- * 加它不破坏任何老实现:老 client 见到不认识的码会走兜底展示(`message || code`),
23617
- * 所以中继 MUST 同时带上一句中文 message(规范 §12.3 E1)。
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.0.7/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
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.0.7/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
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.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
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
- sessionId: nonEmpty2.optional(),
23969
- /** 谁收的场。手机只按"要不要收掉这张卡"读它,所以不做必填。 */
23970
- by: external_exports.enum(["desktop", "cancelled"]).optional()
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
- * 为什么审批有而提问没有过:主机侧的提问等待本来就带超时(`questionTimeoutMs`,默认 300s),
23988
- * 但手机上那张卡**没有任何倒计时**——用户看不见自己按的按钮什么时候失效,
23989
- * 而这违反伞仓 `docs/PRODUCT.md` §3 第 3 条「超时必须可见」。
23990
- * 与审批那条同一个形状:可选、ISO 字符串、由主机每次发问时现算。
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: nonEmpty2.optional(),
23998
- /** 谁收的场。手机只按"要不要收掉这张卡"读它,所以不做必填。 */
23999
- by: external_exports.enum(["desktop", "cancelled"]).optional()
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
- * 必填:老主机不带这个字段时 mp 端宁可忽略这一帧,也不显示错的。
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
- * ⚠️ 它**不是**能力位那样的"可选支持":老主机不发这个字段时,手机看到 `undefined`
24130
- * 就退回 v2 的重拉一页(能力位 `drc.payload.resume` 决定走哪条路)。
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.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
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.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
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 = 1;
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" || entry.hostId.length > MAX_HOST_ID_LEN || entry.hostId !== "" && entry.hostId.trim() !== entry.hostId || !isNonEmptyString(entry.token) || !HOST_TOKEN_RE.test(entry.token) || !isTimestamp(entry.issuedAt) || !isTimestamp(entry.lastUsedAt) || seen.has(entry.hostId)) {
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
- const restored = {
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
- const snapshot = { version: STATE_FILE_VERSION, savedAt: now, conversations };
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 一机一枚(外加 `UNBOUND_HOST_TOKEN_KEY` 那个未绑定槽位)。 */
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
- // 协议版本协商的观测面(2026-10-07)。有了版本闸之后,"为什么这个对端连不上"
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
- reject(new Error("body_too_large"));
25922
- req.destroy();
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
- const raw = await readBody(req, CLAIM_BODY_MAX_BYTES);
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 !== "" && (rawHostId.length > MAX_HOST_ID_LEN2 || !HOST_ID_RE.test(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(slot, token, now);
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(rawHostId === "" ? "host token claimed (unbound)" : "host token claimed", {
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.3") return "3.1.3";
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-remote/relay",
3
- "version": "3.1.3",
3
+ "version": "3.1.4",
4
4
  "description": "DSH Remote Control 的零知识 WebSocket 中继:只做路由,不碰载荷明文。可自托管的单文件产物。",
5
5
  "type": "module",
6
6
  "license": "MIT",