@agentunion/fastaun-browser 0.2.18 → 0.2.20

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/_packed_docs/CHANGELOG.md +26 -0
  3. package/_packed_docs/agent.md/SCHEMA.md +173 -0
  4. package/_packed_docs/agent.md/examples/codeagent-claudecode.md +61 -0
  5. package/_packed_docs/agent.md/examples/human-developer.md +60 -0
  6. package/_packed_docs/agent.md/examples/openclaw-lobster.md +52 -0
  7. package/_packed_docs/agent.md/examples/signed-openclaw-lobster.md +43 -0
  8. package/_packed_docs/protocol/00-/346/200/273/350/247/210/344/270/216/345/210/206/345/261/202.md +205 -0
  9. package/_packed_docs/protocol/00A-/350/256/276/350/256/241/345/216/237/345/210/231-/344/270/272Agent/350/200/214/347/224/237.md +197 -0
  10. package/_packed_docs/protocol/01-/350/272/253/344/273/275/344/270/216/345/207/255/350/257/201/345/215/217/350/256/256-auth.md +549 -0
  11. package/_packed_docs/protocol/02-/350/257/201/344/271/246/344/270/216/344/277/241/344/273/273/344/275/223/347/263/273.md +810 -0
  12. package/_packed_docs/protocol/03-Gateway-/350/277/236/346/216/245/346/250/241/345/274/217.md +262 -0
  13. package/_packed_docs/protocol/04-Peer-/345/255/220/345/215/217/350/256/256.md +180 -0
  14. package/_packed_docs/protocol/05-Relay-/345/255/220/345/215/217/350/256/256.md +164 -0
  15. package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +1135 -0
  16. package/_packed_docs/protocol/07-/351/224/231/350/257/257/347/240/201/344/270/216/347/212/266/346/200/201/346/234/272.md +234 -0
  17. package/_packed_docs/protocol/08-AUN-E2EE-Group.md +900 -0
  18. package/_packed_docs/protocol/08-AUN-E2EE.md +413 -0
  19. package/_packed_docs/protocol/09-/345/256/211/345/205/250/350/200/203/350/231/221.md +316 -0
  20. package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +804 -0
  21. package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +271 -0
  22. package/_packed_docs/protocol/12-Stream-/345/255/220/345/215/217/350/256/256.md +329 -0
  23. package/_packed_docs/protocol/13-Agent/350/241/214/344/270/272/350/247/204/350/214/203.md +141 -0
  24. package/_packed_docs/protocol/14-/344/272/244/344/272/222/346/234/272/345/210/266-/345/223/215/345/272/224/346/250/241/345/274/217/344/270/216/350/207/252/344/270/273/346/250/241/345/274/217.md +170 -0
  25. package/_packed_docs/protocol/README.md +71 -0
  26. package/_packed_docs/protocol/agent.md/SCHEMA.md +118 -0
  27. package/_packed_docs/protocol/agent.md/examples/codeagent-claudecode.md +61 -0
  28. package/_packed_docs/protocol/agent.md/examples/human-developer.md +60 -0
  29. package/_packed_docs/protocol/agent.md/examples/openclaw-lobster.md +52 -0
  30. package/_packed_docs/protocol/aun-docs-guide.md +49 -0
  31. package/_packed_docs/protocol/index.md +114 -0
  32. package/_packed_docs/protocol//350/215/211/346/241/210-agent.md/347/255/276/345/220/215/345/215/217/350/256/256.md +205 -0
  33. package/_packed_docs/protocol//350/215/211/346/241/210-/346/213/222/347/273/235/344/277/241/345/217/267/345/215/217/350/256/256.md +249 -0
  34. package/_packed_docs/protocol//351/231/204/345/275/225A-/346/234/257/350/257/255/350/241/250.md +337 -0
  35. package/_packed_docs/protocol//351/231/204/345/275/225B-/346/211/251/345/261/225/346/200/247/346/214/207/345/215/227.md +80 -0
  36. package/_packed_docs/protocol//351/231/204/345/275/225C-/347/247/201/351/222/245/347/256/241/347/220/206/344/270/216/350/272/253/344/273/275/346/201/242/345/244/215.md +704 -0
  37. package/_packed_docs/protocol//351/231/204/345/275/225D-Root_CA_/346/262/273/347/220/206/346/234/272/345/210/266.md +620 -0
  38. package/_packed_docs/protocol//351/231/204/345/275/225E-Root_CA_/345/207/206/345/205/245/346/265/201/347/250/213.md +605 -0
  39. package/_packed_docs/protocol//351/231/204/345/275/225F-Issuer_CA_/347/224/263/350/257/267/346/265/201/347/250/213.md +548 -0
  40. package/_packed_docs/protocol//351/231/204/345/275/225G-AID_/345/255/244/345/204/277/351/242/204/351/230/262/344/270/216/346/225/221/346/217/264/346/234/272/345/210/266.md +513 -0
  41. package/_packed_docs/protocol//351/231/204/345/275/225H-Identity/346/234/215/345/212/241/345/256/236/347/216/260/346/214/207/345/215/227.md +619 -0
  42. package/_packed_docs/protocol//351/231/204/345/275/225I-/350/267/250/345/237/237/346/266/210/346/201/257/350/267/257/347/224/261/345/256/236/347/216/260/346/214/207/345/215/227.md +492 -0
  43. package/_packed_docs/protocol//351/231/204/345/275/225J-/345/256/242/346/210/267/347/253/257/346/216/245/345/205/245/347/244/272/344/276/213.md +402 -0
  44. package/_packed_docs/protocol//351/231/204/345/275/225K-Agent_Web/345/217/221/347/216/260/345/215/217/350/256/256.md +130 -0
  45. package/_packed_docs/protocol//351/231/204/345/275/225L-E2EE/345/256/236/347/216/260/346/214/207/345/215/227.md +267 -0
  46. package/_packed_docs/protocol//351/231/204/345/275/225M-JWT/350/256/244/350/257/201/345/256/236/347/216/260/346/214/207/345/215/227.md +367 -0
  47. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +223 -0
  48. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +354 -0
  49. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +172 -0
  50. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +373 -0
  51. package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +611 -0
  52. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1152 -0
  53. package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +150 -0
  54. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +89 -0
  55. package/_packed_docs/sdk/09-custody-api-manual.md +445 -0
  56. package/_packed_docs/sdk/09-group-rpc-manual.md +1895 -0
  57. package/_packed_docs/sdk/09-message-rpc-manual.md +597 -0
  58. package/_packed_docs/sdk/09-meta-rpc-manual.md +142 -0
  59. package/_packed_docs/sdk/09-payload-reference.md +702 -0
  60. package/_packed_docs/sdk/09-storage-rpc-manual.md +408 -0
  61. package/_packed_docs/sdk/09-stream-rpc-manual.md +275 -0
  62. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +72 -0
  63. package/_packed_docs/sdk/INDEX.md +131 -0
  64. package/_packed_docs/sdk/README.md +307 -0
  65. package/dist/auth.d.ts +11 -1
  66. package/dist/auth.d.ts.map +1 -1
  67. package/dist/auth.js +92 -17
  68. package/dist/auth.js.map +1 -1
  69. package/dist/client.d.ts +51 -9
  70. package/dist/client.d.ts.map +1 -1
  71. package/dist/client.js +394 -122
  72. package/dist/client.js.map +1 -1
  73. package/dist/e2ee.d.ts.map +1 -1
  74. package/dist/e2ee.js +20 -0
  75. package/dist/e2ee.js.map +1 -1
  76. package/dist/keystore/index.d.ts +11 -0
  77. package/dist/keystore/index.d.ts.map +1 -1
  78. package/dist/keystore/indexeddb.d.ts +35 -0
  79. package/dist/keystore/indexeddb.d.ts.map +1 -1
  80. package/dist/keystore/indexeddb.js +91 -0
  81. package/dist/keystore/indexeddb.js.map +1 -1
  82. package/dist/namespaces/auth.d.ts +10 -3
  83. package/dist/namespaces/auth.d.ts.map +1 -1
  84. package/dist/namespaces/auth.js +94 -15
  85. package/dist/namespaces/auth.js.map +1 -1
  86. package/dist/transport.d.ts +9 -1
  87. package/dist/transport.d.ts.map +1 -1
  88. package/dist/transport.js +24 -0
  89. package/dist/transport.js.map +1 -1
  90. package/package.json +4 -1
package/dist/client.js CHANGED
@@ -87,7 +87,7 @@ const DEFAULT_SESSION_OPTIONS = {
87
87
  },
88
88
  timeouts: {
89
89
  connect: 5.0,
90
- call: 10.0,
90
+ call: 35.0,
91
91
  http: 30.0,
92
92
  },
93
93
  };
@@ -99,6 +99,20 @@ const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
99
99
  const PENDING_DECRYPT_LIMIT = 100;
100
100
  const PUSHED_SEQS_LIMIT = 50_000;
101
101
  const PENDING_ORDERED_LIMIT = 50_000;
102
+ // 心跳间隔下/上限(秒)。0 = 关闭心跳;负值视为 0;其余值 clamp 到 [10, 600]。
103
+ // 服务端通过 hello.heartbeat_interval 与 meta.ping pong 中的同名字段下发。
104
+ const HEARTBEAT_MIN_INTERVAL_SECONDS = 10;
105
+ const HEARTBEAT_MAX_INTERVAL_SECONDS = 600;
106
+ function clampHeartbeatInterval(value) {
107
+ const n = Number(value);
108
+ if (!Number.isFinite(n) || n <= 0)
109
+ return 0;
110
+ if (n < HEARTBEAT_MIN_INTERVAL_SECONDS)
111
+ return HEARTBEAT_MIN_INTERVAL_SECONDS;
112
+ if (n > HEARTBEAT_MAX_INTERVAL_SECONDS)
113
+ return HEARTBEAT_MAX_INTERVAL_SECONDS;
114
+ return n;
115
+ }
102
116
  // P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
103
117
  const NON_IDEMPOTENT_TIMEOUT = 35;
104
118
  const NON_IDEMPOTENT_METHODS = new Set([
@@ -321,6 +335,7 @@ export class AUNClient {
321
335
  _gatewayUrl = null;
322
336
  _deviceId;
323
337
  _slotId;
338
+ _connectedAt = 0;
324
339
  _connectDeliveryMode;
325
340
  _defaultConnectDeliveryMode;
326
341
  _closing = false;
@@ -343,6 +358,8 @@ export class AUNClient {
343
358
  _certCache = new Map();
344
359
  _prekeyReplenishInflight = new Set();
345
360
  _prekeyReplenished = new Set();
361
+ // 当前活跃 prekey:只有这个 prekey 被消费时才触发 replenish 上传
362
+ _activePrekeyId = '';
346
363
  _peerPrekeysCache = new Map();
347
364
  // 后台任务 handle(浏览器 setInterval/setTimeout)
348
365
  _heartbeatTimer = null;
@@ -352,6 +369,17 @@ export class AUNClient {
352
369
  _groupEpochCleanupTimer = null;
353
370
  _groupEpochRotateTimer = null;
354
371
  _cacheCleanupTimer = null;
372
+ /**
373
+ * 本地 agent.md 内容对应的 etag(quoted sha256 hex,与服务端 _agent_md_etag 一致)。
374
+ *
375
+ * 浏览器无法直接读本地文件,因此 API 不接收 path,而是接收 markdown 字符串:
376
+ * 应用层(如 `<input type=file>`)读出文本后调用 setLocalAgentMdContent() 设置。
377
+ * 用于跟服务端 RPC 注入的 _meta.agent_md_etag 比对,触发"本地未发布到服务端"或
378
+ * "服务端版本更新"的 UI 提示。
379
+ */
380
+ _localAgentMdEtag = '';
381
+ /** gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。 */
382
+ _remoteAgentMdEtag = '';
355
383
  /** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
356
384
  _seqTracker = new SeqTracker();
357
385
  _seqTrackerContext = null;
@@ -370,14 +398,17 @@ export class AUNClient {
370
398
  _groupEpochRotationRetryTimers = new Map();
371
399
  /** Lazy group sync:首次发送群消息前自动拉取历史 */
372
400
  _groupSynced = new Set();
373
- /** Lazy P2P sync:首次发送 P2P 消息前自动拉取历史 */
374
- _p2pSynced = false;
375
401
  /** gap fill 来源标记:true 表示当前正在补洞(pull 触发),false 表示非补洞 */
376
402
  _gapFillActive = false;
377
403
  // 重连相关
378
404
  _reconnectActive = false;
379
405
  _reconnectAbort = null;
380
406
  _serverKicked = false;
407
+ /**
408
+ * 缓存最近一次服务端 gateway.disconnect 信息(含 code/reason/detail),
409
+ * 让后续 connection.state(terminal_failed) 也能携带 detail(如配额超限信息)。
410
+ */
411
+ _lastDisconnectInfo = null;
381
412
  // Logger(per-client 单例 + 各模块子 logger)
382
413
  _logger;
383
414
  _clientLog;
@@ -430,6 +461,7 @@ export class AUNClient {
430
461
  timeout: DEFAULT_SESSION_OPTIONS.timeouts.call,
431
462
  onDisconnect: (error, closeCode) => this._handleTransportDisconnect(error, closeCode),
432
463
  });
464
+ this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
433
465
  this._e2ee = new E2EEManager({
434
466
  identityFn: () => this._identity ?? {},
435
467
  deviceIdFn: () => this._deviceId,
@@ -492,14 +524,66 @@ export class AUNClient {
492
524
  });
493
525
  }
494
526
  // 服务端主动断开通知:记录日志并标记不重连
495
- this._dispatcher.subscribe('_raw.gateway.disconnect', (data) => {
496
- this._onGatewayDisconnect(data);
527
+ this._dispatcher.subscribe('_raw.gateway.disconnect', async (data) => {
528
+ await this._onGatewayDisconnect(data);
497
529
  });
498
530
  }
499
531
  // ── 属性 ──────────────────────────────────────────
500
532
  get aid() {
501
533
  return this._aid;
502
534
  }
535
+ /**
536
+ * 设置本地 agent.md 内容并一次性计算 etag(quoted sha256,与服务端一致)。
537
+ *
538
+ * 浏览器环境无法直接读本地文件,应用应通过 `<input type=file>` 等方式读出 markdown
539
+ * 文本后调用本方法。content 为空字符串时清除本地 etag。失败不抛异常(debug 日志记录),
540
+ * 应用可读 getLocalAgentMdEtag() 返回值或本方法返回值判断。
541
+ *
542
+ * 返回当前 etag(quoted hex 或空串)。
543
+ */
544
+ async setLocalAgentMdContent(content) {
545
+ const text = String(content ?? '');
546
+ if (text.length === 0) {
547
+ this._localAgentMdEtag = '';
548
+ return '';
549
+ }
550
+ try {
551
+ const data = new TextEncoder().encode(text);
552
+ const buf = await crypto.subtle.digest('SHA-256', data);
553
+ const bytes = new Uint8Array(buf);
554
+ let hex = '';
555
+ for (let i = 0; i < bytes.length; i++) {
556
+ hex += bytes[i].toString(16).padStart(2, '0');
557
+ }
558
+ this._localAgentMdEtag = `"${hex}"`;
559
+ }
560
+ catch (exc) {
561
+ this._clientLog.warn(`setLocalAgentMdContent sha256 failed: ${String(exc)}`);
562
+ this._localAgentMdEtag = '';
563
+ }
564
+ return this._localAgentMdEtag;
565
+ }
566
+ /** 返回 setLocalAgentMdContent 计算的 etag;未设置或计算失败时返回空串。 */
567
+ getLocalAgentMdEtag() {
568
+ return this._localAgentMdEtag;
569
+ }
570
+ /**
571
+ * 返回 gateway 在最近一次 RPC envelope._meta 注入的服务端 agent.md etag。
572
+ *
573
+ * 未收到过则为空串;不阻塞调用,纯内存读。
574
+ */
575
+ getRemoteAgentMdEtag() {
576
+ return this._remoteAgentMdEtag;
577
+ }
578
+ /** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
579
+ _observeRpcMeta(meta) {
580
+ if (!isJsonObject(meta))
581
+ return;
582
+ const etag = String(meta.agent_md_etag ?? '').trim();
583
+ if (etag) {
584
+ this._remoteAgentMdEtag = etag;
585
+ }
586
+ }
503
587
  get state() {
504
588
  return this._state;
505
589
  }
@@ -714,6 +798,7 @@ export class AUNClient {
714
798
  return this._sendEncrypted(p);
715
799
  }
716
800
  // encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
801
+ this._maybeAppendEchoTraceSend(p);
717
802
  }
718
803
  // 自动加密:group.send 默认加密(encrypt 默认 true)
719
804
  if (method === 'group.send') {
@@ -722,6 +807,7 @@ export class AUNClient {
722
807
  if (encrypt) {
723
808
  return this._sendGroupEncrypted(p);
724
809
  }
810
+ this._maybeAppendEchoTraceSend(p);
725
811
  }
726
812
  if (method === 'group.thought.put') {
727
813
  const encrypt = p.encrypt !== undefined ? p.encrypt : true;
@@ -788,7 +874,6 @@ export class AUNClient {
788
874
  if (method === 'group.pull' && isJsonObject(result)) {
789
875
  const r = result;
790
876
  const messages = r.messages;
791
- // 先保存原始消息(解密前),用于喂 SeqTracker(与 P2P message.pull 路径对齐)
792
877
  const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
793
878
  if (rawMessages.length) {
794
879
  r.messages = await this._decryptGroupMessages(rawMessages);
@@ -796,13 +881,28 @@ export class AUNClient {
796
881
  const gid = (p.group_id ?? '');
797
882
  if (gid) {
798
883
  const ns = `group:${gid}`;
799
- // ⚠️ 使用原始消息(rawMessages)喂 SeqTracker,与 P2P message.pull 路径一致
800
- if (rawMessages.length) {
801
- this._seqTracker.onPullResult(ns, rawMessages);
884
+ // 区分解密成功 / 失败:失败的 payload 仍是 e2ee.group_encrypted。
885
+ const decryptedOnly = [];
886
+ let failedCount = 0;
887
+ const decryptedMessages = Array.isArray(r.messages) ? r.messages : [];
888
+ for (const m of decryptedMessages) {
889
+ if (!isJsonObject(m))
890
+ continue;
891
+ const payload = isJsonObject(m.payload) ? m.payload : {};
892
+ const ptype = payload.type;
893
+ if (ptype === 'e2ee.group_encrypted') {
894
+ failedCount++;
895
+ this._enqueuePendingDecrypt(gid, m);
896
+ }
897
+ else {
898
+ decryptedOnly.push(m);
899
+ }
900
+ }
901
+ if (decryptedOnly.length) {
902
+ // 仅用解密成功的消息推进 contig;失败的等 retry 解密成功才推进。
903
+ this._seqTracker.onPullResult(ns, decryptedOnly);
802
904
  }
803
905
  // ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
804
- // 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
805
- // 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
806
906
  const cursor = isJsonObject(r.cursor) ? r.cursor : null;
807
907
  if (cursor) {
808
908
  const serverAck = Number(cursor.current_seq ?? 0);
@@ -815,9 +915,9 @@ export class AUNClient {
815
915
  }
816
916
  }
817
917
  this._saveSeqTrackerState();
818
- // auto-ack contiguous_seq
918
+ // auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
819
919
  const contig = this._seqTracker.getContiguousSeq(ns);
820
- const shouldAck = rawMessages.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0);
920
+ const shouldAck = failedCount === 0 && (decryptedOnly.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0));
821
921
  if (contig > 0 && shouldAck) {
822
922
  this._transport.call('group.ack_messages', {
823
923
  group_id: gid,
@@ -826,6 +926,10 @@ export class AUNClient {
826
926
  slot_id: this._slotId,
827
927
  }).catch((e) => { this._clientLog.warn('group.pull auto-ack failed: group=' + gid, e); });
828
928
  }
929
+ // 有解密失败时调度 recovery 兜底定时
930
+ if (failedCount > 0) {
931
+ this._scheduleRecoveryTimeout(gid);
932
+ }
829
933
  }
830
934
  }
831
935
  if (method === 'group.thought.get' && isJsonObject(result)) {
@@ -920,12 +1024,26 @@ export class AUNClient {
920
1024
  }
921
1025
  // 拦截 P2P 传输的群组密钥分发/请求/响应消息
922
1026
  if (await this._tryHandleGroupKeyMessage(msg)) {
1027
+ // group_key 控制消息也要推进 seq tracker + auto-ack,
1028
+ // 否则 fillP2pGap 会因为 contig 卡在此 seq 之前而重复拉取同样的历史消息。
1029
+ const seq = msg.seq;
1030
+ if (seq !== undefined && seq !== null && this._aid) {
1031
+ const ns = `p2p:${this._aid}`;
1032
+ this._seqTracker.onMessageSeq(ns, seq);
1033
+ this._saveSeqTrackerState();
1034
+ const contig = this._seqTracker.getContiguousSeq(ns);
1035
+ if (contig > 0) {
1036
+ this._transport.call('message.ack', {
1037
+ seq: contig,
1038
+ device_id: this._deviceId,
1039
+ slot_id: this._slotId,
1040
+ }).catch(() => { });
1041
+ }
1042
+ }
923
1043
  return;
924
1044
  }
925
1045
  // P2P 空洞检测
926
1046
  const seq = msg.seq;
927
- // 推送路径收到 P2P 消息 → 标记已同步,后续发送无需再 lazySyncP2p
928
- this._p2pSynced = true;
929
1047
  if (seq !== undefined && seq !== null && this._aid) {
930
1048
  const ns = `p2p:${this._aid}`;
931
1049
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
@@ -1004,8 +1122,12 @@ export class AUNClient {
1004
1122
  return;
1005
1123
  }
1006
1124
  const decrypted = await this._decryptGroupMessage(msg);
1007
- // 只有带 payload 的真实消息,在同步解密/恢复尝试结束后才推进游标。
1008
- if (groupId && seq !== undefined && seq !== null) {
1125
+ // 解密失败时**不推进 seq tracker / 不 auto-ack**:让服务端 cursor 留在原位,
1126
+ // 等密钥恢复后 retry 解密成功才推进 + ack;recovery 真的失败时由
1127
+ // _retryPendingDecryptMsgs(forceAdvanceOnFail=true) 兜底强制推进。
1128
+ const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
1129
+ const isDecryptFail = payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee;
1130
+ if (!isDecryptFail && groupId && seq !== undefined && seq !== null) {
1009
1131
  const ns = `group:${groupId}`;
1010
1132
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1011
1133
  if (needPull) {
@@ -1023,10 +1145,12 @@ export class AUNClient {
1023
1145
  this._saveSeqTrackerState();
1024
1146
  }
1025
1147
  // R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
1026
- const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
1027
- if (payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
1028
- if (groupId)
1148
+ if (isDecryptFail) {
1149
+ if (groupId) {
1029
1150
  this._enqueuePendingDecrypt(groupId, msg);
1151
+ // 触发 recovery 兜底定时(30s 后如果仍未解开,强制推进)
1152
+ this._scheduleRecoveryTimeout(groupId);
1153
+ }
1030
1154
  await this._publishAppEvent('group.message_undecryptable', {
1031
1155
  message_id: msg.message_id ?? null,
1032
1156
  group_id: groupId,
@@ -1349,8 +1473,61 @@ export class AUNClient {
1349
1473
  return this._attachCurrentInstanceContext(payload);
1350
1474
  }
1351
1475
  async _publishAppEvent(event, payload) {
1476
+ if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
1477
+ this._maybeAppendEchoTraceReceive(payload);
1478
+ }
1479
+ // 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
1480
+ if (isJsonObject(payload)) {
1481
+ try {
1482
+ const localEtag = this._localAgentMdEtag || '';
1483
+ const remoteEtag = this._remoteAgentMdEtag || '';
1484
+ if ((localEtag || remoteEtag) && payload._agent_md === undefined) {
1485
+ payload._agent_md = {
1486
+ local_etag: localEtag,
1487
+ remote_etag: remoteEtag,
1488
+ };
1489
+ }
1490
+ }
1491
+ catch (exc) {
1492
+ this._clientLog.debug(`agent_md etag inject skipped: ${String(exc)}`);
1493
+ }
1494
+ }
1352
1495
  await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
1353
1496
  }
1497
+ _echoTimestamp() {
1498
+ const now = new Date();
1499
+ const hh = String(now.getHours()).padStart(2, '0');
1500
+ const mm = String(now.getMinutes()).padStart(2, '0');
1501
+ const ss = String(now.getSeconds()).padStart(2, '0');
1502
+ const ms = String(now.getMilliseconds()).padStart(3, '0');
1503
+ return `${hh}:${mm}:${ss}.${ms}`;
1504
+ }
1505
+ _isEchoPayload(payload) {
1506
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
1507
+ return false;
1508
+ const text = payload.text;
1509
+ if (typeof text !== 'string' || text.length > 4096)
1510
+ return false;
1511
+ return text.split('\n', 1)[0].toLowerCase().includes('echo');
1512
+ }
1513
+ _maybeAppendEchoTraceSend(params) {
1514
+ const payload = params.payload;
1515
+ if (!this._isEchoPayload(payload))
1516
+ return;
1517
+ const uptime = this._connectedAt ? Math.floor((Date.now() - this._connectedAt) / 1000) : 0;
1518
+ const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
1519
+ params.payload = { ...payload, text: payload.text + '\n' + trace };
1520
+ }
1521
+ _maybeAppendEchoTraceReceive(msg) {
1522
+ if (msg.encrypted)
1523
+ return;
1524
+ const payload = msg.payload;
1525
+ if (!this._isEchoPayload(payload))
1526
+ return;
1527
+ const uptime = this._connectedAt ? Math.floor((Date.now() - this._connectedAt) / 1000) : 0;
1528
+ const trace = `${this._echoTimestamp()} [AUN-SDK.receive] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
1529
+ msg.payload = { ...payload, text: payload.text + '\n' + trace };
1530
+ }
1354
1531
  _messageTargetsCurrentInstance(message) {
1355
1532
  if (!isJsonObject(message))
1356
1533
  return true;
@@ -1864,10 +2041,8 @@ export class AUNClient {
1864
2041
  }
1865
2042
  const persistRequired = Boolean(params.persist_required || params.durable);
1866
2043
  const protectedHeaders = this._protectedHeadersFromParams(params);
1867
- // Lazy P2P sync:首次发送前自动拉取历史,避免重连后 seq 空洞
1868
- if (!this._p2pSynced) {
1869
- await this._lazySyncP2p();
1870
- }
2044
+ // 惰性 P2P 同步由 connect/reconnect 完成后的 _fillP2pGap 异步触发,
2045
+ // 不再在 send 路径上 await(与 C++ FillP2PGap 行为对齐,避免阻塞用户发送)。
1871
2046
  // 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
1872
2047
  const sendAttempt = async (refreshPeerMaterial = false) => {
1873
2048
  const recipientPrekeys = refreshPeerMaterial
@@ -1876,20 +2051,13 @@ export class AUNClient {
1876
2051
  const selfSyncCopies = await this._buildSelfSyncCopies({
1877
2052
  logicalToAid: toAid, payload, messageId, timestamp, protectedHeaders,
1878
2053
  });
1879
- // 多设备过滤:只保留有有效 device_id 的可路由 prekey
1880
- // 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
2054
+ // 统一 multi-device 路径:必须有 routable prekey
1881
2055
  const routablePrekeys = recipientPrekeys.filter(pk => {
1882
2056
  const did = String(pk.device_id ?? '').trim();
1883
2057
  return did && did !== PREKEY_FALLBACK_DEVICE_ID;
1884
2058
  });
1885
- const canUseMultiDevice = routablePrekeys.length > 0
1886
- && (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
1887
- if (!canUseMultiDevice) {
1888
- return await this._sendEncryptedSingle({
1889
- toAid, payload, messageId, timestamp,
1890
- prekey: routablePrekeys[0] ?? recipientPrekeys[0],
1891
- persistRequired, protectedHeaders,
1892
- });
2059
+ if (routablePrekeys.length === 0) {
2060
+ throw new Error(`no registered device prekeys for ${toAid}, cannot send encrypted message`);
1893
2061
  }
1894
2062
  const recipientCopies = await this._buildRecipientDeviceCopies({
1895
2063
  toAid, payload, messageId, timestamp,
@@ -1932,63 +2100,6 @@ export class AUNClient {
1932
2100
  throw err;
1933
2101
  }
1934
2102
  }
1935
- /**
1936
- * 首次发送 P2P 消息前懒拉取历史消息,同步 seqTracker 避免空洞。
1937
- * 只在本连接周期内执行一次。
1938
- */
1939
- async _lazySyncP2p() {
1940
- this._p2pSynced = true;
1941
- if (!this._aid)
1942
- return;
1943
- const ns = `p2p:${this._aid}`;
1944
- const afterSeq = this._seqTracker.getContiguousSeq(ns);
1945
- try {
1946
- const result = await this._transport.call('message.pull', {
1947
- after_seq: afterSeq,
1948
- limit: 200,
1949
- });
1950
- if (isJsonObject(result)) {
1951
- const messages = result.messages;
1952
- if (Array.isArray(messages) && messages.length > 0) {
1953
- this._seqTracker.onPullResult(ns, messages.filter(isJsonObject));
1954
- this._saveSeqTrackerState();
1955
- }
1956
- }
1957
- }
1958
- catch (exc) {
1959
- this._clientLog.warn(`lazySyncP2p failed:${String(exc)}`);
1960
- }
1961
- }
1962
- async _sendEncryptedSingle(opts) {
1963
- let prekey = opts.prekey;
1964
- if (prekey === undefined) {
1965
- prekey = await this._fetchPeerPrekey(opts.toAid);
1966
- }
1967
- const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
1968
- const peerCertPem = await this._fetchPeerCert(opts.toAid, peerCertFingerprint);
1969
- const [envelope, encryptResult] = await this._encryptCopyPayload({
1970
- logicalToAid: opts.toAid,
1971
- payload: opts.payload,
1972
- peerCertPem,
1973
- prekey,
1974
- messageId: opts.messageId,
1975
- timestamp: opts.timestamp,
1976
- protectedHeaders: opts.protectedHeaders,
1977
- });
1978
- await this._ensureEncryptResult(opts.toAid, encryptResult);
1979
- const sendParams = {
1980
- to: opts.toAid,
1981
- payload: envelope,
1982
- type: 'e2ee.encrypted',
1983
- encrypted: true,
1984
- message_id: opts.messageId,
1985
- timestamp: opts.timestamp,
1986
- };
1987
- if (opts.persistRequired) {
1988
- sendParams.persist_required = true;
1989
- }
1990
- return this._transport.call('message.send', sendParams);
1991
- }
1992
2103
  async _buildRecipientDeviceCopies(opts) {
1993
2104
  const recipientCopies = [];
1994
2105
  const certCache = new Map();
@@ -2731,23 +2842,47 @@ export class AUNClient {
2731
2842
  queue.push(msg);
2732
2843
  this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
2733
2844
  }
2734
- async _retryPendingDecryptMsgs(groupId) {
2845
+ async _retryPendingDecryptMsgs(groupId, forceAdvanceOnFail = false) {
2735
2846
  const ns = `group:${groupId}`;
2736
2847
  const queue = this._pendingDecryptMsgs.get(ns);
2737
2848
  if (!queue || queue.length === 0)
2738
2849
  return;
2739
2850
  this._pendingDecryptMsgs.set(ns, []);
2740
2851
  const stillPending = [];
2852
+ let forceAdvancedAny = false;
2741
2853
  for (const msg of queue) {
2742
2854
  try {
2743
2855
  const decrypted = await this._decryptGroupMessage(msg);
2744
2856
  const payload = isJsonObject(msg.payload) ? msg.payload : null;
2745
2857
  if (payload?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
2746
- stillPending.push(msg);
2858
+ if (forceAdvanceOnFail) {
2859
+ // recovery 真的失败:强制推进 + 发 undecryptable
2860
+ this._clientLog.info(`group recovery give up: group=${groupId} seq=${String(msg.seq ?? '')} → force advance + publish undecryptable`);
2861
+ const seq = msg.seq;
2862
+ if (seq !== undefined && seq !== null) {
2863
+ this._seqTracker.onMessageSeq(ns, seq);
2864
+ this._saveSeqTrackerState();
2865
+ forceAdvancedAny = true;
2866
+ }
2867
+ await this._publishAppEvent('group.message_undecryptable', {
2868
+ message_id: msg.message_id,
2869
+ group_id: groupId,
2870
+ from: msg.from,
2871
+ seq,
2872
+ timestamp: msg.timestamp,
2873
+ _decrypt_error: 'epoch recovery failed',
2874
+ });
2875
+ }
2876
+ else {
2877
+ stillPending.push(msg);
2878
+ }
2747
2879
  continue;
2748
2880
  }
2749
2881
  const seq = msg.seq;
2750
2882
  if (seq !== undefined && seq !== null) {
2883
+ // 推进 seq tracker(之前 push/pull 失败时没推进)
2884
+ this._seqTracker.onMessageSeq(ns, seq);
2885
+ this._saveSeqTrackerState();
2751
2886
  await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
2752
2887
  }
2753
2888
  else {
@@ -2758,6 +2893,17 @@ export class AUNClient {
2758
2893
  stillPending.push(msg);
2759
2894
  }
2760
2895
  }
2896
+ if (forceAdvancedAny) {
2897
+ const contig = this._seqTracker.getContiguousSeq(ns);
2898
+ if (contig > 0) {
2899
+ this._transport.call('group.ack_messages', {
2900
+ group_id: groupId,
2901
+ msg_seq: contig,
2902
+ device_id: this._deviceId,
2903
+ slot_id: this._slotId,
2904
+ }).catch((e) => { this._clientLog.warn('group recovery force-advance ack failed: group=' + groupId, e); });
2905
+ }
2906
+ }
2761
2907
  const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
2762
2908
  const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
2763
2909
  if (mergedPending.length)
@@ -2765,6 +2911,25 @@ export class AUNClient {
2765
2911
  else
2766
2912
  this._pendingDecryptMsgs.delete(ns);
2767
2913
  }
2914
+ // recovery 兜底定时去重:每个 group 在 30s 内最多调度一次"超时强制推进"任务
2915
+ _recoveryTimeoutScheduled = new Map();
2916
+ _scheduleRecoveryTimeout(groupId, timeoutMs = 30000) {
2917
+ if (!groupId)
2918
+ return;
2919
+ const now = Date.now();
2920
+ const last = this._recoveryTimeoutScheduled.get(groupId) ?? 0;
2921
+ if (last && (last + timeoutMs) > now)
2922
+ return;
2923
+ this._recoveryTimeoutScheduled.set(groupId, now);
2924
+ setTimeout(() => {
2925
+ const ns = `group:${groupId}`;
2926
+ const queue = this._pendingDecryptMsgs.get(ns);
2927
+ if (!queue || queue.length === 0)
2928
+ return;
2929
+ this._clientLog.info(`group recovery timeout: group=${groupId} → force advance`);
2930
+ this._safeAsync(this._retryPendingDecryptMsgs(groupId, true));
2931
+ }, timeoutMs);
2932
+ }
2768
2933
  _scheduleRetryPendingDecryptMsgs(groupId) {
2769
2934
  if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
2770
2935
  return;
@@ -3395,11 +3560,31 @@ export class AUNClient {
3395
3560
  let result = null;
3396
3561
  try {
3397
3562
  if (actualPayload.type === 'e2ee.group_key_distribution') {
3563
+ // 快速跳过已过期的历史 epoch 分发:本地已有更高 epoch 时不发任何 RPC,
3564
+ // 避免 fillP2pGap 拉到大量历史群密钥消息时触发 epoch 编排风暴。
3565
+ const distGroupId = String(actualPayload.group_id ?? '');
3566
+ const distEpoch = Number(actualPayload.epoch ?? 0);
3567
+ if (distGroupId && distEpoch > 0) {
3568
+ const localEpoch = await this._groupE2ee.currentEpoch(distGroupId) ?? 0;
3569
+ if (localEpoch >= distEpoch) {
3570
+ this._clientLog.debug(`skip stale group_key_distribution: group=${distGroupId} msg_epoch=${distEpoch} local_epoch=${localEpoch}`);
3571
+ return true;
3572
+ }
3573
+ }
3398
3574
  if (!await this._verifyActiveGroupRotationDistribution(actualPayload)) {
3399
3575
  return true;
3400
3576
  }
3401
3577
  }
3402
3578
  else if (actualPayload.type === 'e2ee.group_key_response') {
3579
+ const respGroupId = String(actualPayload.group_id ?? '');
3580
+ const respEpoch = Number(actualPayload.epoch ?? 0);
3581
+ if (respGroupId && respEpoch > 0) {
3582
+ const localEpoch = await this._groupE2ee.currentEpoch(respGroupId) ?? 0;
3583
+ if (localEpoch >= respEpoch) {
3584
+ this._clientLog.debug(`skip stale group_key_response: group=${respGroupId} msg_epoch=${respEpoch} local_epoch=${localEpoch}`);
3585
+ return true;
3586
+ }
3587
+ }
3403
3588
  if (!await this._verifyGroupKeyResponseEpoch(actualPayload)) {
3404
3589
  return true;
3405
3590
  }
@@ -3681,6 +3866,8 @@ export class AUNClient {
3681
3866
  async _uploadPrekey() {
3682
3867
  const prekeyMaterial = await this._e2ee.generatePrekey();
3683
3868
  const result = await this._transport.call('message.e2ee.put_prekey', prekeyMaterial);
3869
+ // 上传成功后记录为活跃 prekey
3870
+ this._activePrekeyId = String(prekeyMaterial.prekey_id ?? '');
3684
3871
  return isJsonObject(result) ? { ...result } : { ok: true };
3685
3872
  }
3686
3873
  /** 确保发送方证书在本地可用且未过期 */
@@ -3895,6 +4082,11 @@ export class AUNClient {
3895
4082
  const groupId = String(payload.group_id ?? '').trim();
3896
4083
  if (!groupId)
3897
4084
  return false;
4085
+ // 历史群(不在当前 session 活跃列表):跳过 RPC 验证,只做本地 handle_incoming
4086
+ if (!this._groupSynced.has(groupId)) {
4087
+ this._clientLog.debug(`skip RPC verify for inactive group: group=${groupId} rotation=${rotationId}`);
4088
+ return true;
4089
+ }
3898
4090
  const epoch = Number(payload.epoch ?? 0);
3899
4091
  if (!Number.isFinite(epoch) || epoch <= 0)
3900
4092
  return false;
@@ -4679,6 +4871,9 @@ export class AUNClient {
4679
4871
  deviceId: this._deviceId,
4680
4872
  slotId: this._slotId,
4681
4873
  deliveryMode: this._connectDeliveryMode,
4874
+ connectionKind: String(params.connection_kind ?? 'long'),
4875
+ shortTtlMs: Number(params.short_ttl_ms ?? 0),
4876
+ extraInfo: params.extra_info,
4682
4877
  });
4683
4878
  if (isJsonObject(authContext)) {
4684
4879
  const auth = authContext;
@@ -4690,15 +4885,24 @@ export class AUNClient {
4690
4885
  this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
4691
4886
  }
4692
4887
  }
4888
+ if (isJsonObject(auth.hello) && 'heartbeat_interval' in auth.hello) {
4889
+ this._applyServerHeartbeatInterval(auth.hello.heartbeat_interval, 'auth');
4890
+ }
4693
4891
  }
4694
4892
  }
4695
4893
  else {
4696
- await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
4894
+ const hello = await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
4697
4895
  deviceId: this._deviceId,
4698
4896
  slotId: this._slotId,
4699
4897
  deliveryMode: this._connectDeliveryMode,
4898
+ connectionKind: String(params.connection_kind ?? 'long'),
4899
+ shortTtlMs: Number(params.short_ttl_ms ?? 0),
4900
+ extraInfo: params.extra_info,
4700
4901
  });
4701
4902
  await this._syncIdentityAfterConnect(String(params.access_token));
4903
+ if (isJsonObject(hello) && 'heartbeat_interval' in hello) {
4904
+ this._applyServerHeartbeatInterval(hello.heartbeat_interval, 'auth');
4905
+ }
4702
4906
  }
4703
4907
  }
4704
4908
  catch (err) {
@@ -4711,11 +4915,11 @@ export class AUNClient {
4711
4915
  throw err;
4712
4916
  }
4713
4917
  this._state = 'connected';
4918
+ this._connectedAt = Date.now();
4714
4919
  await this._dispatcher.publish('connection.state', {
4715
4920
  state: this._state,
4716
4921
  gateway: gatewayUrl,
4717
4922
  });
4718
- // auth 阶段 aid 可能被 identity 覆盖;若 context 发生变化,重新 refresh + restore。
4719
4923
  if (this._seqTrackerContext !== this._currentSeqTrackerContext()) {
4720
4924
  this._refreshSeqTrackerContext();
4721
4925
  await this._restoreSeqTrackerState();
@@ -4728,6 +4932,9 @@ export class AUNClient {
4728
4932
  catch (exc) {
4729
4933
  this._clientLog.warn(`prekey upload failed:${String(exc)}`);
4730
4934
  }
4935
+ // connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
4936
+ // 群消息按惰性触发,不在此处主动 pull
4937
+ this._safeAsync(this._fillP2pGap());
4731
4938
  this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? '-'}`);
4732
4939
  }
4733
4940
  catch (err) {
@@ -4813,15 +5020,38 @@ export class AUNClient {
4813
5020
  if (request.timeouts !== undefined && !isJsonObject(request.timeouts)) {
4814
5021
  throw new ValidationError('timeouts must be an object');
4815
5022
  }
5023
+ // 长短连接选项:默认 long,向后兼容
5024
+ const kindRaw = request.connection_kind;
5025
+ if (kindRaw == null) {
5026
+ request.connection_kind = 'long';
5027
+ }
5028
+ else {
5029
+ request.connection_kind = String(kindRaw).trim().toLowerCase();
5030
+ }
5031
+ if (request.connection_kind !== 'long' && request.connection_kind !== 'short') {
5032
+ throw new ValidationError("connection_kind must be 'long' or 'short'");
5033
+ }
5034
+ try {
5035
+ request.short_ttl_ms = Math.max(0, Math.floor(Number(request.short_ttl_ms) || 0));
5036
+ }
5037
+ catch {
5038
+ throw new ValidationError('short_ttl_ms must be a non-negative integer');
5039
+ }
5040
+ if (request.connection_kind !== 'short') {
5041
+ request.short_ttl_ms = 0;
5042
+ }
4816
5043
  return request;
4817
5044
  }
4818
5045
  _buildSessionOptions(params) {
5046
+ const connectionKind = String(params.connection_kind ?? 'long');
4819
5047
  const options = {
4820
5048
  auto_reconnect: DEFAULT_SESSION_OPTIONS.auto_reconnect,
4821
5049
  heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
4822
5050
  token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
4823
5051
  retry: { ...DEFAULT_SESSION_OPTIONS.retry },
4824
5052
  timeouts: { ...DEFAULT_SESSION_OPTIONS.timeouts },
5053
+ connection_kind: connectionKind,
5054
+ short_ttl_ms: Number(params.short_ttl_ms ?? 0),
4825
5055
  };
4826
5056
  if ('auto_reconnect' in params) {
4827
5057
  options.auto_reconnect = Boolean(params.auto_reconnect);
@@ -4842,8 +5072,12 @@ export class AUNClient {
4842
5072
  }
4843
5073
  // ── 内部:后台任务 ────────────────────────────────
4844
5074
  _startBackgroundTasks() {
4845
- this._startHeartbeat();
4846
- this._startTokenRefresh();
5075
+ // 短连接不启动 heartbeat 与 token 刷新(生命周期短,不需要长期会话维护);
5076
+ // auto_reconnect 仍允许,由 _sessionOptions.auto_reconnect 决定
5077
+ if (this._sessionOptions?.connection_kind !== 'short') {
5078
+ this._startHeartbeat();
5079
+ this._startTokenRefresh();
5080
+ }
4847
5081
  this._startPrekeyRefresh();
4848
5082
  this._startGroupEpochTasks();
4849
5083
  }
@@ -4881,10 +5115,9 @@ export class AUNClient {
4881
5115
  _startHeartbeat() {
4882
5116
  if (this._heartbeatTimer !== null)
4883
5117
  return;
4884
- const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
4885
- if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
5118
+ const interval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
5119
+ if (interval <= 0)
4886
5120
  return;
4887
- const interval = Math.max(rawIntervalSeconds, 30) * 1000;
4888
5121
  // M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
4889
5122
  // 又把半开连接的检测延迟从 3 个心跳周期降到 2 个,避免 RPC 长时间挂起。
4890
5123
  // 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
@@ -4895,8 +5128,12 @@ export class AUNClient {
4895
5128
  if (this._state !== 'connected' || this._closing)
4896
5129
  return;
4897
5130
  try {
4898
- await this._transport.call('meta.ping', {});
5131
+ const pong = await this._transport.call('meta.ping', {});
4899
5132
  consecutiveFailures = 0;
5133
+ // 服务端可在 pong 中下发新的 heartbeat_interval(秒,0=关闭)
5134
+ if (isJsonObject(pong) && 'heartbeat_interval' in pong) {
5135
+ this._applyServerHeartbeatInterval(pong.heartbeat_interval, 'pong');
5136
+ }
4900
5137
  }
4901
5138
  catch (exc) {
4902
5139
  consecutiveFailures++;
@@ -4907,7 +5144,24 @@ export class AUNClient {
4907
5144
  this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
4908
5145
  }
4909
5146
  }
4910
- }, interval);
5147
+ }, interval * 1000);
5148
+ }
5149
+ /** 服务端通过 hello/pong 下发 heartbeat_interval;clamp 后写入 session_options 并按需重启心跳。 */
5150
+ _applyServerHeartbeatInterval(raw, source) {
5151
+ const newInterval = clampHeartbeatInterval(raw);
5152
+ const oldInterval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval);
5153
+ if (newInterval === oldInterval)
5154
+ return;
5155
+ this._sessionOptions.heartbeat_interval = newInterval;
5156
+ this._clientLog.debug(`heartbeat_interval updated by ${source}: ${oldInterval} -> ${newInterval}`);
5157
+ // 重启定时器以应用新间隔(关闭/启动通过定时器有/无区分)
5158
+ if (this._heartbeatTimer !== null) {
5159
+ clearInterval(this._heartbeatTimer);
5160
+ this._heartbeatTimer = null;
5161
+ }
5162
+ if (newInterval > 0 && this._state === 'connected' && !this._closing) {
5163
+ this._startHeartbeat();
5164
+ }
4911
5165
  }
4912
5166
  /** Token 刷新定时器 */
4913
5167
  _startTokenRefresh() {
@@ -5107,22 +5361,17 @@ export class AUNClient {
5107
5361
  const prekeyId = this._extractConsumedPrekeyId(message);
5108
5362
  if (!prekeyId || this._state !== 'connected')
5109
5363
  return;
5110
- if (this._prekeyReplenished.has(prekeyId))
5111
- return;
5112
- // 同一时刻只允许一个 put_prekey inflight
5113
- if (this._prekeyReplenishInflight.size > 0)
5364
+ // 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
5365
+ if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
5114
5366
  return;
5115
- this._prekeyReplenishInflight.add(prekeyId);
5367
+ // 清空活跃标记,防止重复触发(新上传完成后会设新的 active)
5368
+ this._activePrekeyId = '';
5116
5369
  this._safeAsync((async () => {
5117
5370
  try {
5118
5371
  await this._uploadPrekey();
5119
- this._prekeyReplenished.add(prekeyId);
5120
5372
  }
5121
5373
  catch (exc) {
5122
- this._clientLog.warn(`consume prekey ${prekeyId} then replenish current prekey failed: ${String(exc)}`);
5123
- }
5124
- finally {
5125
- this._prekeyReplenishInflight.delete(prekeyId);
5374
+ this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${String(exc)}`);
5126
5375
  }
5127
5376
  })());
5128
5377
  }
@@ -5191,13 +5440,28 @@ export class AUNClient {
5191
5440
  }
5192
5441
  // ── 内部:断线重连 ────────────────────────────────
5193
5442
  /** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
5194
- static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011]);
5195
- /** 处理服务端主动断开通知 event/gateway.disconnect */
5196
- _onGatewayDisconnect(data) {
5197
- const code = data?.code;
5198
- const reason = data?.reason ?? '';
5199
- this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}`);
5443
+ static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015]);
5444
+ /** 处理服务端主动断开通知 event/gateway.disconnect
5445
+ *
5446
+ * 服务端可能附带结构化 detail 字段(如配额超限时含 aid/device_id/slot_id/quota_kind/evicted_by)。
5447
+ * 透传到应用层可订阅事件 'gateway.disconnect',方便业务定位被踢原因。
5448
+ */
5449
+ async _onGatewayDisconnect(data) {
5450
+ const obj = (data && typeof data === 'object') ? data : {};
5451
+ const code = obj.code;
5452
+ const reason = obj.reason ?? '';
5453
+ const detail = (obj.detail && typeof obj.detail === 'object') ? obj.detail : {};
5454
+ this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
5200
5455
  this._serverKicked = true;
5456
+ // 缓存最近一次 disconnect 信息,让后续 connection.state(terminal_failed) 也能带 detail
5457
+ this._lastDisconnectInfo = { code, reason, detail };
5458
+ // 透传给应用层订阅者
5459
+ try {
5460
+ await this._dispatcher.publish('gateway.disconnect', { code, reason, detail });
5461
+ }
5462
+ catch (exc) {
5463
+ this._clientLog.debug(`publish gateway.disconnect failed: ${exc?.message ?? exc}`);
5464
+ }
5201
5465
  }
5202
5466
  async _handleTransportDisconnect(error, closeCode) {
5203
5467
  if (this._closing || this._state === 'closed')
@@ -5218,9 +5482,19 @@ export class AUNClient {
5218
5482
  this._state = 'terminal_failed';
5219
5483
  const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
5220
5484
  this._clientLog.warn(`suppress auto-reconnect: ${reason}`);
5221
- await this._dispatcher.publish('connection.state', {
5485
+ const disconnectInfo = this._lastDisconnectInfo ?? {};
5486
+ const eventPayload = {
5222
5487
  state: this._state, error, reason,
5223
- });
5488
+ };
5489
+ // 把服务端附带的结构化 detail(如配额超限信息)也带给应用层
5490
+ const detail = disconnectInfo.detail;
5491
+ if (detail && typeof detail === 'object' && Object.keys(detail).length > 0) {
5492
+ eventPayload.detail = detail;
5493
+ }
5494
+ if (disconnectInfo.code !== undefined && disconnectInfo.code !== null) {
5495
+ eventPayload.code = disconnectInfo.code;
5496
+ }
5497
+ await this._dispatcher.publish('connection.state', eventPayload);
5224
5498
  return;
5225
5499
  }
5226
5500
  // 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭
@@ -5526,7 +5800,6 @@ export class AUNClient {
5526
5800
  this._pendingOrderedMsgs.clear();
5527
5801
  this._pendingDecryptMsgs.clear();
5528
5802
  this._groupSynced.clear();
5529
- this._p2pSynced = false;
5530
5803
  }
5531
5804
  _refreshSeqTrackerContext() {
5532
5805
  const nextContext = this._currentSeqTrackerContext();
@@ -5538,7 +5811,6 @@ export class AUNClient {
5538
5811
  this._pendingOrderedMsgs.clear();
5539
5812
  this._pendingDecryptMsgs.clear();
5540
5813
  this._groupSynced.clear();
5541
- this._p2pSynced = false;
5542
5814
  this._seqTrackerContext = nextContext;
5543
5815
  }
5544
5816
  /** 将 SeqTracker 状态保存到 keystore */