@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 +6 -6
- package/deploy/docker/compose.yaml +1 -1
- package/dist/bundle/main.js +228 -26
- package/package.json +1 -1
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` |
|
|
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:-
|
|
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:` 码。
|
package/dist/bundle/main.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|