@agentunion/fastaun 0.3.6 → 0.4.1
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/CHANGELOG.md +31 -0
- package/_packed_docs/AUN_SDK_/351/207/215/346/236/204/345/256/236/346/226/275/350/256/241/345/210/222.md +596 -0
- package/_packed_docs/AUN_SDK_/351/207/215/346/236/204/350/256/276/350/256/241/346/226/271/346/241/210_v3.md +1697 -0
- package/_packed_docs/CHANGELOG.md +31 -0
- package/_packed_docs/INDEX.md +17 -11
- package/_packed_docs/KITE_DOCS_GUIDE.md +11 -10
- package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +134 -158
- package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +11 -7
- package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +98 -119
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +147 -374
- package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +153 -153
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +168 -1383
- package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +71 -91
- package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +76 -63
- package/_packed_docs/sdk/09-custody-api-manual.md +7 -6
- package/_packed_docs/sdk/09-meta-rpc-manual.md +13 -14
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +37 -49
- package/_packed_docs/sdk/INDEX.md +72 -98
- package/_packed_docs/sdk/README.md +85 -266
- package/dist/aid-store.d.ts +130 -0
- package/dist/aid-store.js +540 -0
- package/dist/aid-store.js.map +1 -0
- package/dist/aid.d.ts +58 -0
- package/dist/aid.js +146 -0
- package/dist/aid.js.map +1 -0
- package/dist/auth.js +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/cert-utils.d.ts +29 -0
- package/dist/cert-utils.js +142 -0
- package/dist/cert-utils.js.map +1 -0
- package/dist/client.d.ts +93 -102
- package/dist/client.js +703 -293
- package/dist/client.js.map +1 -1
- package/dist/error-codes.d.ts +25 -0
- package/dist/error-codes.js +26 -0
- package/dist/error-codes.js.map +1 -0
- package/dist/errors.d.ts +4 -1
- package/dist/errors.js +4 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/keystore/aid-db.js +33 -0
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.d.ts +17 -0
- package/dist/keystore/file.js +195 -1
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +2 -0
- package/dist/result.d.ts +17 -0
- package/dist/result.js +10 -0
- package/dist/result.js.map +1 -0
- package/dist/tools/cross-sdk-agent.js +27 -22
- package/dist/tools/cross-sdk-agent.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.js +30 -0
- package/dist/types.js.map +1 -1
- package/dist/v2/e2ee/encrypt-p2p.js +1 -1
- package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -20,14 +20,11 @@ import { configFromMap, getDeviceId, normalizeInstanceId } from './config.js';
|
|
|
20
20
|
import { CryptoProvider } from './crypto.js';
|
|
21
21
|
import { GatewayDiscovery } from './discovery.js';
|
|
22
22
|
import { DnsResilientNet } from './net.js';
|
|
23
|
-
import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, TimeoutError, ValidationError, } from './errors.js';
|
|
23
|
+
import { AUNError, AuthError, ConnectionError, E2EEError, NotFoundError, PermissionError, StateError, TimeoutError, ValidationError, } from './errors.js';
|
|
24
24
|
import { EventDispatcher } from './events.js';
|
|
25
25
|
import { FileKeyStore } from './keystore/file.js';
|
|
26
26
|
import { AUNLogger } from './logger.js';
|
|
27
27
|
import { normalizeGroupId } from './group-id.js';
|
|
28
|
-
import { AuthNamespace } from './namespaces/auth.js';
|
|
29
|
-
import { CustodyNamespace } from './namespaces/custody.js';
|
|
30
|
-
import { MetaNamespace } from './namespaces/meta.js';
|
|
31
28
|
import { RPCTransport } from './transport.js';
|
|
32
29
|
import { AuthFlow } from './auth.js';
|
|
33
30
|
import { SeqTracker } from './seq-tracker.js';
|
|
@@ -35,7 +32,8 @@ import { V2Session } from './v2/session/index.js';
|
|
|
35
32
|
import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2ee/index.js';
|
|
36
33
|
import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
|
|
37
34
|
import { computeStateCommitment } from './v2/state/index.js';
|
|
38
|
-
import { isJsonObject, } from './types.js';
|
|
35
|
+
import { isJsonObject, ConnectionState, STATE_TO_PUBLIC, } from './types.js';
|
|
36
|
+
import { AID } from './aid.js';
|
|
39
37
|
function isPromiseLike(value) {
|
|
40
38
|
return Boolean(value && typeof value.then === 'function');
|
|
41
39
|
}
|
|
@@ -130,6 +128,12 @@ const DEFAULT_SESSION_OPTIONS = {
|
|
|
130
128
|
http: 30.0,
|
|
131
129
|
},
|
|
132
130
|
};
|
|
131
|
+
const PROTECTED_HEADERS_METHODS = new Set([
|
|
132
|
+
'message.send',
|
|
133
|
+
'group.send',
|
|
134
|
+
'message.thought.put',
|
|
135
|
+
'group.thought.put',
|
|
136
|
+
]);
|
|
133
137
|
const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
|
|
134
138
|
const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
|
|
135
139
|
const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
|
|
@@ -200,6 +204,7 @@ const SIGNED_METHODS = new Set([
|
|
|
200
204
|
]);
|
|
201
205
|
/** peer 证书缓存 TTL(1 小时) */
|
|
202
206
|
const PEER_CERT_CACHE_TTL = 3600;
|
|
207
|
+
const AGENT_MD_HTTP_TIMEOUT_MS = 30_000;
|
|
203
208
|
function normalizeV2WrapPolicy(raw) {
|
|
204
209
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
205
210
|
return { explicit: false, version: '', protocol: '', scope: 'device' };
|
|
@@ -377,6 +382,50 @@ function lengthPrefixedBytesKey(...parts) {
|
|
|
377
382
|
}
|
|
378
383
|
return Buffer.concat(chunks);
|
|
379
384
|
}
|
|
385
|
+
function agentMdHttpScheme(gatewayUrl) {
|
|
386
|
+
const raw = String(gatewayUrl ?? '').trim().toLowerCase();
|
|
387
|
+
return raw.startsWith('ws://') ? 'http' : 'https';
|
|
388
|
+
}
|
|
389
|
+
function agentMdAuthority(aid, discoveryPort) {
|
|
390
|
+
const host = String(aid ?? '').trim();
|
|
391
|
+
if (!host)
|
|
392
|
+
return '';
|
|
393
|
+
if (discoveryPort && !host.includes(':'))
|
|
394
|
+
return `${host}:${discoveryPort}`;
|
|
395
|
+
return host;
|
|
396
|
+
}
|
|
397
|
+
async function fetchWithTimeout(input, init, timeoutMs = AGENT_MD_HTTP_TIMEOUT_MS) {
|
|
398
|
+
const controller = new AbortController();
|
|
399
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
400
|
+
try {
|
|
401
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (controller.signal.aborted) {
|
|
405
|
+
throw new AUNError(`agent.md request timed out after ${timeoutMs}ms`);
|
|
406
|
+
}
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function assertClientOptions(value, label) {
|
|
414
|
+
if (value == null)
|
|
415
|
+
return;
|
|
416
|
+
if (typeof value !== 'object' || Array.isArray(value) || value instanceof AID) {
|
|
417
|
+
throw new ValidationError(`${label} must be an options object`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function clientOptionsConfig(options) {
|
|
421
|
+
const raw = { ...(options ?? {}) };
|
|
422
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'aid')) {
|
|
423
|
+
throw new ValidationError('AUNClient options must not include aid; pass an AID object as the first argument');
|
|
424
|
+
}
|
|
425
|
+
delete raw.debug;
|
|
426
|
+
delete raw.protected_headers;
|
|
427
|
+
return raw;
|
|
428
|
+
}
|
|
380
429
|
export class AUNClient {
|
|
381
430
|
/** 原始配置 */
|
|
382
431
|
config;
|
|
@@ -387,7 +436,17 @@ export class AUNClient {
|
|
|
387
436
|
/** 当前身份信息(内存缓存) */
|
|
388
437
|
_identity = null;
|
|
389
438
|
/** 连接状态 */
|
|
390
|
-
_state = '
|
|
439
|
+
_state = 'no_identity';
|
|
440
|
+
/** 当前 AID 值对象(新 API) */
|
|
441
|
+
_currentAid = null;
|
|
442
|
+
/** 实例级 protected_headers */
|
|
443
|
+
_instanceProtectedHeaders = null;
|
|
444
|
+
/** 重连退避时间戳(ms) */
|
|
445
|
+
_nextRetryAt = null;
|
|
446
|
+
_retryAttempt = 0;
|
|
447
|
+
_retryMaxAttempts = 0;
|
|
448
|
+
_lastError = null;
|
|
449
|
+
_lastErrorCode = null;
|
|
391
450
|
/** Gateway URL */
|
|
392
451
|
_gatewayUrl = null;
|
|
393
452
|
/** 是否正在关闭 */
|
|
@@ -402,12 +461,6 @@ export class AUNClient {
|
|
|
402
461
|
_auth;
|
|
403
462
|
/** 密钥存储 */
|
|
404
463
|
_keystore;
|
|
405
|
-
/** Auth 命名空间 */
|
|
406
|
-
auth;
|
|
407
|
-
/** AID 托管命名空间 */
|
|
408
|
-
custody;
|
|
409
|
-
/** Meta 命名空间(心跳、状态、信任根管理) */
|
|
410
|
-
meta;
|
|
411
464
|
/** 会话参数(重连用) */
|
|
412
465
|
_sessionParams = null;
|
|
413
466
|
/** 会话选项 */
|
|
@@ -428,6 +481,9 @@ export class AUNClient {
|
|
|
428
481
|
_remoteAgentMdEtag = '';
|
|
429
482
|
_agentMdCache = new Map();
|
|
430
483
|
_agentMdFetchInflight = new Map();
|
|
484
|
+
_agentMdDownloadInflight = new Map();
|
|
485
|
+
_agentMdDownloadActive = 0;
|
|
486
|
+
_agentMdDownloadWaiters = [];
|
|
431
487
|
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
432
488
|
_seqTracker = new SeqTracker();
|
|
433
489
|
_seqTrackerContext = null;
|
|
@@ -474,8 +530,11 @@ export class AUNClient {
|
|
|
474
530
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
475
531
|
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
476
532
|
static PULL_GATE_STALE_MS = 3000;
|
|
533
|
+
/** 对端 AID 缓存(aid string → AID 对象) */
|
|
534
|
+
_peerCache = new Map();
|
|
477
535
|
static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
478
536
|
static V2_SIG_CACHE_MAX = 16_384;
|
|
537
|
+
static AGENT_MD_DOWNLOAD_CONCURRENCY = 8;
|
|
479
538
|
_reconnectActive = false;
|
|
480
539
|
_reconnectAbort = null;
|
|
481
540
|
_serverKicked = false;
|
|
@@ -483,19 +542,31 @@ export class AUNClient {
|
|
|
483
542
|
_lastDisconnectInfo = null;
|
|
484
543
|
_logger;
|
|
485
544
|
_clientLog;
|
|
486
|
-
constructor(
|
|
487
|
-
|
|
545
|
+
constructor(aid) {
|
|
546
|
+
if (typeof aid === 'string') {
|
|
547
|
+
throw new ValidationError('AUNClient aid must be an AID object, not a string');
|
|
548
|
+
}
|
|
549
|
+
const inputAid = aid instanceof AID ? aid : null;
|
|
550
|
+
const options = {};
|
|
551
|
+
const rawConfig = clientOptionsConfig(options);
|
|
552
|
+
if (inputAid) {
|
|
553
|
+
rawConfig.aun_path = inputAid.aunPath;
|
|
554
|
+
rawConfig.verify_ssl = inputAid.verifySsl;
|
|
555
|
+
if (inputAid.rootCaPath)
|
|
556
|
+
rawConfig.root_ca_path = inputAid.rootCaPath;
|
|
557
|
+
rawConfig.debug = inputAid.debug;
|
|
558
|
+
}
|
|
488
559
|
this._configModel = configFromMap(rawConfig);
|
|
489
|
-
const initAid =
|
|
560
|
+
const initAid = inputAid ? inputAid.aid : null;
|
|
490
561
|
this._agentMdPath = path.join(this._configModel.aunPath, 'AIDs');
|
|
491
562
|
this.config = {
|
|
492
563
|
aun_path: this._configModel.aunPath,
|
|
493
564
|
root_ca_path: this._configModel.rootCaPath,
|
|
494
565
|
seed_password: this._configModel.seedPassword,
|
|
495
566
|
};
|
|
496
|
-
this._deviceId = getDeviceId(this._configModel.aunPath);
|
|
567
|
+
this._deviceId = (inputAid?.deviceId) || getDeviceId(this._configModel.aunPath);
|
|
497
568
|
// 初始化 Logger(per-client 单例,必须最早创建)
|
|
498
|
-
const debugFlag = this._configModel.debug
|
|
569
|
+
const debugFlag = this._configModel.debug;
|
|
499
570
|
this._logger = new AUNLogger({
|
|
500
571
|
debug: debugFlag,
|
|
501
572
|
aunPath: this._configModel.aunPath,
|
|
@@ -530,7 +601,7 @@ export class AUNClient {
|
|
|
530
601
|
catch (err) {
|
|
531
602
|
this._clientLog.warn(`_pending cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
532
603
|
}
|
|
533
|
-
this._slotId = '';
|
|
604
|
+
this._slotId = inputAid?.slotId || 'default';
|
|
534
605
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
535
606
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
536
607
|
this._auth = new AuthFlow({
|
|
@@ -554,9 +625,19 @@ export class AUNClient {
|
|
|
554
625
|
dnsNet,
|
|
555
626
|
});
|
|
556
627
|
this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
628
|
+
if (inputAid) {
|
|
629
|
+
if (!inputAid.isPrivateKeyValid()) {
|
|
630
|
+
throw new StateError('AUNClient requires an AID with a valid private key');
|
|
631
|
+
}
|
|
632
|
+
this._currentAid = inputAid;
|
|
633
|
+
this._identity = {
|
|
634
|
+
aid: inputAid.aid,
|
|
635
|
+
private_key_pem: inputAid._privateKeyPem ?? '',
|
|
636
|
+
public_key_der_b64: inputAid.publicKey,
|
|
637
|
+
cert: inputAid.certPem,
|
|
638
|
+
};
|
|
639
|
+
this._state = 'standby';
|
|
640
|
+
}
|
|
560
641
|
// 内部订阅:推送消息自动解密后 re-publish 给用户
|
|
561
642
|
this._dispatcher.subscribe('_raw.message.received', (data) => this._onRawMessageReceived(data));
|
|
562
643
|
// V2 P2P 推送通知:收到通知后自动走 message.v2.pull 拉取并解密
|
|
@@ -587,6 +668,361 @@ export class AUNClient {
|
|
|
587
668
|
get aid() {
|
|
588
669
|
return this._aid;
|
|
589
670
|
}
|
|
671
|
+
/** 当前 AID 值对象 */
|
|
672
|
+
get currentAid() {
|
|
673
|
+
return this._currentAid;
|
|
674
|
+
}
|
|
675
|
+
get hasIdentity() {
|
|
676
|
+
return this._currentAid !== null && this.state !== ConnectionState.CLOSED;
|
|
677
|
+
}
|
|
678
|
+
get canSign() {
|
|
679
|
+
return this.hasIdentity && !!this._currentAid?.isPrivateKeyValid();
|
|
680
|
+
}
|
|
681
|
+
get canConnect() {
|
|
682
|
+
return this.hasIdentity && this.state !== ConnectionState.CLOSED;
|
|
683
|
+
}
|
|
684
|
+
get canSend() {
|
|
685
|
+
return this.state === ConnectionState.READY;
|
|
686
|
+
}
|
|
687
|
+
get isReady() {
|
|
688
|
+
return this.canSend;
|
|
689
|
+
}
|
|
690
|
+
get isOnline() {
|
|
691
|
+
return this.state === ConnectionState.READY || this.state === ConnectionState.RETRY_BACKOFF || this.state === ConnectionState.RECONNECTING;
|
|
692
|
+
}
|
|
693
|
+
get isClosed() {
|
|
694
|
+
return this.state === ConnectionState.CLOSED;
|
|
695
|
+
}
|
|
696
|
+
get aunPath() {
|
|
697
|
+
return this.hasIdentity ? this._currentAid?.aunPath ?? this._configModel.aunPath : null;
|
|
698
|
+
}
|
|
699
|
+
get nextRetryAt() {
|
|
700
|
+
return this.state === ConnectionState.RETRY_BACKOFF && this._nextRetryAt ? new Date(this._nextRetryAt) : null;
|
|
701
|
+
}
|
|
702
|
+
get nextRetryInSeconds() {
|
|
703
|
+
return this.state === ConnectionState.RETRY_BACKOFF && this._nextRetryAt ? Math.max(0, Math.ceil((this._nextRetryAt - Date.now()) / 1000)) : null;
|
|
704
|
+
}
|
|
705
|
+
get retryAttempt() {
|
|
706
|
+
return this._retryAttempt;
|
|
707
|
+
}
|
|
708
|
+
get retryMaxAttempts() {
|
|
709
|
+
return this._retryMaxAttempts;
|
|
710
|
+
}
|
|
711
|
+
get lastError() {
|
|
712
|
+
return this._lastError;
|
|
713
|
+
}
|
|
714
|
+
get lastErrorCode() {
|
|
715
|
+
return this._lastErrorCode;
|
|
716
|
+
}
|
|
717
|
+
loadIdentity(aid) {
|
|
718
|
+
if (!aid?.isPrivateKeyValid()) {
|
|
719
|
+
throw new StateError('loadIdentity requires an AID with a valid private key');
|
|
720
|
+
}
|
|
721
|
+
const publicState = this.state;
|
|
722
|
+
if (publicState !== ConnectionState.NO_IDENTITY && publicState !== ConnectionState.CLOSED) {
|
|
723
|
+
throw new StateError(`loadIdentity not allowed in state ${publicState}`);
|
|
724
|
+
}
|
|
725
|
+
this._currentAid = aid;
|
|
726
|
+
this._aid = aid.aid;
|
|
727
|
+
this._identity = {
|
|
728
|
+
aid: aid.aid,
|
|
729
|
+
private_key_pem: aid._privateKeyPem ?? '',
|
|
730
|
+
public_key_der_b64: aid.publicKey,
|
|
731
|
+
cert: aid.certPem,
|
|
732
|
+
};
|
|
733
|
+
this._auth._aid = aid.aid;
|
|
734
|
+
this._state = 'standby';
|
|
735
|
+
this._closing = false;
|
|
736
|
+
this._lastError = null;
|
|
737
|
+
this._lastErrorCode = null;
|
|
738
|
+
this._retryAttempt = 0;
|
|
739
|
+
this._nextRetryAt = null;
|
|
740
|
+
}
|
|
741
|
+
setProtectedHeaders(headers) {
|
|
742
|
+
if (!headers) {
|
|
743
|
+
this._instanceProtectedHeaders = null;
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
// 字段规范:key 限 [a-z0-9_-],_auth 为保留键不可设置。
|
|
747
|
+
// 非法 key 静默跳过(不报错),值强转 str。
|
|
748
|
+
const cleaned = {};
|
|
749
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
750
|
+
const keyStr = String(key);
|
|
751
|
+
if (keyStr === '_auth')
|
|
752
|
+
continue;
|
|
753
|
+
if (!/^[a-z0-9_-]+$/.test(keyStr))
|
|
754
|
+
continue;
|
|
755
|
+
cleaned[keyStr] = value == null ? '' : String(value);
|
|
756
|
+
}
|
|
757
|
+
this._instanceProtectedHeaders = Object.keys(cleaned).length ? cleaned : null;
|
|
758
|
+
}
|
|
759
|
+
getProtectedHeaders() {
|
|
760
|
+
return this._instanceProtectedHeaders ? { ...this._instanceProtectedHeaders } : null;
|
|
761
|
+
}
|
|
762
|
+
cachePeer(aid) {
|
|
763
|
+
if (!this.hasIdentity)
|
|
764
|
+
throw new StateError('cachePeer requires a loaded identity');
|
|
765
|
+
if (!aid.isCertValid())
|
|
766
|
+
throw new ValidationError('cachePeer requires an AID with a valid certificate');
|
|
767
|
+
this._peerCache.set(aid.aid, aid);
|
|
768
|
+
return aid;
|
|
769
|
+
}
|
|
770
|
+
getPeer(aid) {
|
|
771
|
+
if (!this.hasIdentity)
|
|
772
|
+
throw new StateError('getPeer requires a loaded identity');
|
|
773
|
+
return this._peerCache.get(String(aid ?? '').trim()) ?? null;
|
|
774
|
+
}
|
|
775
|
+
async lookupPeer(aid) {
|
|
776
|
+
if (!this.hasIdentity)
|
|
777
|
+
throw new StateError('lookupPeer requires a loaded identity');
|
|
778
|
+
const target = String(aid ?? '').trim();
|
|
779
|
+
if (!target)
|
|
780
|
+
throw new ValidationError('lookupPeer requires non-empty aid');
|
|
781
|
+
const cached = this._peerCache.get(target);
|
|
782
|
+
if (cached)
|
|
783
|
+
return cached;
|
|
784
|
+
throw new NotFoundError(`peer not found in cache: ${target}`);
|
|
785
|
+
}
|
|
786
|
+
peers() {
|
|
787
|
+
if (!this.hasIdentity)
|
|
788
|
+
throw new StateError('peers requires a loaded identity');
|
|
789
|
+
return [...this._peerCache.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, v]) => v);
|
|
790
|
+
}
|
|
791
|
+
async _resolveAgentMdUrl(aid) {
|
|
792
|
+
const target = String(aid ?? '').trim();
|
|
793
|
+
if (!target)
|
|
794
|
+
throw new ValidationError('agent.md requires non-empty aid');
|
|
795
|
+
let gatewayUrl = String(this._gatewayUrl ?? '').trim();
|
|
796
|
+
if (!gatewayUrl) {
|
|
797
|
+
try {
|
|
798
|
+
gatewayUrl = await this._resolveGatewayForAid(target);
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
gatewayUrl = '';
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return `${agentMdHttpScheme(gatewayUrl)}://${agentMdAuthority(target, this._configModel.discoveryPort)}/agent.md`;
|
|
805
|
+
}
|
|
806
|
+
async _ensureAgentMdUploadToken(aid, gatewayUrl) {
|
|
807
|
+
let identity = this._auth.loadIdentityOrNone(aid);
|
|
808
|
+
if (!identity && this._identity && String(this._identity.aid ?? '') === aid) {
|
|
809
|
+
identity = this._identity;
|
|
810
|
+
}
|
|
811
|
+
if (!identity) {
|
|
812
|
+
throw new StateError('no local identity found, register or load an AID first');
|
|
813
|
+
}
|
|
814
|
+
const cachedToken = String(identity.access_token ?? '');
|
|
815
|
+
const expiresAt = this._auth.getAccessTokenExpiry(identity);
|
|
816
|
+
if (cachedToken && (expiresAt === null || expiresAt > Date.now() / 1000 + 30)) {
|
|
817
|
+
return cachedToken;
|
|
818
|
+
}
|
|
819
|
+
if (identity.refresh_token) {
|
|
820
|
+
try {
|
|
821
|
+
const refreshed = await this._auth.refreshCachedTokens(gatewayUrl, identity);
|
|
822
|
+
const refreshedToken = String(refreshed.access_token ?? '');
|
|
823
|
+
const refreshedExpiry = this._auth.getAccessTokenExpiry(refreshed);
|
|
824
|
+
if (refreshedToken && (refreshedExpiry === null || refreshedExpiry > Date.now() / 1000 + 30)) {
|
|
825
|
+
this._identity = refreshed;
|
|
826
|
+
return refreshedToken;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
// refresh 失败时回退到完整 authenticate。
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const result = await this._auth.authenticate(gatewayUrl, { aid });
|
|
834
|
+
const token = String(result.access_token ?? '');
|
|
835
|
+
if (!token)
|
|
836
|
+
throw new StateError('authenticate did not return access_token');
|
|
837
|
+
this._identity = this._auth.loadIdentityOrNone(aid) ?? {
|
|
838
|
+
...identity,
|
|
839
|
+
access_token: token,
|
|
840
|
+
refresh_token: String(result.refresh_token ?? identity.refresh_token ?? ''),
|
|
841
|
+
access_token_expires_at: typeof result.expires_at === 'number' ? result.expires_at : identity.access_token_expires_at,
|
|
842
|
+
token_exp: typeof result.expires_at === 'number' ? result.expires_at : identity.token_exp,
|
|
843
|
+
expires_at: typeof result.expires_at === 'number' ? result.expires_at : identity.expires_at,
|
|
844
|
+
};
|
|
845
|
+
return token;
|
|
846
|
+
}
|
|
847
|
+
async _uploadAgentMd(content) {
|
|
848
|
+
const target = String(this._aid ?? this._currentAid?.aid ?? '').trim();
|
|
849
|
+
if (!target)
|
|
850
|
+
throw new StateError('uploadAgentMd requires local AID');
|
|
851
|
+
const gatewayUrl = await this._resolveGatewayForAid(target);
|
|
852
|
+
this._gatewayUrl = gatewayUrl;
|
|
853
|
+
const token = await this._ensureAgentMdUploadToken(target, gatewayUrl);
|
|
854
|
+
const response = await fetchWithTimeout(await this._resolveAgentMdUrl(target), {
|
|
855
|
+
method: 'PUT',
|
|
856
|
+
headers: {
|
|
857
|
+
Authorization: `Bearer ${token}`,
|
|
858
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
859
|
+
},
|
|
860
|
+
body: content,
|
|
861
|
+
});
|
|
862
|
+
if (response.status === 404) {
|
|
863
|
+
throw new NotFoundError(`agent.md endpoint not found for aid: ${target}`);
|
|
864
|
+
}
|
|
865
|
+
if (!response.ok) {
|
|
866
|
+
const message = (await response.text()).trim();
|
|
867
|
+
throw new AUNError(`upload agent.md failed: HTTP ${response.status}${message ? ` - ${message}` : ''}`);
|
|
868
|
+
}
|
|
869
|
+
const payload = await response.json();
|
|
870
|
+
if (!isJsonObject(payload))
|
|
871
|
+
throw new AUNError('upload agent.md returned invalid JSON payload');
|
|
872
|
+
return payload;
|
|
873
|
+
}
|
|
874
|
+
async _acquireAgentMdDownloadSlot() {
|
|
875
|
+
if (this._agentMdDownloadActive < AUNClient.AGENT_MD_DOWNLOAD_CONCURRENCY) {
|
|
876
|
+
this._agentMdDownloadActive += 1;
|
|
877
|
+
return () => this._releaseAgentMdDownloadSlot();
|
|
878
|
+
}
|
|
879
|
+
await new Promise((resolve) => {
|
|
880
|
+
this._agentMdDownloadWaiters.push(resolve);
|
|
881
|
+
});
|
|
882
|
+
return () => this._releaseAgentMdDownloadSlot();
|
|
883
|
+
}
|
|
884
|
+
_releaseAgentMdDownloadSlot() {
|
|
885
|
+
const next = this._agentMdDownloadWaiters.shift();
|
|
886
|
+
if (next) {
|
|
887
|
+
next();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (this._agentMdDownloadActive > 0)
|
|
891
|
+
this._agentMdDownloadActive -= 1;
|
|
892
|
+
}
|
|
893
|
+
async _downloadAgentMd(aid) {
|
|
894
|
+
const target = String(aid ?? '').trim();
|
|
895
|
+
if (!target)
|
|
896
|
+
throw new ValidationError('downloadAgentMd requires non-empty aid');
|
|
897
|
+
const existing = this._agentMdDownloadInflight.get(target);
|
|
898
|
+
if (existing)
|
|
899
|
+
return await existing;
|
|
900
|
+
const task = (async () => {
|
|
901
|
+
const release = await this._acquireAgentMdDownloadSlot();
|
|
902
|
+
try {
|
|
903
|
+
return await this._downloadAgentMdOnce(target);
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
release();
|
|
907
|
+
}
|
|
908
|
+
})();
|
|
909
|
+
this._agentMdDownloadInflight.set(target, task);
|
|
910
|
+
task.finally(() => {
|
|
911
|
+
if (this._agentMdDownloadInflight.get(target) === task) {
|
|
912
|
+
this._agentMdDownloadInflight.delete(target);
|
|
913
|
+
}
|
|
914
|
+
}).catch(() => undefined);
|
|
915
|
+
return await task;
|
|
916
|
+
}
|
|
917
|
+
async _downloadAgentMdOnce(target) {
|
|
918
|
+
const cached = this._agentMdCache.get(target);
|
|
919
|
+
const url = await this._resolveAgentMdUrl(target);
|
|
920
|
+
let response = await fetchWithTimeout(url, {
|
|
921
|
+
method: 'GET',
|
|
922
|
+
headers: { Accept: 'text/markdown' },
|
|
923
|
+
redirect: 'follow',
|
|
924
|
+
});
|
|
925
|
+
if (response.status === 304 && typeof cached?.text === 'string') {
|
|
926
|
+
return String(cached.text);
|
|
927
|
+
}
|
|
928
|
+
if (response.status === 304) {
|
|
929
|
+
response = await fetchWithTimeout(url, {
|
|
930
|
+
method: 'GET',
|
|
931
|
+
headers: { Accept: 'text/markdown' },
|
|
932
|
+
cache: 'reload',
|
|
933
|
+
redirect: 'follow',
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
if (response.status === 404) {
|
|
937
|
+
throw new NotFoundError(`agent.md not found for aid: ${target}`);
|
|
938
|
+
}
|
|
939
|
+
if (!response.ok) {
|
|
940
|
+
const message = (await response.text()).trim();
|
|
941
|
+
throw new AUNError(`download agent.md failed: HTTP ${response.status}${message ? ` - ${message}` : ''}`);
|
|
942
|
+
}
|
|
943
|
+
const text = await response.text();
|
|
944
|
+
const etag = String(response.headers?.get('ETag') ?? response.headers?.get('etag') ?? '').trim();
|
|
945
|
+
const lastModified = String(response.headers?.get('Last-Modified') ?? response.headers?.get('last-modified') ?? '').trim();
|
|
946
|
+
this._agentMdCache.set(target, {
|
|
947
|
+
...(cached ?? {}),
|
|
948
|
+
text,
|
|
949
|
+
etag,
|
|
950
|
+
lastModified,
|
|
951
|
+
remote_etag: etag,
|
|
952
|
+
last_modified: lastModified,
|
|
953
|
+
});
|
|
954
|
+
return text;
|
|
955
|
+
}
|
|
956
|
+
async _headAgentMd(aid) {
|
|
957
|
+
const target = String(aid ?? '').trim();
|
|
958
|
+
if (!target)
|
|
959
|
+
throw new ValidationError('headAgentMd requires non-empty aid');
|
|
960
|
+
const response = await fetchWithTimeout(await this._resolveAgentMdUrl(target), {
|
|
961
|
+
method: 'HEAD',
|
|
962
|
+
headers: { Accept: 'text/markdown' },
|
|
963
|
+
redirect: 'follow',
|
|
964
|
+
}, 15_000);
|
|
965
|
+
const cached = this._agentMdCache.get(target) ?? {};
|
|
966
|
+
const etag = String(response.headers?.get('ETag') ?? response.headers?.get('etag') ?? '').trim();
|
|
967
|
+
const lastModified = String(response.headers?.get('Last-Modified') ?? response.headers?.get('last-modified') ?? '').trim();
|
|
968
|
+
if (response.status === 404) {
|
|
969
|
+
return { aid: target, found: false, etag: '', last_modified: '', status: 404 };
|
|
970
|
+
}
|
|
971
|
+
const resultEtag = response.status === 304 ? (etag || String(cached.etag ?? cached.remote_etag ?? '')) : etag;
|
|
972
|
+
const resultLastModified = response.status === 304 ? (lastModified || String(cached.lastModified ?? cached.last_modified ?? '')) : lastModified;
|
|
973
|
+
if (response.status < 200 || (response.status >= 300 && response.status !== 304)) {
|
|
974
|
+
throw new AUNError(`head agent.md failed: HTTP ${response.status}`);
|
|
975
|
+
}
|
|
976
|
+
this._agentMdCache.set(target, {
|
|
977
|
+
...cached,
|
|
978
|
+
etag: resultEtag,
|
|
979
|
+
lastModified: resultLastModified,
|
|
980
|
+
remote_etag: resultEtag,
|
|
981
|
+
last_modified: resultLastModified,
|
|
982
|
+
});
|
|
983
|
+
return { aid: target, found: true, etag: resultEtag, last_modified: resultLastModified, status: response.status };
|
|
984
|
+
}
|
|
985
|
+
async _verifyAgentMd(content, aid, certPem) {
|
|
986
|
+
const target = String(aid ?? '').trim();
|
|
987
|
+
if (!target)
|
|
988
|
+
throw new ValidationError('verifyAgentMd requires non-empty aid');
|
|
989
|
+
let peer = target === this._currentAid?.aid ? this._currentAid : null;
|
|
990
|
+
if (!peer) {
|
|
991
|
+
let resolvedCert = String(certPem ?? '').trim();
|
|
992
|
+
if (!resolvedCert) {
|
|
993
|
+
try {
|
|
994
|
+
resolvedCert = String(this._keystore.loadCert(target) ?? '').trim();
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
resolvedCert = '';
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (!resolvedCert) {
|
|
1001
|
+
if (!this._gatewayUrl) {
|
|
1002
|
+
try {
|
|
1003
|
+
this._gatewayUrl = await this._resolveGatewayForAid(target);
|
|
1004
|
+
}
|
|
1005
|
+
catch { /* best-effort before cert fetch */ }
|
|
1006
|
+
}
|
|
1007
|
+
resolvedCert = String(await this._fetchPeerCert(target) ?? '').trim();
|
|
1008
|
+
}
|
|
1009
|
+
if (!resolvedCert)
|
|
1010
|
+
throw new NotFoundError(`certificate not found for aid: ${target}`);
|
|
1011
|
+
peer = AID._create({
|
|
1012
|
+
aid: target,
|
|
1013
|
+
aunPath: this._configModel.aunPath,
|
|
1014
|
+
certPem: resolvedCert,
|
|
1015
|
+
privateKeyPem: null,
|
|
1016
|
+
certValid: true,
|
|
1017
|
+
privateKeyValid: false,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
const result = peer.verifyAgentMd(content);
|
|
1021
|
+
if (!result.ok)
|
|
1022
|
+
throw new AUNError(result.error.message);
|
|
1023
|
+
const vd = result.data;
|
|
1024
|
+
return { ...vd, verified: vd.status === 'verified' };
|
|
1025
|
+
}
|
|
590
1026
|
/**
|
|
591
1027
|
* 读取 {agentMdPath}/{self_aid}/agent.md,签名后上传,并把签名结果原子写回本地。
|
|
592
1028
|
*/
|
|
@@ -596,14 +1032,18 @@ export class AUNClient {
|
|
|
596
1032
|
throw new ValidationError('publishAgentMd requires local AID');
|
|
597
1033
|
}
|
|
598
1034
|
const content = this._readAgentMdContent(target);
|
|
599
|
-
const signed =
|
|
600
|
-
|
|
601
|
-
|
|
1035
|
+
const signed = this._currentAid?.signAgentMd(content);
|
|
1036
|
+
if (!signed?.ok) {
|
|
1037
|
+
throw new StateError(signed?.error.message ?? 'publishAgentMd requires a valid local AID private key');
|
|
1038
|
+
}
|
|
1039
|
+
const signedContent = signed.data.signed;
|
|
1040
|
+
const result = await this._uploadAgentMd(signedContent);
|
|
1041
|
+
this._localAgentMdEtag = this._agentMdContentEtag(signedContent);
|
|
602
1042
|
const remoteEtag = isJsonObject(result) ? String(result.etag ?? '').trim() : '';
|
|
603
1043
|
if (remoteEtag)
|
|
604
1044
|
this._remoteAgentMdEtag = remoteEtag;
|
|
605
1045
|
this._saveAgentMdRecord(target, {
|
|
606
|
-
content:
|
|
1046
|
+
content: signedContent,
|
|
607
1047
|
local_etag: this._localAgentMdEtag,
|
|
608
1048
|
remote_etag: remoteEtag || undefined,
|
|
609
1049
|
last_modified: isJsonObject(result) ? String(result.last_modified ?? '').trim() : '',
|
|
@@ -613,16 +1053,6 @@ export class AUNClient {
|
|
|
613
1053
|
});
|
|
614
1054
|
return result;
|
|
615
1055
|
}
|
|
616
|
-
/**
|
|
617
|
-
* 下载 agent.md 并自动验签;内容固定保存到 {agentMdPath}/{aid}/agent.md。
|
|
618
|
-
*/
|
|
619
|
-
async fetchAgentMd(aid) {
|
|
620
|
-
const target = String(aid ?? this._aid ?? '').trim();
|
|
621
|
-
if (!target) {
|
|
622
|
-
throw new ValidationError('fetchAgentMd requires aid (or local AID)');
|
|
623
|
-
}
|
|
624
|
-
return await this._startAgentMdFetchTask(target);
|
|
625
|
-
}
|
|
626
1056
|
async _startAgentMdFetchTask(target) {
|
|
627
1057
|
const existing = this._agentMdFetchInflight.get(target);
|
|
628
1058
|
if (existing) {
|
|
@@ -638,8 +1068,8 @@ export class AUNClient {
|
|
|
638
1068
|
return await task;
|
|
639
1069
|
}
|
|
640
1070
|
async _fetchAgentMdOnce(target) {
|
|
641
|
-
const content = await this.
|
|
642
|
-
const signature = await this.
|
|
1071
|
+
const content = await this._downloadAgentMd(target);
|
|
1072
|
+
const signature = await this._verifyAgentMd(content, target);
|
|
643
1073
|
const isSelf = target === (this._aid ?? '');
|
|
644
1074
|
const localEtag = this._agentMdContentEtag(content);
|
|
645
1075
|
const cacheMeta = this._agentMdAuthCacheMeta(target);
|
|
@@ -678,7 +1108,7 @@ export class AUNClient {
|
|
|
678
1108
|
/**
|
|
679
1109
|
* 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AIDs。
|
|
680
1110
|
*/
|
|
681
|
-
|
|
1111
|
+
_setAgentMdRoot(root) {
|
|
682
1112
|
const raw = String(root ?? '').trim();
|
|
683
1113
|
const next = raw || path.join(this._configModel.aunPath, 'AIDs');
|
|
684
1114
|
fs.mkdirSync(next, { recursive: true });
|
|
@@ -686,47 +1116,6 @@ export class AUNClient {
|
|
|
686
1116
|
this._agentMdCache.clear();
|
|
687
1117
|
return this._agentMdPath;
|
|
688
1118
|
}
|
|
689
|
-
SetAgentMDPath(root) {
|
|
690
|
-
return this.setAgentMdPath(root);
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* 记录本地 agent.md 文件路径并一次性计算 etag(quoted sha256,与服务端一致)。
|
|
694
|
-
*
|
|
695
|
-
* - path 为空字符串:清除本地 path 与 etag。
|
|
696
|
-
* - 文件不存在 / 读取失败:清除 etag 并返回空串,不抛异常(应用可读 getLocalAgentMdEtag()
|
|
697
|
-
* 为空判断)。
|
|
698
|
-
* - 浏览器环境无文件系统:直接返回空串,记录 warn 日志。
|
|
699
|
-
* - 文件变更后需要重新调用 setLocalAgentMdPath() 触发重算(按设计:设置时一次性计算)。
|
|
700
|
-
*
|
|
701
|
-
* 返回当前 etag(quoted hex 或空串)。
|
|
702
|
-
*/
|
|
703
|
-
setLocalAgentMdPath(path) {
|
|
704
|
-
const rawPath = String(path ?? '').trim();
|
|
705
|
-
if (!rawPath) {
|
|
706
|
-
this._localAgentMdPath = '';
|
|
707
|
-
this._localAgentMdEtag = '';
|
|
708
|
-
return '';
|
|
709
|
-
}
|
|
710
|
-
// 浏览器环境没有 fs,直接退回空串。Node 环境才尝试读文件。
|
|
711
|
-
const isNode = typeof process !== 'undefined' && !!process.versions?.node;
|
|
712
|
-
if (!isNode) {
|
|
713
|
-
this._clientLog.warn(`setLocalAgentMdPath skipped: not running in Node.js (path=${rawPath})`);
|
|
714
|
-
this._localAgentMdPath = rawPath;
|
|
715
|
-
this._localAgentMdEtag = '';
|
|
716
|
-
return '';
|
|
717
|
-
}
|
|
718
|
-
this._localAgentMdPath = rawPath;
|
|
719
|
-
try {
|
|
720
|
-
const data = fs.readFileSync(rawPath);
|
|
721
|
-
const digest = crypto.createHash('sha256').update(data).digest('hex');
|
|
722
|
-
this._localAgentMdEtag = `"${digest}"`;
|
|
723
|
-
}
|
|
724
|
-
catch (err) {
|
|
725
|
-
this._clientLog.warn(`setLocalAgentMdPath 读取失败 path=${rawPath} err=${err instanceof Error ? err.message : String(err)}`);
|
|
726
|
-
this._localAgentMdEtag = '';
|
|
727
|
-
}
|
|
728
|
-
return this._localAgentMdEtag;
|
|
729
|
-
}
|
|
730
1119
|
/** 返回 setLocalAgentMdPath 计算的 etag;未设置或读取失败时返回空串。 */
|
|
731
1120
|
getLocalAgentMdEtag() {
|
|
732
1121
|
return this._localAgentMdEtag;
|
|
@@ -886,8 +1275,7 @@ export class AUNClient {
|
|
|
886
1275
|
}
|
|
887
1276
|
_agentMdAuthCacheMeta(aid) {
|
|
888
1277
|
try {
|
|
889
|
-
const
|
|
890
|
-
const record = store?.get(String(aid ?? '').trim());
|
|
1278
|
+
const record = this._agentMdCache.get(String(aid ?? '').trim());
|
|
891
1279
|
return record && typeof record === 'object' ? { ...record } : {};
|
|
892
1280
|
}
|
|
893
1281
|
catch {
|
|
@@ -1007,7 +1395,7 @@ export class AUNClient {
|
|
|
1007
1395
|
return;
|
|
1008
1396
|
if (this._agentMdFetchInflight.has(target))
|
|
1009
1397
|
return;
|
|
1010
|
-
void this.
|
|
1398
|
+
void this._startAgentMdFetchTask(target).catch((err) => {
|
|
1011
1399
|
this._saveAgentMdRecord(target, {
|
|
1012
1400
|
last_error: err instanceof Error ? err.message : String(err),
|
|
1013
1401
|
remote_status: 'found',
|
|
@@ -1063,7 +1451,7 @@ export class AUNClient {
|
|
|
1063
1451
|
}
|
|
1064
1452
|
this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
|
|
1065
1453
|
}
|
|
1066
|
-
async
|
|
1454
|
+
async _checkAgentMdCache(aid, maxUnsyncedDays = 1) {
|
|
1067
1455
|
const target = String(aid ?? this._aid ?? '').trim();
|
|
1068
1456
|
if (!target)
|
|
1069
1457
|
throw new ValidationError('checkAgentMd requires aid (or local AID)');
|
|
@@ -1114,7 +1502,7 @@ export class AUNClient {
|
|
|
1114
1502
|
const now = Date.now();
|
|
1115
1503
|
let remote;
|
|
1116
1504
|
try {
|
|
1117
|
-
remote = await this.
|
|
1505
|
+
remote = await this._headAgentMd(target);
|
|
1118
1506
|
}
|
|
1119
1507
|
catch (err) {
|
|
1120
1508
|
this._saveAgentMdRecord(target, { checked_at: now, remote_status: 'error', last_error: err instanceof Error ? err.message : String(err) });
|
|
@@ -1169,42 +1557,101 @@ export class AUNClient {
|
|
|
1169
1557
|
}
|
|
1170
1558
|
/** 连接状态 */
|
|
1171
1559
|
get state() {
|
|
1172
|
-
return this._state;
|
|
1560
|
+
return this._publicState(this._state);
|
|
1561
|
+
}
|
|
1562
|
+
_publicState(state) {
|
|
1563
|
+
return STATE_TO_PUBLIC[state] ?? state;
|
|
1173
1564
|
}
|
|
1174
1565
|
/** 最近一次 gateway health check 结果,null 表示尚未检查 */
|
|
1175
1566
|
get gatewayHealth() {
|
|
1176
1567
|
return this._discovery.lastHealthy;
|
|
1177
1568
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1569
|
+
// ── 生命周期 ──────────────────────────────────────────────
|
|
1570
|
+
/** 仅认证当前身份,获取/刷新 token,但不建立长连接。 */
|
|
1571
|
+
async authenticate(options = {}) {
|
|
1180
1572
|
const tStart = Date.now();
|
|
1181
|
-
this.
|
|
1573
|
+
const target = this._currentAid?.aid ?? this._aid ?? '';
|
|
1574
|
+
if (!target || !this._currentAid?.isPrivateKeyValid()) {
|
|
1575
|
+
throw new StateError('authenticate requires a loaded AID with a valid private key');
|
|
1576
|
+
}
|
|
1577
|
+
const publicState = this.state;
|
|
1578
|
+
if (publicState !== ConnectionState.STANDBY) {
|
|
1579
|
+
throw new StateError(`authenticate not allowed in state ${publicState}`);
|
|
1580
|
+
}
|
|
1581
|
+
if ('aid' in options || 'access_token' in options || 'token' in options || 'kite_token' in options) {
|
|
1582
|
+
throw new ValidationError('authenticate options must not include aid or token fields; load an AID object first');
|
|
1583
|
+
}
|
|
1584
|
+
this._state = 'connecting';
|
|
1182
1585
|
try {
|
|
1183
|
-
const
|
|
1184
|
-
this.
|
|
1586
|
+
const gateway = String(options.gateway ?? this._gatewayUrl ?? await this._resolveGatewayForAid(target)).trim();
|
|
1587
|
+
const result = await this._auth.authenticate(gateway, { aid: target });
|
|
1588
|
+
this._gatewayUrl = String(result.gateway ?? gateway);
|
|
1589
|
+
this._identity = this._auth.loadIdentityOrNone(target);
|
|
1590
|
+
this._state = 'authenticated';
|
|
1591
|
+
this._lastError = null;
|
|
1592
|
+
this._lastErrorCode = null;
|
|
1593
|
+
this._clientLog.debug(`authenticate exit: elapsed=${Date.now() - tStart}ms aid=${target}`);
|
|
1185
1594
|
return result;
|
|
1186
1595
|
}
|
|
1187
1596
|
catch (err) {
|
|
1188
|
-
this.
|
|
1597
|
+
this._state = 'standby';
|
|
1598
|
+
this._lastError = err instanceof Error ? err : new Error(String(err));
|
|
1599
|
+
this._lastErrorCode = 'AUTHENTICATE_FAILED';
|
|
1600
|
+
this._clientLog.debug(`authenticate exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
1189
1601
|
throw err;
|
|
1190
1602
|
}
|
|
1191
1603
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
* 连接到 Gateway。
|
|
1195
|
-
*
|
|
1196
|
-
* @param auth - 认证参数(必须包含 access_token 和 gateway)
|
|
1197
|
-
* @param options - 会话选项(auto_reconnect、heartbeat_interval 等)
|
|
1198
|
-
*/
|
|
1199
|
-
async connect(auth, options) {
|
|
1604
|
+
/** 连接到 Gateway;身份来自构造函数或 loadIdentity(aid),认证由 SDK 内部自动完成。 */
|
|
1605
|
+
async connect(opts) {
|
|
1200
1606
|
const tStart = Date.now();
|
|
1201
|
-
if (
|
|
1202
|
-
|
|
1607
|
+
if (opts !== undefined && typeof opts === 'object') {
|
|
1608
|
+
const raw = opts;
|
|
1609
|
+
if ('gateway' in raw || 'access_token' in raw || 'aid' in raw || 'token' in raw) {
|
|
1610
|
+
throw new ValidationError('connect options must not include gateway/access_token/aid; these are managed internally');
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
const options = {};
|
|
1614
|
+
if (opts?.auto_reconnect !== undefined)
|
|
1615
|
+
options.auto_reconnect = opts.auto_reconnect;
|
|
1616
|
+
if (opts?.heartbeat_interval !== undefined)
|
|
1617
|
+
options.heartbeat_interval = opts.heartbeat_interval;
|
|
1618
|
+
if (opts?.connect_timeout !== undefined || opts?.call_timeout !== undefined) {
|
|
1619
|
+
options.timeouts = {
|
|
1620
|
+
...(opts.connect_timeout !== undefined ? { connect: opts.connect_timeout } : {}),
|
|
1621
|
+
...(opts.call_timeout !== undefined ? { call: opts.call_timeout } : {}),
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
if (opts?.retry_initial_delay !== undefined || opts?.retry_max_delay !== undefined || opts?.retry_max_attempts !== undefined) {
|
|
1625
|
+
options.retry = {
|
|
1626
|
+
initial_delay: opts.retry_initial_delay ?? 1,
|
|
1627
|
+
max_delay: opts.retry_max_delay ?? 64,
|
|
1628
|
+
max_attempts: opts.retry_max_attempts ?? 0,
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
const target = this._currentAid?.aid ?? this._aid ?? '';
|
|
1632
|
+
if (!target || !this._currentAid?.isPrivateKeyValid()) {
|
|
1633
|
+
throw new StateError('connect requires a loaded AID with a valid private key');
|
|
1634
|
+
}
|
|
1635
|
+
const publicState = this.state;
|
|
1636
|
+
const allowed = new Set([
|
|
1637
|
+
ConnectionState.STANDBY,
|
|
1638
|
+
ConnectionState.AUTHENTICATED,
|
|
1639
|
+
ConnectionState.RETRY_BACKOFF,
|
|
1640
|
+
ConnectionState.CONNECTION_FAILED,
|
|
1641
|
+
]);
|
|
1642
|
+
if (!allowed.has(publicState)) {
|
|
1643
|
+
throw new StateError(`connect not allowed in state ${publicState}`);
|
|
1644
|
+
}
|
|
1645
|
+
if (publicState === ConnectionState.RETRY_BACKOFF) {
|
|
1646
|
+
this._stopReconnect();
|
|
1647
|
+
}
|
|
1648
|
+
// gateway 来自 authenticate() 缓存的 this._gatewayUrl;未认证则自动 authenticate()
|
|
1649
|
+
if (!this._gatewayUrl) {
|
|
1650
|
+
await this.authenticate();
|
|
1203
1651
|
}
|
|
1204
1652
|
this._state = 'connecting';
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
Object.assign(params, options);
|
|
1653
|
+
const gateway = String(this._gatewayUrl ?? '').trim();
|
|
1654
|
+
const params = { ...options, gateway };
|
|
1208
1655
|
const normalized = this._normalizeConnectParams(params);
|
|
1209
1656
|
this._captureCapabilitiesFromConnect(normalized);
|
|
1210
1657
|
this._sessionParams = normalized;
|
|
@@ -1218,7 +1665,9 @@ export class AUNClient {
|
|
|
1218
1665
|
for (const gw of gateways) {
|
|
1219
1666
|
try {
|
|
1220
1667
|
const gwParams = { ...normalized, gateway: gw };
|
|
1221
|
-
await this._connectOnce(gwParams,
|
|
1668
|
+
await this._connectOnce(gwParams, true);
|
|
1669
|
+
this._lastError = null;
|
|
1670
|
+
this._lastErrorCode = null;
|
|
1222
1671
|
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
|
|
1223
1672
|
return;
|
|
1224
1673
|
}
|
|
@@ -1227,14 +1676,15 @@ export class AUNClient {
|
|
|
1227
1676
|
if (gateways.length > 1) {
|
|
1228
1677
|
this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${formatCaughtError(err)}`);
|
|
1229
1678
|
}
|
|
1230
|
-
if (this._state
|
|
1679
|
+
if (this._state !== 'closed')
|
|
1231
1680
|
this._state = 'connecting';
|
|
1232
|
-
}
|
|
1233
1681
|
}
|
|
1234
1682
|
}
|
|
1235
1683
|
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1236
|
-
this._state = '
|
|
1684
|
+
this._state = 'connection_failed';
|
|
1237
1685
|
}
|
|
1686
|
+
this._lastError = lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
1687
|
+
this._lastErrorCode = 'CONNECT_FAILED';
|
|
1238
1688
|
this._clientLog.error(`connect failed: ${formatCaughtError(lastErr)}`, lastErr instanceof Error ? lastErr : undefined);
|
|
1239
1689
|
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1240
1690
|
throw lastErr;
|
|
@@ -1248,13 +1698,13 @@ export class AUNClient {
|
|
|
1248
1698
|
this._saveSeqTrackerState();
|
|
1249
1699
|
this._stopBackgroundTasks();
|
|
1250
1700
|
this._stopReconnect();
|
|
1251
|
-
if (this.
|
|
1701
|
+
if (this.state === ConnectionState.NO_IDENTITY || this.state === ConnectionState.CLOSED) {
|
|
1252
1702
|
const closableKeyStore = this._keystore;
|
|
1253
1703
|
closableKeyStore.close?.();
|
|
1254
1704
|
this._state = 'closed';
|
|
1255
1705
|
this._logger.close();
|
|
1256
1706
|
this._resetSeqTrackingState();
|
|
1257
|
-
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms (was
|
|
1707
|
+
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms (was no_identity/closed)`);
|
|
1258
1708
|
return;
|
|
1259
1709
|
}
|
|
1260
1710
|
await this._transport.close();
|
|
@@ -1262,7 +1712,7 @@ export class AUNClient {
|
|
|
1262
1712
|
closableKeyStore.close?.();
|
|
1263
1713
|
this._state = 'closed';
|
|
1264
1714
|
this._logger.close();
|
|
1265
|
-
await this._dispatcher.publish('
|
|
1715
|
+
await this._dispatcher.publish('state_change', { state: this._publicState(this._state) });
|
|
1266
1716
|
this._resetSeqTrackingState();
|
|
1267
1717
|
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms`);
|
|
1268
1718
|
}
|
|
@@ -1284,7 +1734,14 @@ export class AUNClient {
|
|
|
1284
1734
|
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms (closing)`);
|
|
1285
1735
|
return;
|
|
1286
1736
|
}
|
|
1287
|
-
if (
|
|
1737
|
+
if (![
|
|
1738
|
+
ConnectionState.AUTHENTICATED,
|
|
1739
|
+
ConnectionState.CONNECTING,
|
|
1740
|
+
ConnectionState.READY,
|
|
1741
|
+
ConnectionState.RETRY_BACKOFF,
|
|
1742
|
+
ConnectionState.RECONNECTING,
|
|
1743
|
+
ConnectionState.CONNECTION_FAILED,
|
|
1744
|
+
].includes(this.state)) {
|
|
1288
1745
|
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms (state=${this._state})`);
|
|
1289
1746
|
return;
|
|
1290
1747
|
}
|
|
@@ -1292,8 +1749,8 @@ export class AUNClient {
|
|
|
1292
1749
|
this._stopBackgroundTasks();
|
|
1293
1750
|
this._stopReconnect();
|
|
1294
1751
|
await this._transport.close();
|
|
1295
|
-
this._state = '
|
|
1296
|
-
await this._dispatcher.publish('
|
|
1752
|
+
this._state = 'standby';
|
|
1753
|
+
await this._dispatcher.publish('state_change', { state: this._publicState(this._state) });
|
|
1297
1754
|
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms`);
|
|
1298
1755
|
}
|
|
1299
1756
|
catch (err) {
|
|
@@ -1301,83 +1758,6 @@ export class AUNClient {
|
|
|
1301
1758
|
throw err;
|
|
1302
1759
|
}
|
|
1303
1760
|
}
|
|
1304
|
-
/**
|
|
1305
|
-
* 列出本地身份摘要。
|
|
1306
|
-
*
|
|
1307
|
-
* @param opts.all=false(默认):仅返回严格校验通过的可用身份——
|
|
1308
|
-
* keypair 完整 + cert 公钥 == keypair 公钥 + cert 时间窗口有效
|
|
1309
|
-
* @param opts.all=true:返回所有 AIDs/ 子目录(不含 _pending/);
|
|
1310
|
-
* 每项含 valid=bool 和 reason=string 字段
|
|
1311
|
-
*/
|
|
1312
|
-
listIdentities(opts) {
|
|
1313
|
-
const tStart = Date.now();
|
|
1314
|
-
const includeAll = !!opts?.all;
|
|
1315
|
-
this._clientLog.debug(`listIdentities enter all=${includeAll}`);
|
|
1316
|
-
try {
|
|
1317
|
-
const listFn = this._keystore.listIdentities;
|
|
1318
|
-
if (typeof listFn !== 'function') {
|
|
1319
|
-
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms (no_list_fn)`);
|
|
1320
|
-
return [];
|
|
1321
|
-
}
|
|
1322
|
-
const aids = listFn.call(this._keystore);
|
|
1323
|
-
const summaries = [];
|
|
1324
|
-
for (const aid of [...aids].sort()) {
|
|
1325
|
-
const { valid, reason } = this._validateLocalIdentity(aid);
|
|
1326
|
-
if (!includeAll && !valid)
|
|
1327
|
-
continue;
|
|
1328
|
-
const summary = { aid, valid };
|
|
1329
|
-
if (reason)
|
|
1330
|
-
summary.reason = reason;
|
|
1331
|
-
const loadMetadata = this._keystore.loadMetadata;
|
|
1332
|
-
if (typeof loadMetadata === 'function') {
|
|
1333
|
-
const md = loadMetadata.call(this._keystore, aid);
|
|
1334
|
-
if (md)
|
|
1335
|
-
summary.metadata = md;
|
|
1336
|
-
}
|
|
1337
|
-
summaries.push(summary);
|
|
1338
|
-
}
|
|
1339
|
-
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms all=${includeAll} count=${summaries.length}`);
|
|
1340
|
-
return summaries;
|
|
1341
|
-
}
|
|
1342
|
-
catch (err) {
|
|
1343
|
-
this._clientLog.debug(`listIdentities exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
1344
|
-
throw err;
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
/**
|
|
1348
|
-
* 严格校验本地身份的可用性。返回 {valid, reason}。
|
|
1349
|
-
* 4 项校验:keypair 完整 + cert 存在 + cert 公钥 == keypair 公钥 + cert 时间窗口有效。
|
|
1350
|
-
*/
|
|
1351
|
-
_validateLocalIdentity(aid) {
|
|
1352
|
-
const identity = this._keystore.loadIdentity(aid);
|
|
1353
|
-
if (!identity)
|
|
1354
|
-
return { valid: false, reason: 'no identity record' };
|
|
1355
|
-
const priv = String(identity.private_key_pem ?? '');
|
|
1356
|
-
const pubB64 = String(identity.public_key_der_b64 ?? '');
|
|
1357
|
-
const certPem = String(identity.cert ?? '');
|
|
1358
|
-
if (!priv || !pubB64)
|
|
1359
|
-
return { valid: false, reason: 'missing keypair' };
|
|
1360
|
-
if (!certPem)
|
|
1361
|
-
return { valid: false, reason: 'missing certificate' };
|
|
1362
|
-
try {
|
|
1363
|
-
const crypto = require('node:crypto');
|
|
1364
|
-
const cert = new crypto.X509Certificate(certPem);
|
|
1365
|
-
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
1366
|
-
const localPubDer = Buffer.from(pubB64, 'base64');
|
|
1367
|
-
if (!certPubDer.equals(localPubDer)) {
|
|
1368
|
-
return { valid: false, reason: 'cert public key does not match keypair' };
|
|
1369
|
-
}
|
|
1370
|
-
const now = Date.now();
|
|
1371
|
-
if (now < new Date(cert.validFrom).getTime())
|
|
1372
|
-
return { valid: false, reason: 'cert not yet valid' };
|
|
1373
|
-
if (now > new Date(cert.validTo).getTime())
|
|
1374
|
-
return { valid: false, reason: 'cert expired' };
|
|
1375
|
-
return { valid: true, reason: '' };
|
|
1376
|
-
}
|
|
1377
|
-
catch (e) {
|
|
1378
|
-
return { valid: false, reason: `cert parse error: ${e instanceof Error ? e.message : String(e)}` };
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
1761
|
// ── RPC ───────────────────────────────────────────────────
|
|
1382
1762
|
/**
|
|
1383
1763
|
* 发送 JSON-RPC 调用。
|
|
@@ -1387,7 +1767,7 @@ export class AUNClient {
|
|
|
1387
1767
|
const tStart = Date.now();
|
|
1388
1768
|
this._clientLog.debug(`call enter: method=${method}`);
|
|
1389
1769
|
try {
|
|
1390
|
-
if (this.
|
|
1770
|
+
if (this.state !== ConnectionState.READY) {
|
|
1391
1771
|
throw new ConnectionError('client is not connected');
|
|
1392
1772
|
}
|
|
1393
1773
|
if (INTERNAL_ONLY_METHODS.has(method)) {
|
|
@@ -1397,6 +1777,10 @@ export class AUNClient {
|
|
|
1397
1777
|
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
1398
1778
|
}
|
|
1399
1779
|
const p = { ...(params ?? {}) };
|
|
1780
|
+
if (this._instanceProtectedHeaders && PROTECTED_HEADERS_METHODS.has(method)) {
|
|
1781
|
+
const existing = isJsonObject(p.protected_headers) ? p.protected_headers : {};
|
|
1782
|
+
p.protected_headers = { ...this._instanceProtectedHeaders, ...existing };
|
|
1783
|
+
}
|
|
1400
1784
|
const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
|
|
1401
1785
|
delete p._rpc_background;
|
|
1402
1786
|
const runWithRpcPriority = async (operation) => {
|
|
@@ -1446,7 +1830,7 @@ export class AUNClient {
|
|
|
1446
1830
|
const encrypt = p.encrypt ?? true;
|
|
1447
1831
|
delete p.encrypt;
|
|
1448
1832
|
if (encrypt) {
|
|
1449
|
-
return await runWithRpcPriority(() => this.
|
|
1833
|
+
return await runWithRpcPriority(() => this._sendV2(String(p.to ?? ''), p.payload, {
|
|
1450
1834
|
messageId: String(p.message_id ?? '') || undefined,
|
|
1451
1835
|
timestamp: p.timestamp,
|
|
1452
1836
|
protectedHeaders: this._protectedHeadersFromParams(p),
|
|
@@ -1461,7 +1845,7 @@ export class AUNClient {
|
|
|
1461
1845
|
const encrypt = p.encrypt ?? true;
|
|
1462
1846
|
delete p.encrypt;
|
|
1463
1847
|
if (encrypt) {
|
|
1464
|
-
return await runWithRpcPriority(() => this.
|
|
1848
|
+
return await runWithRpcPriority(() => this._sendGroupV2(String(p.group_id ?? ''), p.payload, {
|
|
1465
1849
|
messageId: String(p.message_id ?? '') || undefined,
|
|
1466
1850
|
timestamp: p.timestamp,
|
|
1467
1851
|
protectedHeaders: this._protectedHeadersFromParams(p),
|
|
@@ -1497,20 +1881,20 @@ export class AUNClient {
|
|
|
1497
1881
|
const afterSeq = Number(p.after_seq ?? 0) || 0;
|
|
1498
1882
|
const limit = Number(p.limit ?? 50) || 50;
|
|
1499
1883
|
const messages = skipAutoAck
|
|
1500
|
-
? await runWithRpcPriority(() => this.
|
|
1501
|
-
: await runWithRpcPriority(() => this.
|
|
1884
|
+
? await runWithRpcPriority(() => this._pullV2(afterSeq, limit, { skipAutoAck: true, gateLocked: true, force }))
|
|
1885
|
+
: await runWithRpcPriority(() => this._pullV2(afterSeq, limit, { gateLocked: true, force }));
|
|
1502
1886
|
return { messages };
|
|
1503
1887
|
}
|
|
1504
1888
|
if (method === 'message.ack' || method === 'message.v2.ack') {
|
|
1505
1889
|
await this._ensureV2SessionReady('message.ack');
|
|
1506
|
-
return await runWithRpcPriority(() => this.
|
|
1890
|
+
return await runWithRpcPriority(() => this._ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined));
|
|
1507
1891
|
}
|
|
1508
1892
|
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
1509
1893
|
if (!String(p.group_id ?? '').trim()) {
|
|
1510
1894
|
throw new ValidationError('group.pull requires group_id');
|
|
1511
1895
|
}
|
|
1512
1896
|
await this._ensureV2SessionReady('group.pull');
|
|
1513
|
-
const messages = await runWithRpcPriority(() => this.
|
|
1897
|
+
const messages = await runWithRpcPriority(() => this._pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50, { gateLocked: true }));
|
|
1514
1898
|
return { messages };
|
|
1515
1899
|
}
|
|
1516
1900
|
if (method === 'group.ack_messages' || method === 'group.v2.ack') {
|
|
@@ -1518,7 +1902,7 @@ export class AUNClient {
|
|
|
1518
1902
|
throw new ValidationError('group.ack_messages requires group_id');
|
|
1519
1903
|
}
|
|
1520
1904
|
await this._ensureV2SessionReady('group.ack_messages');
|
|
1521
|
-
return await runWithRpcPriority(() => this.
|
|
1905
|
+
return await runWithRpcPriority(() => this._ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined));
|
|
1522
1906
|
}
|
|
1523
1907
|
if (method === 'message.pull') {
|
|
1524
1908
|
delete p._skip_auto_ack;
|
|
@@ -1578,49 +1962,6 @@ export class AUNClient {
|
|
|
1578
1962
|
throw err;
|
|
1579
1963
|
}
|
|
1580
1964
|
}
|
|
1581
|
-
// ── 便利方法 ──────────────────────────────────────────────
|
|
1582
|
-
/** 心跳检测 */
|
|
1583
|
-
async ping(params) {
|
|
1584
|
-
const tStart = Date.now();
|
|
1585
|
-
this._clientLog.debug(`ping enter`);
|
|
1586
|
-
try {
|
|
1587
|
-
const result = await this.call('meta.ping', params ?? {});
|
|
1588
|
-
this._clientLog.debug(`ping exit: elapsed=${Date.now() - tStart}ms`);
|
|
1589
|
-
return result;
|
|
1590
|
-
}
|
|
1591
|
-
catch (err) {
|
|
1592
|
-
this._clientLog.debug(`ping exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
1593
|
-
throw err;
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
/** 获取服务端状态 */
|
|
1597
|
-
async status(params) {
|
|
1598
|
-
const tStart = Date.now();
|
|
1599
|
-
this._clientLog.debug(`status enter`);
|
|
1600
|
-
try {
|
|
1601
|
-
const result = await this.call('meta.status', params ?? {});
|
|
1602
|
-
this._clientLog.debug(`status exit: elapsed=${Date.now() - tStart}ms`);
|
|
1603
|
-
return result;
|
|
1604
|
-
}
|
|
1605
|
-
catch (err) {
|
|
1606
|
-
this._clientLog.debug(`status exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
1607
|
-
throw err;
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
/** 获取信任根证书列表 */
|
|
1611
|
-
async trustRoots(params) {
|
|
1612
|
-
const tStart = Date.now();
|
|
1613
|
-
this._clientLog.debug(`trustRoots enter`);
|
|
1614
|
-
try {
|
|
1615
|
-
const result = await this.call('meta.trust_roots', params ?? {});
|
|
1616
|
-
this._clientLog.debug(`trustRoots exit: elapsed=${Date.now() - tStart}ms`);
|
|
1617
|
-
return result;
|
|
1618
|
-
}
|
|
1619
|
-
catch (err) {
|
|
1620
|
-
this._clientLog.debug(`trustRoots exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
1621
|
-
throw err;
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
1965
|
// ── 事件 ──────────────────────────────────────────────────
|
|
1625
1966
|
/** 订阅事件 */
|
|
1626
1967
|
on(event, handler) {
|
|
@@ -1787,7 +2128,7 @@ export class AUNClient {
|
|
|
1787
2128
|
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1788
2129
|
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1789
2130
|
this._clientLog.debug(`P2P push auto-ack send: ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1790
|
-
this._withBackgroundRpc(() => this.
|
|
2131
|
+
this._withBackgroundRpc(() => this._ackV2(ackSeq))
|
|
1791
2132
|
.then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
|
|
1792
2133
|
.catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1793
2134
|
}
|
|
@@ -1881,7 +2222,7 @@ export class AUNClient {
|
|
|
1881
2222
|
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1882
2223
|
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1883
2224
|
this._clientLog.debug(`group push auto-ack send: group=${groupId}, ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1884
|
-
this._withBackgroundRpc(() => this.
|
|
2225
|
+
this._withBackgroundRpc(() => this._ackGroupV2(groupId, ackSeq))
|
|
1885
2226
|
.then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
|
|
1886
2227
|
.catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1887
2228
|
}
|
|
@@ -1912,7 +2253,7 @@ export class AUNClient {
|
|
|
1912
2253
|
this._clientLog.debug(`auto pull group messages start: group=${groupId}, after_seq=${afterSeq}, seq=${String(notification.seq ?? '')}`);
|
|
1913
2254
|
const started = await this._tryRunBackgroundPull(ns, async () => {
|
|
1914
2255
|
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1915
|
-
const messages = await this.
|
|
2256
|
+
const messages = await this._pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
1916
2257
|
this._prunePushedSeqs(ns);
|
|
1917
2258
|
return messages.length;
|
|
1918
2259
|
}, true);
|
|
@@ -1940,7 +2281,7 @@ export class AUNClient {
|
|
|
1940
2281
|
this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1941
2282
|
let filled = 0;
|
|
1942
2283
|
try {
|
|
1943
|
-
const messages = await this._withBackgroundRpc(() => this.
|
|
2284
|
+
const messages = await this._withBackgroundRpc(() => this._pullGroupV2(groupId, afterSeq, 50, { gateLocked: true }));
|
|
1944
2285
|
filled = messages.length;
|
|
1945
2286
|
this._prunePushedSeqs(ns);
|
|
1946
2287
|
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
@@ -1979,7 +2320,7 @@ export class AUNClient {
|
|
|
1979
2320
|
this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
|
|
1980
2321
|
let filled = 0;
|
|
1981
2322
|
try {
|
|
1982
|
-
const messages = await this._withBackgroundRpc(() => this.
|
|
2323
|
+
const messages = await this._withBackgroundRpc(() => this._pullV2(afterSeq, 50, { skipAutoAck: true, gateLocked: true }));
|
|
1983
2324
|
filled = messages.length;
|
|
1984
2325
|
this._prunePushedSeqs(ns);
|
|
1985
2326
|
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
@@ -1988,7 +2329,7 @@ export class AUNClient {
|
|
|
1988
2329
|
}
|
|
1989
2330
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1990
2331
|
if (contig > 0 && contig !== afterSeq) {
|
|
1991
|
-
await this._withBackgroundRpc(() => this.
|
|
2332
|
+
await this._withBackgroundRpc(() => this._ackV2(contig));
|
|
1992
2333
|
}
|
|
1993
2334
|
this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
|
|
1994
2335
|
}
|
|
@@ -2036,7 +2377,7 @@ export class AUNClient {
|
|
|
2036
2377
|
this._clientLog.debug(`P2P pending pull upper already covered: ns=${ns}, upper_seq=${upperSeq}, contiguous=${contig}, reason=${reason}`);
|
|
2037
2378
|
return false;
|
|
2038
2379
|
}
|
|
2039
|
-
if (this.
|
|
2380
|
+
if (this.state !== ConnectionState.READY || this._closing) {
|
|
2040
2381
|
this._clientLog.debug(`P2P pending pull postponed: ns=${ns}, upper_seq=${upperSeq}, contiguous=${contig}, state=${this._state}, closing=${this._closing}, reason=${reason}`);
|
|
2041
2382
|
return false;
|
|
2042
2383
|
}
|
|
@@ -2427,14 +2768,14 @@ export class AUNClient {
|
|
|
2427
2768
|
try {
|
|
2428
2769
|
await this._withBackgroundRpc(async () => {
|
|
2429
2770
|
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2430
|
-
await this.
|
|
2771
|
+
await this._pullV2(Number(next.after_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2431
2772
|
return;
|
|
2432
2773
|
}
|
|
2433
2774
|
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2434
2775
|
const groupId = String(next.group_id ?? '').trim();
|
|
2435
2776
|
if (!groupId)
|
|
2436
2777
|
return;
|
|
2437
|
-
await this.
|
|
2778
|
+
await this._pullGroupV2(groupId, Number(next.after_seq ?? next.after_message_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2438
2779
|
return;
|
|
2439
2780
|
}
|
|
2440
2781
|
await this.call(method, next);
|
|
@@ -3679,10 +4020,10 @@ export class AUNClient {
|
|
|
3679
4020
|
this._applyServerHeartbeatInterval(hello.heartbeat_interval, 'auth');
|
|
3680
4021
|
}
|
|
3681
4022
|
}
|
|
3682
|
-
this._state = '
|
|
4023
|
+
this._state = 'ready';
|
|
3683
4024
|
this._connectedAt = Date.now();
|
|
3684
4025
|
this._clientLog.debug(`auth complete, connection ready: aid=${this._aid ?? ''}, gateway=${gatewayUrl}`);
|
|
3685
|
-
await this._dispatcher.publish('
|
|
4026
|
+
await this._dispatcher.publish('state_change', { state: this._publicState(this._state), gateway: gatewayUrl });
|
|
3686
4027
|
// auth 阶段 aid 可能被 identity 覆盖(上方 this._aid = identity.aid);
|
|
3687
4028
|
// 若 context 发生变化,重新 refresh + restore,保持 tracker 与真实身份一致。
|
|
3688
4029
|
if (this._seqTrackerContext !== this._currentSeqTrackerContext()) {
|
|
@@ -3692,7 +4033,7 @@ export class AUNClient {
|
|
|
3692
4033
|
this._startBackgroundTasks();
|
|
3693
4034
|
// V2 E2EE:初始化 session 并注册本设备 SPK。
|
|
3694
4035
|
try {
|
|
3695
|
-
await this.
|
|
4036
|
+
await this._initV2Session();
|
|
3696
4037
|
}
|
|
3697
4038
|
catch (exc) {
|
|
3698
4039
|
this._clientLog.warn(`V2 session init failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
@@ -3705,7 +4046,7 @@ export class AUNClient {
|
|
|
3705
4046
|
this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl}, aid=${this._aid ?? ''}`);
|
|
3706
4047
|
}
|
|
3707
4048
|
catch (err) {
|
|
3708
|
-
this._state = prevState === 'connected' ? '
|
|
4049
|
+
this._state = (prevState === 'connected' || prevState === 'ready') ? 'standby' : (this._currentAid ? 'standby' : 'no_identity');
|
|
3709
4050
|
this._clientLog.debug(`_connectOnce exit (error): elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3710
4051
|
throw err;
|
|
3711
4052
|
}
|
|
@@ -3747,7 +4088,7 @@ export class AUNClient {
|
|
|
3747
4088
|
* 初始化 V2 session:IK 使用 AID 长期私钥,SPK 存储在 per-AID SQLite 的 v2_device_keys 表。
|
|
3748
4089
|
* connect 成功后会自动调用;重复调用幂等。
|
|
3749
4090
|
*/
|
|
3750
|
-
async
|
|
4091
|
+
async _initV2Session() {
|
|
3751
4092
|
if (!this._aid)
|
|
3752
4093
|
return;
|
|
3753
4094
|
const existing = this._v2Session;
|
|
@@ -4195,7 +4536,7 @@ export class AUNClient {
|
|
|
4195
4536
|
return envelope;
|
|
4196
4537
|
}
|
|
4197
4538
|
/** V2 P2P 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
|
|
4198
|
-
async
|
|
4539
|
+
async _sendV2(to, payload, opts) {
|
|
4199
4540
|
await this._ensureV2SessionReady('message.send', 'V2 session not initialized; encrypted message.send requires V2 (V1 E2EE removed)');
|
|
4200
4541
|
const toAid = String(to ?? '').trim();
|
|
4201
4542
|
if (!toAid)
|
|
@@ -4240,11 +4581,11 @@ export class AUNClient {
|
|
|
4240
4581
|
}
|
|
4241
4582
|
}
|
|
4242
4583
|
/** V2 P2P 拉取并解密;直接方法返回消息数组,call("message.pull") 会包装为 {messages}. */
|
|
4243
|
-
async
|
|
4584
|
+
async _pullV2(afterSeq = 0, limit = 50, opts) {
|
|
4244
4585
|
await this._ensureV2SessionReady('message.pull');
|
|
4245
4586
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
4246
4587
|
if (ns && !opts?.gateLocked) {
|
|
4247
|
-
return await this._runPullSerialized(ns, async () => this.
|
|
4588
|
+
return await this._runPullSerialized(ns, async () => this._pullV2(afterSeq, limit, {
|
|
4248
4589
|
...(opts ?? {}),
|
|
4249
4590
|
gateLocked: true,
|
|
4250
4591
|
scheduleFollowup: true,
|
|
@@ -4356,7 +4697,7 @@ export class AUNClient {
|
|
|
4356
4697
|
}
|
|
4357
4698
|
if (messages.length > 0 && contigAdvanced && ackSeq > 0 && !opts?.skipAutoAck) {
|
|
4358
4699
|
this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
4359
|
-
this._safeAsync(this.
|
|
4700
|
+
this._safeAsync(this._ackV2(ackSeq).then(() => undefined));
|
|
4360
4701
|
}
|
|
4361
4702
|
}
|
|
4362
4703
|
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
@@ -4371,7 +4712,7 @@ export class AUNClient {
|
|
|
4371
4712
|
return decrypted;
|
|
4372
4713
|
}
|
|
4373
4714
|
/** V2 P2P ack,并触发旧 SPK 销毁自检。 */
|
|
4374
|
-
async
|
|
4715
|
+
async _ackV2(upToSeq) {
|
|
4375
4716
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
4376
4717
|
let seq = Number(upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
|
|
4377
4718
|
if (!Number.isFinite(seq) || seq <= 0) {
|
|
@@ -4419,7 +4760,7 @@ export class AUNClient {
|
|
|
4419
4760
|
return result;
|
|
4420
4761
|
}
|
|
4421
4762
|
/** V2 Group 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
|
|
4422
|
-
async
|
|
4763
|
+
async _sendGroupV2(groupId, payload, opts) {
|
|
4423
4764
|
await this._ensureV2SessionReady('group.send', 'V2 session not initialized; encrypted group.send requires V2 (V1 E2EE removed)');
|
|
4424
4765
|
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
4425
4766
|
if (!gid)
|
|
@@ -4599,17 +4940,17 @@ export class AUNClient {
|
|
|
4599
4940
|
return envelope;
|
|
4600
4941
|
}
|
|
4601
4942
|
async _pullGroupV2Internal(params) {
|
|
4602
|
-
await this.
|
|
4943
|
+
await this._pullGroupV2(params.group_id, params.after_seq, params.limit, { gateLocked: true });
|
|
4603
4944
|
}
|
|
4604
4945
|
/** V2 Group 拉取并解密;直接方法返回消息数组,call("group.pull") 会包装为 {messages}. */
|
|
4605
|
-
async
|
|
4946
|
+
async _pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
|
|
4606
4947
|
await this._ensureV2SessionReady('group.pull');
|
|
4607
4948
|
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
4608
4949
|
if (!gid)
|
|
4609
4950
|
throw new ValidationError('group.pull requires group_id');
|
|
4610
4951
|
const ns = `group:${gid}`;
|
|
4611
4952
|
if (!opts?.gateLocked) {
|
|
4612
|
-
return await this._runPullSerialized(ns, async () => this.
|
|
4953
|
+
return await this._runPullSerialized(ns, async () => this._pullGroupV2(gid, afterSeq, limit, {
|
|
4613
4954
|
...(opts ?? {}),
|
|
4614
4955
|
gateLocked: true,
|
|
4615
4956
|
scheduleFollowup: true,
|
|
@@ -4721,7 +5062,7 @@ export class AUNClient {
|
|
|
4721
5062
|
}
|
|
4722
5063
|
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
4723
5064
|
this._clientLog.debug(`group.v2.pull scheduling auto-ack: group=${gid}, ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
4724
|
-
this._safeAsync(this.
|
|
5065
|
+
this._safeAsync(this._ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
4725
5066
|
}
|
|
4726
5067
|
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4727
5068
|
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
@@ -4735,7 +5076,7 @@ export class AUNClient {
|
|
|
4735
5076
|
return decrypted;
|
|
4736
5077
|
}
|
|
4737
5078
|
/** V2 Group ack。 */
|
|
4738
|
-
async
|
|
5079
|
+
async _ackGroupV2(groupId, upToSeq) {
|
|
4739
5080
|
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
4740
5081
|
if (!gid)
|
|
4741
5082
|
throw new ValidationError('group.ack_messages requires group_id');
|
|
@@ -5930,7 +6271,7 @@ export class AUNClient {
|
|
|
5930
6271
|
}
|
|
5931
6272
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
5932
6273
|
try {
|
|
5933
|
-
const pulled = await this.
|
|
6274
|
+
const pulled = await this._pullV2(0, 50, { gateLocked: true });
|
|
5934
6275
|
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5935
6276
|
this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
|
|
5936
6277
|
if (newContig <= operationBefore)
|
|
@@ -6019,7 +6360,7 @@ export class AUNClient {
|
|
|
6019
6360
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
6020
6361
|
try {
|
|
6021
6362
|
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}`);
|
|
6022
|
-
const pulled = await this.
|
|
6363
|
+
const pulled = await this._pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
6023
6364
|
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
6024
6365
|
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}, contiguous=${newContig}`);
|
|
6025
6366
|
if (newContig <= pullAfterSeq)
|
|
@@ -6056,6 +6397,56 @@ export class AUNClient {
|
|
|
6056
6397
|
this._clientLog.debug(`SPK rotation after V2 epoch change failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
6057
6398
|
}
|
|
6058
6399
|
}
|
|
6400
|
+
/** 按当前 AID 发现 Gateway;用于 authenticate()/connect() 的新入口。 */
|
|
6401
|
+
async _resolveGatewayForAid(aid) {
|
|
6402
|
+
const resolvedAid = String(aid ?? this._aid ?? '').trim();
|
|
6403
|
+
if (!resolvedAid) {
|
|
6404
|
+
throw new StateError('gateway discovery requires a loaded AID');
|
|
6405
|
+
}
|
|
6406
|
+
if (this._gatewayUrl)
|
|
6407
|
+
return this._gatewayUrl;
|
|
6408
|
+
try {
|
|
6409
|
+
const loadMetadata = this._keystore.loadMetadata;
|
|
6410
|
+
const cachedGateway = typeof loadMetadata === 'function'
|
|
6411
|
+
? String(loadMetadata.call(this._keystore, resolvedAid)?.gateway_url ?? '').trim()
|
|
6412
|
+
: '';
|
|
6413
|
+
if (cachedGateway) {
|
|
6414
|
+
this._gatewayUrl = cachedGateway;
|
|
6415
|
+
return cachedGateway;
|
|
6416
|
+
}
|
|
6417
|
+
}
|
|
6418
|
+
catch {
|
|
6419
|
+
// 缓存读取失败不影响发现流程。
|
|
6420
|
+
}
|
|
6421
|
+
const dotIdx = resolvedAid.indexOf('.');
|
|
6422
|
+
const issuerDomain = dotIdx >= 0 ? resolvedAid.slice(dotIdx + 1) : resolvedAid;
|
|
6423
|
+
const portSuffix = this._configModel.discoveryPort ? `:${this._configModel.discoveryPort}` : '';
|
|
6424
|
+
const aidUrl = `https://${resolvedAid}${portSuffix}/.well-known/aun-gateway`;
|
|
6425
|
+
const gatewayDomainUrl = `https://gateway.${issuerDomain}${portSuffix}/.well-known/aun-gateway`;
|
|
6426
|
+
const candidates = this._configModel.verifySsl ? [aidUrl, gatewayDomainUrl] : [gatewayDomainUrl, aidUrl];
|
|
6427
|
+
let lastErr = null;
|
|
6428
|
+
for (const url of candidates) {
|
|
6429
|
+
try {
|
|
6430
|
+
const gateway = await this._discovery.discover(url);
|
|
6431
|
+
this._gatewayUrl = gateway;
|
|
6432
|
+
try {
|
|
6433
|
+
const saveMetadata = this._keystore.saveMetadata;
|
|
6434
|
+
if (typeof saveMetadata === 'function') {
|
|
6435
|
+
saveMetadata.call(this._keystore, resolvedAid, { gateway_url: gateway, gateway_cached_at: Date.now() });
|
|
6436
|
+
}
|
|
6437
|
+
}
|
|
6438
|
+
catch {
|
|
6439
|
+
// 缓存写入失败不影响连接。
|
|
6440
|
+
}
|
|
6441
|
+
return gateway;
|
|
6442
|
+
}
|
|
6443
|
+
catch (err) {
|
|
6444
|
+
lastErr = err;
|
|
6445
|
+
this._clientLog.warn(`gateway discovery failed: aid=${resolvedAid} url=${url} err=${formatCaughtError(err)}`);
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
throw lastErr instanceof Error ? lastErr : new ConnectionError(`gateway discovery failed for ${resolvedAid}`);
|
|
6449
|
+
}
|
|
6059
6450
|
/** 从参数中解析 Gateway URL */
|
|
6060
6451
|
_resolveGateway(params) {
|
|
6061
6452
|
const gateways = this._resolveGateways(params);
|
|
@@ -6106,15 +6497,19 @@ export class AUNClient {
|
|
|
6106
6497
|
}
|
|
6107
6498
|
// ── 内部:参数处理 ────────────────────────────────────────
|
|
6108
6499
|
/** 规范化连接参数 */
|
|
6109
|
-
_normalizeConnectParams(params) {
|
|
6500
|
+
_normalizeConnectParams(params, opts = {}) {
|
|
6110
6501
|
const request = { ...params };
|
|
6111
6502
|
const accessToken = String(request.access_token ?? '');
|
|
6112
|
-
if (!accessToken)
|
|
6503
|
+
if (!accessToken && opts.requireAccessToken === true) {
|
|
6113
6504
|
throw new StateError('connect requires non-empty access_token');
|
|
6505
|
+
}
|
|
6114
6506
|
const gateway = String(request.gateway ?? this._gatewayUrl ?? '');
|
|
6115
6507
|
if (!gateway)
|
|
6116
6508
|
throw new StateError('connect requires non-empty gateway');
|
|
6117
|
-
|
|
6509
|
+
if (accessToken)
|
|
6510
|
+
request.access_token = accessToken;
|
|
6511
|
+
else
|
|
6512
|
+
delete request.access_token;
|
|
6118
6513
|
request.gateway = gateway;
|
|
6119
6514
|
request.device_id = this._deviceId;
|
|
6120
6515
|
request.slot_id = normalizeInstanceId(request.slot_id ?? this._slotId, 'slot_id', { allowEmpty: true });
|
|
@@ -6222,7 +6617,7 @@ export class AUNClient {
|
|
|
6222
6617
|
let consecutiveFailures = 0;
|
|
6223
6618
|
const maxFailures = 2;
|
|
6224
6619
|
this._heartbeatTimer = setInterval(() => {
|
|
6225
|
-
if (this._closing || this.
|
|
6620
|
+
if (this._closing || this.state !== ConnectionState.READY)
|
|
6226
6621
|
return;
|
|
6227
6622
|
this._transport.call('meta.ping', {}).then((pong) => {
|
|
6228
6623
|
consecutiveFailures = 0;
|
|
@@ -6257,7 +6652,7 @@ export class AUNClient {
|
|
|
6257
6652
|
clearInterval(this._heartbeatTimer);
|
|
6258
6653
|
this._heartbeatTimer = null;
|
|
6259
6654
|
}
|
|
6260
|
-
if (newInterval > 0 && this.
|
|
6655
|
+
if (newInterval > 0 && this.state === ConnectionState.READY && !this._closing) {
|
|
6261
6656
|
this._startHeartbeatTask();
|
|
6262
6657
|
}
|
|
6263
6658
|
}
|
|
@@ -6276,7 +6671,7 @@ export class AUNClient {
|
|
|
6276
6671
|
if (this._closing)
|
|
6277
6672
|
return;
|
|
6278
6673
|
this._tokenRefreshTimer = null;
|
|
6279
|
-
if (this.
|
|
6674
|
+
if (this.state !== ConnectionState.READY || !this._gatewayUrl) {
|
|
6280
6675
|
scheduleNext();
|
|
6281
6676
|
return;
|
|
6282
6677
|
}
|
|
@@ -6295,12 +6690,17 @@ export class AUNClient {
|
|
|
6295
6690
|
scheduleNext();
|
|
6296
6691
|
return;
|
|
6297
6692
|
}
|
|
6298
|
-
if (this._closing || this.
|
|
6693
|
+
if (this._closing || this.state !== ConnectionState.READY || !this._gatewayUrl) {
|
|
6299
6694
|
scheduleNext();
|
|
6300
6695
|
return;
|
|
6301
6696
|
}
|
|
6302
6697
|
try {
|
|
6303
6698
|
identity = await this._auth.refreshCachedTokens(this._gatewayUrl, identity);
|
|
6699
|
+
// 刷新期间可能已断线,复检状态,避免写回 stale identity
|
|
6700
|
+
if (this.state !== ConnectionState.READY) {
|
|
6701
|
+
scheduleNext();
|
|
6702
|
+
return;
|
|
6703
|
+
}
|
|
6304
6704
|
this._identity = identity;
|
|
6305
6705
|
if (this._sessionParams !== null && identity.access_token) {
|
|
6306
6706
|
this._sessionParams.access_token = identity.access_token;
|
|
@@ -6465,7 +6865,7 @@ export class AUNClient {
|
|
|
6465
6865
|
: {};
|
|
6466
6866
|
this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
|
|
6467
6867
|
this._serverKicked = true;
|
|
6468
|
-
// 缓存最近一次 disconnect 信息,让后续 connection.state(
|
|
6868
|
+
// 缓存最近一次 disconnect 信息,让后续 connection.state(connection_failed) 也能带 detail
|
|
6469
6869
|
this._lastDisconnectInfo = { code, reason, detail };
|
|
6470
6870
|
// 透传给应用层订阅者
|
|
6471
6871
|
try {
|
|
@@ -6481,27 +6881,27 @@ export class AUNClient {
|
|
|
6481
6881
|
}
|
|
6482
6882
|
/** 传输层断线回调 */
|
|
6483
6883
|
async _handleTransportDisconnect(error, closeCode) {
|
|
6484
|
-
if (this._closing || this.
|
|
6884
|
+
if (this._closing || this.state === ConnectionState.CLOSED)
|
|
6485
6885
|
return;
|
|
6486
6886
|
// 已在重连中则跳过,避免心跳超时和 transport 断线回调重复触发
|
|
6487
6887
|
if (this._reconnectActive)
|
|
6488
6888
|
return;
|
|
6489
6889
|
this._clientLog.warn(`transport disconnected: closeCode=${closeCode ?? 'none'}, error=${error ? formatCaughtError(error) : 'none'}`);
|
|
6490
|
-
this._state = '
|
|
6890
|
+
this._state = 'standby';
|
|
6491
6891
|
this._stopBackgroundTasks();
|
|
6492
|
-
await this._dispatcher.publish('
|
|
6892
|
+
await this._dispatcher.publish('state_change', { state: this._publicState(this._state), error });
|
|
6493
6893
|
if (!this._sessionOptions.auto_reconnect)
|
|
6494
6894
|
return;
|
|
6495
6895
|
if (this._reconnectActive)
|
|
6496
6896
|
return;
|
|
6497
6897
|
// 不重连 close code(认证失败/权限错误/被踢等)或服务端通知断开:抑制重连
|
|
6498
6898
|
if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
|
|
6499
|
-
this._state = '
|
|
6899
|
+
this._state = 'connection_failed';
|
|
6500
6900
|
const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
|
|
6501
6901
|
this._clientLog.warn(`suppressing auto-reconnect: ${reason}`);
|
|
6502
6902
|
const disconnectInfo = this._lastDisconnectInfo ?? {};
|
|
6503
6903
|
const eventPayload = {
|
|
6504
|
-
state: this._state, error, reason,
|
|
6904
|
+
state: this._publicState(this._state), error, reason,
|
|
6505
6905
|
};
|
|
6506
6906
|
// 把服务端附带的结构化 detail(如配额超限信息)也带给应用层
|
|
6507
6907
|
if (disconnectInfo.detail && Object.keys(disconnectInfo.detail).length > 0) {
|
|
@@ -6510,7 +6910,7 @@ export class AUNClient {
|
|
|
6510
6910
|
if (disconnectInfo.code !== undefined && disconnectInfo.code !== null) {
|
|
6511
6911
|
eventPayload.code = disconnectInfo.code;
|
|
6512
6912
|
}
|
|
6513
|
-
await this._dispatcher.publish('
|
|
6913
|
+
await this._dispatcher.publish('state_change', eventPayload);
|
|
6514
6914
|
return;
|
|
6515
6915
|
}
|
|
6516
6916
|
// 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭
|
|
@@ -6534,30 +6934,39 @@ export class AUNClient {
|
|
|
6534
6934
|
const maxBaseDelay = clampReconnectDelayMs(Number(retry.max_delay ?? 64.0) * 1000, RECONNECT_MAX_BASE_DELAY_MS);
|
|
6535
6935
|
const maxAttemptsRaw = Number(retry.max_attempts ?? 0);
|
|
6536
6936
|
const maxAttempts = Number.isFinite(maxAttemptsRaw) && maxAttemptsRaw > 0 ? Math.floor(maxAttemptsRaw) : 0;
|
|
6937
|
+
this._retryMaxAttempts = maxAttempts;
|
|
6537
6938
|
let delay = clampReconnectDelayMs(serverInitiated ? 16_000 : Number(retry.initial_delay ?? 1.0) * 1000, serverInitiated ? 16_000 : RECONNECT_MIN_BASE_DELAY_MS, maxBaseDelay);
|
|
6538
6939
|
for (let attempt = 1; !this._reconnectAbort?.signal.aborted; attempt++) {
|
|
6539
6940
|
if (this._closing)
|
|
6540
6941
|
break;
|
|
6541
6942
|
// max_attempts 检查在循环顶部,覆盖所有路径(含 health-fail)
|
|
6542
6943
|
if (maxAttempts > 0 && attempt > maxAttempts) {
|
|
6543
|
-
this._state = '
|
|
6544
|
-
await this._dispatcher.publish('
|
|
6545
|
-
state: this._state,
|
|
6944
|
+
this._state = 'connection_failed';
|
|
6945
|
+
await this._dispatcher.publish('state_change', {
|
|
6946
|
+
state: this._publicState(this._state),
|
|
6546
6947
|
attempt: attempt - 1,
|
|
6547
6948
|
reason: 'max_attempts_exhausted',
|
|
6548
6949
|
});
|
|
6549
6950
|
break;
|
|
6550
6951
|
}
|
|
6551
|
-
this.
|
|
6552
|
-
|
|
6553
|
-
|
|
6952
|
+
this._retryAttempt = attempt;
|
|
6953
|
+
this._nextRetryAt = Date.now() + reconnectSleepDelayMs(delay, maxBaseDelay);
|
|
6954
|
+
this._state = 'retry_backoff';
|
|
6955
|
+
await this._dispatcher.publish('state_change', {
|
|
6956
|
+
state: this._publicState(this._state),
|
|
6554
6957
|
attempt,
|
|
6958
|
+
next_retry_at: this._nextRetryAt,
|
|
6555
6959
|
});
|
|
6556
6960
|
try {
|
|
6557
6961
|
// 固定上限抖动:base=[1s, max_base],delay=base+rand(0..max_base)。
|
|
6558
|
-
await this._sleep(
|
|
6962
|
+
await this._sleep(Math.max(0, this._nextRetryAt - Date.now()));
|
|
6559
6963
|
if (this._reconnectAbort?.signal.aborted || this._closing)
|
|
6560
6964
|
break;
|
|
6965
|
+
this._state = 'reconnecting';
|
|
6966
|
+
await this._dispatcher.publish('state_change', {
|
|
6967
|
+
state: this._publicState(this._state),
|
|
6968
|
+
attempt,
|
|
6969
|
+
});
|
|
6561
6970
|
// 重连前先 GET /health 探测,不健康则跳过本轮
|
|
6562
6971
|
if (this._gatewayUrl) {
|
|
6563
6972
|
const healthy = await this._discovery.checkHealth(this._gatewayUrl, 5_000);
|
|
@@ -6573,6 +6982,7 @@ export class AUNClient {
|
|
|
6573
6982
|
await this._connectOnce(this._sessionParams, true);
|
|
6574
6983
|
// 重连成功,退出循环
|
|
6575
6984
|
this._clientLog.debug(`reconnect success: attempt=${attempt}, aid=${this._aid ?? ''}`);
|
|
6985
|
+
this._nextRetryAt = null;
|
|
6576
6986
|
this._reconnectActive = false;
|
|
6577
6987
|
this._reconnectAbort = null;
|
|
6578
6988
|
return;
|
|
@@ -6583,9 +6993,9 @@ export class AUNClient {
|
|
|
6583
6993
|
attempt,
|
|
6584
6994
|
});
|
|
6585
6995
|
if (!AUNClient._shouldRetryReconnect(exc)) {
|
|
6586
|
-
this._state = '
|
|
6587
|
-
await this._dispatcher.publish('
|
|
6588
|
-
state: this._state,
|
|
6996
|
+
this._state = 'connection_failed';
|
|
6997
|
+
await this._dispatcher.publish('state_change', {
|
|
6998
|
+
state: this._publicState(this._state),
|
|
6589
6999
|
error: formatCaughtError(exc),
|
|
6590
7000
|
attempt,
|
|
6591
7001
|
});
|