@w3lib/solana-batch-call 0.5.0 → 0.7.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.
Files changed (220) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +129 -9
  3. package/dist/accountReader.d.ts +57 -0
  4. package/dist/accountReader.d.ts.map +1 -0
  5. package/dist/accountReader.js +149 -0
  6. package/dist/accountReader.js.map +1 -0
  7. package/dist/address.d.ts +9 -0
  8. package/dist/address.d.ts.map +1 -1
  9. package/dist/address.js +36 -6
  10. package/dist/address.js.map +1 -1
  11. package/dist/approvals.d.ts +5 -3
  12. package/dist/approvals.d.ts.map +1 -1
  13. package/dist/approvals.js +25 -36
  14. package/dist/approvals.js.map +1 -1
  15. package/dist/balances.d.ts +178 -0
  16. package/dist/balances.d.ts.map +1 -0
  17. package/dist/balances.js +524 -0
  18. package/dist/balances.js.map +1 -0
  19. package/dist/batchPlan.d.ts +52 -0
  20. package/dist/batchPlan.d.ts.map +1 -0
  21. package/dist/batchPlan.js +64 -0
  22. package/dist/batchPlan.js.map +1 -0
  23. package/dist/bytes.d.ts +31 -0
  24. package/dist/bytes.d.ts.map +1 -0
  25. package/dist/bytes.js +74 -0
  26. package/dist/bytes.js.map +1 -0
  27. package/dist/claim.d.ts +163 -0
  28. package/dist/claim.d.ts.map +1 -0
  29. package/dist/claim.js +197 -0
  30. package/dist/claim.js.map +1 -0
  31. package/dist/client.d.ts +26 -41
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +41 -986
  34. package/dist/client.js.map +1 -1
  35. package/dist/clientConfig.d.ts +11 -0
  36. package/dist/clientConfig.d.ts.map +1 -0
  37. package/dist/clientConfig.js +3 -0
  38. package/dist/clientConfig.js.map +1 -0
  39. package/dist/decode.d.ts +22 -0
  40. package/dist/decode.d.ts.map +1 -0
  41. package/dist/decode.js +160 -0
  42. package/dist/decode.js.map +1 -0
  43. package/dist/describe.d.ts +26 -0
  44. package/dist/describe.d.ts.map +1 -0
  45. package/dist/describe.js +58 -0
  46. package/dist/describe.js.map +1 -0
  47. package/dist/fees.d.ts.map +1 -1
  48. package/dist/fees.js +2 -12
  49. package/dist/fees.js.map +1 -1
  50. package/dist/history.d.ts +125 -0
  51. package/dist/history.d.ts.map +1 -0
  52. package/dist/history.js +220 -0
  53. package/dist/history.js.map +1 -0
  54. package/dist/historyHelius.d.ts +52 -0
  55. package/dist/historyHelius.d.ts.map +1 -0
  56. package/dist/historyHelius.js +125 -0
  57. package/dist/historyHelius.js.map +1 -0
  58. package/dist/index.d.ts +3 -3
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/layout.d.ts +21 -0
  63. package/dist/layout.d.ts.map +1 -1
  64. package/dist/layout.js +50 -69
  65. package/dist/layout.js.map +1 -1
  66. package/dist/liquidity/common.js +2 -2
  67. package/dist/liquidity/common.js.map +1 -1
  68. package/dist/liquidity/orca.d.ts +6 -0
  69. package/dist/liquidity/orca.d.ts.map +1 -1
  70. package/dist/liquidity/orca.js +14 -3
  71. package/dist/liquidity/orca.js.map +1 -1
  72. package/dist/liquidity/raydiumClmm.d.ts +6 -0
  73. package/dist/liquidity/raydiumClmm.d.ts.map +1 -1
  74. package/dist/liquidity/raydiumClmm.js +12 -5
  75. package/dist/liquidity/raydiumClmm.js.map +1 -1
  76. package/dist/lp.d.ts +55 -12
  77. package/dist/lp.d.ts.map +1 -1
  78. package/dist/lp.js +158 -70
  79. package/dist/lp.js.map +1 -1
  80. package/dist/lpMath.d.ts +22 -0
  81. package/dist/lpMath.d.ts.map +1 -1
  82. package/dist/lpMath.js +21 -0
  83. package/dist/lpMath.js.map +1 -1
  84. package/dist/portfolio.d.ts.map +1 -1
  85. package/dist/portfolio.js +3 -8
  86. package/dist/portfolio.js.map +1 -1
  87. package/dist/programs.d.ts +19 -0
  88. package/dist/programs.d.ts.map +1 -0
  89. package/dist/programs.js +175 -0
  90. package/dist/programs.js.map +1 -0
  91. package/dist/reclaim.d.ts +192 -0
  92. package/dist/reclaim.d.ts.map +1 -0
  93. package/dist/reclaim.js +399 -0
  94. package/dist/reclaim.js.map +1 -0
  95. package/dist/risk.js +2 -2
  96. package/dist/risk.js.map +1 -1
  97. package/dist/rpc.d.ts +30 -7
  98. package/dist/rpc.d.ts.map +1 -1
  99. package/dist/rpc.js +404 -111
  100. package/dist/rpc.js.map +1 -1
  101. package/dist/simulate.d.ts +106 -0
  102. package/dist/simulate.d.ts.map +1 -0
  103. package/dist/simulate.js +395 -0
  104. package/dist/simulate.js.map +1 -0
  105. package/dist/source.d.ts +8 -4
  106. package/dist/source.d.ts.map +1 -1
  107. package/dist/source.js +11 -4
  108. package/dist/source.js.map +1 -1
  109. package/dist/subpaths/approvals.d.ts +3 -3
  110. package/dist/subpaths/approvals.d.ts.map +1 -1
  111. package/dist/subpaths/approvals.js.map +1 -1
  112. package/dist/subpaths/claim.d.ts +31 -0
  113. package/dist/subpaths/claim.d.ts.map +1 -0
  114. package/dist/subpaths/claim.js +31 -0
  115. package/dist/subpaths/claim.js.map +1 -0
  116. package/dist/subpaths/history.d.ts +21 -0
  117. package/dist/subpaths/history.d.ts.map +1 -0
  118. package/dist/subpaths/history.js +22 -0
  119. package/dist/subpaths/history.js.map +1 -0
  120. package/dist/subpaths/lp.d.ts +2 -2
  121. package/dist/subpaths/lp.d.ts.map +1 -1
  122. package/dist/subpaths/lp.js +2 -2
  123. package/dist/subpaths/lp.js.map +1 -1
  124. package/dist/subpaths/reclaim.d.ts +34 -0
  125. package/dist/subpaths/reclaim.d.ts.map +1 -0
  126. package/dist/subpaths/reclaim.js +34 -0
  127. package/dist/subpaths/reclaim.js.map +1 -0
  128. package/dist/subpaths/simulate.d.ts +22 -0
  129. package/dist/subpaths/simulate.d.ts.map +1 -0
  130. package/dist/subpaths/simulate.js +23 -0
  131. package/dist/subpaths/simulate.js.map +1 -0
  132. package/dist/swap/index.js +3 -3
  133. package/dist/swap/index.js.map +1 -1
  134. package/dist/tokenDetails.d.ts +12 -0
  135. package/dist/tokenDetails.d.ts.map +1 -0
  136. package/dist/tokenDetails.js +196 -0
  137. package/dist/tokenDetails.js.map +1 -0
  138. package/dist/tokenMeta.d.ts +63 -0
  139. package/dist/tokenMeta.d.ts.map +1 -0
  140. package/dist/tokenMeta.js +226 -0
  141. package/dist/tokenMeta.js.map +1 -0
  142. package/dist/tradeCheck.js +3 -3
  143. package/dist/tradeCheck.js.map +1 -1
  144. package/dist/util.d.ts +22 -0
  145. package/dist/util.d.ts.map +1 -0
  146. package/dist/util.js +107 -0
  147. package/dist/util.js.map +1 -0
  148. package/dist/write/chain.d.ts +51 -0
  149. package/dist/write/chain.d.ts.map +1 -0
  150. package/dist/write/chain.js +184 -0
  151. package/dist/write/chain.js.map +1 -0
  152. package/dist/write/collect.js +2 -2
  153. package/dist/write/collect.js.map +1 -1
  154. package/dist/write/errors.d.ts +10 -0
  155. package/dist/write/errors.d.ts.map +1 -0
  156. package/dist/write/errors.js +77 -0
  157. package/dist/write/errors.js.map +1 -0
  158. package/dist/write/instructions.d.ts +2 -0
  159. package/dist/write/instructions.d.ts.map +1 -1
  160. package/dist/write/instructions.js +16 -0
  161. package/dist/write/instructions.js.map +1 -1
  162. package/dist/write/pack.d.ts +28 -0
  163. package/dist/write/pack.d.ts.map +1 -0
  164. package/dist/write/pack.js +125 -0
  165. package/dist/write/pack.js.map +1 -0
  166. package/dist/write/send.d.ts +23 -9
  167. package/dist/write/send.d.ts.map +1 -1
  168. package/dist/write/send.js +53 -351
  169. package/dist/write/send.js.map +1 -1
  170. package/dist/write/tracker.d.ts +24 -0
  171. package/dist/write/tracker.d.ts.map +1 -0
  172. package/dist/write/tracker.js +129 -0
  173. package/dist/write/tracker.js.map +1 -0
  174. package/package.json +21 -1
  175. package/src/accountReader.ts +187 -0
  176. package/src/address.ts +41 -6
  177. package/src/approvals.ts +36 -44
  178. package/src/balances.ts +678 -0
  179. package/src/batchPlan.ts +117 -0
  180. package/src/bytes.ts +89 -0
  181. package/src/claim.ts +327 -0
  182. package/src/client.ts +59 -1239
  183. package/src/clientConfig.ts +12 -0
  184. package/src/decode.ts +176 -0
  185. package/src/describe.ts +77 -0
  186. package/src/fees.ts +2 -9
  187. package/src/history.ts +325 -0
  188. package/src/historyHelius.ts +136 -0
  189. package/src/index.ts +13 -1
  190. package/src/layout.ts +63 -82
  191. package/src/liquidity/common.ts +2 -2
  192. package/src/liquidity/orca.ts +15 -5
  193. package/src/liquidity/raydiumClmm.ts +13 -5
  194. package/src/lp.ts +226 -75
  195. package/src/lpMath.ts +38 -0
  196. package/src/portfolio.ts +8 -11
  197. package/src/programs.ts +216 -0
  198. package/src/reclaim.ts +550 -0
  199. package/src/risk.ts +2 -2
  200. package/src/rpc.ts +476 -118
  201. package/src/simulate.ts +517 -0
  202. package/src/source.ts +14 -5
  203. package/src/subpaths/approvals.ts +3 -2
  204. package/src/subpaths/claim.ts +54 -0
  205. package/src/subpaths/history.ts +38 -0
  206. package/src/subpaths/lp.ts +5 -1
  207. package/src/subpaths/reclaim.ts +58 -0
  208. package/src/subpaths/simulate.ts +37 -0
  209. package/src/swap/index.ts +3 -3
  210. package/src/tokenDetails.ts +240 -0
  211. package/src/tokenMeta.ts +270 -0
  212. package/src/tradeCheck.ts +3 -3
  213. package/src/util.ts +110 -0
  214. package/src/write/chain.ts +218 -0
  215. package/src/write/collect.ts +2 -2
  216. package/src/write/errors.ts +82 -0
  217. package/src/write/instructions.ts +17 -0
  218. package/src/write/pack.ts +142 -0
  219. package/src/write/send.ts +74 -407
  220. package/src/write/tracker.ts +152 -0
package/dist/rpc.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mapPool, sleep } from './util.js';
1
2
  /** 节点返回的 JSON-RPC 错误 */
2
3
  export class RpcError extends Error {
3
4
  code;
@@ -46,7 +47,7 @@ const RATE_LIMITED = /rate.?limit|too many requests/i;
46
47
  /** 服务端临时故障的报错(如 -32000 server is busy、-32603 + 503 overloaded) */
47
48
  const TRANSIENT = /\bbusy\b|overload|temporar(il)?y|unavailable|try again|timed? ?out|timeout/i;
48
49
  /** 节点限制单次 getMultipleAccounts 的账户数(如 “Too many accounts requested”):缩小每次的账户数后重试,不是节点故障 */
49
- const TOO_MANY_ACCOUNTS = /too many accounts/i;
50
+ const TOO_MANY_ACCOUNTS = /too many accounts|maximum of \d+ accounts/i;
50
51
  /**
51
52
  * publicnode 对单次超过 10 个账户的 getMultipleAccounts 返回 HTTP 403 +“Request blocked”(2026-10,而且要等 3 秒多才返回):
52
53
  * 账户数超过 BLOCKED_LIMIT 时直接按它拆分(逐次减半要多等好几轮),还被拒绝再减半;
@@ -55,17 +56,44 @@ const TOO_MANY_ACCOUNTS = /too many accounts/i;
55
56
  const REQUEST_BLOCKED = /request blocked/i;
56
57
  const BLOCKED_LIMIT = 10;
57
58
  const BLOCKED_FLOOR = 8;
58
- /** 从报错里学到的节点限制,按节点地址共享(同一个节点的多个客户端不用各自再试一遍) */
59
- const learnedLimits = new Map();
60
- function learnedFor(url) {
61
- let entry = learnedLimits.get(url);
59
+ /** 按节点地址记的表(健康状态、学到的限制、在途请求数)的条目上限,超出时淘汰最早加入的 */
60
+ const MAX_TRACKED_URLS = 1000;
61
+ /** 按节点地址取条目,没有时创建;表的条目数有上限(淘汰最早加入的) */
62
+ function trackedEntry(map, url, create) {
63
+ let entry = map.get(url);
62
64
  if (!entry) {
63
- entry = { methods: new Map() };
64
- learnedLimits.set(url, entry);
65
+ entry = create();
66
+ map.set(url, entry);
67
+ if (map.size > MAX_TRACKED_URLS) {
68
+ map.delete(map.keys().next().value);
69
+ }
65
70
  }
66
71
  return entry;
67
72
  }
68
- /** 测试用:清空学到的节点限制 */
73
+ /**
74
+ * 已知节点的限制(2026-10 实测),首次请求就按它拆分,不必先撞一次报错:
75
+ * publicnode 单次 getMultipleAccounts 最多 10 个账户、一个批量里最多 1 个 getMultipleAccounts,
76
+ * 超过账户数要等 3 秒多才返回 403
77
+ */
78
+ function presetLimits(url) {
79
+ let host = '';
80
+ try {
81
+ host = new URL(url).hostname;
82
+ }
83
+ catch {
84
+ // 地址不合法:没有预置限制
85
+ }
86
+ if (/^solana[\w-]*\.publicnode\.com$/i.test(host)) {
87
+ return { methods: new Map([['getMultipleAccounts', 1]]), maxAccounts: BLOCKED_LIMIT };
88
+ }
89
+ return { methods: new Map() };
90
+ }
91
+ /** 从报错里学到的节点限制(含预置的已知限制),按节点地址共享(同一个节点的多个客户端不用各自再试一遍) */
92
+ const learnedLimits = new Map();
93
+ function learnedFor(url) {
94
+ return trackedEntry(learnedLimits, url, () => presetLimits(url));
95
+ }
96
+ /** 测试用:清空学到的节点限制(预置的已知限制在下次创建连接时重新带上) */
69
97
  export function resetLearnedLimits() {
70
98
  learnedLimits.clear();
71
99
  }
@@ -122,14 +150,126 @@ export function isNodeFault(err) {
122
150
  let nextId = 1;
123
151
  /** 批量请求被拒绝、可以降级为逐条请求的 HTTP 状态 */
124
152
  const BATCH_REJECTED_STATUS = new Set([400, 404, 405, 415, 422]);
125
- /** 节点不支持批量时,多久后重新尝试批量 */
153
+ /** 节点不支持批量 / 限制批量大小时,多久后重新尝试(批量、原来的批量大小) */
126
154
  const BATCH_RETRY_AFTER = 10 * 60 * 1000;
155
+ /** 有副作用、不能去重的方法 */
156
+ const WRITE_METHOD = /^(send|requestAirdrop)/;
157
+ /** 429 时 Retry-After 超过这个时间(毫秒)就不等了,直接报错(交给上层换节点) */
158
+ const MAX_RETRY_AFTER = 10_000;
127
159
  function isBatchLimitError(message) {
128
160
  return !!message && /batch/i.test(message) && /more than \d+|too (large|many|big)|not (allowed|supported)|exceed/i.test(message);
129
161
  }
162
+ /**
163
+ * 429 后的等待时间:有 Retry-After(秒数或 HTTP 日期)时按它,超过 MAX_RETRY_AFTER 时返回 null(不等,直接报错);
164
+ * 否则指数退避(500ms 起)加 ±25% 随机抖动,避免同时被限频的请求又同时重试
165
+ */
166
+ export function retryDelay(attempt, retryAfter) {
167
+ const header = retryAfter?.trim();
168
+ if (header) {
169
+ const seconds = Number(header);
170
+ const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now();
171
+ if (Number.isFinite(ms)) {
172
+ return ms > MAX_RETRY_AFTER ? null : Math.max(0, ms);
173
+ }
174
+ }
175
+ return Math.round(500 * 2 ** attempt * (0.75 + Math.random() * 0.5));
176
+ }
177
+ /** 合并多个 AbortSignal(AbortSignal.any 不可用的旧环境手动合并) */
178
+ function anySignal(signals) {
179
+ if (signals.length === 1) {
180
+ return signals[0];
181
+ }
182
+ if (typeof AbortSignal.any === 'function') {
183
+ return AbortSignal.any(signals);
184
+ }
185
+ const controller = new AbortController();
186
+ for (const signal of signals) {
187
+ if (signal.aborted) {
188
+ controller.abort(signal.reason);
189
+ break;
190
+ }
191
+ signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true, signal: controller.signal });
192
+ }
193
+ return controller.signal;
194
+ }
195
+ const slotsByUrl = new Map();
196
+ /** 拿一个并发名额(先来先得);signal 取消时不再等 */
197
+ function acquireSlot(slots, limit, signal) {
198
+ if (signal.aborted) {
199
+ return Promise.reject(signal.reason);
200
+ }
201
+ if (slots.active < limit && !slots.waiting.length) {
202
+ slots.active++;
203
+ return Promise.resolve();
204
+ }
205
+ return new Promise((resolve, reject) => {
206
+ const entry = {
207
+ limit,
208
+ start: () => {
209
+ signal.removeEventListener('abort', onAbort);
210
+ resolve();
211
+ },
212
+ };
213
+ const onAbort = () => {
214
+ const i = slots.waiting.indexOf(entry);
215
+ if (i !== -1) {
216
+ slots.waiting.splice(i, 1);
217
+ drainSlots(slots);
218
+ }
219
+ reject(signal.reason);
220
+ };
221
+ slots.waiting.push(entry);
222
+ signal.addEventListener('abort', onAbort, { once: true });
223
+ });
224
+ }
225
+ function releaseSlot(slots) {
226
+ slots.active--;
227
+ drainSlots(slots);
228
+ }
229
+ function drainSlots(slots) {
230
+ for (let head = slots.waiting[0]; head && slots.active < head.limit; head = slots.waiting[0]) {
231
+ slots.waiting.shift();
232
+ slots.active++;
233
+ head.start();
234
+ }
235
+ }
236
+ /** 调用方是否都已取消(没有调用方传 signal 时不会) */
237
+ function abandoned(item) {
238
+ return item.waiters.every((w) => w.signal?.aborted);
239
+ }
240
+ /** 还需要结果:没有结果、也没被所有调用方放弃 */
241
+ function live(item) {
242
+ return !item.done && !abandoned(item);
243
+ }
244
+ /** 去重后的结果按调用方各给一份:第一个调用方拿原对象,其余的拿副本(调用方修改结果不会影响别人) */
245
+ function copyResult(value) {
246
+ if (value === null || typeof value !== 'object') {
247
+ return value;
248
+ }
249
+ return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
250
+ }
251
+ function resolveItem(item, value) {
252
+ if (item.done)
253
+ return;
254
+ item.done = true;
255
+ item.waiters.forEach((waiter, i) => {
256
+ waiter.cleanup?.();
257
+ waiter.resolve(i === 0 ? value : copyResult(value));
258
+ });
259
+ }
260
+ function rejectItem(item, reason) {
261
+ if (item.done)
262
+ return;
263
+ item.done = true;
264
+ for (const waiter of item.waiters) {
265
+ waiter.cleanup?.();
266
+ waiter.reject(reason);
267
+ }
268
+ }
130
269
  /**
131
270
  * 基于 fetch 的 JSON-RPC 传输:同一收集窗口内的调用合并成一个 JSON-RPC 批量请求。
132
271
  * 节点不支持批量(返回非数组)或限制批量大小(如 drpc 免费档最多 3 条)时自动降级。
272
+ * 同一节点地址同时在途的 HTTP 请求数有上限(maxConcurrency,默认 4);request 的 signal 会传到 fetch
133
273
  */
134
274
  export class HttpRpc {
135
275
  url;
@@ -141,13 +281,19 @@ export class HttpRpc {
141
281
  #retries;
142
282
  #fetch;
143
283
  #maxBatchSize;
144
- /** 从报错里学到的限制(按节点地址共享):单个方法在一个批量里的数量(如 publicnode:getMultipleAccounts 最多 1 个)、单次的账户数 */
284
+ #maxConcurrency;
285
+ #dedupe;
286
+ /** 节点限制批量大小(413、“more than N”)时临时调小的批量大小,到期恢复 maxBatchSize */
287
+ #batchLimit = null;
288
+ /** 从报错里学到的限制(按节点地址共享,含预置的已知限制):单个方法在一个批量里的数量(如 publicnode:getMultipleAccounts 最多 1 个)、单次的账户数 */
145
289
  #learned;
146
290
  /** options.batch:调用方是否允许批量 */
147
291
  #batchOption;
148
292
  /** 节点明确表示不支持批量时,在这之前逐条请求(之后重新尝试批量) */
149
293
  #batchDisabledUntil = 0;
150
294
  #queue = [];
295
+ /** 队列里可去重的调用(key → 调用),flush 时清空 */
296
+ #queuedByKey = new Map();
151
297
  #timer = null;
152
298
  constructor(url, options = {}) {
153
299
  this.url = url;
@@ -160,18 +306,23 @@ export class HttpRpc {
160
306
  this.#batchWait = options.batchWait ?? 0;
161
307
  this.#retries = Math.max(0, options.retries ?? 2);
162
308
  this.#fetch = options.fetch ?? ((...args) => fetch(...args));
309
+ const concurrency = options.maxConcurrency ?? 4;
310
+ this.#maxConcurrency = concurrency > 0 ? concurrency : Number.POSITIVE_INFINITY;
311
+ this.#dedupe = options.dedupe ?? true;
163
312
  }
164
- request(method, params = []) {
313
+ request(method, params = [], options) {
314
+ const signal = options?.signal;
165
315
  if (method === 'getMultipleAccounts' && Array.isArray(params[0])) {
166
- return this.#getMultipleAccounts(params);
316
+ return this.#getMultipleAccounts(params, signal);
167
317
  }
168
- return this.#enqueue(method, params);
318
+ return this.#enqueue(method, params, signal);
169
319
  }
170
320
  /**
171
321
  * getMultipleAccounts:节点限制单次账户数时(“Too many accounts requested”),减半后分多次请求、合并结果,
172
- * 并记下上限,之后直接按上限拆分。合并后 context.slot 取最小值
322
+ * 并记下上限,之后直接按上限拆分。合并后 context.slot 取最小值。
323
+ * 拆分后的子请求用并发池发出:一个失败后不再发后面的(节点多半在限频或拒绝),取消后也不再发
173
324
  */
174
- async #getMultipleAccounts(params) {
325
+ async #getMultipleAccounts(params, signal) {
175
326
  const [addresses, ...rest] = params;
176
327
  const max = this.#learned.maxAccounts;
177
328
  if (max !== undefined && addresses.length > max) {
@@ -179,7 +330,10 @@ export class HttpRpc {
179
330
  for (let i = 0; i < addresses.length; i += max) {
180
331
  parts.push(addresses.slice(i, i + max));
181
332
  }
182
- const results = (await Promise.all(parts.map((part) => this.#getMultipleAccounts([part, ...rest]))));
333
+ // 同时排队的子请求数:够把所有并发名额用满即可(每个 HTTP 请求能带几个 getMultipleAccounts × 并发上限)
334
+ const perPost = this.#batch ? (this.#learned.methods.get('getMultipleAccounts') ?? this.#batchSize) : 1;
335
+ const limit = Number.isFinite(this.#maxConcurrency) ? this.#maxConcurrency * perPost : parts.length;
336
+ const results = (await mapPool(parts, limit, (part) => this.#getMultipleAccounts([part, ...rest], signal)));
183
337
  const slots = results.map((r) => r?.context?.slot).filter((slot) => typeof slot === 'number');
184
338
  return {
185
339
  ...results[0],
@@ -188,15 +342,17 @@ export class HttpRpc {
188
342
  };
189
343
  }
190
344
  try {
191
- return await this.#enqueue('getMultipleAccounts', params);
345
+ return await this.#enqueue('getMultipleAccounts', params, signal);
192
346
  }
193
347
  catch (err) {
194
348
  const tooMany = err instanceof RpcError && TOO_MANY_ACCOUNTS.test(err.message) && addresses.length > 1;
195
349
  const blocked = err instanceof RpcError && REQUEST_BLOCKED.test(err.message) && addresses.length > BLOCKED_FLOOR;
196
350
  if (tooMany || blocked) {
197
- const next = blocked && addresses.length > BLOCKED_LIMIT ? BLOCKED_LIMIT : Math.floor(addresses.length / 2);
351
+ // publicnode(2026-10):“A maximum of 10 accounts is allowed”,报错里直接给出上限
352
+ const stated = Number(/maximum of (\d+) accounts/i.exec(err.message)?.[1]);
353
+ const next = stated >= 1 ? stated : blocked && addresses.length > BLOCKED_LIMIT ? BLOCKED_LIMIT : Math.floor(addresses.length / 2);
198
354
  this.#learned.maxAccounts = Math.max(1, Math.min(this.#learned.maxAccounts ?? Number.POSITIVE_INFINITY, next));
199
- return this.#getMultipleAccounts(params);
355
+ return this.#getMultipleAccounts(params, signal);
200
356
  }
201
357
  throw err;
202
358
  }
@@ -205,10 +361,50 @@ export class HttpRpc {
205
361
  get #batch() {
206
362
  return this.#batchOption && Date.now() >= this.#batchDisabledUntil;
207
363
  }
208
- #enqueue(method, params) {
364
+ /** 当前的批量大小:节点限制过批量大小时,冷却期内用调小后的值,到期恢复 */
365
+ get #batchSize() {
366
+ const limit = this.#batchLimit;
367
+ if (limit && Date.now() < limit.until) {
368
+ return limit.size;
369
+ }
370
+ this.#batchLimit = null;
371
+ return this.#maxBatchSize;
372
+ }
373
+ /** 节点限制批量大小:10 分钟内按 size 分批,之后恢复(偶发的 413 不会永久缩小批量) */
374
+ #limitBatchSize(size) {
375
+ this.#batchLimit = { size: Math.max(1, Math.min(size, this.#batchSize)), until: Date.now() + BATCH_RETRY_AFTER };
376
+ }
377
+ #enqueue(method, params, signal) {
378
+ if (signal?.aborted) {
379
+ return Promise.reject(signal.reason);
380
+ }
209
381
  return new Promise((resolve, reject) => {
210
- this.#queue.push({ method, params, resolve: resolve, reject });
211
- if (!this.#batch || this.#queue.length >= this.#maxBatchSize) {
382
+ const waiter = { resolve: resolve, reject, signal };
383
+ let key;
384
+ if (this.#dedupe && !WRITE_METHOD.test(method)) {
385
+ try {
386
+ key = `${method}\u0000${JSON.stringify(params)}`;
387
+ }
388
+ catch {
389
+ key = undefined; // 参数不能序列化(如 bigint):不去重,发送时照常报错
390
+ }
391
+ }
392
+ const existing = key === undefined ? undefined : this.#queuedByKey.get(key);
393
+ const item = existing ?? { method, params, key, waiters: [], done: false, posts: new Set() };
394
+ item.waiters.push(waiter);
395
+ if (signal) {
396
+ const onAbort = () => this.#abortWaiter(item, waiter);
397
+ signal.addEventListener('abort', onAbort, { once: true });
398
+ waiter.cleanup = () => signal.removeEventListener('abort', onAbort);
399
+ }
400
+ if (existing) {
401
+ return; // 同一批里相同的调用:共用一个结果
402
+ }
403
+ this.#queue.push(item);
404
+ if (key !== undefined) {
405
+ this.#queuedByKey.set(key, item);
406
+ }
407
+ if (!this.#batch || this.#queue.length >= this.#batchSize) {
212
408
  this.#flush();
213
409
  }
214
410
  else if (!this.#timer) {
@@ -216,6 +412,31 @@ export class HttpRpc {
216
412
  }
217
413
  });
218
414
  }
415
+ /**
416
+ * 调用方取消:立即 reject 这个调用方;同一调用的其他调用方都取消时,还在排队的移出队列,
417
+ * 已发出的 HTTP 请求在所有成员都被取消后中止
418
+ */
419
+ #abortWaiter(item, waiter) {
420
+ waiter.cleanup?.();
421
+ waiter.reject(waiter.signal?.reason);
422
+ if (item.done || !abandoned(item)) {
423
+ return;
424
+ }
425
+ const index = this.#queue.indexOf(item);
426
+ if (index !== -1) {
427
+ this.#queue.splice(index, 1);
428
+ if (item.key !== undefined && this.#queuedByKey.get(item.key) === item) {
429
+ this.#queuedByKey.delete(item.key);
430
+ }
431
+ item.done = true;
432
+ return;
433
+ }
434
+ for (const post of item.posts) {
435
+ if (post.items.every(abandoned)) {
436
+ post.controller.abort(waiter.signal?.reason);
437
+ }
438
+ }
439
+ }
219
440
  #flush() {
220
441
  if (this.#timer) {
221
442
  clearTimeout(this.#timer);
@@ -223,19 +444,21 @@ export class HttpRpc {
223
444
  }
224
445
  const queue = this.#queue;
225
446
  this.#queue = [];
447
+ this.#queuedByKey = new Map();
226
448
  for (const chunk of this.#split(queue)) {
227
449
  void this.#dispatch(chunk);
228
450
  }
229
451
  }
230
- /** 按 maxBatchSize 和各方法的数量限制分批(保持顺序;不受限的方法照常合并) */
452
+ /** 按批量大小和各方法的数量限制分批(保持顺序;不受限的方法照常合并) */
231
453
  #split(items) {
232
454
  const chunks = [];
455
+ const batchSize = this.#batchSize;
233
456
  let current = [];
234
457
  let counts = new Map();
235
458
  for (const item of items) {
236
459
  const limit = this.#learned.methods.get(item.method);
237
460
  const count = counts.get(item.method) ?? 0;
238
- if (current.length >= this.#maxBatchSize || (limit !== undefined && count >= limit)) {
461
+ if (current.length >= batchSize || (limit !== undefined && count >= limit)) {
239
462
  chunks.push(current);
240
463
  current = [];
241
464
  counts = new Map();
@@ -260,24 +483,32 @@ export class HttpRpc {
260
483
  return false;
261
484
  }
262
485
  this.#learned.methods.set(method, limit);
263
- await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)));
486
+ await this.#redispatch(items);
264
487
  return true;
265
488
  }
266
489
  /** retriedServerError:这一批已经因 5xx + JSON-RPC 错误重试过一次 */
267
- async #dispatch(items, retriedServerError = false) {
490
+ async #dispatch(chunk, retriedServerError = false) {
491
+ // 已被所有调用方取消的不再发
492
+ const items = chunk.filter(live);
493
+ if (!items.length) {
494
+ return;
495
+ }
268
496
  if (items.length === 1 || !this.#batch) {
269
497
  await Promise.all(items.map((item) => this.#single(item)));
270
498
  return;
271
499
  }
272
500
  const ids = items.map(() => nextId++);
273
- let body;
501
+ let posted;
274
502
  try {
275
- body = await this.#post(items.map((item, i) => ({ jsonrpc: '2.0', id: ids[i], method: item.method, params: item.params })));
503
+ posted = await this.#post(items.map((item, i) => ({ jsonrpc: '2.0', id: ids[i], method: item.method, params: item.params })), items);
276
504
  }
277
505
  catch (err) {
506
+ if (items.every((item) => !live(item))) {
507
+ return; // 调用方都取消了(各自已经 reject)
508
+ }
278
509
  if (err instanceof HttpError && err.status === 413 && items.length > 1) {
279
- // 请求体太大:批量减半后重发
280
- this.#maxBatchSize = Math.max(1, Math.floor(items.length / 2));
510
+ // 请求体太大:批量减半后重发(一段时间后恢复原来的批量大小)
511
+ this.#limitBatchSize(Math.floor(items.length / 2));
281
512
  await this.#redispatch(items);
282
513
  return;
283
514
  }
@@ -296,19 +527,25 @@ export class HttpRpc {
296
527
  await this.#dispatch(items, true);
297
528
  return;
298
529
  }
299
- const [first, ...rest] = items;
530
+ // 试探用还需要结果的第一条(期间被取消的不再发)
531
+ const remaining = items.filter(live);
532
+ if (!remaining.length) {
533
+ return;
534
+ }
535
+ const [first, ...rest] = remaining;
300
536
  if (await this.#single(first)) {
301
537
  this.#disableBatch();
302
538
  await Promise.all(rest.map((item) => this.#single(item)));
303
539
  }
304
540
  else {
305
- rest.forEach((item) => item.reject(err));
541
+ rest.forEach((item) => rejectItem(item, err));
306
542
  }
307
543
  return;
308
544
  }
309
- items.forEach((item) => item.reject(err));
545
+ items.forEach((item) => rejectItem(item, err));
310
546
  return;
311
547
  }
548
+ const { body } = posted;
312
549
  if (!Array.isArray(body)) {
313
550
  const error = body?.error;
314
551
  // 单个方法在批量里的数量超限(整批被拒):按限制重新分批,不当成节点问题
@@ -318,7 +555,7 @@ export class HttpRpc {
318
555
  const rpcError = new RpcError(error?.message ?? 'Invalid JSON-RPC batch response', error?.code, error?.data);
319
556
  // 限频 / 鉴权等节点问题:原样报错,不降级(降级成逐条请求只会让限频更严重)
320
557
  if (rpcError.nodeFault) {
321
- items.forEach((item) => item.reject(rpcError));
558
+ items.forEach((item) => rejectItem(item, rpcError));
322
559
  return;
323
560
  }
324
561
  // 整批被拒(不是账户数超限):节点多半不支持批量(各家错误码不同:-32600 / -32601 / -32700 …),
@@ -338,11 +575,11 @@ export class HttpRpc {
338
575
  }
339
576
  const limited = body.find((res) => isBatchLimitError(res.error?.message));
340
577
  if (limited) {
341
- // 如 “Batch of more than 3 requests are not allowed”:按限制缩小批量后重发;
578
+ // 如 “Batch of more than 3 requests are not allowed”:按限制缩小批量后重发(一段时间后恢复);
342
579
  // 限制不小于当前批量(节点的上限判断有偏差)或解析不出数字时,降级为逐条请求,避免无限重发
343
580
  const max = Number(/more than (\d+)/i.exec(limited.error?.message ?? '')?.[1]);
344
581
  if (Number.isFinite(max) && max > 1 && max < items.length) {
345
- this.#maxBatchSize = max;
582
+ this.#limitBatchSize(max);
346
583
  await this.#redispatch(items);
347
584
  }
348
585
  else {
@@ -365,23 +602,31 @@ export class HttpRpc {
365
602
  }
366
603
  });
367
604
  if (limitedItems.length) {
368
- await new Promise((r) => setTimeout(r, 500));
605
+ const wait = retryDelay(0, posted.retryAfter);
606
+ if (wait === null) {
607
+ limitedItems.forEach((item) => settle(item, byId.get(ids[items.indexOf(item)])));
608
+ return;
609
+ }
610
+ await sleep(wait);
369
611
  await Promise.all(limitedItems.map((item) => this.#single(item, 0)));
370
612
  }
371
613
  }
372
- /** 按当前的 maxBatchSize 重新分批发送(每批都比原来小,不会无限循环) */
614
+ /** 按当前的批量大小重新分批发送(每批都比原来小,不会无限循环) */
373
615
  async #redispatch(items) {
374
- await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)));
616
+ await Promise.all(this.#split(items.filter(live)).map((chunk) => this.#dispatch(chunk)));
375
617
  }
376
618
  /** 逐条发送一个调用;返回是否拿到了结果(没有出错) */
377
619
  async #single(item, retries = this.#retries) {
620
+ if (!live(item)) {
621
+ return false;
622
+ }
378
623
  try {
379
- const body = (await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params }, retries));
624
+ const { body } = await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params }, [item], retries);
380
625
  settle(item, body);
381
626
  return !body?.error;
382
627
  }
383
628
  catch (err) {
384
- item.reject(err);
629
+ rejectItem(item, err);
385
630
  return false;
386
631
  }
387
632
  }
@@ -389,62 +634,109 @@ export class HttpRpc {
389
634
  #disableBatch() {
390
635
  this.#batchDisabledUntil = Date.now() + BATCH_RETRY_AFTER;
391
636
  }
392
- async #post(payload, retries = this.#retries) {
393
- for (let attempt = 0;; attempt++) {
394
- let response;
395
- try {
396
- response = await this.#fetch(this.url, {
397
- method: 'POST',
398
- headers: this.#headers,
399
- body: JSON.stringify(payload),
400
- signal: this.#timeout > 0 && Number.isFinite(this.#timeout) ? AbortSignal.timeout(this.#timeout) : undefined,
401
- });
402
- }
403
- catch (err) {
404
- // AbortSignal.timeout 触发时是 TimeoutError(DOMException)
405
- const timeout = err?.name === 'TimeoutError';
406
- throw new HttpError(`Request to ${this.#safeUrl} failed: ${err?.message ?? err}`, undefined, timeout);
407
- }
408
- if (response.status === 429 && attempt < retries) {
409
- await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
410
- continue;
411
- }
412
- let text;
413
- try {
414
- text = await response.text();
415
- }
416
- catch (err) {
417
- // 读响应体时超时或连接中断:同样包装成 HttpError(原生 DOMException / TypeError 不会被当成可恢复的节点问题)
418
- const timeout = err?.name === 'TimeoutError';
419
- throw new HttpError(`Reading response from ${this.#safeUrl} failed: ${err?.message ?? err}`, response.status, timeout);
420
- }
421
- let body;
422
- try {
423
- body = parseJson(text);
424
- }
425
- catch {
426
- throw new HttpError(`Invalid JSON from ${this.#safeUrl} (HTTP ${response.status})`, response.status);
427
- }
428
- // 非 2xx 但返回了 JSON-RPC 结构(如 403 Access forbidden、drpc 的 500 + 批量错误数组):交给上层解析
429
- if (!response.ok && !Array.isArray(body) && !body?.error) {
430
- throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}`, response.status);
431
- }
432
- // 5xx + 单个 JSON-RPC 错误:
433
- // - 繁忙、过载等明确的临时故障:按 HTTP 错误处理,直接换节点(不重试、不逐条,不给过载的节点加量)
434
- // - -32603 internal error:含义不明确,标记 rpcError,由 #dispatch 判断是临时故障还是节点用 5xx 拒绝批量
435
- // 其他错误(方法数量超限、账户数超限、参数错误、minContextSlot、限频等)照常交给上层按类型处理
436
- const bodyError = response.status >= 500 && !Array.isArray(body) ? body?.error : undefined;
437
- const kind = bodyError ? classifyRpcError(bodyError.code, bodyError.message ?? '') : undefined;
438
- if (kind === 'transient' || kind === 'internal') {
439
- const message = bodyError?.message;
440
- throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}${message ? `: ${message}` : ''}`, response.status, false, kind === 'internal');
441
- }
442
- // 有的节点限频时返回 HTTP 200 + JSON-RPC 错误码 429:同样退避重试
443
- if (attempt < retries && isRateLimited(body)) {
444
- await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
445
- continue;
637
+ /**
638
+ * 发一次 HTTP 请求(含 429 退避重试)。items 是这次请求的成员:都被调用方取消时中止 fetch / 退避等待,抛出取消的 reason。
639
+ * 同一节点地址同时在途的请求数受 maxConcurrency 限制:排队最多等 timeout 毫秒(超时抛 HttpError timeout,交给上层换节点),发出后重新计时
640
+ */
641
+ async #post(payload, items, retries = this.#retries) {
642
+ const post = { items, controller: new AbortController() };
643
+ const cancel = post.controller.signal;
644
+ items.forEach((item) => item.posts.add(post));
645
+ const slots = trackedEntry(slotsByUrl, this.url, () => ({ active: 0, waiting: [] }));
646
+ try {
647
+ for (let attempt = 0;; attempt++) {
648
+ const limited = this.#timeout > 0 && Number.isFinite(this.#timeout);
649
+ // 排队等名额也受 timeout 限制:同一地址的其他连接把名额占住不放(如 timeout: 0 的请求挂住)时不会无限等下去
650
+ const queueTimeout = limited ? AbortSignal.timeout(this.#timeout) : null;
651
+ try {
652
+ await acquireSlot(slots, this.#maxConcurrency, queueTimeout ? anySignal([cancel, queueTimeout]) : cancel);
653
+ }
654
+ catch (err) {
655
+ if (cancel.aborted) {
656
+ throw cancel.reason;
657
+ }
658
+ throw new HttpError(`Request to ${this.#safeUrl} timed out waiting for a free connection slot (maxConcurrency ${this.#maxConcurrency})`, undefined, true);
659
+ }
660
+ let response;
661
+ let text = '';
662
+ /** 429 后要等多久再重试;null 表示不重试 */
663
+ let wait = null;
664
+ try {
665
+ const signal = limited ? anySignal([cancel, AbortSignal.timeout(this.#timeout)]) : cancel;
666
+ try {
667
+ response = await this.#fetch(this.url, { method: 'POST', headers: this.#headers, body: JSON.stringify(payload), signal });
668
+ }
669
+ catch (err) {
670
+ if (cancel.aborted) {
671
+ throw cancel.reason;
672
+ }
673
+ // AbortSignal.timeout 触发时是 TimeoutError(DOMException)
674
+ const timeout = err?.name === 'TimeoutError';
675
+ throw new HttpError(`Request to ${this.#safeUrl} failed: ${err?.message ?? err}`, undefined, timeout);
676
+ }
677
+ if (response.status === 429 && attempt < retries) {
678
+ wait = retryDelay(attempt, response.headers?.get?.('retry-after'));
679
+ }
680
+ if (wait !== null) {
681
+ void response.body?.cancel().catch(() => { });
682
+ }
683
+ else {
684
+ try {
685
+ text = await response.text();
686
+ }
687
+ catch (err) {
688
+ if (cancel.aborted) {
689
+ throw cancel.reason;
690
+ }
691
+ // 读响应体时超时或连接中断:同样包装成 HttpError(原生 DOMException / TypeError 不会被当成可恢复的节点问题)
692
+ const timeout = err?.name === 'TimeoutError';
693
+ throw new HttpError(`Reading response from ${this.#safeUrl} failed: ${err?.message ?? err}`, response.status, timeout);
694
+ }
695
+ }
696
+ }
697
+ finally {
698
+ releaseSlot(slots);
699
+ }
700
+ if (wait !== null) {
701
+ // 退避期间不占并发名额
702
+ await sleep(wait, cancel);
703
+ continue;
704
+ }
705
+ let body;
706
+ try {
707
+ body = parseJson(text);
708
+ }
709
+ catch {
710
+ throw new HttpError(`Invalid JSON from ${this.#safeUrl} (HTTP ${response.status})`, response.status);
711
+ }
712
+ // 非 2xx 但返回了 JSON-RPC 结构(如 403 Access forbidden、drpc 的 500 + 批量错误数组):交给上层解析
713
+ if (!response.ok && !Array.isArray(body) && !body?.error) {
714
+ throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}`, response.status);
715
+ }
716
+ // 5xx + 单个 JSON-RPC 错误:
717
+ // - 繁忙、过载等明确的临时故障:按 HTTP 错误处理,直接换节点(不重试、不逐条,不给过载的节点加量)
718
+ // - -32603 internal error:含义不明确,标记 rpcError,由 #dispatch 判断是临时故障还是节点用 5xx 拒绝批量
719
+ // 其他错误(方法数量超限、账户数超限、参数错误、minContextSlot、限频等)照常交给上层按类型处理
720
+ const bodyError = response.status >= 500 && !Array.isArray(body) ? body?.error : undefined;
721
+ const kind = bodyError ? classifyRpcError(bodyError.code, bodyError.message ?? '') : undefined;
722
+ if (kind === 'transient' || kind === 'internal') {
723
+ const message = bodyError?.message;
724
+ throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}${message ? `: ${message}` : ''}`, response.status, false, kind === 'internal');
725
+ }
726
+ const retryAfter = response.headers?.get?.('retry-after') ?? null;
727
+ // 有的节点限频时返回 HTTP 200 + JSON-RPC 错误码 429:同样退避重试
728
+ if (attempt < retries && isRateLimited(body)) {
729
+ const wait = retryDelay(attempt, retryAfter);
730
+ if (wait !== null) {
731
+ await sleep(wait, cancel);
732
+ continue;
733
+ }
734
+ }
735
+ return { body, retryAfter };
446
736
  }
447
- return body;
737
+ }
738
+ finally {
739
+ items.forEach((item) => item.posts.delete(post));
448
740
  }
449
741
  }
450
742
  }
@@ -486,13 +778,13 @@ function isRateLimited(body) {
486
778
  }
487
779
  function settle(item, response) {
488
780
  if (!response) {
489
- item.reject(new HttpError('Missing response in JSON-RPC batch'));
781
+ rejectItem(item, new HttpError('Missing response in JSON-RPC batch'));
490
782
  }
491
783
  else if (response.error) {
492
- item.reject(new RpcError(response.error.message ?? 'JSON-RPC error', response.error.code, response.error.data));
784
+ rejectItem(item, new RpcError(response.error.message ?? 'JSON-RPC error', response.error.code, response.error.data));
493
785
  }
494
786
  else {
495
- item.resolve(response.result);
787
+ resolveItem(item, response.result);
496
788
  }
497
789
  }
498
790
  const requestListeners = new Set();
@@ -549,7 +841,6 @@ function unwrap(node) {
549
841
  // 节点健康状态按节点共享(模块级):客户端重建后,刚出过错的节点仍排在后面。URL 节点按完整 URL,其他按对象
550
842
  const healthByUrl = new Map();
551
843
  let healthByObject = new WeakMap();
552
- const MAX_TRACKED_URLS = 1000;
553
844
  /** 连续超时这么多次才冷却(单次超时多半只是这次请求慢) */
554
845
  const TIMEOUTS_BEFORE_COOLDOWN = 3;
555
846
  /** 健康状态的 key:HttpRpc 按完整 URL(不同连接对象共享),其他节点按对象(去掉校验层) */
@@ -560,15 +851,7 @@ function healthKeyOf(node) {
560
851
  /** 每次用时按 key 从全局表里取:表淘汰条目后,各实例仍取到同一个对象 */
561
852
  function healthOf(key) {
562
853
  if (typeof key === 'string') {
563
- let health = healthByUrl.get(key);
564
- if (!health) {
565
- health = { failedAt: 0, timeouts: 0 };
566
- healthByUrl.set(key, health);
567
- if (healthByUrl.size > MAX_TRACKED_URLS) {
568
- healthByUrl.delete(healthByUrl.keys().next().value);
569
- }
570
- }
571
- return health;
854
+ return trackedEntry(healthByUrl, key, () => ({ failedAt: 0, timeouts: 0 }));
572
855
  }
573
856
  let health = healthByObject.get(key);
574
857
  if (!health) {
@@ -606,17 +889,23 @@ export class FallbackRpc {
606
889
  this.#labels = nodes.map(nodeLabel);
607
890
  this.#healthKeys = nodes.map(healthKeyOf);
608
891
  }
609
- async request(method, params) {
892
+ async request(method, params, options) {
893
+ const signal = options?.signal;
610
894
  const errors = [];
611
895
  const order = this.#order();
612
896
  for (let attempt = 0; attempt < order.length; attempt++) {
897
+ // 已取消:不再发、不再换节点
898
+ if (signal?.aborted) {
899
+ throw signal.reason;
900
+ }
613
901
  const index = order[attempt];
614
902
  const node = this.nodes[index];
615
903
  const label = this.#labels[index];
616
904
  const health = healthOf(this.#healthKeys[index]);
617
905
  const started = Date.now();
618
906
  try {
619
- const result = await node.request(method, params);
907
+ // 没有 signal 时按两个参数调用(自定义传输不受影响)
908
+ const result = await (signal ? node.request(method, params, { signal }) : node.request(method, params));
620
909
  health.failedAt = 0;
621
910
  health.timeouts = 0;
622
911
  emit({ node: label, method, ms: Date.now() - started, ok: true, attempt });
@@ -624,6 +913,10 @@ export class FallbackRpc {
624
913
  }
625
914
  catch (err) {
626
915
  emit({ node: label, method, ms: Date.now() - started, ok: false, error: err, attempt });
916
+ // 调用方取消导致的失败:不算节点故障,也不换节点
917
+ if (signal?.aborted) {
918
+ throw signal.reason;
919
+ }
627
920
  if (!isNodeFault(err)) {
628
921
  throw err;
629
922
  }