@agentunion/fastaun-browser 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -343,6 +343,8 @@ export class AUNClient {
343
343
  _certCache = new Map();
344
344
  _prekeyReplenishInflight = new Set();
345
345
  _prekeyReplenished = new Set();
346
+ // 当前活跃 prekey:只有这个 prekey 被消费时才触发 replenish 上传
347
+ _activePrekeyId = '';
346
348
  _peerPrekeysCache = new Map();
347
349
  // 后台任务 handle(浏览器 setInterval/setTimeout)
348
350
  _heartbeatTimer = null;
@@ -378,6 +380,11 @@ export class AUNClient {
378
380
  _reconnectActive = false;
379
381
  _reconnectAbort = null;
380
382
  _serverKicked = false;
383
+ /**
384
+ * 缓存最近一次服务端 gateway.disconnect 信息(含 code/reason/detail),
385
+ * 让后续 connection.state(terminal_failed) 也能携带 detail(如配额超限信息)。
386
+ */
387
+ _lastDisconnectInfo = null;
381
388
  // Logger(per-client 单例 + 各模块子 logger)
382
389
  _logger;
383
390
  _clientLog;
@@ -492,8 +499,8 @@ export class AUNClient {
492
499
  });
493
500
  }
494
501
  // 服务端主动断开通知:记录日志并标记不重连
495
- this._dispatcher.subscribe('_raw.gateway.disconnect', (data) => {
496
- this._onGatewayDisconnect(data);
502
+ this._dispatcher.subscribe('_raw.gateway.disconnect', async (data) => {
503
+ await this._onGatewayDisconnect(data);
497
504
  });
498
505
  }
499
506
  // ── 属性 ──────────────────────────────────────────
@@ -788,7 +795,6 @@ export class AUNClient {
788
795
  if (method === 'group.pull' && isJsonObject(result)) {
789
796
  const r = result;
790
797
  const messages = r.messages;
791
- // 先保存原始消息(解密前),用于喂 SeqTracker(与 P2P message.pull 路径对齐)
792
798
  const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
793
799
  if (rawMessages.length) {
794
800
  r.messages = await this._decryptGroupMessages(rawMessages);
@@ -796,13 +802,28 @@ export class AUNClient {
796
802
  const gid = (p.group_id ?? '');
797
803
  if (gid) {
798
804
  const ns = `group:${gid}`;
799
- // ⚠️ 使用原始消息(rawMessages)喂 SeqTracker,与 P2P message.pull 路径一致
800
- if (rawMessages.length) {
801
- this._seqTracker.onPullResult(ns, rawMessages);
805
+ // 区分解密成功 / 失败:失败的 payload 仍是 e2ee.group_encrypted。
806
+ const decryptedOnly = [];
807
+ let failedCount = 0;
808
+ const decryptedMessages = Array.isArray(r.messages) ? r.messages : [];
809
+ for (const m of decryptedMessages) {
810
+ if (!isJsonObject(m))
811
+ continue;
812
+ const payload = isJsonObject(m.payload) ? m.payload : {};
813
+ const ptype = payload.type;
814
+ if (ptype === 'e2ee.group_encrypted') {
815
+ failedCount++;
816
+ this._enqueuePendingDecrypt(gid, m);
817
+ }
818
+ else {
819
+ decryptedOnly.push(m);
820
+ }
821
+ }
822
+ if (decryptedOnly.length) {
823
+ // 仅用解密成功的消息推进 contig;失败的等 retry 解密成功才推进。
824
+ this._seqTracker.onPullResult(ns, decryptedOnly);
802
825
  }
803
826
  // ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
804
- // 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
805
- // 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
806
827
  const cursor = isJsonObject(r.cursor) ? r.cursor : null;
807
828
  if (cursor) {
808
829
  const serverAck = Number(cursor.current_seq ?? 0);
@@ -815,9 +836,9 @@ export class AUNClient {
815
836
  }
816
837
  }
817
838
  this._saveSeqTrackerState();
818
- // auto-ack contiguous_seq
839
+ // auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
819
840
  const contig = this._seqTracker.getContiguousSeq(ns);
820
- const shouldAck = rawMessages.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0);
841
+ const shouldAck = failedCount === 0 && (decryptedOnly.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0));
821
842
  if (contig > 0 && shouldAck) {
822
843
  this._transport.call('group.ack_messages', {
823
844
  group_id: gid,
@@ -826,6 +847,10 @@ export class AUNClient {
826
847
  slot_id: this._slotId,
827
848
  }).catch((e) => { this._clientLog.warn('group.pull auto-ack failed: group=' + gid, e); });
828
849
  }
850
+ // 有解密失败时调度 recovery 兜底定时
851
+ if (failedCount > 0) {
852
+ this._scheduleRecoveryTimeout(gid);
853
+ }
829
854
  }
830
855
  }
831
856
  if (method === 'group.thought.get' && isJsonObject(result)) {
@@ -920,6 +945,22 @@ export class AUNClient {
920
945
  }
921
946
  // 拦截 P2P 传输的群组密钥分发/请求/响应消息
922
947
  if (await this._tryHandleGroupKeyMessage(msg)) {
948
+ // group_key 控制消息也要推进 seq tracker + auto-ack,
949
+ // 否则 fillP2pGap 会因为 contig 卡在此 seq 之前而重复拉取同样的历史消息。
950
+ const seq = msg.seq;
951
+ if (seq !== undefined && seq !== null && this._aid) {
952
+ const ns = `p2p:${this._aid}`;
953
+ this._seqTracker.onMessageSeq(ns, seq);
954
+ this._saveSeqTrackerState();
955
+ const contig = this._seqTracker.getContiguousSeq(ns);
956
+ if (contig > 0) {
957
+ this._transport.call('message.ack', {
958
+ seq: contig,
959
+ device_id: this._deviceId,
960
+ slot_id: this._slotId,
961
+ }).catch(() => { });
962
+ }
963
+ }
923
964
  return;
924
965
  }
925
966
  // P2P 空洞检测
@@ -1004,8 +1045,12 @@ export class AUNClient {
1004
1045
  return;
1005
1046
  }
1006
1047
  const decrypted = await this._decryptGroupMessage(msg);
1007
- // 只有带 payload 的真实消息,在同步解密/恢复尝试结束后才推进游标。
1008
- if (groupId && seq !== undefined && seq !== null) {
1048
+ // 解密失败时**不推进 seq tracker / 不 auto-ack**:让服务端 cursor 留在原位,
1049
+ // 等密钥恢复后 retry 解密成功才推进 + ack;recovery 真的失败时由
1050
+ // _retryPendingDecryptMsgs(forceAdvanceOnFail=true) 兜底强制推进。
1051
+ const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
1052
+ const isDecryptFail = payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee;
1053
+ if (!isDecryptFail && groupId && seq !== undefined && seq !== null) {
1009
1054
  const ns = `group:${groupId}`;
1010
1055
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1011
1056
  if (needPull) {
@@ -1023,10 +1068,12 @@ export class AUNClient {
1023
1068
  this._saveSeqTrackerState();
1024
1069
  }
1025
1070
  // 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)
1071
+ if (isDecryptFail) {
1072
+ if (groupId) {
1029
1073
  this._enqueuePendingDecrypt(groupId, msg);
1074
+ // 触发 recovery 兜底定时(30s 后如果仍未解开,强制推进)
1075
+ this._scheduleRecoveryTimeout(groupId);
1076
+ }
1030
1077
  await this._publishAppEvent('group.message_undecryptable', {
1031
1078
  message_id: msg.message_id ?? null,
1032
1079
  group_id: groupId,
@@ -1882,8 +1929,10 @@ export class AUNClient {
1882
1929
  const did = String(pk.device_id ?? '').trim();
1883
1930
  return did && did !== PREKEY_FALLBACK_DEVICE_ID;
1884
1931
  });
1885
- const canUseMultiDevice = routablePrekeys.length > 0
1886
- && (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
1932
+ // 只要有 routable prekey 就走 multi_device 路径(即使只有 1 个 recipient device + 0 self copies)。
1933
+ // 这确保服务端为每个已注册设备存储副本,离线设备重连后能 pull 到。
1934
+ // single 路径仅在完全没有 routable prekey 时使用(legacy 兼容)。
1935
+ const canUseMultiDevice = routablePrekeys.length > 0;
1887
1936
  if (!canUseMultiDevice) {
1888
1937
  return await this._sendEncryptedSingle({
1889
1938
  toAid, payload, messageId, timestamp,
@@ -2731,23 +2780,47 @@ export class AUNClient {
2731
2780
  queue.push(msg);
2732
2781
  this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
2733
2782
  }
2734
- async _retryPendingDecryptMsgs(groupId) {
2783
+ async _retryPendingDecryptMsgs(groupId, forceAdvanceOnFail = false) {
2735
2784
  const ns = `group:${groupId}`;
2736
2785
  const queue = this._pendingDecryptMsgs.get(ns);
2737
2786
  if (!queue || queue.length === 0)
2738
2787
  return;
2739
2788
  this._pendingDecryptMsgs.set(ns, []);
2740
2789
  const stillPending = [];
2790
+ let forceAdvancedAny = false;
2741
2791
  for (const msg of queue) {
2742
2792
  try {
2743
2793
  const decrypted = await this._decryptGroupMessage(msg);
2744
2794
  const payload = isJsonObject(msg.payload) ? msg.payload : null;
2745
2795
  if (payload?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
2746
- stillPending.push(msg);
2796
+ if (forceAdvanceOnFail) {
2797
+ // recovery 真的失败:强制推进 + 发 undecryptable
2798
+ this._clientLog.info(`group recovery give up: group=${groupId} seq=${String(msg.seq ?? '')} → force advance + publish undecryptable`);
2799
+ const seq = msg.seq;
2800
+ if (seq !== undefined && seq !== null) {
2801
+ this._seqTracker.onMessageSeq(ns, seq);
2802
+ this._saveSeqTrackerState();
2803
+ forceAdvancedAny = true;
2804
+ }
2805
+ await this._publishAppEvent('group.message_undecryptable', {
2806
+ message_id: msg.message_id,
2807
+ group_id: groupId,
2808
+ from: msg.from,
2809
+ seq,
2810
+ timestamp: msg.timestamp,
2811
+ _decrypt_error: 'epoch recovery failed',
2812
+ });
2813
+ }
2814
+ else {
2815
+ stillPending.push(msg);
2816
+ }
2747
2817
  continue;
2748
2818
  }
2749
2819
  const seq = msg.seq;
2750
2820
  if (seq !== undefined && seq !== null) {
2821
+ // 推进 seq tracker(之前 push/pull 失败时没推进)
2822
+ this._seqTracker.onMessageSeq(ns, seq);
2823
+ this._saveSeqTrackerState();
2751
2824
  await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
2752
2825
  }
2753
2826
  else {
@@ -2758,6 +2831,17 @@ export class AUNClient {
2758
2831
  stillPending.push(msg);
2759
2832
  }
2760
2833
  }
2834
+ if (forceAdvancedAny) {
2835
+ const contig = this._seqTracker.getContiguousSeq(ns);
2836
+ if (contig > 0) {
2837
+ this._transport.call('group.ack_messages', {
2838
+ group_id: groupId,
2839
+ msg_seq: contig,
2840
+ device_id: this._deviceId,
2841
+ slot_id: this._slotId,
2842
+ }).catch((e) => { this._clientLog.warn('group recovery force-advance ack failed: group=' + groupId, e); });
2843
+ }
2844
+ }
2761
2845
  const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
2762
2846
  const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
2763
2847
  if (mergedPending.length)
@@ -2765,6 +2849,25 @@ export class AUNClient {
2765
2849
  else
2766
2850
  this._pendingDecryptMsgs.delete(ns);
2767
2851
  }
2852
+ // recovery 兜底定时去重:每个 group 在 30s 内最多调度一次"超时强制推进"任务
2853
+ _recoveryTimeoutScheduled = new Map();
2854
+ _scheduleRecoveryTimeout(groupId, timeoutMs = 30000) {
2855
+ if (!groupId)
2856
+ return;
2857
+ const now = Date.now();
2858
+ const last = this._recoveryTimeoutScheduled.get(groupId) ?? 0;
2859
+ if (last && (last + timeoutMs) > now)
2860
+ return;
2861
+ this._recoveryTimeoutScheduled.set(groupId, now);
2862
+ setTimeout(() => {
2863
+ const ns = `group:${groupId}`;
2864
+ const queue = this._pendingDecryptMsgs.get(ns);
2865
+ if (!queue || queue.length === 0)
2866
+ return;
2867
+ this._clientLog.info(`group recovery timeout: group=${groupId} → force advance`);
2868
+ this._safeAsync(this._retryPendingDecryptMsgs(groupId, true));
2869
+ }, timeoutMs);
2870
+ }
2768
2871
  _scheduleRetryPendingDecryptMsgs(groupId) {
2769
2872
  if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
2770
2873
  return;
@@ -3395,11 +3498,31 @@ export class AUNClient {
3395
3498
  let result = null;
3396
3499
  try {
3397
3500
  if (actualPayload.type === 'e2ee.group_key_distribution') {
3501
+ // 快速跳过已过期的历史 epoch 分发:本地已有更高 epoch 时不发任何 RPC,
3502
+ // 避免 fillP2pGap 拉到大量历史群密钥消息时触发 epoch 编排风暴。
3503
+ const distGroupId = String(actualPayload.group_id ?? '');
3504
+ const distEpoch = Number(actualPayload.epoch ?? 0);
3505
+ if (distGroupId && distEpoch > 0) {
3506
+ const localEpoch = await this._groupE2ee.currentEpoch(distGroupId) ?? 0;
3507
+ if (localEpoch >= distEpoch) {
3508
+ this._clientLog.debug(`skip stale group_key_distribution: group=${distGroupId} msg_epoch=${distEpoch} local_epoch=${localEpoch}`);
3509
+ return true;
3510
+ }
3511
+ }
3398
3512
  if (!await this._verifyActiveGroupRotationDistribution(actualPayload)) {
3399
3513
  return true;
3400
3514
  }
3401
3515
  }
3402
3516
  else if (actualPayload.type === 'e2ee.group_key_response') {
3517
+ const respGroupId = String(actualPayload.group_id ?? '');
3518
+ const respEpoch = Number(actualPayload.epoch ?? 0);
3519
+ if (respGroupId && respEpoch > 0) {
3520
+ const localEpoch = await this._groupE2ee.currentEpoch(respGroupId) ?? 0;
3521
+ if (localEpoch >= respEpoch) {
3522
+ this._clientLog.debug(`skip stale group_key_response: group=${respGroupId} msg_epoch=${respEpoch} local_epoch=${localEpoch}`);
3523
+ return true;
3524
+ }
3525
+ }
3403
3526
  if (!await this._verifyGroupKeyResponseEpoch(actualPayload)) {
3404
3527
  return true;
3405
3528
  }
@@ -3681,6 +3804,8 @@ export class AUNClient {
3681
3804
  async _uploadPrekey() {
3682
3805
  const prekeyMaterial = await this._e2ee.generatePrekey();
3683
3806
  const result = await this._transport.call('message.e2ee.put_prekey', prekeyMaterial);
3807
+ // 上传成功后记录为活跃 prekey
3808
+ this._activePrekeyId = String(prekeyMaterial.prekey_id ?? '');
3684
3809
  return isJsonObject(result) ? { ...result } : { ok: true };
3685
3810
  }
3686
3811
  /** 确保发送方证书在本地可用且未过期 */
@@ -3895,6 +4020,11 @@ export class AUNClient {
3895
4020
  const groupId = String(payload.group_id ?? '').trim();
3896
4021
  if (!groupId)
3897
4022
  return false;
4023
+ // 历史群(不在当前 session 活跃列表):跳过 RPC 验证,只做本地 handle_incoming
4024
+ if (!this._groupSynced.has(groupId)) {
4025
+ this._clientLog.debug(`skip RPC verify for inactive group: group=${groupId} rotation=${rotationId}`);
4026
+ return true;
4027
+ }
3898
4028
  const epoch = Number(payload.epoch ?? 0);
3899
4029
  if (!Number.isFinite(epoch) || epoch <= 0)
3900
4030
  return false;
@@ -4679,6 +4809,9 @@ export class AUNClient {
4679
4809
  deviceId: this._deviceId,
4680
4810
  slotId: this._slotId,
4681
4811
  deliveryMode: this._connectDeliveryMode,
4812
+ connectionKind: String(params.connection_kind ?? 'long'),
4813
+ shortTtlMs: Number(params.short_ttl_ms ?? 0),
4814
+ extraInfo: params.extra_info,
4682
4815
  });
4683
4816
  if (isJsonObject(authContext)) {
4684
4817
  const auth = authContext;
@@ -4697,6 +4830,9 @@ export class AUNClient {
4697
4830
  deviceId: this._deviceId,
4698
4831
  slotId: this._slotId,
4699
4832
  deliveryMode: this._connectDeliveryMode,
4833
+ connectionKind: String(params.connection_kind ?? 'long'),
4834
+ shortTtlMs: Number(params.short_ttl_ms ?? 0),
4835
+ extraInfo: params.extra_info,
4700
4836
  });
4701
4837
  await this._syncIdentityAfterConnect(String(params.access_token));
4702
4838
  }
@@ -4728,6 +4864,9 @@ export class AUNClient {
4728
4864
  catch (exc) {
4729
4865
  this._clientLog.warn(`prekey upload failed:${String(exc)}`);
4730
4866
  }
4867
+ // connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
4868
+ // 群消息按惰性触发,不在此处主动 pull
4869
+ this._safeAsync(this._fillP2pGap());
4731
4870
  this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? '-'}`);
4732
4871
  }
4733
4872
  catch (err) {
@@ -4813,15 +4952,42 @@ export class AUNClient {
4813
4952
  if (request.timeouts !== undefined && !isJsonObject(request.timeouts)) {
4814
4953
  throw new ValidationError('timeouts must be an object');
4815
4954
  }
4955
+ // 长短连接选项:默认 long,向后兼容
4956
+ const kindRaw = request.connection_kind;
4957
+ if (kindRaw == null) {
4958
+ request.connection_kind = 'long';
4959
+ }
4960
+ else {
4961
+ request.connection_kind = String(kindRaw).trim().toLowerCase();
4962
+ }
4963
+ if (request.connection_kind !== 'long' && request.connection_kind !== 'short') {
4964
+ throw new ValidationError("connection_kind must be 'long' or 'short'");
4965
+ }
4966
+ try {
4967
+ request.short_ttl_ms = Math.max(0, Math.floor(Number(request.short_ttl_ms) || 0));
4968
+ }
4969
+ catch {
4970
+ throw new ValidationError('short_ttl_ms must be a non-negative integer');
4971
+ }
4972
+ if (request.connection_kind !== 'short') {
4973
+ request.short_ttl_ms = 0;
4974
+ }
4816
4975
  return request;
4817
4976
  }
4818
4977
  _buildSessionOptions(params) {
4978
+ const connectionKind = String(params.connection_kind ?? 'long');
4979
+ // 短连接默认禁用 auto_reconnect:短连接生命周期短,自动重连无意义
4980
+ const defaultAutoReconnect = connectionKind === 'short'
4981
+ ? false
4982
+ : DEFAULT_SESSION_OPTIONS.auto_reconnect;
4819
4983
  const options = {
4820
- auto_reconnect: DEFAULT_SESSION_OPTIONS.auto_reconnect,
4984
+ auto_reconnect: defaultAutoReconnect,
4821
4985
  heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
4822
4986
  token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
4823
4987
  retry: { ...DEFAULT_SESSION_OPTIONS.retry },
4824
4988
  timeouts: { ...DEFAULT_SESSION_OPTIONS.timeouts },
4989
+ connection_kind: connectionKind,
4990
+ short_ttl_ms: Number(params.short_ttl_ms ?? 0),
4825
4991
  };
4826
4992
  if ('auto_reconnect' in params) {
4827
4993
  options.auto_reconnect = Boolean(params.auto_reconnect);
@@ -4842,6 +5008,10 @@ export class AUNClient {
4842
5008
  }
4843
5009
  // ── 内部:后台任务 ────────────────────────────────
4844
5010
  _startBackgroundTasks() {
5011
+ // 短连接生命周期短,禁用心跳与 token 刷新(不接收推送、不需要长期会话维护)
5012
+ if (this._sessionOptions?.connection_kind === 'short') {
5013
+ return;
5014
+ }
4845
5015
  this._startHeartbeat();
4846
5016
  this._startTokenRefresh();
4847
5017
  this._startPrekeyRefresh();
@@ -5107,22 +5277,17 @@ export class AUNClient {
5107
5277
  const prekeyId = this._extractConsumedPrekeyId(message);
5108
5278
  if (!prekeyId || this._state !== 'connected')
5109
5279
  return;
5110
- if (this._prekeyReplenished.has(prekeyId))
5280
+ // 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
5281
+ if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
5111
5282
  return;
5112
- // 同一时刻只允许一个 put_prekey inflight
5113
- if (this._prekeyReplenishInflight.size > 0)
5114
- return;
5115
- this._prekeyReplenishInflight.add(prekeyId);
5283
+ // 清空活跃标记,防止重复触发(新上传完成后会设新的 active)
5284
+ this._activePrekeyId = '';
5116
5285
  this._safeAsync((async () => {
5117
5286
  try {
5118
5287
  await this._uploadPrekey();
5119
- this._prekeyReplenished.add(prekeyId);
5120
5288
  }
5121
5289
  catch (exc) {
5122
- this._clientLog.warn(`consume prekey ${prekeyId} then replenish current prekey failed: ${String(exc)}`);
5123
- }
5124
- finally {
5125
- this._prekeyReplenishInflight.delete(prekeyId);
5290
+ this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${String(exc)}`);
5126
5291
  }
5127
5292
  })());
5128
5293
  }
@@ -5191,13 +5356,28 @@ export class AUNClient {
5191
5356
  }
5192
5357
  // ── 内部:断线重连 ────────────────────────────────
5193
5358
  /** 不重连 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}`);
5359
+ static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015]);
5360
+ /** 处理服务端主动断开通知 event/gateway.disconnect
5361
+ *
5362
+ * 服务端可能附带结构化 detail 字段(如配额超限时含 aid/device_id/slot_id/quota_kind/evicted_by)。
5363
+ * 透传到应用层可订阅事件 'gateway.disconnect',方便业务定位被踢原因。
5364
+ */
5365
+ async _onGatewayDisconnect(data) {
5366
+ const obj = (data && typeof data === 'object') ? data : {};
5367
+ const code = obj.code;
5368
+ const reason = obj.reason ?? '';
5369
+ const detail = (obj.detail && typeof obj.detail === 'object') ? obj.detail : {};
5370
+ this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
5200
5371
  this._serverKicked = true;
5372
+ // 缓存最近一次 disconnect 信息,让后续 connection.state(terminal_failed) 也能带 detail
5373
+ this._lastDisconnectInfo = { code, reason, detail };
5374
+ // 透传给应用层订阅者
5375
+ try {
5376
+ await this._dispatcher.publish('gateway.disconnect', { code, reason, detail });
5377
+ }
5378
+ catch (exc) {
5379
+ this._clientLog.debug(`publish gateway.disconnect failed: ${exc?.message ?? exc}`);
5380
+ }
5201
5381
  }
5202
5382
  async _handleTransportDisconnect(error, closeCode) {
5203
5383
  if (this._closing || this._state === 'closed')
@@ -5218,9 +5398,19 @@ export class AUNClient {
5218
5398
  this._state = 'terminal_failed';
5219
5399
  const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
5220
5400
  this._clientLog.warn(`suppress auto-reconnect: ${reason}`);
5221
- await this._dispatcher.publish('connection.state', {
5401
+ const disconnectInfo = this._lastDisconnectInfo ?? {};
5402
+ const eventPayload = {
5222
5403
  state: this._state, error, reason,
5223
- });
5404
+ };
5405
+ // 把服务端附带的结构化 detail(如配额超限信息)也带给应用层
5406
+ const detail = disconnectInfo.detail;
5407
+ if (detail && typeof detail === 'object' && Object.keys(detail).length > 0) {
5408
+ eventPayload.detail = detail;
5409
+ }
5410
+ if (disconnectInfo.code !== undefined && disconnectInfo.code !== null) {
5411
+ eventPayload.code = disconnectInfo.code;
5412
+ }
5413
+ await this._dispatcher.publish('connection.state', eventPayload);
5224
5414
  return;
5225
5415
  }
5226
5416
  // 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭