@w3lib/solana-batch-call 0.2.0 → 0.2.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 +18 -0
- package/README.md +3 -2
- package/dist/address.d.ts +1 -1
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +10 -1
- package/dist/address.js.map +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +191 -78
- package/dist/client.js.map +1 -1
- package/dist/rpc.d.ts +5 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +139 -24
- package/dist/rpc.js.map +1 -1
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +13 -1
- package/dist/shortcuts.js.map +1 -1
- package/package.json +1 -1
- package/src/address.ts +11 -1
- package/src/client.ts +239 -80
- package/src/rpc.ts +150 -24
- package/src/shortcuts.ts +14 -1
package/src/rpc.ts
CHANGED
|
@@ -24,10 +24,14 @@ export class RpcError extends Error {
|
|
|
24
24
|
// 节点高度没到 minContextSlot:换节点可能成功
|
|
25
25
|
code === MIN_CONTEXT_SLOT_NOT_REACHED ||
|
|
26
26
|
/minimum context slot/i.test(message) ||
|
|
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)
|
|
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) &&
|
|
28
|
+
!TOO_MANY_ACCOUNTS.test(message))
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** 节点限制单次 getMultipleAccounts 的账户数(如 “Too many accounts requested”):缩小每次的账户数后重试,不是节点故障 */
|
|
33
|
+
const TOO_MANY_ACCOUNTS = /too many accounts/i
|
|
34
|
+
|
|
31
35
|
/** HTTP 层错误(非 2xx、网络错误、超时) */
|
|
32
36
|
export class HttpError extends Error {
|
|
33
37
|
readonly status: number | undefined
|
|
@@ -118,6 +122,9 @@ let nextId = 1
|
|
|
118
122
|
/** 批量请求被拒绝、可以降级为逐条请求的 HTTP 状态 */
|
|
119
123
|
const BATCH_REJECTED_STATUS = new Set([400, 404, 405, 415, 422])
|
|
120
124
|
|
|
125
|
+
/** 节点不支持批量时,多久后重新尝试批量 */
|
|
126
|
+
const BATCH_RETRY_AFTER = 10 * 60 * 1000
|
|
127
|
+
|
|
121
128
|
function isBatchLimitError(message: string | undefined): boolean {
|
|
122
129
|
return !!message && /batch/i.test(message) && /more than \d+|too (large|many|big)|not (allowed|supported)|exceed/i.test(message)
|
|
123
130
|
}
|
|
@@ -136,7 +143,14 @@ export class HttpRpc implements RpcTransport {
|
|
|
136
143
|
readonly #retries: number
|
|
137
144
|
readonly #fetch: typeof fetch
|
|
138
145
|
#maxBatchSize: number
|
|
139
|
-
|
|
146
|
+
/** 节点对单个方法在一个批量里的数量限制(如 publicnode:getMultipleAccounts 最多 1 个),从报错里学到 */
|
|
147
|
+
readonly #methodLimits = new Map<string, number>()
|
|
148
|
+
/** options.batch:调用方是否允许批量 */
|
|
149
|
+
readonly #batchOption: boolean
|
|
150
|
+
/** 节点明确表示不支持批量时,在这之前逐条请求(之后重新尝试批量) */
|
|
151
|
+
#batchDisabledUntil = 0
|
|
152
|
+
/** 节点限制单次 getMultipleAccounts 的账户数时学到的上限 */
|
|
153
|
+
#maxAccounts: number | undefined
|
|
140
154
|
#queue: Pending[] = []
|
|
141
155
|
#timer: ReturnType<typeof setTimeout> | null = null
|
|
142
156
|
|
|
@@ -146,13 +160,59 @@ export class HttpRpc implements RpcTransport {
|
|
|
146
160
|
this.#timeout = options.timeout ?? 10_000
|
|
147
161
|
this.#headers = { 'content-type': 'application/json', ...options.headers }
|
|
148
162
|
this.#maxBatchSize = Math.max(1, options.maxBatchSize ?? 20)
|
|
149
|
-
this.#
|
|
163
|
+
this.#batchOption = options.batch ?? true
|
|
150
164
|
this.#batchWait = options.batchWait ?? 0
|
|
151
165
|
this.#retries = Math.max(0, options.retries ?? 2)
|
|
152
166
|
this.#fetch = options.fetch ?? ((...args) => fetch(...args))
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
request<T = unknown>(method: string, params: readonly unknown[] = []): Promise<T> {
|
|
170
|
+
if (method === 'getMultipleAccounts' && Array.isArray(params[0])) {
|
|
171
|
+
return this.#getMultipleAccounts(params) as Promise<T>
|
|
172
|
+
}
|
|
173
|
+
return this.#enqueue<T>(method, params)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* getMultipleAccounts:节点限制单次账户数时(“Too many accounts requested”),减半后分多次请求、合并结果,
|
|
178
|
+
* 并记下上限,之后直接按上限拆分。合并后 context.slot 取最小值
|
|
179
|
+
*/
|
|
180
|
+
async #getMultipleAccounts(params: readonly unknown[]): Promise<unknown> {
|
|
181
|
+
const [addresses, ...rest] = params as [unknown[], ...unknown[]]
|
|
182
|
+
const max = this.#maxAccounts
|
|
183
|
+
if (max !== undefined && addresses.length > max) {
|
|
184
|
+
const parts: unknown[][] = []
|
|
185
|
+
for (let i = 0; i < addresses.length; i += max) {
|
|
186
|
+
parts.push(addresses.slice(i, i + max))
|
|
187
|
+
}
|
|
188
|
+
const results = (await Promise.all(parts.map((part) => this.#getMultipleAccounts([part, ...rest])))) as Array<{
|
|
189
|
+
context?: { slot?: number }
|
|
190
|
+
value?: unknown[]
|
|
191
|
+
}>
|
|
192
|
+
const slots = results.map((r) => r?.context?.slot).filter((slot): slot is number => typeof slot === 'number')
|
|
193
|
+
return {
|
|
194
|
+
...results[0],
|
|
195
|
+
context: { ...results[0]?.context, ...(slots.length ? { slot: Math.min(...slots) } : {}) },
|
|
196
|
+
value: results.flatMap((r) => r?.value ?? []),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return await this.#enqueue('getMultipleAccounts', params)
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof RpcError && TOO_MANY_ACCOUNTS.test(err.message) && addresses.length > 1) {
|
|
203
|
+
this.#maxAccounts = Math.max(1, Math.min(this.#maxAccounts ?? Number.POSITIVE_INFINITY, Math.floor(addresses.length / 2)))
|
|
204
|
+
return this.#getMultipleAccounts(params)
|
|
205
|
+
}
|
|
206
|
+
throw err
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 是否合并成批量请求:调用方允许,且不在 “节点不支持批量” 的冷却期内 */
|
|
211
|
+
get #batch(): boolean {
|
|
212
|
+
return this.#batchOption && Date.now() >= this.#batchDisabledUntil
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#enqueue<T>(method: string, params: readonly unknown[]): Promise<T> {
|
|
156
216
|
return new Promise<T>((resolve, reject) => {
|
|
157
217
|
this.#queue.push({ method, params, resolve: resolve as (value: unknown) => void, reject })
|
|
158
218
|
if (!this.#batch || this.#queue.length >= this.#maxBatchSize) {
|
|
@@ -170,11 +230,49 @@ export class HttpRpc implements RpcTransport {
|
|
|
170
230
|
}
|
|
171
231
|
const queue = this.#queue
|
|
172
232
|
this.#queue = []
|
|
173
|
-
for (
|
|
174
|
-
void this.#dispatch(
|
|
233
|
+
for (const chunk of this.#split(queue)) {
|
|
234
|
+
void this.#dispatch(chunk)
|
|
175
235
|
}
|
|
176
236
|
}
|
|
177
237
|
|
|
238
|
+
/** 按 maxBatchSize 和各方法的数量限制分批(保持顺序;不受限的方法照常合并) */
|
|
239
|
+
#split(items: readonly Pending[]): Pending[][] {
|
|
240
|
+
const chunks: Pending[][] = []
|
|
241
|
+
let current: Pending[] = []
|
|
242
|
+
let counts = new Map<string, number>()
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
const limit = this.#methodLimits.get(item.method)
|
|
245
|
+
const count = counts.get(item.method) ?? 0
|
|
246
|
+
if (current.length >= this.#maxBatchSize || (limit !== undefined && count >= limit)) {
|
|
247
|
+
chunks.push(current)
|
|
248
|
+
current = []
|
|
249
|
+
counts = new Map()
|
|
250
|
+
}
|
|
251
|
+
current.push(item)
|
|
252
|
+
counts.set(item.method, (counts.get(item.method) ?? 0) + 1)
|
|
253
|
+
}
|
|
254
|
+
if (current.length) {
|
|
255
|
+
chunks.push(current)
|
|
256
|
+
}
|
|
257
|
+
return chunks
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 报错是 “某方法在批量里的数量超限” 时(如 “Maximum number of 'getMultipleAccounts' calls in a batch request is 1”),
|
|
262
|
+
* 记下限制并按限制重新分批,返回 true;限制解析不出或这批本来就没超限(避免无限重发)时返回 false
|
|
263
|
+
*/
|
|
264
|
+
async #retryWithMethodLimit(items: Pending[], message: string | undefined): Promise<boolean> {
|
|
265
|
+
const match = /maximum number of '([^']+)' calls in a batch request is (\d+)/i.exec(message ?? '')
|
|
266
|
+
const method = match?.[1]
|
|
267
|
+
const limit = Number(match?.[2])
|
|
268
|
+
if (!method || !Number.isFinite(limit) || limit < 1 || items.filter((item) => item.method === method).length <= limit) {
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
this.#methodLimits.set(method, limit)
|
|
272
|
+
await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)))
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
178
276
|
async #dispatch(items: Pending[]): Promise<void> {
|
|
179
277
|
if (items.length === 1 || !this.#batch) {
|
|
180
278
|
await Promise.all(items.map((item) => this.#single(item)))
|
|
@@ -203,18 +301,31 @@ export class HttpRpc implements RpcTransport {
|
|
|
203
301
|
}
|
|
204
302
|
if (!Array.isArray(body)) {
|
|
205
303
|
const error = (body as RpcResponse)?.error
|
|
304
|
+
// 单个方法在批量里的数量超限(整批被拒):按限制重新分批,不当成节点问题
|
|
305
|
+
if (await this.#retryWithMethodLimit(items, error?.message)) {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
206
308
|
const rpcError = new RpcError(error?.message ?? 'Invalid JSON-RPC batch response', error?.code, error?.data)
|
|
207
309
|
// 限频 / 鉴权等节点问题:原样报错,不降级(降级成逐条请求只会让限频更严重)
|
|
208
310
|
if (rpcError.nodeFault) {
|
|
209
311
|
items.forEach((item) => item.reject(rpcError))
|
|
210
312
|
return
|
|
211
313
|
}
|
|
212
|
-
//
|
|
213
|
-
|
|
314
|
+
// 整批被拒(不是账户数超限):节点多半不支持批量(各家错误码不同:-32600 / -32601 / -32700 …),
|
|
315
|
+
// 一段时间内改为逐条请求(10 分钟后重新尝试批量,偶发的错误不会永久关闭);不关的话每批都要先失败一次再逐条重发。
|
|
316
|
+
// 账户数超限只把这一批逐条重发(由 HTTP 层按节点缩小单次读取数量)
|
|
317
|
+
if (!TOO_MANY_ACCOUNTS.test(error?.message ?? '')) {
|
|
318
|
+
this.#disableBatch()
|
|
319
|
+
}
|
|
214
320
|
await Promise.all(items.map((item) => this.#single(item)))
|
|
215
321
|
return
|
|
216
322
|
}
|
|
217
323
|
const byId = new Map((body as RpcResponse[]).map((res) => [res.id, res]))
|
|
324
|
+
// 有的节点把 “方法数量超限” 放在批量结果的某一项里
|
|
325
|
+
const methodLimited = (body as RpcResponse[]).find((res) => /maximum number of '[^']+' calls in a batch/i.test(res.error?.message ?? ''))
|
|
326
|
+
if (methodLimited && (await this.#retryWithMethodLimit(items, methodLimited.error?.message))) {
|
|
327
|
+
return
|
|
328
|
+
}
|
|
218
329
|
const limited = (body as RpcResponse[]).find((res) => isBatchLimitError(res.error?.message))
|
|
219
330
|
if (limited) {
|
|
220
331
|
// 如 “Batch of more than 3 requests are not allowed”:按限制缩小批量后重发;
|
|
@@ -229,11 +340,13 @@ export class HttpRpc implements RpcTransport {
|
|
|
229
340
|
}
|
|
230
341
|
return
|
|
231
342
|
}
|
|
232
|
-
// 批量里个别调用被限频(JSON-RPC 429
|
|
343
|
+
// 批量里个别调用被限频(JSON-RPC 429):只把这些调用退避后单独重试一次(不再叠加 #post 的重试);
|
|
344
|
+
// 整批都是 429 时 #post 已经退避重试过整批,不再逐条重试——否则请求数会成倍放大
|
|
345
|
+
const allLimited = isRateLimited(body)
|
|
233
346
|
const limitedItems: Pending[] = []
|
|
234
347
|
items.forEach((item, i) => {
|
|
235
348
|
const response = byId.get(ids[i])
|
|
236
|
-
if (response?.error?.code === 429 && this.#retries > 0) {
|
|
349
|
+
if (response?.error?.code === 429 && this.#retries > 0 && !allLimited) {
|
|
237
350
|
limitedItems.push(item)
|
|
238
351
|
} else {
|
|
239
352
|
settle(item, response)
|
|
@@ -241,34 +354,30 @@ export class HttpRpc implements RpcTransport {
|
|
|
241
354
|
})
|
|
242
355
|
if (limitedItems.length) {
|
|
243
356
|
await new Promise((r) => setTimeout(r, 500))
|
|
244
|
-
await Promise.all(limitedItems.map((item) => this.#single(item)))
|
|
357
|
+
await Promise.all(limitedItems.map((item) => this.#single(item, 0)))
|
|
245
358
|
}
|
|
246
359
|
}
|
|
247
360
|
|
|
248
361
|
/** 按当前的 maxBatchSize 重新分批发送(每批都比原来小,不会无限循环) */
|
|
249
362
|
async #redispatch(items: Pending[]): Promise<void> {
|
|
250
|
-
|
|
251
|
-
const chunks: Pending[][] = []
|
|
252
|
-
for (let i = 0; i < items.length; i += size) {
|
|
253
|
-
chunks.push(items.slice(i, i + size))
|
|
254
|
-
}
|
|
255
|
-
await Promise.all(chunks.map((chunk) => this.#dispatch(chunk)))
|
|
363
|
+
await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)))
|
|
256
364
|
}
|
|
257
365
|
|
|
258
|
-
async #single(item: Pending): Promise<void> {
|
|
366
|
+
async #single(item: Pending, retries = this.#retries): Promise<void> {
|
|
259
367
|
try {
|
|
260
|
-
const body = (await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params })) as RpcResponse
|
|
368
|
+
const body = (await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params }, retries)) as RpcResponse
|
|
261
369
|
settle(item, body)
|
|
262
370
|
} catch (err) {
|
|
263
371
|
item.reject(err)
|
|
264
372
|
}
|
|
265
373
|
}
|
|
266
374
|
|
|
375
|
+
/** 节点明确表示不支持批量:10 分钟内逐条请求,之后重新尝试批量(节点可能只是临时出错或已经升级) */
|
|
267
376
|
#disableBatch(): void {
|
|
268
|
-
this.#
|
|
377
|
+
this.#batchDisabledUntil = Date.now() + BATCH_RETRY_AFTER
|
|
269
378
|
}
|
|
270
379
|
|
|
271
|
-
async #post(payload: unknown): Promise<unknown> {
|
|
380
|
+
async #post(payload: unknown, retries = this.#retries): Promise<unknown> {
|
|
272
381
|
for (let attempt = 0; ; attempt++) {
|
|
273
382
|
let response: Response
|
|
274
383
|
try {
|
|
@@ -283,11 +392,18 @@ export class HttpRpc implements RpcTransport {
|
|
|
283
392
|
const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
284
393
|
throw new HttpError(`Request to ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, undefined, timeout)
|
|
285
394
|
}
|
|
286
|
-
if (response.status === 429 && attempt <
|
|
395
|
+
if (response.status === 429 && attempt < retries) {
|
|
287
396
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
|
|
288
397
|
continue
|
|
289
398
|
}
|
|
290
|
-
|
|
399
|
+
let text: string
|
|
400
|
+
try {
|
|
401
|
+
text = await response.text()
|
|
402
|
+
} catch (err) {
|
|
403
|
+
// 读响应体时超时或连接中断:同样包装成 HttpError(原生 DOMException / TypeError 不会被当成可恢复的节点问题)
|
|
404
|
+
const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
405
|
+
throw new HttpError(`Reading response from ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, response.status, timeout)
|
|
406
|
+
}
|
|
291
407
|
let body: unknown
|
|
292
408
|
try {
|
|
293
409
|
body = parseJson(text)
|
|
@@ -299,7 +415,7 @@ export class HttpRpc implements RpcTransport {
|
|
|
299
415
|
throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}`, response.status)
|
|
300
416
|
}
|
|
301
417
|
// 有的节点限频时返回 HTTP 200 + JSON-RPC 错误码 429:同样退避重试
|
|
302
|
-
if (attempt <
|
|
418
|
+
if (attempt < retries && isRateLimited(body)) {
|
|
303
419
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
|
|
304
420
|
continue
|
|
305
421
|
}
|
|
@@ -326,7 +442,17 @@ function parseJson(text: string): unknown {
|
|
|
326
442
|
typeof value === 'number' && !Number.isSafeInteger(value) && Number.isInteger(value) && context?.source ? context.source : value,
|
|
327
443
|
)
|
|
328
444
|
}
|
|
329
|
-
return JSON.parse(text
|
|
445
|
+
return JSON.parse(quoteBigIntegers(text))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 把 JSON 文本里数值位置上 16 位以上的整数改成字符串(不支持 source text access 的环境用)。
|
|
450
|
+
* 跳过字符串字面量:错误信息等字符串里的长数字不会被改动
|
|
451
|
+
*/
|
|
452
|
+
export function quoteBigIntegers(text: string): string {
|
|
453
|
+
return text.replace(/"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (token) =>
|
|
454
|
+
token[0] !== '"' && /^-?\d{16,}$/.test(token) ? `"${token}"` : token,
|
|
455
|
+
)
|
|
330
456
|
}
|
|
331
457
|
|
|
332
458
|
function safeOrigin(url: string): string {
|
package/src/shortcuts.ts
CHANGED
|
@@ -38,11 +38,24 @@ function setBounded<K, V>(map: Map<K, V>, key: K, value: V): void {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// 配置里的函数(如自定义 fetch)按对象编号参与缓存键:同一个函数对象复用同一个客户端(WeakMap 不阻止回收)
|
|
42
|
+
const functionIds = new WeakMap<object, number>()
|
|
43
|
+
let nextFunctionId = 0
|
|
44
|
+
|
|
45
|
+
/** 配置的缓存键:对象的键按字母排序(写法顺序不同视为同一份配置),函数按对象编号;无法序列化时返回 null(不缓存) */
|
|
41
46
|
function configKey(config: ClientConfig): string | null {
|
|
42
47
|
try {
|
|
43
48
|
return JSON.stringify(config, (_key, value: unknown) => {
|
|
44
49
|
if (typeof value === 'function') {
|
|
45
|
-
|
|
50
|
+
let id = functionIds.get(value)
|
|
51
|
+
if (id === undefined) {
|
|
52
|
+
id = ++nextFunctionId
|
|
53
|
+
functionIds.set(value, id)
|
|
54
|
+
}
|
|
55
|
+
return `function#${id}`
|
|
56
|
+
}
|
|
57
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
58
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
46
59
|
}
|
|
47
60
|
return value
|
|
48
61
|
})
|