@w3lib/solana-batch-call 0.1.0 → 0.2.0
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 +23 -0
- package/README.md +117 -16
- package/dist/address.d.ts +6 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +46 -7
- package/dist/address.js.map +1 -1
- package/dist/client.d.ts +115 -21
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +616 -220
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +31 -2
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +68 -8
- package/dist/layout.js.map +1 -1
- package/dist/rpc.d.ts +60 -4
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +181 -18
- package/dist/rpc.js.map +1 -1
- package/dist/shortcuts.d.ts +38 -5
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +156 -11
- package/dist/shortcuts.js.map +1 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +2 -4
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +54 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +13 -10
- package/src/address.ts +49 -7
- package/src/client.ts +783 -238
- package/src/index.ts +47 -3
- package/src/layout.ts +90 -8
- package/src/rpc.ts +218 -18
- package/src/shortcuts.ts +209 -15
- package/src/source.ts +2 -4
- package/src/types.ts +59 -3
package/src/index.ts
CHANGED
|
@@ -1,21 +1,43 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
SolanaClient,
|
|
3
|
+
exportTokenMetaCache,
|
|
4
|
+
importTokenMetaCache,
|
|
5
|
+
persistTokenMetaCache,
|
|
6
|
+
type AccountsOptions,
|
|
7
|
+
type BalanceQuery,
|
|
8
|
+
type BalancesOptions,
|
|
9
|
+
type ClientConfig,
|
|
10
|
+
type OwnerTokensOptions,
|
|
11
|
+
type PersistTokenMetaOptions,
|
|
12
|
+
type RequestOptions,
|
|
13
|
+
type SlotOptions,
|
|
14
|
+
type SolBalancesOptions,
|
|
15
|
+
type TokenMetaSnapshot,
|
|
16
|
+
type TokensOptions,
|
|
17
|
+
} from './client.js'
|
|
2
18
|
export {
|
|
3
19
|
getBalances,
|
|
20
|
+
getMultiBalances,
|
|
4
21
|
getNftOwners,
|
|
5
22
|
getNfts,
|
|
6
23
|
getOwnerNfts,
|
|
7
24
|
getOwnerTokens,
|
|
8
25
|
getSolBalances,
|
|
9
26
|
getTokens,
|
|
27
|
+
watchBalances,
|
|
10
28
|
type GetBalancesOptions,
|
|
29
|
+
type GetNftsOptions,
|
|
11
30
|
type GetOwnerTokensOptions,
|
|
31
|
+
type GetSolBalancesOptions,
|
|
12
32
|
type GetTokensOptions,
|
|
33
|
+
type WatchBalancesOptions,
|
|
13
34
|
type ShortcutOptions,
|
|
14
35
|
} from './shortcuts.js'
|
|
15
36
|
export {
|
|
16
37
|
DEFAULT_TOKEN_FIELDS,
|
|
17
38
|
type Commitment,
|
|
18
39
|
type DefaultTokenField,
|
|
40
|
+
type FailureReason,
|
|
19
41
|
type NftDetails,
|
|
20
42
|
type NftOwner,
|
|
21
43
|
type OwnedToken,
|
|
@@ -23,7 +45,11 @@ export {
|
|
|
23
45
|
type TokenBalance,
|
|
24
46
|
type TokenDetails,
|
|
25
47
|
type TokenField,
|
|
48
|
+
type TokenProgramName,
|
|
49
|
+
type TokenProgramOption,
|
|
26
50
|
type TokenStandard,
|
|
51
|
+
type TransferFee,
|
|
52
|
+
type TransferFeeConfig,
|
|
27
53
|
} from './types.js'
|
|
28
54
|
export {
|
|
29
55
|
ASSOCIATED_TOKEN_PROGRAM_ID,
|
|
@@ -38,17 +64,35 @@ export {
|
|
|
38
64
|
clusterOfGenesis,
|
|
39
65
|
type Cluster,
|
|
40
66
|
} from './constants.js'
|
|
41
|
-
export { decodeAddress, encodeAddress, findProgramAddress, getAssociatedTokenAddress, getMetadataAddress, isAddress } from './address.js'
|
|
67
|
+
export { decodeAddress, encodeAddress, findProgramAddress, getAssociatedTokenAddress, getMetadataAddress, isAddress, isOnCurve } from './address.js'
|
|
42
68
|
export {
|
|
43
69
|
formatAmount,
|
|
70
|
+
formatUnits,
|
|
44
71
|
parseMetaplexMetadata,
|
|
45
72
|
parseMint,
|
|
46
73
|
parseTokenAccount,
|
|
74
|
+
parseTransferFeeConfig,
|
|
47
75
|
type AccountInfo,
|
|
48
76
|
type MetaplexMetadata,
|
|
49
77
|
type MintInfo,
|
|
50
78
|
type TokenAccountInfo,
|
|
51
79
|
type TokenMetadata,
|
|
80
|
+
type TransferFeeConfigInfo,
|
|
81
|
+
type TransferFeeInfo,
|
|
52
82
|
} from './layout.js'
|
|
53
|
-
export {
|
|
83
|
+
export {
|
|
84
|
+
AllNodesFailedError,
|
|
85
|
+
FallbackRpc,
|
|
86
|
+
HttpError,
|
|
87
|
+
HttpRpc,
|
|
88
|
+
MIN_CONTEXT_SLOT_NOT_REACHED,
|
|
89
|
+
RpcError,
|
|
90
|
+
isNodeFault,
|
|
91
|
+
nodeLabel,
|
|
92
|
+
onRequest,
|
|
93
|
+
type FallbackOptions,
|
|
94
|
+
type HttpRpcOptions,
|
|
95
|
+
type RequestEvent,
|
|
96
|
+
type RpcTransport,
|
|
97
|
+
} from './rpc.js'
|
|
54
98
|
export { NetworkCheckedRpc, NetworkMismatchError, type RpcSource, type SourceOptions } from './source.js'
|
package/src/layout.ts
CHANGED
|
@@ -107,6 +107,7 @@ const ACCOUNT_TYPE_OFFSET = 165
|
|
|
107
107
|
const EXTENSIONS_OFFSET = 166
|
|
108
108
|
const ACCOUNT_TYPE_MINT = 1
|
|
109
109
|
const ACCOUNT_TYPE_ACCOUNT = 2
|
|
110
|
+
const EXTENSION_TRANSFER_FEE_CONFIG = 1
|
|
110
111
|
const EXTENSION_TOKEN_METADATA = 19
|
|
111
112
|
|
|
112
113
|
/** 解析 Mint 账户(SPL Token / Token-2022 通用,Token-2022 额外解析 TokenMetadata 扩展) */
|
|
@@ -133,7 +134,11 @@ export function parseMint(data: Uint8Array): MintInfo {
|
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
|
|
137
|
+
/** 在 Token-2022 账户的扩展(TLV:u16 类型 + u16 长度 + 数据)里找指定类型,返回其数据;没有或数据不完整时为 null */
|
|
138
|
+
function findExtension(data: Uint8Array, extensionType: number): Uint8Array | null {
|
|
139
|
+
if (data.length <= EXTENSIONS_OFFSET) {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
137
142
|
try {
|
|
138
143
|
const r = new Reader(data, EXTENSIONS_OFFSET)
|
|
139
144
|
while (r.offset + 4 <= data.length) {
|
|
@@ -142,11 +147,11 @@ function parseTokenMetadataExtension(data: Uint8Array): TokenMetadata | null {
|
|
|
142
147
|
if (type === 0 && length === 0) {
|
|
143
148
|
return null // 未初始化的填充
|
|
144
149
|
}
|
|
145
|
-
if (type ===
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return
|
|
150
|
+
if (type === extensionType) {
|
|
151
|
+
if (r.offset + length > data.length) {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
return data.slice(r.offset, r.offset + length)
|
|
150
155
|
}
|
|
151
156
|
r.skip(length)
|
|
152
157
|
}
|
|
@@ -156,6 +161,77 @@ function parseTokenMetadataExtension(data: Uint8Array): TokenMetadata | null {
|
|
|
156
161
|
return null
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
function parseTokenMetadataExtension(data: Uint8Array): TokenMetadata | null {
|
|
165
|
+
const ext = findExtension(data, EXTENSION_TOKEN_METADATA)
|
|
166
|
+
if (!ext) {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const m = new Reader(ext)
|
|
171
|
+
m.pubkey() // updateAuthority
|
|
172
|
+
m.pubkey() // mint
|
|
173
|
+
return { name: m.string(), symbol: m.string(), uri: m.string() }
|
|
174
|
+
} catch {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Token-2022 转账手续费的一套配置:从 epoch 开始生效,费率 basisPoints(万分比),单笔最多 maximumFee(最小单位) */
|
|
180
|
+
export interface TransferFeeInfo {
|
|
181
|
+
epoch: bigint
|
|
182
|
+
maximumFee: bigint
|
|
183
|
+
basisPoints: number
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Token-2022 TransferFeeConfig 扩展 */
|
|
187
|
+
export interface TransferFeeConfigInfo {
|
|
188
|
+
/** 可以修改手续费的地址;null 表示不能再改 */
|
|
189
|
+
transferFeeConfigAuthority: string | null
|
|
190
|
+
withdrawWithheldAuthority: string | null
|
|
191
|
+
/** mint 上暂存的、已收取未提取的手续费 */
|
|
192
|
+
withheldAmount: bigint
|
|
193
|
+
/** 旧配置:当前 epoch < newer.epoch 时生效 */
|
|
194
|
+
older: TransferFeeInfo
|
|
195
|
+
/** 新配置:从 newer.epoch 起生效 */
|
|
196
|
+
newer: TransferFeeInfo
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const ZERO_PUBKEY = '11111111111111111111111111111111'
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 解析 mint 账户里的 Token-2022 TransferFeeConfig 扩展(类型 1,108 字节);
|
|
203
|
+
* SPL Token、没有这个扩展或数据不完整时为 null。
|
|
204
|
+
* 当前生效的是 `epoch >= newer.epoch ? newer : older`
|
|
205
|
+
*/
|
|
206
|
+
export function parseTransferFeeConfig(data: Uint8Array): TransferFeeConfigInfo | null {
|
|
207
|
+
if (!(data.length > ACCOUNT_TYPE_OFFSET && data[ACCOUNT_TYPE_OFFSET] === ACCOUNT_TYPE_MINT)) {
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
const ext = findExtension(data, EXTENSION_TRANSFER_FEE_CONFIG)
|
|
211
|
+
if (!ext) {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const r = new Reader(ext)
|
|
216
|
+
// OptionalNonZeroPubkey:全 0 表示没有
|
|
217
|
+
const configAuthority = r.pubkey()
|
|
218
|
+
const withdrawAuthority = r.pubkey()
|
|
219
|
+
const withheldAmount = r.u64()
|
|
220
|
+
const fee = (): TransferFeeInfo => ({ epoch: r.u64(), maximumFee: r.u64(), basisPoints: r.u16() })
|
|
221
|
+
const older = fee()
|
|
222
|
+
const newer = fee()
|
|
223
|
+
return {
|
|
224
|
+
transferFeeConfigAuthority: configAuthority === ZERO_PUBKEY ? null : configAuthority,
|
|
225
|
+
withdrawWithheldAuthority: withdrawAuthority === ZERO_PUBKEY ? null : withdrawAuthority,
|
|
226
|
+
withheldAmount,
|
|
227
|
+
older,
|
|
228
|
+
newer,
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
159
235
|
export interface TokenAccountInfo {
|
|
160
236
|
mint: string
|
|
161
237
|
owner: string
|
|
@@ -228,11 +304,17 @@ export function parseMetaplexMetadata(data: Uint8Array): MetaplexMetadata {
|
|
|
228
304
|
return result
|
|
229
305
|
}
|
|
230
306
|
|
|
231
|
-
/**
|
|
232
|
-
export function
|
|
307
|
+
/** 按精度格式化(与 ethers 的 formatUnits 同名同义):formatUnits(1_500_000_000n, 9) === '1.5';不四舍五入,整数不带小数点 */
|
|
308
|
+
export function formatUnits(value: bigint, decimals: number): string {
|
|
233
309
|
const negative = value < 0n
|
|
234
310
|
const digits = (negative ? -value : value).toString().padStart(decimals + 1, '0')
|
|
235
311
|
const whole = digits.slice(0, digits.length - decimals)
|
|
236
312
|
const fraction = digits.slice(digits.length - decimals).replace(/0+$/, '')
|
|
237
313
|
return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`
|
|
238
314
|
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 同 formatUnits。
|
|
318
|
+
* @deprecated 改用 formatUnits:formatAmount 这个名字容易和项目里的数字展示函数撞名
|
|
319
|
+
*/
|
|
320
|
+
export const formatAmount = formatUnits
|
package/src/rpc.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** 最小的 JSON-RPC 传输接口:可以传入自己的实现(如包一层 @solana/kit 的 rpc) */
|
|
2
2
|
export interface RpcTransport {
|
|
3
3
|
request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T>
|
|
4
|
+
/** 节点名称,用于 AllNodesFailedError / onRequest 里标识节点;不传时自定义传输显示为 'custom' */
|
|
5
|
+
readonly label?: string
|
|
4
6
|
}
|
|
5
7
|
|
|
6
8
|
/** 节点返回的 JSON-RPC 错误 */
|
|
@@ -19,6 +21,9 @@ export class RpcError extends Error {
|
|
|
19
21
|
code === 403 ||
|
|
20
22
|
code === 429 ||
|
|
21
23
|
code === -32005 ||
|
|
24
|
+
// 节点高度没到 minContextSlot:换节点可能成功
|
|
25
|
+
code === MIN_CONTEXT_SLOT_NOT_REACHED ||
|
|
26
|
+
/minimum context slot/i.test(message) ||
|
|
22
27
|
/forbidden|blocked|personal token|api.?key|rate.?limit|too many|not allowed|unauthori[sz]ed|limit exceeded|disabled|not available|not supported/i.test(message)
|
|
23
28
|
}
|
|
24
29
|
}
|
|
@@ -26,15 +31,52 @@ export class RpcError extends Error {
|
|
|
26
31
|
/** HTTP 层错误(非 2xx、网络错误、超时) */
|
|
27
32
|
export class HttpError extends Error {
|
|
28
33
|
readonly status: number | undefined
|
|
34
|
+
/** 请求超时(HttpRpcOptions.timeout) */
|
|
35
|
+
readonly timeout: boolean
|
|
29
36
|
|
|
30
|
-
constructor(message: string, status?: number) {
|
|
37
|
+
constructor(message: string, status?: number, timeout = false) {
|
|
31
38
|
super(message)
|
|
32
39
|
this.name = 'HttpError'
|
|
33
40
|
this.status = status
|
|
41
|
+
this.timeout = timeout
|
|
34
42
|
}
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
/**
|
|
45
|
+
/** JSON-RPC 错误码:节点高度还没到请求的 minContextSlot */
|
|
46
|
+
export const MIN_CONTEXT_SLOT_NOT_REACHED = -32016
|
|
47
|
+
|
|
48
|
+
/** 是否是请求超时(HttpRpc 的超时,或自定义传输抛出的 TimeoutError) */
|
|
49
|
+
export function isTimeout(err: unknown): boolean {
|
|
50
|
+
return (err instanceof HttpError && err.timeout) || (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 是否是“节点高度没到 minContextSlot”(所有节点都落后时也算) */
|
|
54
|
+
export function isBehind(err: unknown): boolean {
|
|
55
|
+
if (err instanceof AllNodesFailedError) {
|
|
56
|
+
return err.errors.length > 0 && err.errors.every((e) => isBehind(e.error))
|
|
57
|
+
}
|
|
58
|
+
return err instanceof RpcError && (err.code === MIN_CONTEXT_SLOT_NOT_REACHED || /minimum context slot/i.test(err.message))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 多节点时所有节点都失败(都是节点问题)。errors 是各节点的错误,按尝试顺序;
|
|
63
|
+
* node 是节点名称(URL 只保留 host,不会带出 path / query 里的 API Key)
|
|
64
|
+
*/
|
|
65
|
+
export class AllNodesFailedError extends Error {
|
|
66
|
+
readonly errors: ReadonlyArray<{ node: string; error: unknown }>
|
|
67
|
+
|
|
68
|
+
constructor(errors: ReadonlyArray<{ node: string; error: unknown }>) {
|
|
69
|
+
super(`All RPC nodes failed: ${errors.map((e) => `${e.node}: ${errorMessage(e.error)}`).join(' | ')}`, { cause: errors[errors.length - 1]?.error })
|
|
70
|
+
this.name = 'AllNodesFailedError'
|
|
71
|
+
this.errors = errors
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function errorMessage(err: unknown): string {
|
|
76
|
+
return err instanceof Error ? err.message : String(err)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 是否是节点问题(应换节点重试):除了确定性的 JSON-RPC 错误(如参数错误),其余都算(包括 AllNodesFailedError) */
|
|
38
80
|
export function isNodeFault(err: unknown): boolean {
|
|
39
81
|
if (err instanceof RpcError) {
|
|
40
82
|
return err.nodeFault
|
|
@@ -86,6 +128,8 @@ function isBatchLimitError(message: string | undefined): boolean {
|
|
|
86
128
|
*/
|
|
87
129
|
export class HttpRpc implements RpcTransport {
|
|
88
130
|
readonly url: string
|
|
131
|
+
/** 报错信息里用的地址:只保留 origin,不带 path / query(常含 API Key) */
|
|
132
|
+
readonly #safeUrl: string
|
|
89
133
|
readonly #timeout: number
|
|
90
134
|
readonly #headers: Record<string, string>
|
|
91
135
|
readonly #batchWait: number
|
|
@@ -98,6 +142,7 @@ export class HttpRpc implements RpcTransport {
|
|
|
98
142
|
|
|
99
143
|
constructor(url: string, options: HttpRpcOptions = {}) {
|
|
100
144
|
this.url = url
|
|
145
|
+
this.#safeUrl = safeOrigin(url)
|
|
101
146
|
this.#timeout = options.timeout ?? 10_000
|
|
102
147
|
this.#headers = { 'content-type': 'application/json', ...options.headers }
|
|
103
148
|
this.#maxBatchSize = Math.max(1, options.maxBatchSize ?? 20)
|
|
@@ -234,7 +279,9 @@ export class HttpRpc implements RpcTransport {
|
|
|
234
279
|
signal: this.#timeout > 0 && Number.isFinite(this.#timeout) ? AbortSignal.timeout(this.#timeout) : undefined,
|
|
235
280
|
})
|
|
236
281
|
} catch (err) {
|
|
237
|
-
|
|
282
|
+
// AbortSignal.timeout 触发时是 TimeoutError(DOMException)
|
|
283
|
+
const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
284
|
+
throw new HttpError(`Request to ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, undefined, timeout)
|
|
238
285
|
}
|
|
239
286
|
if (response.status === 429 && attempt < this.#retries) {
|
|
240
287
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
|
|
@@ -245,11 +292,11 @@ export class HttpRpc implements RpcTransport {
|
|
|
245
292
|
try {
|
|
246
293
|
body = parseJson(text)
|
|
247
294
|
} catch {
|
|
248
|
-
throw new HttpError(`Invalid JSON from ${this
|
|
295
|
+
throw new HttpError(`Invalid JSON from ${this.#safeUrl} (HTTP ${response.status})`, response.status)
|
|
249
296
|
}
|
|
250
297
|
// 非 2xx 但返回了 JSON-RPC 结构(如 403 Access forbidden、drpc 的 500 + 批量错误数组):交给上层解析
|
|
251
298
|
if (!response.ok && !Array.isArray(body) && !(body as RpcResponse)?.error) {
|
|
252
|
-
throw new HttpError(`HTTP ${response.status} from ${this
|
|
299
|
+
throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}`, response.status)
|
|
253
300
|
}
|
|
254
301
|
// 有的节点限频时返回 HTTP 200 + JSON-RPC 错误码 429:同样退避重试
|
|
255
302
|
if (attempt < this.#retries && isRateLimited(body)) {
|
|
@@ -282,6 +329,14 @@ function parseJson(text: string): unknown {
|
|
|
282
329
|
return JSON.parse(text.replace(/([:[,]\s*)(-?\d{16,})(?=\s*[,}\]])/g, '$1"$2"'))
|
|
283
330
|
}
|
|
284
331
|
|
|
332
|
+
function safeOrigin(url: string): string {
|
|
333
|
+
try {
|
|
334
|
+
return new URL(url).origin
|
|
335
|
+
} catch {
|
|
336
|
+
return 'invalid URL'
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
285
340
|
function isRateLimited(body: unknown): boolean {
|
|
286
341
|
const responses = (Array.isArray(body) ? body : [body]) as RpcResponse[]
|
|
287
342
|
return responses.length > 0 && responses.every((res) => res?.error?.code === 429)
|
|
@@ -297,19 +352,143 @@ function settle(item: Pending, response: RpcResponse | undefined): void {
|
|
|
297
352
|
}
|
|
298
353
|
}
|
|
299
354
|
|
|
355
|
+
/**
|
|
356
|
+
* onRequest 的事件:每个 JSON-RPC 调用对每个节点的每次尝试一条(合并成批量 HTTP 请求时,批里每个调用各一条,
|
|
357
|
+
* ms 是该调用从发出到返回的时间;HttpRpc 内部的 429 退避重试算在同一条里)
|
|
358
|
+
*/
|
|
359
|
+
export interface RequestEvent {
|
|
360
|
+
/** 节点名称:URL 的 host,自定义传输的 label(没有时为 'custom') */
|
|
361
|
+
node: string
|
|
362
|
+
method: string
|
|
363
|
+
ms: number
|
|
364
|
+
ok: boolean
|
|
365
|
+
/** 失败时的错误 */
|
|
366
|
+
error?: unknown
|
|
367
|
+
/** 本次调用按故障切换顺序尝试的第几个节点(0 起);大于 0 说明换过节点 */
|
|
368
|
+
attempt: number
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const requestListeners = new Set<(event: RequestEvent) => void>()
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 监听所有客户端发出的 JSON-RPC 调用(节点、方法、耗时、成功与否、第几次尝试),用于统计和排查。返回取消监听的函数。
|
|
375
|
+
*
|
|
376
|
+
* ```ts
|
|
377
|
+
* const stop = onRequest((e) => console.log(e.node, e.method, e.ms, e.ok, e.attempt))
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
export function onRequest(listener: (event: RequestEvent) => void): () => void {
|
|
381
|
+
requestListeners.add(listener)
|
|
382
|
+
return () => {
|
|
383
|
+
requestListeners.delete(listener)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function emit(event: RequestEvent): void {
|
|
388
|
+
for (const listener of requestListeners) {
|
|
389
|
+
try {
|
|
390
|
+
listener(event)
|
|
391
|
+
} catch {
|
|
392
|
+
// 监听函数出错不影响请求
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 节点名称:优先用传输自带的 label;HttpRpc 取 URL 的 host(不带 path / query,避免泄露 API Key) */
|
|
398
|
+
export function nodeLabel(node: RpcTransport): string {
|
|
399
|
+
if (typeof node.label === 'string') {
|
|
400
|
+
return node.label
|
|
401
|
+
}
|
|
402
|
+
const base = unwrap(node)
|
|
403
|
+
if (base !== node && typeof base.label === 'string') {
|
|
404
|
+
return base.label
|
|
405
|
+
}
|
|
406
|
+
if (base instanceof HttpRpc) {
|
|
407
|
+
try {
|
|
408
|
+
return new URL(base.url).host || 'custom'
|
|
409
|
+
} catch {
|
|
410
|
+
return 'custom'
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return 'custom'
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 去掉校验层等包装(有 inner 属性的传输),取底层节点 */
|
|
417
|
+
function unwrap(node: RpcTransport): RpcTransport {
|
|
418
|
+
let current = node
|
|
419
|
+
for (let inner = (current as { inner?: RpcTransport }).inner; inner && typeof inner.request === 'function'; inner = (current as { inner?: RpcTransport }).inner) {
|
|
420
|
+
current = inner
|
|
421
|
+
}
|
|
422
|
+
return current
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
interface NodeHealth {
|
|
426
|
+
/** 上次出错(进入冷却)的时间,0 表示健康 */
|
|
427
|
+
failedAt: number
|
|
428
|
+
/** 连续超时次数 */
|
|
429
|
+
timeouts: number
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 节点健康状态按节点共享(模块级):客户端重建后,刚出过错的节点仍排在后面。URL 节点按完整 URL,其他按对象
|
|
433
|
+
const healthByUrl = new Map<string, NodeHealth>()
|
|
434
|
+
let healthByObject = new WeakMap<object, NodeHealth>()
|
|
435
|
+
const MAX_TRACKED_URLS = 1000
|
|
436
|
+
/** 连续超时这么多次才冷却(单次超时多半只是这次请求慢) */
|
|
437
|
+
const TIMEOUTS_BEFORE_COOLDOWN = 3
|
|
438
|
+
|
|
439
|
+
/** 健康状态的 key:HttpRpc 按完整 URL(不同连接对象共享),其他节点按对象(去掉校验层) */
|
|
440
|
+
function healthKeyOf(node: RpcTransport): string | object {
|
|
441
|
+
const base = unwrap(node)
|
|
442
|
+
return base instanceof HttpRpc ? base.url : base
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 每次用时按 key 从全局表里取:表淘汰条目后,各实例仍取到同一个对象 */
|
|
446
|
+
function healthOf(key: string | object): NodeHealth {
|
|
447
|
+
if (typeof key === 'string') {
|
|
448
|
+
let health = healthByUrl.get(key)
|
|
449
|
+
if (!health) {
|
|
450
|
+
health = { failedAt: 0, timeouts: 0 }
|
|
451
|
+
healthByUrl.set(key, health)
|
|
452
|
+
if (healthByUrl.size > MAX_TRACKED_URLS) {
|
|
453
|
+
healthByUrl.delete(healthByUrl.keys().next().value as string)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return health
|
|
457
|
+
}
|
|
458
|
+
let health = healthByObject.get(key)
|
|
459
|
+
if (!health) {
|
|
460
|
+
health = { failedAt: 0, timeouts: 0 }
|
|
461
|
+
healthByObject.set(key, health)
|
|
462
|
+
}
|
|
463
|
+
return health
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** 测试用:清空节点健康状态 */
|
|
467
|
+
export function resetNodeHealth(): void {
|
|
468
|
+
healthByUrl.clear()
|
|
469
|
+
healthByObject = new WeakMap()
|
|
470
|
+
}
|
|
471
|
+
|
|
300
472
|
export interface FallbackOptions {
|
|
301
473
|
/** 出错节点的冷却时间(毫秒),期间排到最后。默认 30000 */
|
|
302
474
|
cooldown?: number
|
|
303
475
|
}
|
|
304
476
|
|
|
305
477
|
/**
|
|
306
|
-
* 多节点故障切换:按顺序使用,节点问题(网络、超时、限频、需要 Key、403
|
|
307
|
-
*
|
|
478
|
+
* 多节点故障切换:按顺序使用,节点问题(网络、超时、限频、需要 Key、403、高度不够等)换下一个;
|
|
479
|
+
* 参数错误等确定性错误直接抛出。单个节点也用它包一层(统一 onRequest 事件),此时失败抛原始错误。
|
|
480
|
+
*
|
|
481
|
+
* - 出错的节点在冷却期内排到最后;健康状态按节点全局共享(URL 相同即同一节点),客户端重建后仍然有效
|
|
482
|
+
* - 超时只换节点、不冷却(大批量查询慢一点很正常),同一节点连续 3 次超时才冷却
|
|
483
|
+
* - 节点高度没到 minContextSlot 只换节点、不冷却(只是这次请求要求的 slot 太新)
|
|
484
|
+
* - 多个节点都失败时抛 AllNodesFailedError,带上每个节点的错误
|
|
308
485
|
*/
|
|
309
486
|
export class FallbackRpc implements RpcTransport {
|
|
310
487
|
readonly nodes: readonly RpcTransport[]
|
|
311
488
|
readonly #cooldown: number
|
|
312
|
-
|
|
489
|
+
/** 节点标识和健康状态的 key 不会变,构造时算好(每次请求只做一次 Map 查找) */
|
|
490
|
+
readonly #labels: readonly string[]
|
|
491
|
+
readonly #healthKeys: ReadonlyArray<string | object>
|
|
313
492
|
|
|
314
493
|
constructor(nodes: readonly RpcTransport[], options: FallbackOptions = {}) {
|
|
315
494
|
if (!nodes.length) {
|
|
@@ -317,33 +496,54 @@ export class FallbackRpc implements RpcTransport {
|
|
|
317
496
|
}
|
|
318
497
|
this.nodes = nodes
|
|
319
498
|
this.#cooldown = options.cooldown ?? 30_000
|
|
320
|
-
this.#
|
|
499
|
+
this.#labels = nodes.map(nodeLabel)
|
|
500
|
+
this.#healthKeys = nodes.map(healthKeyOf)
|
|
321
501
|
}
|
|
322
502
|
|
|
323
503
|
async request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T> {
|
|
324
|
-
|
|
325
|
-
|
|
504
|
+
const errors: Array<{ node: string; error: unknown }> = []
|
|
505
|
+
const order = this.#order()
|
|
506
|
+
for (let attempt = 0; attempt < order.length; attempt++) {
|
|
507
|
+
const index = order[attempt] as number
|
|
508
|
+
const node = this.nodes[index] as RpcTransport
|
|
509
|
+
const label = this.#labels[index] as string
|
|
510
|
+
const health = healthOf(this.#healthKeys[index] as string | object)
|
|
511
|
+
const started = Date.now()
|
|
326
512
|
try {
|
|
327
|
-
const result = await
|
|
328
|
-
|
|
513
|
+
const result = await node.request<T>(method, params)
|
|
514
|
+
health.failedAt = 0
|
|
515
|
+
health.timeouts = 0
|
|
516
|
+
emit({ node: label, method, ms: Date.now() - started, ok: true, attempt })
|
|
329
517
|
return result
|
|
330
518
|
} catch (err) {
|
|
519
|
+
emit({ node: label, method, ms: Date.now() - started, ok: false, error: err, attempt })
|
|
331
520
|
if (!isNodeFault(err)) {
|
|
332
521
|
throw err
|
|
333
522
|
}
|
|
334
|
-
|
|
335
|
-
|
|
523
|
+
if (isTimeout(err)) {
|
|
524
|
+
if (++health.timeouts >= TIMEOUTS_BEFORE_COOLDOWN) {
|
|
525
|
+
health.failedAt = Date.now()
|
|
526
|
+
health.timeouts = 0
|
|
527
|
+
}
|
|
528
|
+
} else if (!isBehind(err)) {
|
|
529
|
+
health.failedAt = Date.now()
|
|
530
|
+
}
|
|
531
|
+
errors.push({ node: label, error: err })
|
|
336
532
|
}
|
|
337
533
|
}
|
|
338
|
-
|
|
534
|
+
if (this.nodes.length === 1) {
|
|
535
|
+
throw errors[0]?.error
|
|
536
|
+
}
|
|
537
|
+
throw new AllNodesFailedError(errors)
|
|
339
538
|
}
|
|
340
539
|
|
|
341
540
|
#order(): number[] {
|
|
342
541
|
const now = Date.now()
|
|
542
|
+
const failedAt = this.#healthKeys.map((key) => healthOf(key).failedAt)
|
|
343
543
|
const healthy: number[] = []
|
|
344
544
|
const cooling: number[] = []
|
|
345
|
-
|
|
346
|
-
cooling.sort((a, b) => (
|
|
545
|
+
failedAt.forEach((at, i) => (at && now - at < this.#cooldown ? cooling : healthy).push(i))
|
|
546
|
+
cooling.sort((a, b) => (failedAt[a] as number) - (failedAt[b] as number))
|
|
347
547
|
return [...healthy, ...cooling]
|
|
348
548
|
}
|
|
349
549
|
}
|