@agentunion/fastaun 0.5.4 → 0.5.6

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 (133) hide show
  1. package/CHANGELOG.md +126 -90
  2. package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/216/245/345/205/245/344/270/216/346/274/224/347/244/272/346/214/207/345/215/227.md +290 -0
  3. package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/234/215/345/212/241/346/236/266/346/236/204/344/270/216/350/257/246/347/273/206/345/256/236/347/216/260/350/256/241/345/210/222-codex.md +994 -0
  4. package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/234/215/345/212/241/350/277/220/347/273/264/344/270/216/345/217/221/345/270/203/346/214/207/345/215/227.md +144 -0
  5. package/_packed_docs/CHANGELOG.md +126 -90
  6. package/_packed_docs/INDEX.md +127 -69
  7. package/_packed_docs/KITE_DOCS_GUIDE.md +63 -29
  8. package/_packed_docs/audit/AUN/346/234/215/345/212/241Go/345/214/226/351/207/215/346/236/204/347/262/276/347/273/206/345/214/226/346/272/220/347/240/201/345/256/241/346/237/245-20260718.md +366 -0
  9. package/_packed_docs/aun/345/205/254/347/275/221/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +227 -0
  10. package/_packed_docs/aun/345/210/206/345/270/203/345/274/217/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +1004 -0
  11. package/_packed_docs/aun/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +999 -0
  12. package/_packed_docs/cli/CLI/346/211/213/345/206/214.md +6 -4
  13. package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +58 -20
  14. package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +219 -247
  15. package/_packed_docs/protocol/12-Stream-/345/255/220/345/215/217/350/256/256.md +14 -14
  16. package/_packed_docs/protocol/13-Agent/350/241/214/344/270/272/350/247/204/350/214/203.md +3 -3
  17. package/_packed_docs/protocol/15-/347/246/273/347/272/277/346/216/250/351/200/201/351/200/232/347/237/245/345/215/217/350/256/256.md +165 -421
  18. package/_packed_docs/protocol/README.md +1 -0
  19. package/_packed_docs/protocol/aun-docs-guide.md +7 -4
  20. package/_packed_docs/protocol/index.md +25 -19
  21. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +62 -17
  22. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +22 -0
  23. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +67 -78
  24. package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +6 -2
  25. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +50 -14
  26. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +18 -5
  27. package/_packed_docs/sdk/09-group-rpc-manual.md +312 -282
  28. package/_packed_docs/sdk/09-message-rpc-manual.md +142 -103
  29. package/_packed_docs/sdk/09-payload-reference.md +1 -1
  30. package/_packed_docs/sdk/09-stream-rpc-manual.md +8 -8
  31. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +26 -17
  32. package/_packed_docs/sdk/INDEX.md +42 -34
  33. package/_packed_docs/sdk/README.md +7 -6
  34. package/_packed_docs//346/266/210/346/201/257/345/220/214/346/255/245/344/270/216/345/216/206/345/217/262/346/213/211/345/217/226/344/273/243/347/240/201/345/256/241/346/237/245/351/227/256/351/242/230/346/270/205/345/215/225.md +229 -0
  35. package/_packed_docs//346/266/210/346/201/257/345/220/214/346/255/245/344/270/216/345/216/206/345/217/262/346/213/211/345/217/226/346/224/271/351/200/240/346/226/271/346/241/210.md +750 -0
  36. package/dist/agent-md.js +8 -2
  37. package/dist/agent-md.js.map +1 -1
  38. package/dist/aid-store.d.ts +2 -0
  39. package/dist/aid-store.js +43 -7
  40. package/dist/aid-store.js.map +1 -1
  41. package/dist/auth.d.ts +0 -2
  42. package/dist/auth.js +97 -50
  43. package/dist/auth.js.map +1 -1
  44. package/dist/client/delivery.d.ts +70 -7
  45. package/dist/client/delivery.js +1332 -236
  46. package/dist/client/delivery.js.map +1 -1
  47. package/dist/client/group-state.js +25 -22
  48. package/dist/client/group-state.js.map +1 -1
  49. package/dist/client/lifecycle.js +46 -3
  50. package/dist/client/lifecycle.js.map +1 -1
  51. package/dist/client/mention-mode.d.ts +6 -0
  52. package/dist/client/mention-mode.js +184 -0
  53. package/dist/client/mention-mode.js.map +1 -0
  54. package/dist/client/peers.d.ts +1 -1
  55. package/dist/client/peers.js +26 -3
  56. package/dist/client/peers.js.map +1 -1
  57. package/dist/client/rpc-pipeline.d.ts +20 -1
  58. package/dist/client/rpc-pipeline.js +332 -73
  59. package/dist/client/rpc-pipeline.js.map +1 -1
  60. package/dist/client/v2-e2ee.d.ts +20 -1
  61. package/dist/client/v2-e2ee.js +625 -105
  62. package/dist/client/v2-e2ee.js.map +1 -1
  63. package/dist/client.d.ts +17 -5
  64. package/dist/client.js +332 -167
  65. package/dist/client.js.map +1 -1
  66. package/dist/errors.js +4 -1
  67. package/dist/errors.js.map +1 -1
  68. package/dist/facades.d.ts +3 -0
  69. package/dist/facades.js +109 -53
  70. package/dist/facades.js.map +1 -1
  71. package/dist/group-index.d.ts +5 -0
  72. package/dist/group-index.js +65 -30
  73. package/dist/group-index.js.map +1 -1
  74. package/dist/keystore/aid-db.d.ts +12 -0
  75. package/dist/keystore/aid-db.js +68 -4
  76. package/dist/keystore/aid-db.js.map +1 -1
  77. package/dist/keystore/index.d.ts +12 -0
  78. package/dist/keystore/local-identity-store.d.ts +10 -0
  79. package/dist/keystore/local-identity-store.js +8 -2
  80. package/dist/keystore/local-identity-store.js.map +1 -1
  81. package/dist/keystore/local-token-store.d.ts +10 -0
  82. package/dist/keystore/local-token-store.js +8 -2
  83. package/dist/keystore/local-token-store.js.map +1 -1
  84. package/dist/register-flow.d.ts +0 -1
  85. package/dist/register-flow.js +96 -38
  86. package/dist/register-flow.js.map +1 -1
  87. package/dist/secret-store/file-store.d.ts +3 -14
  88. package/dist/secret-store/file-store.js +7 -98
  89. package/dist/secret-store/file-store.js.map +1 -1
  90. package/dist/secret-store/index.d.ts +1 -4
  91. package/dist/secret-store/index.js +2 -2
  92. package/dist/secret-store/index.js.map +1 -1
  93. package/dist/seq-tracker.d.ts +29 -9
  94. package/dist/seq-tracker.js +233 -64
  95. package/dist/seq-tracker.js.map +1 -1
  96. package/dist/storage/vfs.d.ts +1 -0
  97. package/dist/storage/vfs.js +28 -4
  98. package/dist/storage/vfs.js.map +1 -1
  99. package/dist/tools/cross-sdk-agent.js +360 -8
  100. package/dist/tools/cross-sdk-agent.js.map +1 -1
  101. package/dist/transport.d.ts +19 -0
  102. package/dist/transport.js +197 -14
  103. package/dist/transport.js.map +1 -1
  104. package/dist/v2/e2ee/decrypt.js +26 -23
  105. package/dist/v2/e2ee/decrypt.js.map +1 -1
  106. package/dist/v2/session/session.js +3 -4
  107. package/dist/v2/session/session.js.map +1 -1
  108. package/dist/v2/state/commitment.js +1 -2
  109. package/dist/v2/state/commitment.js.map +1 -1
  110. package/dist/version.d.ts +1 -1
  111. package/dist/version.js +1 -1
  112. package/package.json +5 -4
  113. package/dist/e2ee-group.d.ts +0 -249
  114. package/dist/e2ee-group.js +0 -1567
  115. package/dist/e2ee-group.js.map +0 -1
  116. package/dist/group-resources.d.ts +0 -31
  117. package/dist/group-resources.js +0 -84
  118. package/dist/group-resources.js.map +0 -1
  119. package/dist/keystore/file.d.ts +0 -109
  120. package/dist/keystore/file.js +0 -772
  121. package/dist/keystore/file.js.map +0 -1
  122. package/dist/keystore/sqlite-backup.d.ts +0 -44
  123. package/dist/keystore/sqlite-backup.js +0 -382
  124. package/dist/keystore/sqlite-backup.js.map +0 -1
  125. package/dist/namespaces/auth.d.ts +0 -118
  126. package/dist/namespaces/auth.js +0 -968
  127. package/dist/namespaces/auth.js.map +0 -1
  128. package/dist/namespaces/custody.d.ts +0 -48
  129. package/dist/namespaces/custody.js +0 -313
  130. package/dist/namespaces/custody.js.map +0 -1
  131. package/dist/namespaces/meta.d.ts +0 -76
  132. package/dist/namespaces/meta.js +0 -490
  133. package/dist/namespaces/meta.js.map +0 -1
package/dist/client.js CHANGED
@@ -23,7 +23,6 @@ import { AUNError, AuthError, ConnectionError, E2EEError, NotFoundError, Permiss
23
23
  import { EventDispatcher } from './events.js';
24
24
  import { LocalTokenStore } from './keystore/local-token-store.js';
25
25
  import { AUNLogger } from './logger.js';
26
- import { normalizeGroupId } from './group-id.js';
27
26
  import { validateAIDFormat, validateGroupIDFormat } from './validators.js';
28
27
  import { RPCTransport } from './transport.js';
29
28
  import { AuthFlow } from './auth.js';
@@ -32,6 +31,7 @@ import { GroupIndexMetaCache } from './group-index.js';
32
31
  import { SeqTracker } from './seq-tracker.js';
33
32
  import { ClientRuntime } from './client/runtime.js';
34
33
  import { MessageDeliveryEngine } from './client/delivery.js';
34
+ import { normalizeGroupMentionMode } from './client/mention-mode.js';
35
35
  import { IdentityRuntimeManager } from './client/identity.js';
36
36
  import { LifecycleController } from './client/lifecycle.js';
37
37
  import { PeerDirectory } from './client/peers.js';
@@ -103,7 +103,7 @@ function getV2DeviceId(dev) {
103
103
  }
104
104
  const DEFAULT_SESSION_OPTIONS = {
105
105
  auto_reconnect: true,
106
- heartbeat_interval: 30.0,
106
+ heartbeat_interval: 0.0,
107
107
  token_refresh_before: 1800.0,
108
108
  retry: {
109
109
  initial_delay: 1.0,
@@ -121,20 +121,6 @@ const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
121
121
  const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
122
122
  const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
123
123
  const MAX_NOTIFY_PAYLOAD_SIZE = 64 * 1024;
124
- // 心跳间隔下/上限(秒)。0 = 关闭心跳;负值视为 0;其余值 clamp 到 [10, 600]。
125
- // 服务端通过 hello.heartbeat_interval 与 meta.ping pong 中的同名字段下发。
126
- const HEARTBEAT_MIN_INTERVAL_SECONDS = 10;
127
- const HEARTBEAT_MAX_INTERVAL_SECONDS = 600;
128
- function clampHeartbeatInterval(value) {
129
- const n = Number(value);
130
- if (!Number.isFinite(n) || n <= 0)
131
- return 0;
132
- if (n < HEARTBEAT_MIN_INTERVAL_SECONDS)
133
- return HEARTBEAT_MIN_INTERVAL_SECONDS;
134
- if (n > HEARTBEAT_MAX_INTERVAL_SECONDS)
135
- return HEARTBEAT_MAX_INTERVAL_SECONDS;
136
- return n;
137
- }
138
124
  // P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
139
125
  const NON_IDEMPOTENT_TIMEOUT_MS = 35_000;
140
126
  const NON_IDEMPOTENT_METHODS = new Set([
@@ -227,7 +213,9 @@ function clampReconnectDelayMs(value, fallback, upper = RECONNECT_MAX_BASE_DELAY
227
213
  return Math.min(Math.max(ms, RECONNECT_MIN_BASE_DELAY_MS), upper);
228
214
  }
229
215
  function reconnectSleepDelayMs(baseDelay, maxBaseDelay) {
230
- return baseDelay + Math.random() * maxBaseDelay;
216
+ // 抖动只随当前退避基数增长,并限制在 maxBaseDelay 内。
217
+ const jitterLimit = Math.min(Math.max(baseDelay, 0), Math.max(maxBaseDelay - baseDelay, 0));
218
+ return baseDelay + Math.random() * jitterLimit;
231
219
  }
232
220
  /** peer 证书缓存 TTL(1 小时) */
233
221
  const PEER_CERT_CACHE_TTL = 3600;
@@ -400,7 +388,6 @@ function createAgentMdManagerForRuntime(opts) {
400
388
  if (current?.aid === target) {
401
389
  if (!expectedFp || certMatchesFingerprint(current.certPem, expectedFp))
402
390
  return current;
403
- throw new StateError(`current AID certificate fingerprint mismatch for ${target}`);
404
391
  }
405
392
  if (!opts.gateway.get()) {
406
393
  try {
@@ -445,6 +432,7 @@ export class AUNClient {
445
432
  _lastErrorCode = null;
446
433
  /** Gateway URL */
447
434
  _gatewayUrl = null;
435
+ _gatewayCandidates = [];
448
436
  /** 是否正在关闭 */
449
437
  _closing = false;
450
438
  /** 事件调度器 */
@@ -662,9 +650,28 @@ export class AUNClient {
662
650
  // P2P 消息撤回推送:在线 push 与 pull tombstone 去重后再发布给应用层
663
651
  this._dispatcher.subscribe('_raw.message.recalled', (data) => this._safeAsync(this._onRawMessageRecalled(data)));
664
652
  // V2 P2P 推送通知:收到通知后自动走 message.v2.pull 拉取并解密
665
- this._dispatcher.subscribe('_raw.peer.v2.message_received', (data) => this._safeAsync(this._onV2PushNotification(data)));
653
+ this._dispatcher.subscribe('_raw.peer.v2.message_received', (data) => {
654
+ const ns = this._aid ? `p2p:${this._aid}` : '';
655
+ const hasInline = isJsonObject(data)
656
+ && isJsonObject(data.inline_message);
657
+ const inlineGeneration = hasInline ? this._delivery.captureInlineAckGeneration() : undefined;
658
+ this._safeAsync(hasInline
659
+ ? this._delivery.runPushProcessSerialized(ns, () => this._onV2PushNotification(data, 'v2', inlineGeneration), inlineGeneration)
660
+ : this._onV2PushNotification(data, 'v2'));
661
+ });
666
662
  // V2 群组消息推送通知:收到通知后自动走 group.v2.pull 拉取并解密
667
- this._dispatcher.subscribe('_raw.group.v2.message_created', (data) => this._safeAsync(this._onRawGroupV2MessageCreated(data)));
663
+ this._dispatcher.subscribe('_raw.group.v2.message_created', (data) => {
664
+ const groupId = isJsonObject(data)
665
+ ? String(data.group_id ?? '').trim()
666
+ : '';
667
+ const hasInline = isJsonObject(data)
668
+ && String(data.kind ?? '').trim() !== 'group.online_unread_hint'
669
+ && isJsonObject(data.inline_message);
670
+ const inlineGeneration = hasInline ? this._delivery.captureInlineAckGeneration() : undefined;
671
+ this._safeAsync(hasInline
672
+ ? this._delivery.runPushProcessSerialized(groupId ? `group:${groupId}` : '', () => this._onRawGroupV2MessageCreated(data, 'v2', inlineGeneration), inlineGeneration)
673
+ : this._onRawGroupV2MessageCreated(data, 'v2'));
674
+ });
668
675
  // 群组消息推送:自动解密后 re-publish
669
676
  this._dispatcher.subscribe('_raw.group.message_created', (data) => this._onRawGroupMessageCreated(data));
670
677
  // 群组消息撤回推送:在线 push 通道,与 pull 双 tombstone 兜底互补(SDK 去重只回调一次)
@@ -918,8 +925,8 @@ export class AUNClient {
918
925
  getPeer(aid) {
919
926
  return this._peerDirectory.getPeer(aid);
920
927
  }
921
- async lookupPeer(aid) {
922
- return this._peerDirectory.lookupPeer(aid);
928
+ async lookupPeer(aid, certFingerprint) {
929
+ return this._peerDirectory.lookupPeer(aid, certFingerprint);
923
930
  }
924
931
  peers() {
925
932
  return this._peerDirectory.peers();
@@ -1420,15 +1427,16 @@ export class AUNClient {
1420
1427
  }
1421
1428
  if (targetGroupId) {
1422
1429
  const eventMethod = AUNClient._validateNotifyEventMethod(method);
1423
- const normalizedGroupId = normalizeGroupId(targetGroupId);
1424
- if (!normalizedGroupId) {
1430
+ if (!targetGroupId) {
1425
1431
  throw new ValidationError('group_id is required for group notify');
1426
1432
  }
1427
1433
  const routeParams = {
1428
- group_aid: normalizedGroupId,
1429
- group_id: normalizedGroupId,
1434
+ group_id: targetGroupId,
1430
1435
  deliver: { method: eventMethod, params: payload },
1431
1436
  };
1437
+ const explicitGroupAid = String(options.group_aid ?? options.groupAid ?? '').trim();
1438
+ if (explicitGroupAid)
1439
+ routeParams.group_aid = explicitGroupAid;
1432
1440
  if (ttl !== undefined)
1433
1441
  routeParams.ttl_ms = ttl;
1434
1442
  await this._transport.notify('notification/group.route', routeParams);
@@ -1454,17 +1462,26 @@ export class AUNClient {
1454
1462
  }
1455
1463
  async _callRawV2Rpc(method, params) {
1456
1464
  const p = { ...(params ?? {}) };
1457
- const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
1465
+ const forceForeground = Boolean(p._rpc_foreground);
1466
+ const rpcBackground = !forceForeground
1467
+ && (Boolean(p._rpc_background) || this._backgroundRpcDepth > 0);
1468
+ delete p._rpc_foreground;
1458
1469
  delete p._rpc_background;
1459
1470
  delete p._pull_gate_locked;
1460
1471
  delete p._skip_auto_ack;
1461
1472
  delete p.skip_auto_ack;
1462
1473
  delete p._group_cursor_params;
1463
- if (method.startsWith('group.') && p.group_aid !== undefined && p.group_aid !== null) {
1464
- p.group_aid = normalizeGroupId(String(p.group_aid)) || String(p.group_aid);
1465
- }
1466
- else if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
1467
- p.group_aid = normalizeGroupId(String(p.group_id)) || String(p.group_id);
1474
+ if (method.startsWith('group.')) {
1475
+ const rawGroupAid = String(p.group_aid ?? p.groupAid ?? '').trim();
1476
+ const rawGroupId = String(p.group_id ?? p.groupId ?? '').trim();
1477
+ if (rawGroupId)
1478
+ p.group_id = rawGroupId;
1479
+ else if (rawGroupAid)
1480
+ p.group_id = rawGroupAid;
1481
+ if (rawGroupAid)
1482
+ p.group_aid = rawGroupAid;
1483
+ delete p.groupAid;
1484
+ delete p.groupId;
1468
1485
  }
1469
1486
  if (method.startsWith('group.') && p.device_id === undefined) {
1470
1487
  p.device_id = this._deviceId;
@@ -1711,8 +1728,11 @@ export class AUNClient {
1711
1728
  this._backgroundRpcDepth = Math.max(0, this._backgroundRpcDepth - 1);
1712
1729
  }
1713
1730
  }
1714
- async _runPullSerialized(key, operation) {
1715
- return await this._rpcPipeline.runPullSerialized(key, operation);
1731
+ async _runPullSerialized(key, operation, background = false) {
1732
+ return await this._rpcPipeline.runPullSerialized(key, operation, background);
1733
+ }
1734
+ async _tryRunInlineRealtime(namespace, seq, operation) {
1735
+ return await this._rpcPipeline.tryRunInlineRealtime(namespace, seq, operation);
1716
1736
  }
1717
1737
  async _tryRunBackgroundPull(key, operation, followupOnMessages = false, onBusy) {
1718
1738
  if (key && this._isPullResponseProcessing(key)) {
@@ -1749,16 +1769,6 @@ export class AUNClient {
1749
1769
  async _publishPulledMessage(event, ns, seq, payload) {
1750
1770
  return this._delivery.publishPulledMessage(event, ns, seq, payload);
1751
1771
  }
1752
- _markPulledSeqDelivered(ns, seq) {
1753
- // Pull 批次是 after_seq 之后服务端当前可用的结果集,可能跨过永久空洞。
1754
- // 这里仅在应用层发布返回后推进已交付游标,不能改成 push 的相邻 seq 门控。
1755
- const seqNum = Number(seq);
1756
- if (!ns || !Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0)
1757
- return false;
1758
- const before = this._seqTracker.getContiguousSeq(ns);
1759
- this._seqTracker.forceContiguousSeq(ns, seqNum);
1760
- return this._seqTracker.getContiguousSeq(ns) !== before;
1761
- }
1762
1772
  _markOrderedSeqDelivered(ns, seq) {
1763
1773
  if (!ns || !Number.isFinite(seq) || !Number.isInteger(seq) || seq <= 0)
1764
1774
  return false;
@@ -1768,21 +1778,21 @@ export class AUNClient {
1768
1778
  }
1769
1779
  _extractGroupIdFromResult(result) {
1770
1780
  const group = isJsonObject(result.group) ? result.group : null;
1771
- const gid = group ? String(group.group_aid ?? group.group_id ?? '') : '';
1781
+ const gid = group ? String(group.group_id ?? group.groupId ?? group.group_aid ?? group.groupAid ?? '') : '';
1772
1782
  if (gid)
1773
1783
  return gid;
1774
- const directGid = String(result.group_aid ?? result.group_id ?? '');
1784
+ const directGid = String(result.group_id ?? result.groupId ?? result.group_aid ?? result.groupAid ?? '');
1775
1785
  if (directGid)
1776
1786
  return directGid;
1777
1787
  const member = isJsonObject(result.member) ? result.member : null;
1778
- return member ? String(member.group_aid ?? member.group_id ?? '') : '';
1788
+ return member ? String(member.group_id ?? member.groupId ?? member.group_aid ?? member.groupAid ?? '') : '';
1779
1789
  }
1780
1790
  async _onRawGroupChanged(data) {
1781
1791
  const tStart = Date.now();
1782
1792
  try {
1783
1793
  if (isJsonObject(data)) {
1784
1794
  const d = data;
1785
- const groupId = String(d.group_aid ?? d.group_id ?? '');
1795
+ const groupId = String(d.group_id ?? d.group_aid ?? '');
1786
1796
  const action = String(d.action ?? '');
1787
1797
  this._clientLog.debug(`_onRawGroupChanged enter: group_id=${groupId}, action=${action}, event_seq=${String(d.event_seq ?? '')}`);
1788
1798
  // 验签:有 client_signature 就验,没有默认安全(H20: 严格 boolean)
@@ -2095,6 +2105,7 @@ export class AUNClient {
2095
2105
  _resetSeqTrackingState() {
2096
2106
  this._saveSeqTrackerState();
2097
2107
  this._resetV2IdentityRuntime();
2108
+ this._delivery.resetInlineAckState();
2098
2109
  this._seqTracker = new SeqTracker();
2099
2110
  this._seqTrackerContext = null;
2100
2111
  this._gapFillDone.clear();
@@ -2116,6 +2127,7 @@ export class AUNClient {
2116
2127
  if (nextContext === this._seqTrackerContext)
2117
2128
  return;
2118
2129
  this._saveSeqTrackerState();
2130
+ this._delivery.resetInlineAckState();
2119
2131
  this._seqTracker = new SeqTracker();
2120
2132
  this._gapFillDone.clear();
2121
2133
  this._pushedSeqs.clear();
@@ -2139,6 +2151,9 @@ export class AUNClient {
2139
2151
  _persistSeq(ns, forceSeq) {
2140
2152
  return this._delivery.persistSeq(ns, forceSeq);
2141
2153
  }
2154
+ _commitSeqTrackerState(ns) {
2155
+ return this._delivery.commitSeqTrackerState(ns);
2156
+ }
2142
2157
  _persistRepairedSeq(ns) {
2143
2158
  return this._delivery.persistRepairedSeq(ns);
2144
2159
  }
@@ -2258,8 +2273,20 @@ export class AUNClient {
2258
2273
  this._applyServerHeartbeatInterval(hello.heartbeat_interval, 'auth');
2259
2274
  }
2260
2275
  }
2276
+ if (!this._transport.isConnected()) {
2277
+ throw new ConnectionError('websocket disconnected during authentication');
2278
+ }
2279
+ this._serverKicked = false;
2280
+ this._lastDisconnectInfo = null;
2261
2281
  this._state = 'ready';
2262
2282
  this._connectedAt = Date.now();
2283
+ if (Array.isArray(params.gateways)) {
2284
+ this._gatewayCandidates = [...new Set(params.gateways
2285
+ .map((item) => String(item ?? '').trim()).filter(Boolean))];
2286
+ }
2287
+ else if (gatewayUrl) {
2288
+ this._gatewayCandidates = [gatewayUrl];
2289
+ }
2263
2290
  this._clientLog.debug(`auth complete, connection ready: aid=${this._aid ?? ''}, gateway=${gatewayUrl}`);
2264
2291
  await this._dispatcher.publish('state_change', { state: this._publicState(this._state), gateway: gatewayUrl });
2265
2292
  // auth 阶段 aid 可能被 identity 覆盖(上方 this._aid = identity.aid);
@@ -2434,6 +2461,9 @@ export class AUNClient {
2434
2461
  async _getV2SenderPubDer(fromAid, senderDeviceId, certFingerprint) {
2435
2462
  return await this._v2E2EE.getV2SenderPubDer(fromAid, senderDeviceId, certFingerprint);
2436
2463
  }
2464
+ async _fetchV2SenderIKOnce(fromAid, senderDeviceId, groupId, certFingerprint) {
2465
+ return await this._v2E2EE.fetchSenderIKOnce(fromAid, senderDeviceId, groupId, certFingerprint);
2466
+ }
2437
2467
  _cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
2438
2468
  const session = this._v2Session;
2439
2469
  if (!session || !isJsonObject(dev))
@@ -2489,11 +2519,11 @@ export class AUNClient {
2489
2519
  return await this._callRawV2Rpc('group.ack_messages', p);
2490
2520
  }
2491
2521
  /** V2 Group ack。 */
2492
- async _ackGroupV2(groupId, upToSeq) {
2493
- return await this._v2E2EE.ackGroupV2(groupId, upToSeq);
2522
+ async _ackGroupV2(groupId, upToSeq, groupAid) {
2523
+ return await this._v2E2EE.ackGroupV2(groupId, upToSeq, groupAid);
2494
2524
  }
2495
2525
  /** 解密单条 V2 pull 消息。缺 sender IK 时先入 pending,后台补齐后重试。 */
2496
- async _decryptV2Message(msg, allowPending = true) {
2526
+ async _decryptV2Message(msg, allowPending = true, emitUndecryptable = true, rotateKeys = true, observeAgentMd = true) {
2497
2527
  const session = this._v2Session;
2498
2528
  if (!session)
2499
2529
  return null;
@@ -2509,7 +2539,8 @@ export class AUNClient {
2509
2539
  return null;
2510
2540
  }
2511
2541
  const e2eeMeta = this._v2E2eeMeta(envelope);
2512
- this._agentMdManager.observeEnvelope(envelope);
2542
+ if (observeAgentMd)
2543
+ this._agentMdManager.observeEnvelope(envelope);
2513
2544
  let spkId = '';
2514
2545
  let recipientKeySource = '';
2515
2546
  if (isJsonObject(envelope.recipient)) {
@@ -2570,16 +2601,20 @@ export class AUNClient {
2570
2601
  };
2571
2602
  this._attachV2EnvelopeMetadata(event, e2eeMeta);
2572
2603
  this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
2573
- await this._dispatcher.publish(undecryptableEvent, event);
2604
+ if (emitUndecryptable)
2605
+ await this._dispatcher.publish(undecryptableEvent, event);
2574
2606
  return null;
2575
2607
  }
2576
2608
  this._clientLog.debug(`V2 decrypt key lookup ok: seq=${String(msg.seq ?? '')}, group=${groupIdForKeys || '<p2p>'}, ik_len=${ikPriv.byteLength}, spk_len=${spkPriv?.byteLength ?? 0}`);
2577
2609
  const fromAid = String(msg.from_aid ?? '');
2578
2610
  const senderDeviceId = String(aad.from_device ?? '');
2579
2611
  const senderCertFingerprint = String(envelope.sender_cert_fingerprint ?? '').trim().toLowerCase();
2580
- const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId, senderCertFingerprint);
2612
+ let senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId, senderCertFingerprint);
2581
2613
  if (!senderPubDer) {
2582
2614
  this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
2615
+ senderPubDer = await this._fetchV2SenderIKOnce(fromAid, senderDeviceId, groupIdForKeys, senderCertFingerprint);
2616
+ }
2617
+ if (!senderPubDer) {
2583
2618
  if (allowPending) {
2584
2619
  this._scheduleV2SenderIKPending({ msg, fromAid, senderDeviceId, groupId: groupIdForKeys });
2585
2620
  return null;
@@ -2599,7 +2634,8 @@ export class AUNClient {
2599
2634
  };
2600
2635
  this._attachV2EnvelopeMetadata(event, e2eeMeta);
2601
2636
  this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
2602
- await this._dispatcher.publish(undecryptableEvent, event);
2637
+ if (emitUndecryptable)
2638
+ await this._dispatcher.publish(undecryptableEvent, event);
2603
2639
  return null;
2604
2640
  }
2605
2641
  let plaintext;
@@ -2623,29 +2659,18 @@ export class AUNClient {
2623
2659
  };
2624
2660
  this._attachV2EnvelopeMetadata(event, e2eeMeta);
2625
2661
  this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
2626
- await this._dispatcher.publish(undecryptableEvent, event);
2662
+ if (emitUndecryptable)
2663
+ await this._dispatcher.publish(undecryptableEvent, event);
2627
2664
  return null;
2628
2665
  }
2629
2666
  if (plaintext === null) {
2630
2667
  this._clientLog.debug(`V2 decrypt returned null plaintext: seq=${String(msg.seq ?? '')}, group=${groupIdForKeys || '<p2p>'}`);
2631
2668
  return null;
2632
2669
  }
2633
- // 消费触发 SPK 轮换
2634
- if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
2635
- this._v2E2EE.scheduleGroupSpkRotation(groupIdForKeys, { reason: 'group_spk_consumed' });
2636
- }
2637
- else if (groupIdForKeys && recipientKeySource === 'peer_device_prekey') {
2638
- this._v2E2EE.scheduleGroupSpkRegistrationAfterPeerFallback(groupIdForKeys);
2639
- }
2640
- else if (!groupIdForKeys && session.isLastUploadedSPK(spkId)) {
2641
- // P2P SPK 消费触发轮换
2642
- const callFn = async (method, params) => this.call(method, params);
2643
- session.rotateSPK(callFn).catch(exc => {
2644
- this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${formatCaughtError(exc)}`);
2645
- });
2646
- }
2670
+ if (rotateKeys)
2671
+ this._scheduleV2KeyRotationForMessage(msg, envelope);
2647
2672
  const e2ee = this._v2E2eeMeta(envelope);
2648
- const result = {
2673
+ let result = {
2649
2674
  message_id: String(msg.message_id ?? ''),
2650
2675
  from: fromAid,
2651
2676
  to: this._aid ?? '',
@@ -2663,9 +2688,65 @@ export class AUNClient {
2663
2688
  result.slot_id = msg.slot_id;
2664
2689
  attachGatewayProximity(result, msg);
2665
2690
  this._attachV2EnvelopeMetadata(result, e2ee);
2691
+ if (groupIdForKeys)
2692
+ result = normalizeGroupMentionMode(result, msg, envelope);
2666
2693
  this._logMessageDebug('decrypt-ok', 'v2.decrypt', groupIdForKeys ? 'group.message_created' : 'message.received', result);
2667
2694
  return result;
2668
2695
  }
2696
+ /** inline 在绑定、游标提交和 ACK 都成功后才调用,避免伪造 Head 过早消耗密钥。 */
2697
+ _postInlineV2CommitAndAck(msg) {
2698
+ const envelopeJson = msg.envelope_json;
2699
+ if (typeof envelopeJson !== 'string' || !envelopeJson)
2700
+ return;
2701
+ try {
2702
+ const envelope = JSON.parse(envelopeJson);
2703
+ this._agentMdManager.observeEnvelope(envelope);
2704
+ this._scheduleV2KeyRotationForMessage(msg, envelope);
2705
+ }
2706
+ catch (exc) {
2707
+ this._clientLog.debug(`inline V2 post-ack rotation skipped: invalid envelope: ${formatCaughtError(exc)}`);
2708
+ }
2709
+ }
2710
+ _scheduleV2KeyRotationForMessage(msg, envelope) {
2711
+ const session = this._v2Session;
2712
+ if (!session)
2713
+ return;
2714
+ let spkId = '';
2715
+ let recipientKeySource = '';
2716
+ if (isJsonObject(envelope.recipient)) {
2717
+ const recipient = envelope.recipient;
2718
+ spkId = String(recipient.spk_id ?? '');
2719
+ recipientKeySource = String(recipient.key_source ?? '');
2720
+ }
2721
+ else if (Array.isArray(envelope.recipients)) {
2722
+ spkId = String(msg.spk_id ?? '');
2723
+ for (const row of envelope.recipients) {
2724
+ if (!Array.isArray(row) || row.length < 6
2725
+ || String(row[0] ?? '') !== this._aid
2726
+ || (String(row[1] ?? '') !== this._deviceId && String(row[1] ?? '') !== ''))
2727
+ continue;
2728
+ if (!spkId)
2729
+ spkId = String(row[5] ?? '');
2730
+ if (row.length > 3)
2731
+ recipientKeySource = String(row[3] ?? '');
2732
+ break;
2733
+ }
2734
+ }
2735
+ const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
2736
+ const groupIdForKeys = String(aad.group_id ?? msg.group_id ?? '').trim();
2737
+ if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
2738
+ this._v2E2EE.scheduleGroupSpkRotation(groupIdForKeys, { reason: 'group_spk_consumed' });
2739
+ }
2740
+ else if (groupIdForKeys && recipientKeySource === 'peer_device_prekey') {
2741
+ this._v2E2EE.scheduleGroupSpkRegistrationAfterPeerFallback(groupIdForKeys);
2742
+ }
2743
+ else if (!groupIdForKeys && session.isLastUploadedSPK(spkId)) {
2744
+ const callFn = async (method, params) => this.call(method, params);
2745
+ session.rotateSPK(callFn).catch(exc => {
2746
+ this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${formatCaughtError(exc)}`);
2747
+ });
2748
+ }
2749
+ }
2669
2750
  _v2E2eeMeta(envelope) {
2670
2751
  const suite = String(envelope.suite ?? '');
2671
2752
  const meta = {
@@ -2785,8 +2866,8 @@ export class AUNClient {
2785
2866
  async _v2AutoConfirmPendingProposals() {
2786
2867
  return this._groupState.autoConfirmPendingProposals();
2787
2868
  }
2788
- async _onV2PushNotification(data) {
2789
- return this._delivery.onV2PushNotification(data);
2869
+ async _onV2PushNotification(data, expectedVersion, inlineGeneration) {
2870
+ return this._delivery.onV2PushNotification(data, expectedVersion, inlineGeneration);
2790
2871
  }
2791
2872
  async _onV2StateProposed(data) {
2792
2873
  return this._groupState.onV2StateProposed(data);
@@ -2797,8 +2878,8 @@ export class AUNClient {
2797
2878
  async _onV2StateConfirmed(data) {
2798
2879
  return this._groupState.onV2StateConfirmed(data);
2799
2880
  }
2800
- async _onRawGroupV2MessageCreated(data) {
2801
- return this._delivery.onRawGroupV2MessageCreated(data);
2881
+ async _onRawGroupV2MessageCreated(data, expectedVersion, inlineGeneration) {
2882
+ return this._delivery.onRawGroupV2MessageCreated(data, expectedVersion, inlineGeneration);
2802
2883
  }
2803
2884
  /** Push 通知带 payload 时的就地解密(复用 _decryptV2Message) */
2804
2885
  async _decryptV2PushMessage(data) {
@@ -2806,20 +2887,35 @@ export class AUNClient {
2806
2887
  }
2807
2888
  /** 按当前 AID 发现 Gateway;用于 authenticate()/connect() 的新入口。 */
2808
2889
  async _resolveGatewayForAid(aid) {
2890
+ const gateways = await this._resolveGatewayCandidatesForAid(aid);
2891
+ return gateways[0];
2892
+ }
2893
+ /** 发现并缓存全部 Gateway 候选,供首连和自动重连故障转移使用。 */
2894
+ async _resolveGatewayCandidatesForAid(aid) {
2809
2895
  const resolvedAid = String(aid ?? this._aid ?? '').trim();
2810
2896
  if (!resolvedAid) {
2811
2897
  throw new StateError('gateway discovery requires a loaded AID');
2812
2898
  }
2813
- if (this._gatewayUrl)
2814
- return this._gatewayUrl;
2899
+ if (this._gatewayCandidates.length > 0)
2900
+ return [...this._gatewayCandidates];
2901
+ if (this._gatewayUrl) {
2902
+ this._gatewayCandidates = [this._gatewayUrl];
2903
+ return [...this._gatewayCandidates];
2904
+ }
2815
2905
  try {
2816
2906
  const loadMetadata = this._tokenStore.loadMetadata;
2817
- const cachedGateway = typeof loadMetadata === 'function'
2818
- ? String(loadMetadata.call(this._tokenStore, resolvedAid)?.gateway_url ?? '').trim()
2819
- : '';
2907
+ const metadata = typeof loadMetadata === 'function'
2908
+ ? loadMetadata.call(this._tokenStore, resolvedAid)
2909
+ : null;
2910
+ const cachedList = isJsonObject(metadata) && Array.isArray(metadata.gateway_urls)
2911
+ ? metadata.gateway_urls.map((item) => String(item ?? '').trim()).filter(Boolean)
2912
+ : [];
2913
+ const cachedGateway = cachedList[0]
2914
+ ?? (isJsonObject(metadata) ? String(metadata.gateway_url ?? '').trim() : '');
2820
2915
  if (cachedGateway) {
2821
- this._gatewayUrl = cachedGateway;
2822
- return cachedGateway;
2916
+ this._gatewayCandidates = [...new Set([cachedGateway, ...cachedList])];
2917
+ this._gatewayUrl = this._gatewayCandidates[0];
2918
+ return [...this._gatewayCandidates];
2823
2919
  }
2824
2920
  }
2825
2921
  catch {
@@ -2830,22 +2926,38 @@ export class AUNClient {
2830
2926
  const portSuffix = this._configModel.discoveryPort ? `:${this._configModel.discoveryPort}` : '';
2831
2927
  const aidUrl = `https://${resolvedAid}${portSuffix}/.well-known/aun-gateway`;
2832
2928
  const gatewayDomainUrl = `https://gateway.${issuerDomain}${portSuffix}/.well-known/aun-gateway`;
2833
- const candidates = this._configModel.verifySsl ? [aidUrl, gatewayDomainUrl] : [gatewayDomainUrl, aidUrl];
2929
+ const candidates = [aidUrl, gatewayDomainUrl];
2834
2930
  let lastErr = null;
2835
2931
  for (const url of candidates) {
2836
2932
  try {
2837
- const gateway = await this._discovery.discover(url);
2933
+ // discoverAll 是生产路径;若调用方注入了旧版/测试 discovery
2934
+ // 替身而只提供 discover,则保留单候选兼容,不影响多候选发现。
2935
+ const discovery = this._discovery;
2936
+ const hasInjectedDiscover = Object.prototype.hasOwnProperty.call(discovery, 'discover')
2937
+ && typeof discovery.discover === 'function';
2938
+ const discovered = hasInjectedDiscover
2939
+ ? [await discovery.discover(url)]
2940
+ : await discovery.discoverAll(url);
2941
+ const gatewayUrls = [...new Set(discovered.map((item) => String(item ?? '').trim()).filter(Boolean))];
2942
+ if (gatewayUrls.length === 0)
2943
+ throw new ValidationError('gateway discovery returned no candidates');
2944
+ this._gatewayCandidates = gatewayUrls;
2945
+ const gateway = gatewayUrls[0];
2838
2946
  this._gatewayUrl = gateway;
2839
2947
  try {
2840
2948
  const saveMetadata = this._tokenStore.saveMetadata;
2841
2949
  if (typeof saveMetadata === 'function') {
2842
- saveMetadata.call(this._tokenStore, resolvedAid, { gateway_url: gateway, gateway_cached_at: Date.now() });
2950
+ saveMetadata.call(this._tokenStore, resolvedAid, {
2951
+ gateway_url: gateway,
2952
+ gateway_urls: gatewayUrls,
2953
+ gateway_cached_at: Date.now(),
2954
+ });
2843
2955
  }
2844
2956
  }
2845
2957
  catch {
2846
2958
  // 缓存写入失败不影响连接。
2847
2959
  }
2848
- return gateway;
2960
+ return [...gatewayUrls];
2849
2961
  }
2850
2962
  catch (err) {
2851
2963
  lastErr = err;
@@ -2856,6 +2968,9 @@ export class AUNClient {
2856
2968
  }
2857
2969
  /** 从参数中解析 Gateway URL */
2858
2970
  _resolveGateway(params) {
2971
+ const explicitGateway = String(params.gateway ?? '').trim();
2972
+ if (explicitGateway)
2973
+ return explicitGateway;
2859
2974
  const gateways = this._resolveGateways(params);
2860
2975
  return gateways[0];
2861
2976
  }
@@ -2872,7 +2987,9 @@ export class AUNClient {
2872
2987
  throw new ValidationError('relay topology is not implemented in the TypeScript SDK');
2873
2988
  }
2874
2989
  }
2875
- const gw = params.gateway ?? params.gateways;
2990
+ const gw = Array.isArray(params.gateways) && params.gateways.length > 0
2991
+ ? params.gateways
2992
+ : params.gateway;
2876
2993
  if (Array.isArray(gw)) {
2877
2994
  const urls = gw.map(g => String(g ?? '')).filter(u => u.length > 0);
2878
2995
  if (urls.length > 0)
@@ -2907,7 +3024,13 @@ export class AUNClient {
2907
3024
  if (!accessToken && opts.requireAccessToken === true) {
2908
3025
  throw new StateError('connect requires non-empty access_token');
2909
3026
  }
2910
- const gateway = String(request.gateway ?? this._gatewayUrl ?? '');
3027
+ const rawGateways = request.gateways;
3028
+ const normalizedGateways = Array.isArray(rawGateways)
3029
+ ? [...new Set(rawGateways.map((item) => String(item ?? '').trim()).filter(Boolean))]
3030
+ : [];
3031
+ if (normalizedGateways.length > 0)
3032
+ request.gateways = normalizedGateways;
3033
+ const gateway = String(request.gateway ?? normalizedGateways[0] ?? this._gatewayUrl ?? '');
2911
3034
  if (!gateway)
2912
3035
  throw new StateError('connect requires non-empty gateway');
2913
3036
  if (accessToken)
@@ -2969,8 +3092,6 @@ export class AUNClient {
2969
3092
  };
2970
3093
  if ('auto_reconnect' in params)
2971
3094
  options.auto_reconnect = Boolean(params.auto_reconnect);
2972
- if ('heartbeat_interval' in params)
2973
- options.heartbeat_interval = Number(params.heartbeat_interval);
2974
3095
  if ('token_refresh_before' in params)
2975
3096
  options.token_refresh_before = Number(params.token_refresh_before);
2976
3097
  if ('retry' in params && isJsonObject(params.retry)) {
@@ -2986,10 +3107,9 @@ export class AUNClient {
2986
3107
  // ── 内部:后台任务 ────────────────────────────────────────
2987
3108
  /** 启动所有后台任务 */
2988
3109
  _startBackgroundTasks() {
2989
- // 短连接不启动 heartbeat token 刷新(生命周期短,不需要长期会话维护);
2990
- // auto_reconnect 仍允许,由 _sessionOptions.auto_reconnect 决定
3110
+ // WebSocket Ping/Pong Gateway 与传输层处理;SDK 不发送 RPC 心跳。
3111
+ // 短连接生命周期短,不需要 token 刷新。
2991
3112
  if (this._sessionOptions.connection_kind !== 'short') {
2992
- this._startHeartbeatTask();
2993
3113
  this._startTokenRefreshTask();
2994
3114
  }
2995
3115
  this._startV2MaintenanceTasks();
@@ -3011,56 +3131,19 @@ export class AUNClient {
3011
3131
  }
3012
3132
  /** 启动心跳任务 */
3013
3133
  _startHeartbeatTask() {
3014
- if (this._heartbeatTimer !== null)
3015
- return;
3016
- const interval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
3017
- if (interval <= 0)
3018
- return;
3019
- // M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
3020
- // 又把半开连接的检测延迟从 3 个心跳周期降到 2 个。
3021
- // 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
3022
- // 不依赖此心跳路径。
3023
- let consecutiveFailures = 0;
3024
- const maxFailures = 2;
3025
- this._heartbeatTimer = setInterval(() => {
3026
- if (this._closing || this.state !== ConnectionState.READY)
3027
- return;
3028
- this._transport.call('meta.ping', {}).then((pong) => {
3029
- consecutiveFailures = 0;
3030
- // 服务端可在 pong 中下发新的 heartbeat_interval(秒,0=关闭)
3031
- if (isJsonObject(pong) && 'heartbeat_interval' in pong) {
3032
- this._applyServerHeartbeatInterval(pong.heartbeat_interval, 'pong');
3033
- }
3034
- }).catch((exc) => {
3035
- consecutiveFailures++;
3036
- this._clientLog.warn(`heartbeat failed (${consecutiveFailures}/${maxFailures}): ${formatCaughtError(exc)}`);
3037
- this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) }).catch(() => { });
3038
- if (consecutiveFailures >= maxFailures) {
3039
- this._clientLog.warn(`${maxFailures} consecutive heartbeat failures, triggering reconnect`);
3040
- this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
3041
- }
3042
- });
3043
- }, interval * 1000);
3044
- // 允许 Node.js 进程在只剩定时器时退出
3045
- if (this._heartbeatTimer && typeof this._heartbeatTimer === 'object' && 'unref' in this._heartbeatTimer) {
3046
- this._heartbeatTimer.unref();
3134
+ if (this._heartbeatTimer !== null) {
3135
+ clearInterval(this._heartbeatTimer);
3136
+ this._heartbeatTimer = null;
3047
3137
  }
3138
+ this._sessionOptions.heartbeat_interval = 0;
3048
3139
  }
3049
- /** 服务端通过 hello/pong 下发 heartbeat_interval;clamp 后写入 session_options 并按需重启心跳。 */
3050
- _applyServerHeartbeatInterval(raw, source) {
3051
- const newInterval = clampHeartbeatInterval(raw);
3052
- const oldInterval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval);
3053
- if (newInterval === oldInterval)
3054
- return;
3055
- this._sessionOptions.heartbeat_interval = newInterval;
3056
- this._clientLog.debug(`heartbeat_interval updated by ${source}: ${oldInterval} -> ${newInterval}`);
3140
+ /** 忽略旧 Gateway RPC 心跳配置,固定使用 WebSocket Ping/Pong。 */
3141
+ _applyServerHeartbeatInterval(_raw, _source) {
3057
3142
  if (this._heartbeatTimer !== null) {
3058
3143
  clearInterval(this._heartbeatTimer);
3059
3144
  this._heartbeatTimer = null;
3060
3145
  }
3061
- if (newInterval > 0 && this.state === ConnectionState.READY && !this._closing) {
3062
- this._startHeartbeatTask();
3063
- }
3146
+ this._sessionOptions.heartbeat_interval = 0;
3064
3147
  }
3065
3148
  /** 启动 token 刷新任务 */
3066
3149
  _startTokenRefreshTask() {
@@ -3194,7 +3277,7 @@ export class AUNClient {
3194
3277
  }
3195
3278
  // ── 内部:断线重连 ────────────────────────────────────────
3196
3279
  /** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
3197
- static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015]);
3280
+ static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4013, 4014, 4015]);
3198
3281
  /** 处理服务端主动断开通知 event/gateway.disconnect。
3199
3282
  *
3200
3283
  * 服务端可能附带结构化 detail 字段(如配额超限时含 aid/device_id/slot_id/quota_kind/evicted_by)。
@@ -3208,7 +3291,11 @@ export class AUNClient {
3208
3291
  ? payload.detail
3209
3292
  : {};
3210
3293
  this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
3211
- this._serverKicked = true;
3294
+ const reasonText = String(reason ?? '').trim().toLowerCase();
3295
+ const retryable = Number(code) === 4012
3296
+ || reasonText.includes('long_connection_already_exists')
3297
+ || reasonText.includes('long connection already exists');
3298
+ this._serverKicked = !retryable;
3212
3299
  // 缓存最近一次 disconnect 信息,让后续 connection.state(connection_failed) 也能带 detail
3213
3300
  this._lastDisconnectInfo = { code, reason, detail };
3214
3301
  // 透传给应用层订阅者
@@ -3230,21 +3317,27 @@ export class AUNClient {
3230
3317
  // 已在重连中则跳过,避免心跳超时和 transport 断线回调重复触发
3231
3318
  if (this._reconnectActive)
3232
3319
  return;
3320
+ // 不等待状态事件或 transport 清理,先隔离已在途的 inline Push 回调。
3321
+ this._delivery.resetInlineAckState();
3233
3322
  // 原子化标志,防止 await 期间并发调用再次进入
3234
3323
  this._reconnectActive = true;
3235
3324
  this._clientLog.warn(`transport disconnected: closeCode=${closeCode ?? 'none'}, error=${error ? formatCaughtError(error) : 'none'}`);
3236
3325
  this._state = 'standby';
3237
3326
  this._stopBackgroundTasks();
3238
- void this._transport.close().catch((exc) => {
3327
+ try {
3328
+ await this._transport.close();
3329
+ }
3330
+ catch (exc) {
3239
3331
  this._clientLog.debug(`transport cleanup skipped: ${formatCaughtError(exc)}`);
3240
- });
3332
+ }
3241
3333
  await this._dispatcher.publish('state_change', { state: this._publicState(this._state), error });
3242
3334
  if (!this._sessionOptions.auto_reconnect) {
3243
3335
  this._reconnectActive = false;
3244
3336
  return;
3245
3337
  }
3246
3338
  // 不重连 close code(认证失败/权限错误/被踢等)或服务端通知断开:抑制重连
3247
- if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
3339
+ const retryableClose = closeCode === 4012;
3340
+ if ((this._serverKicked && !retryableClose) || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
3248
3341
  this._state = 'connection_failed';
3249
3342
  const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
3250
3343
  this._clientLog.warn(`suppressing auto-reconnect: ${reason}`);
@@ -3268,26 +3361,47 @@ export class AUNClient {
3268
3361
  this._reconnectActive = false;
3269
3362
  this._startReconnect(serverInitiated);
3270
3363
  }
3271
- /** 启动重连循环(默认无限重试 + 指数退避 + 固定上限抖动,仅在不可重试错误、close() 或 max_attempts 耗尽时终止) */
3364
+ /** 启动重连循环(默认无限重试 + 当前基数抖动,仅在不可重试错误、close() 或 max_attempts 耗尽时终止) */
3272
3365
  _startReconnect(serverInitiated = false) {
3273
3366
  if (this._reconnectActive)
3274
3367
  return;
3275
3368
  this._reconnectActive = true;
3276
- this._reconnectAbort = new AbortController();
3369
+ const reconnectAbort = new AbortController();
3370
+ this._reconnectAbort = reconnectAbort;
3277
3371
  this._clientLog.debug(`reconnect loop started: serverInitiated=${String(serverInitiated)}, aid=${this._aid ?? ''}`);
3278
- this._reconnectLoop(serverInitiated).catch((exc) => {
3372
+ this._reconnectLoop(serverInitiated, reconnectAbort).catch((exc) => {
3279
3373
  this._clientLog.warn(`reconnect loop error: ${formatCaughtError(exc)}`);
3280
3374
  });
3281
3375
  }
3282
- /** 重连循环(for 循环 + AbortController,与 JS/Python 对齐) */
3283
- async _reconnectLoop(serverInitiated) {
3376
+ /** 重连循环入口;无论正常退出还是意外异常,都只清理自己这一代控制器。 */
3377
+ async _reconnectLoop(serverInitiated, reconnectAbort = this._reconnectAbort) {
3378
+ let releasedOwnGeneration = false;
3379
+ try {
3380
+ await this._runReconnectLoop(serverInitiated, reconnectAbort);
3381
+ }
3382
+ finally {
3383
+ if (this._reconnectAbort === reconnectAbort) {
3384
+ this._nextRetryAt = null;
3385
+ this._reconnectActive = false;
3386
+ this._reconnectAbort = null;
3387
+ releasedOwnGeneration = true;
3388
+ }
3389
+ }
3390
+ // 认证成功到重连代际释放之间若再次断线,断线回调会因 _reconnectActive
3391
+ // 被合并;释放后必须复核底层连接,避免留下“状态 ready、实际离线”。
3392
+ if (releasedOwnGeneration && !this._closing && this._state === 'ready' && !this._transport.isConnected()) {
3393
+ await this._handleTransportDisconnect(new ConnectionError('websocket disconnected while reconnect completed'));
3394
+ }
3395
+ }
3396
+ /** 重连循环主体(for 循环 + AbortController,与 JS/Python 对齐) */
3397
+ async _runReconnectLoop(serverInitiated, reconnectAbort) {
3284
3398
  const retry = this._sessionOptions.retry;
3285
3399
  const maxBaseDelay = clampReconnectDelayMs(Number(retry.max_delay ?? 64.0) * 1000, RECONNECT_MAX_BASE_DELAY_MS);
3286
3400
  const maxAttemptsRaw = Number(retry.max_attempts ?? 0);
3287
3401
  const maxAttempts = Number.isFinite(maxAttemptsRaw) && maxAttemptsRaw > 0 ? Math.floor(maxAttemptsRaw) : 0;
3288
3402
  this._retryMaxAttempts = maxAttempts;
3289
3403
  let delay = clampReconnectDelayMs(serverInitiated ? 16_000 : Number(retry.initial_delay ?? 1.0) * 1000, serverInitiated ? 16_000 : RECONNECT_MIN_BASE_DELAY_MS, maxBaseDelay);
3290
- for (let attempt = 1; !this._reconnectAbort?.signal.aborted; attempt++) {
3404
+ for (let attempt = 1; !reconnectAbort?.signal.aborted; attempt++) {
3291
3405
  if (this._closing)
3292
3406
  break;
3293
3407
  // max_attempts 检查在循环顶部,覆盖所有路径(含 health-fail)
@@ -3309,22 +3423,25 @@ export class AUNClient {
3309
3423
  next_retry_at: this._nextRetryAt,
3310
3424
  });
3311
3425
  try {
3312
- // 固定上限抖动:base=[1s, max_base],delay=base+rand(0..max_base)。
3313
- await this._sleep(Math.max(0, this._nextRetryAt - Date.now()));
3314
- if (this._reconnectAbort?.signal.aborted || this._closing)
3426
+ // 抖动随当前 base 增长且不超过 max_base,避免首次重连被放大到几十秒。
3427
+ await this._sleep(Math.max(0, this._nextRetryAt - Date.now()), reconnectAbort?.signal);
3428
+ if (reconnectAbort?.signal.aborted || this._closing)
3315
3429
  break;
3316
3430
  this._state = 'reconnecting';
3317
3431
  await this._dispatcher.publish('state_change', {
3318
3432
  state: this._publicState(this._state),
3319
3433
  attempt,
3320
3434
  });
3321
- // 重连前先 GET /health 探测,不健康则跳过本轮
3435
+ // /health 仅作旁路诊断,不能阻止实际 WebSocket 重连。
3322
3436
  if (this._gatewayUrl) {
3323
- const healthy = await this._discovery.checkHealth(this._gatewayUrl, 5_000);
3324
- if (!healthy) {
3325
- delay = Math.min(delay * 2, maxBaseDelay);
3326
- continue;
3327
- }
3437
+ void this._discovery.checkHealth(this._gatewayUrl, 5_000)
3438
+ .then((healthy) => {
3439
+ if (!healthy)
3440
+ this._clientLog.warn(`reconnect health advisory failed: gateway=${this._gatewayUrl}`);
3441
+ })
3442
+ .catch((exc) => {
3443
+ this._clientLog.debug(`reconnect health advisory error: ${formatCaughtError(exc)}`);
3444
+ });
3328
3445
  }
3329
3446
  await this._transport.close();
3330
3447
  if (this._sessionParams === null) {
@@ -3348,12 +3465,35 @@ export class AUNClient {
3348
3465
  this._sessionParams.access_token = '';
3349
3466
  }
3350
3467
  }
3351
- await this._connectOnce(this._sessionParams, true);
3468
+ const candidates = this._orderedReconnectGateways(this._sessionParams);
3469
+ let lastGatewayError = null;
3470
+ let connected = false;
3471
+ for (const gateway of candidates) {
3472
+ const attemptParams = { ...this._sessionParams, gateway };
3473
+ try {
3474
+ await this._connectOnce(attemptParams, true);
3475
+ if (!this._transport.isConnected()) {
3476
+ throw new ConnectionError('websocket disconnected while reconnecting');
3477
+ }
3478
+ this._sessionParams.gateway = gateway;
3479
+ connected = true;
3480
+ break;
3481
+ }
3482
+ catch (exc) {
3483
+ lastGatewayError = exc;
3484
+ await this._transport.close().catch(() => { });
3485
+ if (candidates.length > 1) {
3486
+ this._clientLog.warn(`reconnect gateway failed, trying next: gateway=${gateway}, error=${formatCaughtError(exc)}`);
3487
+ }
3488
+ if (!AUNClient._shouldRetryReconnect(exc))
3489
+ throw exc;
3490
+ }
3491
+ }
3492
+ if (!connected)
3493
+ throw (lastGatewayError instanceof Error ? lastGatewayError : new ConnectionError('all gateway candidates failed'));
3352
3494
  // 重连成功,退出循环
3353
3495
  this._clientLog.debug(`reconnect success: attempt=${attempt}, aid=${this._aid ?? ''}`);
3354
3496
  this._nextRetryAt = null;
3355
- this._reconnectActive = false;
3356
- this._reconnectAbort = null;
3357
3497
  return;
3358
3498
  }
3359
3499
  catch (exc) {
@@ -3373,17 +3513,39 @@ export class AUNClient {
3373
3513
  delay = Math.min(delay * 2, maxBaseDelay);
3374
3514
  }
3375
3515
  }
3376
- this._reconnectActive = false;
3377
- this._reconnectAbort = null;
3516
+ }
3517
+ /** 每轮重连从当前地址之后开始轮转,避免永远黏在失效的首个 Gateway。 */
3518
+ _orderedReconnectGateways(params) {
3519
+ const raw = Array.isArray(params.gateways) && params.gateways.length > 0
3520
+ ? params.gateways
3521
+ : (this._gatewayCandidates.length > 0 ? this._gatewayCandidates : [params.gateway ?? this._gatewayUrl]);
3522
+ const candidates = [...new Set(raw.map((item) => String(item ?? '').trim()).filter(Boolean))];
3523
+ if (candidates.length <= 1)
3524
+ return candidates;
3525
+ const current = String(this._gatewayUrl ?? params.gateway ?? '').trim();
3526
+ const index = candidates.indexOf(current);
3527
+ const start = index >= 0 ? (index + 1) % candidates.length : 0;
3528
+ return [...candidates.slice(start), ...candidates.slice(0, start)];
3378
3529
  }
3379
3530
  /** 可取消的 sleep,监听 _reconnectAbort.signal,abort 时提前 resolve */
3380
- _sleep(ms) {
3531
+ _sleep(ms, signal) {
3381
3532
  return new Promise((resolve) => {
3382
- const timer = setTimeout(resolve, ms);
3533
+ let settled = false;
3534
+ const finish = () => {
3535
+ if (settled)
3536
+ return;
3537
+ settled = true;
3538
+ clearTimeout(timer);
3539
+ signal?.removeEventListener('abort', finish);
3540
+ resolve();
3541
+ };
3542
+ const timer = setTimeout(finish, ms);
3383
3543
  this._unrefTimer(timer);
3384
- const signal = this._reconnectAbort?.signal;
3385
3544
  if (signal) {
3386
- signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
3545
+ if (signal.aborted)
3546
+ finish();
3547
+ else
3548
+ signal.addEventListener('abort', finish, { once: true });
3387
3549
  }
3388
3550
  });
3389
3551
  }
@@ -3399,7 +3561,10 @@ export class AUNClient {
3399
3561
  static _shouldRetryReconnect(error) {
3400
3562
  if (error instanceof AuthError) {
3401
3563
  const message = String(error.message ?? '').toLowerCase();
3402
- if (message.includes('aid_login1_failed') || message.includes('aid_login2_failed')) {
3564
+ if (message.includes('long_connection_already_exists')
3565
+ || message.includes('long connection already exists')
3566
+ || message.includes('aid_login1_failed')
3567
+ || message.includes('aid_login2_failed')) {
3403
3568
  return true;
3404
3569
  }
3405
3570
  return false;