@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/auth.d.ts +9 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +79 -6
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +13 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +228 -38
- package/dist/client.js.map +1 -1
- package/dist/e2ee.d.ts.map +1 -1
- package/dist/e2ee.js +20 -0
- package/dist/e2ee.js.map +1 -1
- package/dist/keystore/index.d.ts +11 -0
- package/dist/keystore/index.d.ts.map +1 -1
- package/dist/keystore/indexeddb.d.ts +35 -0
- package/dist/keystore/indexeddb.d.ts.map +1 -1
- package/dist/keystore/indexeddb.js +91 -0
- package/dist/keystore/indexeddb.js.map +1 -1
- package/dist/namespaces/auth.d.ts +9 -3
- package/dist/namespaces/auth.d.ts.map +1 -1
- package/dist/namespaces/auth.js +74 -9
- package/dist/namespaces/auth.js.map +1 -1
- package/package.json +37 -37
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
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
839
|
+
// auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
|
|
819
840
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
820
|
-
const shouldAck =
|
|
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
|
-
//
|
|
1008
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1886
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
5280
|
+
// 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
|
|
5281
|
+
if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
|
|
5111
5282
|
return;
|
|
5112
|
-
//
|
|
5113
|
-
|
|
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(`
|
|
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
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
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
|
-
|
|
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 = 服务端主动关闭
|