@dsh-remote/relay 3.2.10 → 3.2.12

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
@@ -37,12 +37,12 @@ curl -s http://127.0.0.1:8787/healthz
37
37
 
38
38
  ### 端点
39
39
 
40
- | 路径 | 说明 |
41
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
- | `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` |
43
- | `GET /api/info` | `{"publicUrl","protocol"}` |
44
- | `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉 |
45
- | WebSocket | 路径不设限,任意路径都能升级 |
40
+ | 路径 | 说明 |
41
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
+ | `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` / `replayRingConversations` / `replayRingFrames` |
43
+ | `GET /api/info` | `{"publicUrl","protocol"}` |
44
+ | `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉 |
45
+ | WebSocket | 路径不设限,任意路径都能升级 |
46
46
 
47
47
  落盘五字段(`persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` /
48
48
  `stateWriteFailures`)是"还在落盘"的唯一读数——换机器先看 `stateWriteFailures`,不是 0 就是没在写。
@@ -65,7 +65,7 @@ services:
65
65
  DRC_STATE_SAVE_MS: ${DRC_STATE_SAVE_MS:-60000}
66
66
  DRC_MAX_CONNS: ${DRC_MAX_CONNS:-200}
67
67
  DRC_CONV_IDLE_TTL_MS: ${DRC_CONV_IDLE_TTL_MS:-604800000}
68
- DRC_HOST_GRACE_MS: ${DRC_HOST_GRACE_MS:-120000}
68
+ DRC_HOST_GRACE_MS: ${DRC_HOST_GRACE_MS:-600000}
69
69
  # 小程序码签发(2026-10-21「双码合一」):**只有中继持有小程序的 appSecret**,
70
70
  # 主机插件那份永远拿不到它。两个都配了才启用 `GET /code?token=`;
71
71
  # 不配(自部署常态)那条路由回 501,主机自动回落到旧 `dshr:` 码。
@@ -23443,7 +23443,7 @@ function date4(params) {
23443
23443
  return _coercedDate(ZodDate, params);
23444
23444
  }
23445
23445
 
23446
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
23446
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
23447
23447
  var MAX_RELAY_MESSAGE_BYTES = 1024 * 1024;
23448
23448
  var MAX_CIPHERTEXT_BYTES = MAX_RELAY_MESSAGE_BYTES - 8 * 1024;
23449
23449
  var MAX_IMAGE_ATTACHMENTS = 4;
@@ -23452,7 +23452,7 @@ var MAX_ATTACHMENT_BYTES = 512 * 1024;
23452
23452
  var MAX_ATTACHMENT_TOTAL_BYTES = 512 * 1024;
23453
23453
  var IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
23454
23454
 
23455
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
23455
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
23456
23456
  var PROTOCOL_VERSION = 1;
23457
23457
  var PAIRING_TOKEN_RE = /^\d{6}$/;
23458
23458
  var nonEmpty = external_exports.string().min(1);
@@ -23498,7 +23498,39 @@ var helloFrame = external_exports.object({
23498
23498
  * 它**不是**协议协商位:两端**永远不因它不同而拒连**(版本兼容性由各自的应用层判,
23499
23499
  * 见小程序的 `MIN_PLUGIN_VERSION`)⇒ 它只是给人看的读数。
23500
23500
  */
23501
- softwareVersion: external_exports.string().min(1).max(64).optional()
23501
+ softwareVersion: external_exports.string().min(1).max(64).optional(),
23502
+ /**
23503
+ * 断线补帧请求(P1-3,2026-10-22):客户端告诉中继"我上次收到这里,
23504
+ * 断线期间的下行帧请从环形缓冲里补给我"。
23505
+ *
23506
+ * ## 为什么搭 `hello` 而不是单独发一帧 `resume`
23507
+ *
23508
+ * 竞态:客户端重连后 socket 一进成员表,主机的下行帧就会**立刻**推给它;
23509
+ * 而一条独立的 `resume` 帧要等下一个事件循环才被处理 —— 中间挤进来的
23510
+ * live 帧会**先于**回放到达(重连发生在输出流中段时几乎必然)。要么在客户端
23511
+ * 做重排缓冲 + 去重,要么在中继侧按 socket 暂停投递 —— 两边都比问题本身大。
23512
+ * 搭 `hello` 则整段(鉴权、进表、回放)在**同一个同步处理**里完成:
23513
+ * 回放帧先于 socket 进入投递表,顺序由构造保证,客户端一行重排代码都不用。
23514
+ *
23515
+ * ## 为什么这是加性变更(§15.1 第一行:新增可选字段,不升 PROTOCOL_VERSION)
23516
+ *
23517
+ * - **旧中继 × 新客户端**:旧 schema 没有这一格 ⇒ zod strip 掉、静默忽略 ⇒
23518
+ * 行为与加这个字段之前逐字节相同(客户端退回收不到回放,走既有历史重读)。
23519
+ * **不会**回 `unknown_frame` —— 这正是不发独立帧的第二个好处。
23520
+ * - **新中继 × 旧客户端**:旧客户端不填 ⇒ 不回放 ⇒ 逐字节现状。
23521
+ * - F2(client 只发 3 类帧 + `session-leave`)**不动**:没有新增客户端出站帧名。
23522
+ *
23523
+ * ⚠️ 只有 `role:'client'` 有意义;主机带了会被忽略(没有"主机断线补下行"的场景)。
23524
+ * ⚠️ 只补**当前配对那一条**会话(小程序同时只有一条有效 convId)。
23525
+ * ⚠️ `fromSeq` 是**中继编号**(`enc`/`enc-batch` 里的 `seq`),不是主机历史游标
23526
+ * `latestSeq` —— 两个序号空间互不相干,混用的症状是"补回来一段莫名其妙的内容"。
23527
+ */
23528
+ resume: external_exports.object({
23529
+ /** 配对通道 id(与 `enc.sessionId` 同义,F3)。 */
23530
+ sessionId: nonEmpty,
23531
+ /** 已收到的最大中继编号;中继回放 `seq > fromSeq` 的存帧。0 = 一帧都没收到过。 */
23532
+ fromSeq: external_exports.number().int().nonnegative()
23533
+ }).optional()
23502
23534
  });
23503
23535
  var publicKeyB64 = external_exports.string().regex(/^[A-Za-z0-9+/]{43}=$/, "\u5FC5\u987B\u662F 32 \u5B57\u8282 base64 \u516C\u94A5");
23504
23536
  var pairBeginFrame = external_exports.object({
@@ -23723,6 +23755,11 @@ var errorFrame = external_exports.object({
23723
23755
  retryAfterMs: external_exports.number().int().positive().max(3e5).optional()
23724
23756
  });
23725
23757
  var pongFrame = external_exports.object({ t: external_exports.literal("pong"), ts: external_exports.number().optional() });
23758
+ var resumeGapFrame = external_exports.object({
23759
+ t: external_exports.literal("resume-gap"),
23760
+ sessionId: nonEmpty,
23761
+ reason: external_exports.enum(["overflow", "reset"])
23762
+ });
23726
23763
  var relayFrame = external_exports.discriminatedUnion("t", [
23727
23764
  helloOkFrame,
23728
23765
  pairReadyFrame,
@@ -23736,14 +23773,15 @@ var relayFrame = external_exports.discriminatedUnion("t", [
23736
23773
  errorFrame,
23737
23774
  pongFrame,
23738
23775
  encFrame,
23739
- encBatchFrame
23776
+ encBatchFrame,
23777
+ resumeGapFrame
23740
23778
  ]);
23741
23779
  function parseEndpointFrame(value) {
23742
23780
  const parsed = endpointFrame.safeParse(value);
23743
23781
  return parsed.success ? parsed.data : null;
23744
23782
  }
23745
23783
 
23746
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/report-version.js
23784
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/report-version.js
23747
23785
  var REPORT_VERSION_ENV = "DRC_REPORT_VERSION";
23748
23786
  function reportSoftwareVersion(env) {
23749
23787
  const raw = (env[REPORT_VERSION_ENV] ?? "").trim().toLowerCase();
@@ -23933,7 +23971,12 @@ function loadConfig(env, version2) {
23933
23971
  // 删的代价是手机再扫一次码;不删的代价是 conversations 计数虚高、排障对不上。
23934
23972
  // socket 断开不打点(D3 免扫码),这条只管"只剩主机"的真空会话。
23935
23973
  conversationEmptyTtlMs: integer2(env.DRC_CONV_EMPTY_TTL_MS, 30 * 60 * 1e3, "DRC_CONV_EMPTY_TTL_MS", problems),
23936
- hostGraceMs: integer2(env.DRC_HOST_GRACE_MS, 12e4, "DRC_HOST_GRACE_MS", problems),
23974
+ // P1-4(2026-10-22 用户拍板 10 分钟):主机 socket 断开的宽限期。原来 120s ——
23975
+ // 太短:一次重启/网络抖动超过 2 分钟就 `dropHost` 删光该主机的会话,手机被迫重扫
23976
+ // (PLAN-smooth-78-92 §L3:今天 9 次 grace expired,最长受害会话 219 帧)。
23977
+ // 代价是孤儿会话多占表一段时间(`maxConversations` 满时只回收"已空"会话,可忽略)。
23978
+ // 治本(休眠会话 + 同 hostId 重连自动回挂)留待观察一周后按投诉量决定。
23979
+ hostGraceMs: integer2(env.DRC_HOST_GRACE_MS, 6e5, "DRC_HOST_GRACE_MS", problems),
23937
23980
  sweepMs: integer2(env.DRC_SWEEP_MS, 5e3, "DRC_SWEEP_MS", problems),
23938
23981
  countersLogMs: integer2(env.DRC_COUNTERS_LOG_MS, 6e4, "DRC_COUNTERS_LOG_MS", problems),
23939
23982
  pingIntervalMs: integer2(env.DRC_PING_INTERVAL_MS, 6e4, "DRC_PING_INTERVAL_MS", problems),
@@ -23995,7 +24038,7 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
23995
24038
  var import_websocket = __toESM(require_websocket(), 1);
23996
24039
  var import_websocket_server = __toESM(require_websocket_server(), 1);
23997
24040
 
23998
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
24041
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
23999
24042
  import { randomBytes, randomUUID } from "node:crypto";
24000
24043
  var CONVERSATION_ID_PREFIX = "c_";
24001
24044
  function newConversationId() {
@@ -24005,7 +24048,7 @@ function newHostId() {
24005
24048
  return randomBytes(4).toString("hex");
24006
24049
  }
24007
24050
 
24008
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
24051
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
24009
24052
  function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION) {
24010
24053
  if (peerProtocol === void 0 || peerProtocol === null) {
24011
24054
  return {
@@ -24125,16 +24168,16 @@ function createMpCodeIssuer(options) {
24125
24168
  };
24126
24169
  }
24127
24170
 
24128
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
24171
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
24129
24172
  var WAIT_HINTABLE = /* @__PURE__ */ new Set(["rate_limited", "pair_table_full"]);
24130
24173
  function wantsRetryAfter(code) {
24131
24174
  return typeof code === "string" && WAIT_HINTABLE.has(code);
24132
24175
  }
24133
24176
 
24134
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
24177
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
24135
24178
  var SLEEP_BACKENDS = ["caffeinate", "systemd-inhibit", "unsupported"];
24136
24179
 
24137
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
24180
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
24138
24181
  var SESSION_STATES = [
24139
24182
  "idle",
24140
24183
  "running",
@@ -24574,7 +24617,7 @@ var PAYLOAD_TYPES = {
24574
24617
  evSessionHistory: "ev.session_history"
24575
24618
  };
24576
24619
 
24577
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
24620
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
24578
24621
  function helloOkForClient(clientId, protocol) {
24579
24622
  return { t: "hello-ok", role: "client", clientId, ...protocol === void 0 ? {} : { protocol } };
24580
24623
  }
@@ -24622,6 +24665,12 @@ function makeError(code, message, retryAfterMs) {
24622
24665
  function pong(ts) {
24623
24666
  return { t: "pong", ...ts === void 0 ? {} : { ts } };
24624
24667
  }
24668
+ function resumeGap(sessionId, reason) {
24669
+ if (reason !== "overflow" && reason !== "reset") {
24670
+ throw new Error(`resume-gap \u7684 reason \u53EA\u80FD\u662F overflow|reset\uFF0C\u6536\u5230 ${JSON.stringify(reason)}`);
24671
+ }
24672
+ return { t: "resume-gap", sessionId, reason };
24673
+ }
24625
24674
  function encToClient(sessionId, seq, ciphertext) {
24626
24675
  if (ciphertext.length > MAX_CIPHERTEXT_BYTES) {
24627
24676
  throw new Error(`\u5BC6\u6587 ${ciphertext.length} \u5B57\u8282\u8D85\u8FC7\u4E0A\u9650 ${MAX_CIPHERTEXT_BYTES}`);
@@ -24661,7 +24710,7 @@ function encBatchToRelay(sessionId, items) {
24661
24710
  return { t: "enc-batch", sessionId, items: items.map((item) => ({ ...item })) };
24662
24711
  }
24663
24712
 
24664
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
24713
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
24665
24714
  var ENDPOINT_FRAME_NAMES = [
24666
24715
  "hello",
24667
24716
  "pair-begin",
@@ -24685,7 +24734,9 @@ var RELAY_FRAME_NAMES = [
24685
24734
  "error",
24686
24735
  "pong",
24687
24736
  "enc",
24688
- "enc-batch"
24737
+ "enc-batch",
24738
+ // P1-3(2026-10-22):`hello.resume` 补不出来时的回执(环滚过/序号重置)。
24739
+ "resume-gap"
24689
24740
  ];
24690
24741
  var FRAME_TYPES = [
24691
24742
  .../* @__PURE__ */ new Set([...ENDPOINT_FRAME_NAMES, ...RELAY_FRAME_NAMES])
@@ -24726,7 +24777,7 @@ var EV_TYPES = [
24726
24777
  ];
24727
24778
  var PAYLOAD_TYPE_SET = /* @__PURE__ */ new Set([...CMD_TYPES, ...EV_TYPES]);
24728
24779
 
24729
- // node_modules/.pnpm/@dsh-remote+protocol@3.2.9/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
24780
+ // node_modules/.pnpm/@dsh-remote+protocol@3.2.12/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
24730
24781
  function frameTypeOf(value) {
24731
24782
  if (typeof value !== "object" || value === null || Array.isArray(value))
24732
24783
  return void 0;
@@ -25027,6 +25078,99 @@ function writeStateFile(path, snapshot, log) {
25027
25078
  }
25028
25079
  }
25029
25080
 
25081
+ // src/replay.ts
25082
+ var REPLAY_MAX_FRAMES = 256;
25083
+ var REPLAY_MAX_BYTES_PER_CONV = 2 * 1024 * 1024;
25084
+ var REPLAY_MAX_BYTES_TOTAL = 64 * 1024 * 1024;
25085
+ var ReplayBuffer = class {
25086
+ rings = /* @__PURE__ */ new Map();
25087
+ totalBytes = 0;
25088
+ tick = 0;
25089
+ /**
25090
+ * 存一帧。**在 seq 分配点调用**——包括"批量帧后半段因会话被删而放弃发送"的那些:
25091
+ * 编号已经被 `nextHostSequence` 消耗掉,环里留着洞的话 `replay()` 的连续性判断
25092
+ * 会把洞当成"已经补上了",那是最坏的静默缺帧。
25093
+ */
25094
+ push(conversationId, seq, ciphertext) {
25095
+ const bytes = Math.min(ciphertext.length, MAX_CIPHERTEXT_BYTES);
25096
+ let ring = this.rings.get(conversationId);
25097
+ if (!ring) {
25098
+ ring = { entries: [], bytes: 0, tick: 0 };
25099
+ this.rings.set(conversationId, ring);
25100
+ }
25101
+ ring.entries.push({ seq, ciphertext });
25102
+ ring.bytes += bytes;
25103
+ this.totalBytes += bytes;
25104
+ this.tick += 1;
25105
+ ring.tick = this.tick;
25106
+ while (ring.entries.length > REPLAY_MAX_FRAMES) {
25107
+ const gone = ring.entries.shift();
25108
+ if (!gone) break;
25109
+ ring.bytes -= Math.min(gone.ciphertext.length, MAX_CIPHERTEXT_BYTES);
25110
+ this.totalBytes -= Math.min(gone.ciphertext.length, MAX_CIPHERTEXT_BYTES);
25111
+ }
25112
+ while (ring.bytes > REPLAY_MAX_BYTES_PER_CONV && ring.entries.length > 1) {
25113
+ const gone = ring.entries.shift();
25114
+ if (!gone) break;
25115
+ const g = Math.min(gone.ciphertext.length, MAX_CIPHERTEXT_BYTES);
25116
+ ring.bytes -= g;
25117
+ this.totalBytes -= g;
25118
+ }
25119
+ while (this.totalBytes > REPLAY_MAX_BYTES_TOTAL && this.rings.size > 1) {
25120
+ let victim = null;
25121
+ let victimTick = Infinity;
25122
+ for (const [id, r] of this.rings) {
25123
+ if (id === conversationId) continue;
25124
+ if (r.tick < victimTick) {
25125
+ victimTick = r.tick;
25126
+ victim = id;
25127
+ }
25128
+ }
25129
+ if (victim === null) break;
25130
+ this.forget(victim);
25131
+ }
25132
+ }
25133
+ /**
25134
+ * 补投 `seq > fromSeq` 的存帧。
25135
+ *
25136
+ * @param seqHost 会话的**权威计数器**(`Conversation.seqHost`):判断"这段是不是
25137
+ * 真的存在过"要用它,环只能证明"我们留住了什么"。中继重启后环空了而计数器
25138
+ * 还在(它落盘)——那正是 `overflow` 的一半成因。
25139
+ */
25140
+ replay(conversationId, fromSeq, seqHost) {
25141
+ if (fromSeq > seqHost) return { ok: false, reason: "reset" };
25142
+ if (fromSeq === seqHost) return { ok: true, frames: [] };
25143
+ const ring = this.rings.get(conversationId);
25144
+ const oldest = ring?.entries[0];
25145
+ const floor = oldest ? oldest.seq : seqHost + 1;
25146
+ if (fromSeq + 1 < floor) return { ok: false, reason: "overflow" };
25147
+ const frames = (ring?.entries ?? []).filter((e) => e.seq > fromSeq);
25148
+ return { ok: true, frames };
25149
+ }
25150
+ /** 会话删除时一起清(作废时残留要一起清)。幂等。 */
25151
+ forget(conversationId) {
25152
+ const ring = this.rings.get(conversationId);
25153
+ if (!ring) return;
25154
+ this.totalBytes -= ring.bytes;
25155
+ this.rings.delete(conversationId);
25156
+ }
25157
+ /** 全量清空(`RelayState.reset` 一起清:作废时残留要一起清)。幂等。 */
25158
+ clear() {
25159
+ this.rings.clear();
25160
+ this.totalBytes = 0;
25161
+ }
25162
+ /** 诊断用:当前留着几条会话的环。 */
25163
+ size() {
25164
+ return this.rings.size;
25165
+ }
25166
+ /** 诊断用:当前环内帧数(全会话合计)。 */
25167
+ frameCount() {
25168
+ let n = 0;
25169
+ for (const ring of this.rings.values()) n += ring.entries.length;
25170
+ return n;
25171
+ }
25172
+ };
25173
+
25030
25174
  // src/state.ts
25031
25175
  var WS_OPEN = 1;
25032
25176
  var RelayState = class {
@@ -25034,6 +25178,13 @@ var RelayState = class {
25034
25178
  clients = /* @__PURE__ */ new Map();
25035
25179
  pendingPairs = /* @__PURE__ */ new Map();
25036
25180
  conversations = /* @__PURE__ */ new Map();
25181
+ /**
25182
+ * 下行帧环形缓冲(P1-3):**刻意不落盘**——它是"断线那几秒的补帧",
25183
+ * 进程重启后补不回也不该补(`seqHost` 落盘而环不落盘 ⇒ 覆盖判定自然得出
25184
+ * `overflow`,见 `ReplayBuffer` 头注)。挂在 state 上是为了**删会话时一起清**
25185
+ * (作废时残留要一起清),六个删除点全部收在 `dropConv` 里。
25186
+ */
25187
+ replay = new ReplayBuffer();
25037
25188
  /** 按 hostId 一机一枚(键一定是非空 hostId,见 `HostTokenEntry`)。 */
25038
25189
  hostTokens = /* @__PURE__ */ new Map();
25039
25190
  /** 测试可注入固定时钟;默认用真实时间。 */
@@ -25128,7 +25279,7 @@ var RelayState = class {
25128
25279
  let dropped = 0;
25129
25280
  for (const [conversationId] of abandoned) {
25130
25281
  if (this.conversations.size < this.maxConversationsValue) break;
25131
- this.conversations.delete(conversationId);
25282
+ this.purgeConv(conversationId);
25132
25283
  dropped += 1;
25133
25284
  }
25134
25285
  return dropped;
@@ -25239,7 +25390,7 @@ var RelayState = class {
25239
25390
  continue;
25240
25391
  }
25241
25392
  droppedMembers.push({ conversationId, clientIds: [...conv.clients] });
25242
- this.conversations.delete(conversationId);
25393
+ this.purgeConv(conversationId);
25243
25394
  dropped.push(conversationId);
25244
25395
  }
25245
25396
  return { kept, dropped, droppedMembers, emptyAtRisk };
@@ -25321,12 +25472,21 @@ var RelayState = class {
25321
25472
  }
25322
25473
  return true;
25323
25474
  }
25475
+ /**
25476
+ * 删会话记录的唯一咽喉:**环形缓冲一起清**(作废时残留要一起清)。
25477
+ * 六个删除点(空会话回收 / resync / 主动作废 / 主机判死 / 闲置清扫 / 空会话清扫)
25478
+ * 全走这里——漏一处的形状是"会话没了、补帧环还在",属于本仓咬过两次的那类残留。
25479
+ */
25480
+ purgeConv(conversationId) {
25481
+ this.conversations.delete(conversationId);
25482
+ this.replay.forget(conversationId);
25483
+ }
25324
25484
  /** 主机主动作废某条会话(R1):返回被摘掉的客户端,调用方据此发 peer-left。 */
25325
25485
  dropConversation(conversationId) {
25326
25486
  const conv = this.conversations.get(conversationId);
25327
25487
  if (!conv) return void 0;
25328
25488
  const clientIds = [...conv.clients];
25329
- this.conversations.delete(conversationId);
25489
+ this.purgeConv(conversationId);
25330
25490
  return { conversationId, clientIds };
25331
25491
  }
25332
25492
  /** 立即判死某个主机(用于主机主动解配或会话被清)。返回需要向客户端发 peer-left 的清单。 */
@@ -25335,7 +25495,7 @@ var RelayState = class {
25335
25495
  for (const [conversationId, conv] of [...this.conversations]) {
25336
25496
  if (conv.hostId !== hostId) continue;
25337
25497
  dropped.push({ conversationId, hostId, clientIds: [...conv.clients] });
25338
- this.conversations.delete(conversationId);
25498
+ this.purgeConv(conversationId);
25339
25499
  }
25340
25500
  this.hosts.delete(hostId);
25341
25501
  return dropped;
@@ -25400,7 +25560,7 @@ var RelayState = class {
25400
25560
  const dropped = [];
25401
25561
  for (const [conversationId, conv] of [...this.conversations]) {
25402
25562
  if (now - conv.lastActivityAt >= ttlMs) {
25403
- this.conversations.delete(conversationId);
25563
+ this.purgeConv(conversationId);
25404
25564
  dropped.push(conversationId);
25405
25565
  }
25406
25566
  }
@@ -25426,7 +25586,7 @@ var RelayState = class {
25426
25586
  for (const [conversationId, conv] of [...this.conversations]) {
25427
25587
  if (conv.emptySince === void 0) continue;
25428
25588
  if (now - conv.emptySince >= emptyTtlMs) {
25429
- this.conversations.delete(conversationId);
25589
+ this.purgeConv(conversationId);
25430
25590
  dropped.push(conversationId);
25431
25591
  }
25432
25592
  }
@@ -25485,13 +25645,14 @@ var RelayState = class {
25485
25645
  hostTokens: this.hostTokens.size
25486
25646
  };
25487
25647
  }
25488
- /** 全量清空(优雅停机或测试重置)。 */
25648
+ /** 全量清空(优雅停机或测试重置)。环形缓冲一起清(作废时残留要一起清)。 */
25489
25649
  reset() {
25490
25650
  this.hosts.clear();
25491
25651
  this.clients.clear();
25492
25652
  this.pendingPairs.clear();
25493
25653
  this.conversations.clear();
25494
25654
  this.hostTokens.clear();
25655
+ this.replay.clear();
25495
25656
  }
25496
25657
  };
25497
25658
 
@@ -25684,7 +25845,7 @@ function createRelay(config2) {
25684
25845
  }
25685
25846
  if (!hostTokenAccepted(hostId, frame.token)) {
25686
25847
  peer.authAttempts += 1;
25687
- log.warn("host auth failed", { attempts: peer.authAttempts });
25848
+ log.warn("host auth failed", { attempts: peer.authAttempts, ip: peer.ip });
25688
25849
  sendError(peer, "bad_token");
25689
25850
  if (peer.authAttempts >= config2.maxHostAuthAttempts) peer.ws.close(4001, "too_many_auth_attempts");
25690
25851
  return;
@@ -25712,6 +25873,7 @@ function createRelay(config2) {
25712
25873
  replaced.close(CLOSE_REPLACED, "replaced_by_newer_socket");
25713
25874
  }
25714
25875
  sendToPeer(peer, helloOkForClient(clientId, frame.protocol));
25876
+ if (frame.resume) replayToClient(peer, clientId, frame.resume);
25715
25877
  log.info("client online", { clientId, platform: frame.clientMeta?.platform });
25716
25878
  if (config2.reportSoftwareVersion && frame.softwareVersion) {
25717
25879
  log.info("client software version", { clientId, version: frame.softwareVersion });
@@ -25815,6 +25977,38 @@ function createRelay(config2) {
25815
25977
  const entry = state.hosts.get(hostId);
25816
25978
  return entry ? peers.get(entry.ws) : void 0;
25817
25979
  }
25980
+ function replayToClient(peer, clientId, resume) {
25981
+ const rejected = state.routeFrom(resume.sessionId, peer.ws, "client");
25982
+ if (rejected) {
25983
+ log.info("resume skipped", { clientId, sessionId: resume.sessionId, reason: rejected });
25984
+ return;
25985
+ }
25986
+ const conv = state.conversations.get(resume.sessionId);
25987
+ if (!conv) return;
25988
+ const result = state.replay.replay(resume.sessionId, resume.fromSeq, conv.seqHost);
25989
+ if (!result.ok) {
25990
+ sendToPeer(peer, resumeGap(resume.sessionId, result.reason));
25991
+ log.info("resume gap", {
25992
+ clientId,
25993
+ sessionId: resume.sessionId,
25994
+ reason: result.reason,
25995
+ fromSeq: resume.fromSeq,
25996
+ seqHost: conv.seqHost
25997
+ });
25998
+ return;
25999
+ }
26000
+ for (const entry of result.frames) {
26001
+ sendToPeer(peer, encToClient(resume.sessionId, entry.seq, entry.ciphertext));
26002
+ }
26003
+ if (result.frames.length > 0) {
26004
+ log.info("resume replayed", {
26005
+ clientId,
26006
+ sessionId: resume.sessionId,
26007
+ count: result.frames.length,
26008
+ fromSeq: resume.fromSeq
26009
+ });
26010
+ }
26011
+ }
25818
26012
  function forwardEnc(peer, frame) {
25819
26013
  const rejected = state.routeFrom(frame.sessionId, peer.ws, peer.role);
25820
26014
  if (rejected) {
@@ -25837,6 +26031,7 @@ function createRelay(config2) {
25837
26031
  return;
25838
26032
  }
25839
26033
  const seq = state.nextHostSequence(frame.sessionId);
26034
+ if (seq !== void 0) state.replay.push(frame.sessionId, seq, frame.ciphertext);
25840
26035
  const sockets = state.clientSockets(frame.sessionId);
25841
26036
  if (sockets.length === 0) counters.droppedFrames += 1;
25842
26037
  for (const sock of sockets) {
@@ -25866,6 +26061,7 @@ function createRelay(config2) {
25866
26061
  for (const item of frame.items) {
25867
26062
  const seq = state.nextHostSequence(frame.sessionId);
25868
26063
  if (seq === void 0) return;
26064
+ state.replay.push(frame.sessionId, seq, item.ciphertext);
25869
26065
  items.push({ seq, ciphertext: item.ciphertext });
25870
26066
  }
25871
26067
  for (const sock of clients) sendTo(sock, encBatchToClient(frame.sessionId, items));
@@ -25986,7 +26182,7 @@ function createRelay(config2) {
25986
26182
  ws.on("error", (e) => log.warn("socket error", { message: String(e?.message ?? e) }));
25987
26183
  ws.close(code, reason);
25988
26184
  }
25989
- function onConnection(ws) {
26185
+ function onConnection(ws, ip) {
25990
26186
  if (shuttingDown) {
25991
26187
  rejectConnection(ws, 1013, "server_shutdown");
25992
26188
  return;
@@ -25997,6 +26193,7 @@ function createRelay(config2) {
25997
26193
  }
25998
26194
  const peer = {
25999
26195
  ws,
26196
+ ip,
26000
26197
  role: "unknown",
26001
26198
  rate: new FrameRateGate(config2.maxFramesPerSec),
26002
26199
  authAttempts: 0,
@@ -26167,6 +26364,11 @@ function createRelay(config2) {
26167
26364
  // "手机不更新"这类排查分不清"现在是空的"和"一直送不出去"。
26168
26365
  ...state.counts(),
26169
26366
  ...counters,
26367
+ // P1-3 的**可补面**(2026-10-22):`droppedFrames` 里没有收件人的那部分,run 输出帧
26368
+ // 现在多数进了环并在重连时补投。这两个读数回答"此刻环里还剩多少、挂在几条会话上"
26369
+ // (瞬时值,会随补投/淘汰浮动);历史累计仍看 `droppedFrames`。
26370
+ replayRingConversations: state.replay.size(),
26371
+ replayRingFrames: state.replay.frameCount(),
26170
26372
  // 落盘的可观测面。**刻意不暴露文件路径**:`/healthz` 是公网可达的,
26171
26373
  // 而一个绝对路径对排障没用、对探测者有用(告诉它这台机器上有什么、装在哪)。
26172
26374
  // 运维要知道路径看环境变量就够了。
@@ -26392,7 +26594,7 @@ function createRelay(config2) {
26392
26594
  socket.destroy();
26393
26595
  }
26394
26596
  });
26395
- wss.on("connection", (ws) => onConnection(ws));
26597
+ wss.on("connection", (ws, req) => onConnection(ws, req.socket.remoteAddress ?? "unknown"));
26396
26598
  async function drain() {
26397
26599
  if (wss.clients.size === 0) return;
26398
26600
  await new Promise((resolve) => {
@@ -26500,7 +26702,7 @@ function registerSignalHandlers(shutdown) {
26500
26702
 
26501
26703
  // src/main.ts
26502
26704
  function readVersion() {
26503
- if ("3.2.10") return "3.2.10";
26705
+ if ("3.2.12") return "3.2.12";
26504
26706
  try {
26505
26707
  const pkg = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8"));
26506
26708
  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.2.10",
3
+ "version": "3.2.12",
4
4
  "description": "DSH Remote Control 的零知识 WebSocket 中继:只做路由,不碰载荷明文。可自托管的单文件产物。",
5
5
  "type": "module",
6
6
  "license": "MIT",