@yunzai-ng/core 0.1.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 (357) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +51 -0
  3. package/dist/adapter/accounts.d.ts +174 -0
  4. package/dist/adapter/accounts.d.ts.map +1 -0
  5. package/dist/adapter/accounts.js +653 -0
  6. package/dist/adapter/accounts.js.map +1 -0
  7. package/dist/adapter/bots.d.ts +142 -0
  8. package/dist/adapter/bots.d.ts.map +1 -0
  9. package/dist/adapter/bots.js +454 -0
  10. package/dist/adapter/bots.js.map +1 -0
  11. package/dist/adapter/host.d.ts +114 -0
  12. package/dist/adapter/host.d.ts.map +1 -0
  13. package/dist/adapter/host.js +90 -0
  14. package/dist/adapter/host.js.map +1 -0
  15. package/dist/adapter/login.d.ts +174 -0
  16. package/dist/adapter/login.d.ts.map +1 -0
  17. package/dist/adapter/login.js +345 -0
  18. package/dist/adapter/login.js.map +1 -0
  19. package/dist/adapter/registry.d.ts +102 -0
  20. package/dist/adapter/registry.d.ts.map +1 -0
  21. package/dist/adapter/registry.js +150 -0
  22. package/dist/adapter/registry.js.map +1 -0
  23. package/dist/config/core-config.d.ts +151 -0
  24. package/dist/config/core-config.d.ts.map +1 -0
  25. package/dist/config/core-config.js +338 -0
  26. package/dist/config/core-config.js.map +1 -0
  27. package/dist/config/schema.d.ts +449 -0
  28. package/dist/config/schema.d.ts.map +1 -0
  29. package/dist/config/schema.js +871 -0
  30. package/dist/config/schema.js.map +1 -0
  31. package/dist/config/store.d.ts +184 -0
  32. package/dist/config/store.d.ts.map +1 -0
  33. package/dist/config/store.js +428 -0
  34. package/dist/config/store.js.map +1 -0
  35. package/dist/config/yaml.d.ts +23 -0
  36. package/dist/config/yaml.d.ts.map +1 -0
  37. package/dist/config/yaml.js +108 -0
  38. package/dist/config/yaml.js.map +1 -0
  39. package/dist/http/client.d.ts +82 -0
  40. package/dist/http/client.d.ts.map +1 -0
  41. package/dist/http/client.js +658 -0
  42. package/dist/http/client.js.map +1 -0
  43. package/dist/index.d.ts +77 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +94 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/kernel/app.d.ts +244 -0
  48. package/dist/kernel/app.d.ts.map +1 -0
  49. package/dist/kernel/app.js +654 -0
  50. package/dist/kernel/app.js.map +1 -0
  51. package/dist/kernel/kv-sink.d.ts +34 -0
  52. package/dist/kernel/kv-sink.d.ts.map +1 -0
  53. package/dist/kernel/kv-sink.js +25 -0
  54. package/dist/kernel/kv-sink.js.map +1 -0
  55. package/dist/kernel/policy.d.ts +77 -0
  56. package/dist/kernel/policy.d.ts.map +1 -0
  57. package/dist/kernel/policy.js +99 -0
  58. package/dist/kernel/policy.js.map +1 -0
  59. package/dist/kernel/runtime.d.ts +117 -0
  60. package/dist/kernel/runtime.d.ts.map +1 -0
  61. package/dist/kernel/runtime.js +197 -0
  62. package/dist/kernel/runtime.js.map +1 -0
  63. package/dist/kernel/sql-sink.d.ts +36 -0
  64. package/dist/kernel/sql-sink.d.ts.map +1 -0
  65. package/dist/kernel/sql-sink.js +114 -0
  66. package/dist/kernel/sql-sink.js.map +1 -0
  67. package/dist/logger/format.d.ts +67 -0
  68. package/dist/logger/format.d.ts.map +1 -0
  69. package/dist/logger/format.js +218 -0
  70. package/dist/logger/format.js.map +1 -0
  71. package/dist/logger/index.d.ts +97 -0
  72. package/dist/logger/index.d.ts.map +1 -0
  73. package/dist/logger/index.js +363 -0
  74. package/dist/logger/index.js.map +1 -0
  75. package/dist/logger/rotate.d.ts +47 -0
  76. package/dist/logger/rotate.d.ts.map +1 -0
  77. package/dist/logger/rotate.js +242 -0
  78. package/dist/logger/rotate.js.map +1 -0
  79. package/dist/message/segment.d.ts +255 -0
  80. package/dist/message/segment.d.ts.map +1 -0
  81. package/dist/message/segment.js +510 -0
  82. package/dist/message/segment.js.map +1 -0
  83. package/dist/message/split.d.ts +34 -0
  84. package/dist/message/split.d.ts.map +1 -0
  85. package/dist/message/split.js +79 -0
  86. package/dist/message/split.js.map +1 -0
  87. package/dist/message/target.d.ts +29 -0
  88. package/dist/message/target.d.ts.map +1 -0
  89. package/dist/message/target.js +34 -0
  90. package/dist/message/target.js.map +1 -0
  91. package/dist/pipeline/cooldown.d.ts +84 -0
  92. package/dist/pipeline/cooldown.d.ts.map +1 -0
  93. package/dist/pipeline/cooldown.js +94 -0
  94. package/dist/pipeline/cooldown.js.map +1 -0
  95. package/dist/pipeline/dispatch.d.ts +98 -0
  96. package/dist/pipeline/dispatch.d.ts.map +1 -0
  97. package/dist/pipeline/dispatch.js +323 -0
  98. package/dist/pipeline/dispatch.js.map +1 -0
  99. package/dist/pipeline/event.d.ts +130 -0
  100. package/dist/pipeline/event.d.ts.map +1 -0
  101. package/dist/pipeline/event.js +538 -0
  102. package/dist/pipeline/event.js.map +1 -0
  103. package/dist/pipeline/middleware.d.ts +63 -0
  104. package/dist/pipeline/middleware.d.ts.map +1 -0
  105. package/dist/pipeline/middleware.js +174 -0
  106. package/dist/pipeline/middleware.js.map +1 -0
  107. package/dist/pipeline/prompt.d.ts +80 -0
  108. package/dist/pipeline/prompt.d.ts.map +1 -0
  109. package/dist/pipeline/prompt.js +155 -0
  110. package/dist/pipeline/prompt.js.map +1 -0
  111. package/dist/pipeline/router.d.ts +98 -0
  112. package/dist/pipeline/router.d.ts.map +1 -0
  113. package/dist/pipeline/router.js +489 -0
  114. package/dist/pipeline/router.js.map +1 -0
  115. package/dist/platform/detect.d.ts +24 -0
  116. package/dist/platform/detect.d.ts.map +1 -0
  117. package/dist/platform/detect.js +178 -0
  118. package/dist/platform/detect.js.map +1 -0
  119. package/dist/platform/paths.d.ts +38 -0
  120. package/dist/platform/paths.d.ts.map +1 -0
  121. package/dist/platform/paths.js +129 -0
  122. package/dist/platform/paths.js.map +1 -0
  123. package/dist/platform/system.d.ts +71 -0
  124. package/dist/platform/system.d.ts.map +1 -0
  125. package/dist/platform/system.js +242 -0
  126. package/dist/platform/system.js.map +1 -0
  127. package/dist/plugin/context.d.ts +92 -0
  128. package/dist/plugin/context.d.ts.map +1 -0
  129. package/dist/plugin/context.js +573 -0
  130. package/dist/plugin/context.js.map +1 -0
  131. package/dist/plugin/define.d.ts +92 -0
  132. package/dist/plugin/define.d.ts.map +1 -0
  133. package/dist/plugin/define.js +82 -0
  134. package/dist/plugin/define.js.map +1 -0
  135. package/dist/plugin/discover.d.ts +106 -0
  136. package/dist/plugin/discover.d.ts.map +1 -0
  137. package/dist/plugin/discover.js +254 -0
  138. package/dist/plugin/discover.js.map +1 -0
  139. package/dist/plugin/events.d.ts +109 -0
  140. package/dist/plugin/events.d.ts.map +1 -0
  141. package/dist/plugin/events.js +209 -0
  142. package/dist/plugin/events.js.map +1 -0
  143. package/dist/plugin/hooks.d.ts +266 -0
  144. package/dist/plugin/hooks.d.ts.map +1 -0
  145. package/dist/plugin/hooks.js +84 -0
  146. package/dist/plugin/hooks.js.map +1 -0
  147. package/dist/plugin/host.d.ts +160 -0
  148. package/dist/plugin/host.d.ts.map +1 -0
  149. package/dist/plugin/host.js +546 -0
  150. package/dist/plugin/host.js.map +1 -0
  151. package/dist/plugin/market.d.ts +264 -0
  152. package/dist/plugin/market.d.ts.map +1 -0
  153. package/dist/plugin/market.js +700 -0
  154. package/dist/plugin/market.js.map +1 -0
  155. package/dist/plugin/services.d.ts +116 -0
  156. package/dist/plugin/services.d.ts.map +1 -0
  157. package/dist/plugin/services.js +213 -0
  158. package/dist/plugin/services.js.map +1 -0
  159. package/dist/plugin/tar.d.ts +65 -0
  160. package/dist/plugin/tar.d.ts.map +1 -0
  161. package/dist/plugin/tar.js +277 -0
  162. package/dist/plugin/tar.js.map +1 -0
  163. package/dist/render/registry.d.ts +127 -0
  164. package/dist/render/registry.d.ts.map +1 -0
  165. package/dist/render/registry.js +306 -0
  166. package/dist/render/registry.js.map +1 -0
  167. package/dist/scheduler/index.d.ts +67 -0
  168. package/dist/scheduler/index.d.ts.map +1 -0
  169. package/dist/scheduler/index.js +279 -0
  170. package/dist/scheduler/index.js.map +1 -0
  171. package/dist/server/api.d.ts +179 -0
  172. package/dist/server/api.d.ts.map +1 -0
  173. package/dist/server/api.js +602 -0
  174. package/dist/server/api.js.map +1 -0
  175. package/dist/server/auth.d.ts +83 -0
  176. package/dist/server/auth.d.ts.map +1 -0
  177. package/dist/server/auth.js +178 -0
  178. package/dist/server/auth.js.map +1 -0
  179. package/dist/server/browse.d.ts +60 -0
  180. package/dist/server/browse.d.ts.map +1 -0
  181. package/dist/server/browse.js +187 -0
  182. package/dist/server/browse.js.map +1 -0
  183. package/dist/server/files.d.ts +27 -0
  184. package/dist/server/files.d.ts.map +1 -0
  185. package/dist/server/files.js +76 -0
  186. package/dist/server/files.js.map +1 -0
  187. package/dist/server/index.d.ts +189 -0
  188. package/dist/server/index.d.ts.map +1 -0
  189. package/dist/server/index.js +1123 -0
  190. package/dist/server/index.js.map +1 -0
  191. package/dist/server/table.d.ts +105 -0
  192. package/dist/server/table.d.ts.map +1 -0
  193. package/dist/server/table.js +289 -0
  194. package/dist/server/table.js.map +1 -0
  195. package/dist/store/index.d.ts +50 -0
  196. package/dist/store/index.d.ts.map +1 -0
  197. package/dist/store/index.js +120 -0
  198. package/dist/store/index.js.map +1 -0
  199. package/dist/store/json.d.ts +73 -0
  200. package/dist/store/json.d.ts.map +1 -0
  201. package/dist/store/json.js +174 -0
  202. package/dist/store/json.js.map +1 -0
  203. package/dist/store/kv.d.ts +126 -0
  204. package/dist/store/kv.d.ts.map +1 -0
  205. package/dist/store/kv.js +232 -0
  206. package/dist/store/kv.js.map +1 -0
  207. package/dist/store/level.d.ts +103 -0
  208. package/dist/store/level.d.ts.map +1 -0
  209. package/dist/store/level.js +119 -0
  210. package/dist/store/level.js.map +1 -0
  211. package/dist/store/memory.d.ts +61 -0
  212. package/dist/store/memory.d.ts.map +1 -0
  213. package/dist/store/memory.js +94 -0
  214. package/dist/store/memory.js.map +1 -0
  215. package/dist/store/sql.d.ts +142 -0
  216. package/dist/store/sql.d.ts.map +1 -0
  217. package/dist/store/sql.js +246 -0
  218. package/dist/store/sql.js.map +1 -0
  219. package/dist/testing/fake.d.ts +109 -0
  220. package/dist/testing/fake.d.ts.map +1 -0
  221. package/dist/testing/fake.js +196 -0
  222. package/dist/testing/fake.js.map +1 -0
  223. package/dist/testing/index.d.ts +16 -0
  224. package/dist/testing/index.d.ts.map +1 -0
  225. package/dist/testing/index.js +16 -0
  226. package/dist/testing/index.js.map +1 -0
  227. package/dist/testing/mock-adapter.d.ts +205 -0
  228. package/dist/testing/mock-adapter.d.ts.map +1 -0
  229. package/dist/testing/mock-adapter.js +309 -0
  230. package/dist/testing/mock-adapter.js.map +1 -0
  231. package/dist/util/deep.d.ts +108 -0
  232. package/dist/util/deep.d.ts.map +1 -0
  233. package/dist/util/deep.js +275 -0
  234. package/dist/util/deep.js.map +1 -0
  235. package/dist/util/defer.d.ts +107 -0
  236. package/dist/util/defer.d.ts.map +1 -0
  237. package/dist/util/defer.js +163 -0
  238. package/dist/util/defer.js.map +1 -0
  239. package/dist/util/dispose.d.ts +88 -0
  240. package/dist/util/dispose.d.ts.map +1 -0
  241. package/dist/util/dispose.js +143 -0
  242. package/dist/util/dispose.js.map +1 -0
  243. package/dist/util/duration.d.ts +28 -0
  244. package/dist/util/duration.d.ts.map +1 -0
  245. package/dist/util/duration.js +77 -0
  246. package/dist/util/duration.js.map +1 -0
  247. package/dist/util/fs.d.ts +110 -0
  248. package/dist/util/fs.d.ts.map +1 -0
  249. package/dist/util/fs.js +269 -0
  250. package/dist/util/fs.js.map +1 -0
  251. package/dist/util/id.d.ts +49 -0
  252. package/dist/util/id.d.ts.map +1 -0
  253. package/dist/util/id.js +87 -0
  254. package/dist/util/id.js.map +1 -0
  255. package/dist/util/lru.d.ts +74 -0
  256. package/dist/util/lru.d.ts.map +1 -0
  257. package/dist/util/lru.js +156 -0
  258. package/dist/util/lru.js.map +1 -0
  259. package/dist/util/queue.d.ts +64 -0
  260. package/dist/util/queue.d.ts.map +1 -0
  261. package/dist/util/queue.js +147 -0
  262. package/dist/util/queue.js.map +1 -0
  263. package/dist/util/text.d.ts +86 -0
  264. package/dist/util/text.d.ts.map +1 -0
  265. package/dist/util/text.js +182 -0
  266. package/dist/util/text.js.map +1 -0
  267. package/package.json +44 -0
  268. package/src/adapter/accounts.ts +758 -0
  269. package/src/adapter/bots.ts +582 -0
  270. package/src/adapter/host.ts +224 -0
  271. package/src/adapter/login.ts +481 -0
  272. package/src/adapter/registry.ts +202 -0
  273. package/src/config/core-config.test.ts +60 -0
  274. package/src/config/core-config.ts +401 -0
  275. package/src/config/schema.test.ts +179 -0
  276. package/src/config/schema.ts +1036 -0
  277. package/src/config/store.test.ts +245 -0
  278. package/src/config/store.ts +506 -0
  279. package/src/config/yaml.ts +119 -0
  280. package/src/http/client.test.ts +568 -0
  281. package/src/http/client.ts +777 -0
  282. package/src/index.ts +106 -0
  283. package/src/kernel/app.test.ts +267 -0
  284. package/src/kernel/app.ts +843 -0
  285. package/src/kernel/kv-sink.ts +58 -0
  286. package/src/kernel/policy.ts +120 -0
  287. package/src/kernel/runtime.test.ts +542 -0
  288. package/src/kernel/runtime.ts +307 -0
  289. package/src/kernel/sql-sink.ts +154 -0
  290. package/src/logger/format.ts +259 -0
  291. package/src/logger/index.ts +427 -0
  292. package/src/logger/rotate.ts +265 -0
  293. package/src/message/segment.test.ts +335 -0
  294. package/src/message/segment.ts +569 -0
  295. package/src/message/split.ts +97 -0
  296. package/src/message/target.ts +48 -0
  297. package/src/pipeline/cooldown.ts +136 -0
  298. package/src/pipeline/dispatch.ts +419 -0
  299. package/src/pipeline/event.ts +735 -0
  300. package/src/pipeline/middleware.test.ts +426 -0
  301. package/src/pipeline/middleware.ts +199 -0
  302. package/src/pipeline/prompt.ts +212 -0
  303. package/src/pipeline/router.test.ts +432 -0
  304. package/src/pipeline/router.ts +575 -0
  305. package/src/platform/detect.ts +178 -0
  306. package/src/platform/paths.ts +147 -0
  307. package/src/platform/system.test.ts +102 -0
  308. package/src/platform/system.ts +289 -0
  309. package/src/plugin/context.test.ts +459 -0
  310. package/src/plugin/context.ts +757 -0
  311. package/src/plugin/define.test.ts +120 -0
  312. package/src/plugin/define.ts +142 -0
  313. package/src/plugin/discover.test.ts +253 -0
  314. package/src/plugin/discover.ts +353 -0
  315. package/src/plugin/events.test.ts +162 -0
  316. package/src/plugin/events.ts +266 -0
  317. package/src/plugin/hooks.ts +373 -0
  318. package/src/plugin/host.test.ts +696 -0
  319. package/src/plugin/host.ts +738 -0
  320. package/src/plugin/market.test.ts +781 -0
  321. package/src/plugin/market.ts +870 -0
  322. package/src/plugin/services.test.ts +135 -0
  323. package/src/plugin/services.ts +261 -0
  324. package/src/plugin/tar.test.ts +158 -0
  325. package/src/plugin/tar.ts +309 -0
  326. package/src/render/registry.test.ts +127 -0
  327. package/src/render/registry.ts +439 -0
  328. package/src/scheduler/index.ts +357 -0
  329. package/src/server/api.test.ts +802 -0
  330. package/src/server/api.ts +869 -0
  331. package/src/server/auth.ts +201 -0
  332. package/src/server/browse.test.ts +193 -0
  333. package/src/server/browse.ts +240 -0
  334. package/src/server/files.ts +97 -0
  335. package/src/server/index.test.ts +566 -0
  336. package/src/server/index.ts +1386 -0
  337. package/src/server/table.ts +351 -0
  338. package/src/store/index.ts +156 -0
  339. package/src/store/json.ts +198 -0
  340. package/src/store/kv.test.ts +307 -0
  341. package/src/store/kv.ts +256 -0
  342. package/src/store/level.ts +167 -0
  343. package/src/store/memory.ts +109 -0
  344. package/src/store/sql.test.ts +204 -0
  345. package/src/store/sql.ts +324 -0
  346. package/src/testing/fake.ts +297 -0
  347. package/src/testing/index.ts +15 -0
  348. package/src/testing/mock-adapter.ts +588 -0
  349. package/src/util/deep.ts +272 -0
  350. package/src/util/defer.ts +211 -0
  351. package/src/util/dispose.ts +169 -0
  352. package/src/util/duration.ts +82 -0
  353. package/src/util/fs.ts +279 -0
  354. package/src/util/id.ts +94 -0
  355. package/src/util/lru.ts +185 -0
  356. package/src/util/queue.ts +167 -0
  357. package/src/util/text.ts +189 -0
@@ -0,0 +1,777 @@
1
+ /**
2
+ * 模块职责:统一 HTTP 客户端(undici)—— 超时、重试、代理、限长、流式下载
3
+ * 依赖方向:依赖 undici、util/{defer,duration,fs,text}、类型包;不依赖任何插件
4
+ * 生命周期:应用级单例(`createHttpClient` 创建一份,`close()` 时释放连接池)
5
+ * 注意事项:此处是全框架**唯一**发起 HTTP 请求的位置,插件经 `ctx.http` 取得它 —— 各处混用不同
6
+ * 客户端时,代理要各配一遍、超时各实现一套,国内网络下「某个插件没反应」得查三份实现。
7
+ *
8
+ * **连接池必须复用。** `ProxyAgent` / `Agent` 按代理地址缓存,绝不每次请求新建 ——
9
+ * 那会丢掉全部 socket 与 TLS 会话,高频接口退化成每次重新握手。
10
+ *
11
+ * **响应体必须被消费。** undici 的 body 没读完时连接不归还连接池,故丢弃响应
12
+ * (`responseType: "none"`、重试前、报错时)一律显式 `dump()`。
13
+ *
14
+ * **缓冲读取必须有上限。** `maxBodySize` 缺省 64 MiB —— 对端返回一个无限流即可让机器人
15
+ * OOM,而图床故障时返回 HTML 死循环并不罕见。
16
+ *
17
+ * **报错信息不得泄露凭据。** URL 里的 authkey / token 一律打码后才写日志,否则使用者
18
+ * 贴日志求助时会连带泄露。
19
+ */
20
+ import { createWriteStream } from "node:fs"
21
+ import { rename, unlink } from "node:fs/promises"
22
+ import { dirname } from "node:path"
23
+ import { pipeline as pipelineTo, type Readable } from "node:stream"
24
+ import { pipeline } from "node:stream/promises"
25
+ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"
26
+ import { Agent, ProxyAgent, interceptors, request as undiciRequest, type Dispatcher } from "undici"
27
+ import type {
28
+ HttpClient,
29
+ HttpDefaults,
30
+ HttpRequestOptions,
31
+ HttpResponse,
32
+ HttpResponseType,
33
+ HttpRetryOptions,
34
+ Logger
35
+ } from "@yunzai-ng/types"
36
+ import { AbortError, backoffDelay, sleep } from "../util/defer.js"
37
+ import { parseDuration } from "../util/duration.js"
38
+ import { ensureDir } from "../util/fs.js"
39
+ import { maskSecret } from "../util/text.js"
40
+
41
+ /** 默认超时(毫秒) */
42
+ const DEFAULT_TIMEOUT = 10_000
43
+
44
+ /** 默认缓冲上限:64 MiB */
45
+ const DEFAULT_MAX_BODY = 64 * 1024 * 1024
46
+
47
+ /** 默认跟随重定向的次数 */
48
+ const MAX_REDIRECTIONS = 5
49
+
50
+ /** 默认会重试的状态码 */
51
+ const RETRY_STATUSES: readonly number[] = [408, 429, 500, 502, 503, 504]
52
+
53
+ /** 重试等待的上限(毫秒):`Retry-After` 说等一小时也不真等 */
54
+ const MAX_RETRY_DELAY = 30_000
55
+
56
+ /** 查询串里需要打码的键(小写比对) */
57
+ const SECRET_QUERY_KEYS: readonly string[] = [
58
+ "authkey",
59
+ "token",
60
+ "access_token",
61
+ "refresh_token",
62
+ "cookie",
63
+ "sign",
64
+ "secret",
65
+ "key",
66
+ "password",
67
+ "stoken",
68
+ "ltoken"
69
+ ]
70
+
71
+ /** 请求头里需要打码的键(小写比对) */
72
+ const SECRET_HEADER_KEYS: readonly string[] = ["cookie", "authorization", "x-rpc-device_fp", "ds"]
73
+
74
+ /** 从环境变量里读代理的顺序 */
75
+ const PROXY_ENV_KEYS: readonly string[] = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY", "ALL_PROXY"]
76
+
77
+ /** HTTP 请求失败 */
78
+ export class HttpError extends Error {
79
+ /** 错误名 */
80
+ override readonly name = "HttpError"
81
+ /** 状态码;网络层失败时为 0 */
82
+ readonly status: number
83
+ /** 请求方法 */
84
+ readonly method: string
85
+ /** 已打码的请求 URL */
86
+ readonly url: string
87
+ /** 响应体片段(截断后),便于看清对方到底回了什么 */
88
+ readonly body?: string
89
+
90
+ /**
91
+ * @param message 错误信息
92
+ * @param info 附加信息
93
+ * @param info.status 状态码
94
+ * @param info.method 请求方法
95
+ * @param info.url 已打码 URL
96
+ * @param info.body 响应体片段
97
+ * @param info.cause 底层错误
98
+ */
99
+ constructor(
100
+ message: string,
101
+ info: {
102
+ /** 状态码 */
103
+ status: number
104
+ /** 请求方法 */
105
+ method: string
106
+ /** 已打码 URL */
107
+ url: string
108
+ /** 响应体片段 */
109
+ body?: string
110
+ /** 底层错误 */
111
+ cause?: unknown
112
+ }
113
+ ) {
114
+ super(message, info.cause === undefined ? undefined : { cause: info.cause })
115
+ this.status = info.status
116
+ this.method = info.method
117
+ this.url = info.url
118
+ if (info.body !== undefined) this.body = info.body
119
+ }
120
+ }
121
+
122
+ /** 创建客户端的参数 */
123
+ export interface HttpClientOptions extends HttpDefaults {
124
+ /** 日志器(记重试与代理选择) */
125
+ logger?: Logger
126
+ /** 默认 User-Agent */
127
+ userAgent?: string
128
+ /** 缓冲读取上限字节数,缺省 64 MiB */
129
+ maxBodySize?: number
130
+ /** 每个源的最大连接数,缺省 32 */
131
+ connections?: number
132
+ /** 是否读取 `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`,缺省 true */
133
+ useEnvProxy?: boolean
134
+ }
135
+
136
+ /** 带生命周期管理的 HTTP 客户端 */
137
+ export interface ManagedHttpClient extends HttpClient {
138
+ /**
139
+ * 关掉全部连接池
140
+ *
141
+ * 不调用它,进程可能因为 keep-alive 的空闲 socket 迟迟不退出。
142
+ * 这是**终态**:关掉后再发请求会直接抛错,而不是悄悄重建一个池子。
143
+ * @returns 全部关闭后兑现
144
+ */
145
+ close(): Promise<void>
146
+ }
147
+
148
+ /** 一次请求最终生效的参数 */
149
+ interface ResolvedRequest {
150
+ /** 请求方法(大写) */
151
+ method: string
152
+ /** 完整 URL */
153
+ url: string
154
+ /** 请求头(键已小写) */
155
+ headers: Record<string, string>
156
+ /** 请求体 */
157
+ body: string | Uint8Array | undefined
158
+ /** 超时毫秒 */
159
+ timeout: number
160
+ /** 重试策略 */
161
+ retry: HttpRetryOptions
162
+ /** 响应解析方式 */
163
+ responseType: HttpResponseType
164
+ /** 代理地址;false 表示直连 */
165
+ proxy: string | false
166
+ /** 跟随重定向次数 */
167
+ redirections: number
168
+ /** 非 2xx 是否抛错 */
169
+ throwOnError: boolean
170
+ /** 外部中止信号 */
171
+ signal: AbortSignal | undefined
172
+ }
173
+
174
+ /**
175
+ * 把 URL 里的敏感查询值打码
176
+ *
177
+ * 抽卡链接的 `authkey` 有一千多字符且等于账号凭据,直接进日志等于泄号。
178
+ * @param raw 原始 URL
179
+ * @returns 可安全记录的 URL
180
+ */
181
+ export function sanitizeUrl(raw: string): string {
182
+ let parsed: URL
183
+ try {
184
+ parsed = new URL(raw)
185
+ } catch {
186
+ // 拼错的 URL 也可能带密钥,整体截断了事
187
+ return raw.length > 120 ? `${raw.slice(0, 120)}…` : raw
188
+ }
189
+
190
+ for (const key of [...parsed.searchParams.keys()]) {
191
+ if (!SECRET_QUERY_KEYS.includes(key.toLowerCase())) continue
192
+ const value = parsed.searchParams.get(key) ?? ""
193
+ parsed.searchParams.set(key, maskSecret(value))
194
+ }
195
+ return parsed.toString()
196
+ }
197
+
198
+ /**
199
+ * 把请求头里的敏感值打码
200
+ * @param headers 请求头
201
+ * @returns 可安全记录的副本
202
+ */
203
+ export function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
204
+ const safe: Record<string, string> = {}
205
+ for (const [key, value] of Object.entries(headers)) {
206
+ safe[key] = SECRET_HEADER_KEYS.includes(key.toLowerCase()) ? maskSecret(value) : value
207
+ }
208
+ return safe
209
+ }
210
+
211
+ /**
212
+ * 归一化重试选项
213
+ * @param input 数字(次数)或完整选项
214
+ * @returns 完整选项
215
+ */
216
+ function normalizeRetry(input: number | HttpRetryOptions | undefined): HttpRetryOptions {
217
+ if (input === undefined) return { times: 0 }
218
+ if (typeof input === "number") return { times: Math.max(0, Math.trunc(input)) }
219
+ return input
220
+ }
221
+
222
+ /**
223
+ * 拼出完整 URL
224
+ * @param url 绝对 URL 或相对路径
225
+ * @param baseUrl 基址
226
+ * @param query 查询参数
227
+ * @returns 完整 URL
228
+ * @throws 相对路径但没配 baseUrl 时抛错
229
+ */
230
+ function buildUrl(url: string, baseUrl: string | undefined, query: HttpRequestOptions["query"]): string {
231
+ let full: URL
232
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
233
+ full = new URL(url)
234
+ } else if (baseUrl) {
235
+ // 用 URL 的相对解析:baseUrl 带不带尾斜杠都能接上
236
+ full = new URL(url.replace(/^\/+/, ""), baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`)
237
+ } else {
238
+ throw new TypeError(`HTTP 请求的地址不是绝对 URL,且客户端没配 baseUrl:${url}`)
239
+ }
240
+
241
+ for (const [key, value] of Object.entries(query ?? {})) {
242
+ // undefined/null 直接丢掉:让调用方能写 `{ uid, region: maybeUndefined }`
243
+ // 而不必先过滤一遍
244
+ if (value === undefined || value === null) continue
245
+ full.searchParams.set(key, String(value))
246
+ }
247
+ return full.toString()
248
+ }
249
+
250
+ /**
251
+ * 把响应头折叠成小写键的字符串表
252
+ * @param raw undici 的响应头
253
+ * @returns 小写键表
254
+ */
255
+ function flattenHeaders(raw: Record<string, string | string[] | undefined>): Record<string, string> {
256
+ const out: Record<string, string> = {}
257
+ for (const [key, value] of Object.entries(raw)) {
258
+ if (value === undefined) continue
259
+ out[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value
260
+ }
261
+ return out
262
+ }
263
+
264
+ /**
265
+ * 读取响应体,超过上限就中断
266
+ *
267
+ * 不设上限的话,对方返回一个不结束的流就能把进程吃干 —— 图床故障时
268
+ * 返回无限重定向页面是真实发生过的。
269
+ * @param body 响应流
270
+ * @param max 上限字节数
271
+ * @param onOverflow 超限时用于构造错误
272
+ * @returns 字节内容
273
+ * @throws 超过上限时抛 HttpError
274
+ */
275
+ async function readBounded(body: Readable, max: number, onOverflow: (read: number) => HttpError): Promise<Uint8Array> {
276
+ const chunks: Buffer[] = []
277
+ let total = 0
278
+ for await (const chunk of body) {
279
+ const buf = chunk as Buffer
280
+ total += buf.length
281
+ if (total > max) {
282
+ body.destroy()
283
+ throw onOverflow(total)
284
+ }
285
+ chunks.push(buf)
286
+ }
287
+ return Buffer.concat(chunks, total)
288
+ }
289
+
290
+ /**
291
+ * 丢弃响应体
292
+ *
293
+ * undici 的连接要等 body 读完才还回池子;重试前、报错后如果不消费,
294
+ * 池子会被慢慢占满,症状是"跑一段时间后所有请求都卡住"。
295
+ * @param body 响应流
296
+ */
297
+ function discard(body: Readable): void {
298
+ // dump() 会把剩余数据读掉并释放连接;它自己可能因连接已断而拒绝,忽略即可
299
+ const dumpable = body as Readable & {
300
+ /**
301
+ * undici 提供的丢弃方法
302
+ * @returns 丢弃完成
303
+ */
304
+ dump?: () => Promise<void>
305
+ }
306
+ if (typeof dumpable.dump === "function") void dumpable.dump().catch(() => undefined)
307
+ else body.resume()
308
+ }
309
+
310
+ /**
311
+ * 按 `Content-Encoding` 套一层解压
312
+ *
313
+ * 刻意不使用 undici 的 `interceptors.decompress()`:它在 v7 中仍标注 experimental,
314
+ * 加载即向 stderr 输出 `ExperimentalWarning`。机器人启动时出现这样一行,
315
+ * 使用者的第一反应是"是否出现故障",而此处所需仅为三行 zlib。
316
+ *
317
+ * 使用回调版 `pipeline` 而非手工 `pipe`:它保证任一端出错或提前关闭时
318
+ * 两端均被销毁 —— 否则解压流被调用方 destroy 后,undici 一侧的 socket 即被泄漏。
319
+ * @param body 原始响应流
320
+ * @param encoding `Content-Encoding` 头值
321
+ * @returns 解压后的流;无需解压时原样返回
322
+ */
323
+ function decodeStream(body: Readable, encoding: string | undefined): Readable {
324
+ const codec = (encoding ?? "").trim().toLowerCase()
325
+ const decoder =
326
+ codec === "gzip" || codec === "x-gzip"
327
+ ? createGunzip()
328
+ : codec === "deflate"
329
+ ? createInflate()
330
+ : codec === "br"
331
+ ? createBrotliDecompress()
332
+ : undefined
333
+ if (!decoder) return body
334
+ return pipelineTo(body, decoder, () => undefined)
335
+ }
336
+
337
+ /**
338
+ * 解析 `Retry-After` 头
339
+ * @param value 头值
340
+ * @returns 毫秒;无法解析时 undefined
341
+ */
342
+ function parseRetryAfter(value: string | undefined): number | undefined {
343
+ if (!value) return undefined
344
+ const seconds = Number(value)
345
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
346
+ const date = Date.parse(value)
347
+ if (Number.isNaN(date)) return undefined
348
+ return Math.max(0, date - Date.now())
349
+ }
350
+
351
+ /**
352
+ * 判断一个网络层错误是否值得重试
353
+ *
354
+ * 仅重试再次尝试即可能成功者:连接被拒、超时、连接被对端重置。
355
+ * 4xx 语义错误与证书错误重试一百次结果相同,只会刷满日志。
356
+ * @param err 错误
357
+ * @returns 是否重试
358
+ */
359
+ function isRetryableNetworkError(err: unknown): boolean {
360
+ if (err instanceof AbortError) return false
361
+ const code = (err as { code?: unknown } | null)?.code
362
+ if (typeof code !== "string") return false
363
+ return [
364
+ "ECONNRESET",
365
+ "ECONNREFUSED",
366
+ "ENOTFOUND",
367
+ "EAI_AGAIN",
368
+ "EPIPE",
369
+ "ETIMEDOUT",
370
+ "EHOSTUNREACH",
371
+ "ENETUNREACH",
372
+ "UND_ERR_CONNECT_TIMEOUT",
373
+ "UND_ERR_HEADERS_TIMEOUT",
374
+ "UND_ERR_BODY_TIMEOUT",
375
+ "UND_ERR_SOCKET"
376
+ ].includes(code)
377
+ }
378
+
379
+ /**
380
+ * 从环境变量里挑一个代理
381
+ * @returns 代理地址;没配时 undefined
382
+ */
383
+ function proxyFromEnv(): string | undefined {
384
+ for (const key of PROXY_ENV_KEYS) {
385
+ const value = process.env[key]
386
+ if (value && value.trim()) return value.trim()
387
+ }
388
+ return undefined
389
+ }
390
+
391
+ /**
392
+ * 校验请求头是否可用于发送
393
+ *
394
+ * HTTP 头仅允许 latin1 范围内的字节,中文一律非法。插件作者遇到该问题的概率较高
395
+ *(例如将群名置入自定义头),而 undici 仅返回 `invalid x-foo header`,
396
+ * 既不说明原因亦不给出处置方式。此处提前拦截并明确给出处置方式。
397
+ * @param headers 请求头
398
+ * @throws 含非法字符时抛 TypeError
399
+ */
400
+ function assertHeaders(headers: Record<string, string>): void {
401
+ for (const [key, value] of Object.entries(headers)) {
402
+ if (!/^[\w!#$%&'*+.^`|~-]+$/.test(key)) {
403
+ throw new TypeError(`请求头名 ${JSON.stringify(key)} 含非法字符(只允许 ASCII 记号字符)`)
404
+ }
405
+ for (const ch of value) {
406
+ const code = ch.codePointAt(0) ?? 0
407
+ // 放行水平制表符与 latin1 可见字符;控制字符(含 CRLF —— 可被用于注入请求头)
408
+ // 与 U+00FF 以上的字符均不允许
409
+ if (code === 0x09 || (code >= 0x20 && code !== 0x7f && code <= 0xff)) continue
410
+ throw new TypeError(
411
+ `请求头 ${key} 的值含非法字符(HTTP 头仅允许 latin1,中文与换行均不允许)。` +
412
+ `需要传递中文请先 encodeURIComponent 或改用 Base64`
413
+ )
414
+ }
415
+ }
416
+ }
417
+
418
+ /**
419
+ * 创建 HTTP 客户端
420
+ *
421
+ * 返回的客户端是全框架共享的那一份;插件不要自己建,用 `ctx.http`。
422
+ * @param opts 客户端参数
423
+ * @returns 带 `close()` 的客户端
424
+ */
425
+ export function createHttpClient(opts: HttpClientOptions = {}): ManagedHttpClient {
426
+ const logger = opts.logger?.child({ scope: "http" })
427
+ const maxBodySize = opts.maxBodySize ?? DEFAULT_MAX_BODY
428
+ const connections = opts.connections ?? 32
429
+
430
+ /**
431
+ * 是否已 `close()`
432
+ *
433
+ * 关掉之后如果还允许发请求,连接池会被静默重建,于是 `close()` 等于没调 ——
434
+ * 进程照旧被 keep-alive 的空闲 socket 吊着不退出,且没有任何报错可查。
435
+ * 所以 `close()` 是终态,之后的请求一律拒绝。
436
+ */
437
+ let closed = false
438
+
439
+ /** 基础连接池:按代理地址缓存,`""` 是直连 */
440
+ const agents = new Map<string, Agent | ProxyAgent>()
441
+ /** 组合后的派发器:键为 `代理|重定向次数` */
442
+ const dispatchers = new Map<string, Dispatcher>()
443
+
444
+ const envProxy = (opts.useEnvProxy ?? true) ? proxyFromEnv() : undefined
445
+ if (envProxy && opts.proxy === undefined) {
446
+ logger?.info(`已从环境变量读到代理:${sanitizeUrl(envProxy)}`)
447
+ }
448
+
449
+ /**
450
+ * 取(或建)一个基础连接池
451
+ * @param proxy 代理地址;空串表示直连
452
+ * @returns 连接池
453
+ */
454
+ function baseAgent(proxy: string): Agent | ProxyAgent {
455
+ let agent = agents.get(proxy)
456
+ if (agent) return agent
457
+ agent = proxy
458
+ ? new ProxyAgent({ uri: proxy, connections, keepAliveTimeout: 10_000, keepAliveMaxTimeout: 60_000 })
459
+ : new Agent({ connections, keepAliveTimeout: 10_000, keepAliveMaxTimeout: 60_000 })
460
+ agents.set(proxy, agent)
461
+ return agent
462
+ }
463
+
464
+ /**
465
+ * 取(或建)一个带拦截器的派发器
466
+ *
467
+ * 拦截器只有"重定向次数"这一个变量,所以每个代理最多缓存两份
468
+ * (跟随 / 不跟随),不会随请求数增长。
469
+ * @param proxy 代理地址;空串表示直连
470
+ * @param redirections 跟随重定向次数
471
+ * @returns 派发器
472
+ */
473
+ function dispatcherFor(proxy: string, redirections: number): Dispatcher {
474
+ const key = `${proxy}|${redirections}`
475
+ let dispatcher = dispatchers.get(key)
476
+ if (dispatcher) return dispatcher
477
+ // 只装重定向拦截器。解压刻意不走 interceptors.decompress(),改在 once() 里
478
+ // 手工套 zlib —— 理由见 decodeStream 的注释
479
+ dispatcher = baseAgent(proxy).compose(interceptors.redirect({ maxRedirections: redirections }))
480
+ dispatchers.set(key, dispatcher)
481
+ return dispatcher
482
+ }
483
+
484
+ /**
485
+ * 按默认值造一个客户端
486
+ * @param defaults 该客户端的默认值
487
+ * @returns 客户端
488
+ */
489
+ function build(defaults: HttpDefaults): ManagedHttpClient {
490
+ /**
491
+ * 把调用方选项与默认值合成最终参数
492
+ * @param url 地址
493
+ * @param options 调用方选项
494
+ * @returns 最终参数
495
+ */
496
+ function resolve(url: string, options: HttpRequestOptions = {}): ResolvedRequest {
497
+ const headers: Record<string, string> = {
498
+ "accept-encoding": "gzip, deflate",
499
+ "user-agent": opts.userAgent ?? "yunzai-ng"
500
+ }
501
+ for (const [key, value] of Object.entries(defaults.headers ?? {})) headers[key.toLowerCase()] = value
502
+ for (const [key, value] of Object.entries(options.headers ?? {})) headers[key.toLowerCase()] = value
503
+
504
+ let body = options.body
505
+ if (options.json !== undefined) {
506
+ body = JSON.stringify(options.json)
507
+ headers["content-type"] ??= "application/json"
508
+ } else if (options.form !== undefined) {
509
+ const form = new URLSearchParams()
510
+ for (const [key, value] of Object.entries(options.form)) form.set(key, String(value))
511
+ body = form.toString()
512
+ headers["content-type"] ??= "application/x-www-form-urlencoded"
513
+ }
514
+
515
+ const method = (options.method ?? (body === undefined ? "GET" : "POST")).toUpperCase()
516
+ const proxyChoice = options.proxy ?? defaults.proxy ?? envProxy ?? false
517
+ const follow = options.followRedirect ?? true
518
+
519
+ // 提前校验:非法头字符交给 undici 只会换来一句 `invalid x-foo header`
520
+ assertHeaders(headers)
521
+
522
+ return {
523
+ method,
524
+ url: buildUrl(url, defaults.baseUrl, options.query),
525
+ headers,
526
+ body,
527
+ timeout: parseDuration(options.timeout ?? defaults.timeout, DEFAULT_TIMEOUT),
528
+ retry: normalizeRetry(options.retry ?? defaults.retry),
529
+ responseType: options.responseType ?? "json",
530
+ proxy: proxyChoice,
531
+ redirections: follow ? MAX_REDIRECTIONS : 0,
532
+ throwOnError: options.throwOnError ?? true,
533
+ signal: options.signal
534
+ }
535
+ }
536
+
537
+ /**
538
+ * 发一次请求(不含重试)
539
+ * @param req 最终参数
540
+ * @returns 完整响应
541
+ */
542
+ async function once<T>(req: ResolvedRequest): Promise<HttpResponse<T>> {
543
+ const startedAt = Date.now()
544
+ const safeUrl = sanitizeUrl(req.url)
545
+
546
+ // 每次尝试一个独立的超时信号;与调用方信号合并,谁先触发都能中断底层连接。
547
+ // 注意不能用 withTimeout 包一层了事 —— 那样只是调用方不等了,
548
+ // socket 还在那儿挂着,重试几轮就把连接池占满
549
+ const timer = req.timeout > 0 ? AbortSignal.timeout(req.timeout) : undefined
550
+ const signals = [timer, req.signal].filter((s): s is AbortSignal => s !== undefined)
551
+ const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0]
552
+
553
+ let res: Dispatcher.ResponseData
554
+ try {
555
+ res = await undiciRequest(req.url, {
556
+ dispatcher: dispatcherFor(req.proxy === false ? "" : req.proxy, req.redirections),
557
+ method: req.method as Dispatcher.HttpMethod,
558
+ headers: req.headers,
559
+ ...(req.body === undefined ? {} : { body: req.body }),
560
+ ...(signal ? { signal } : {})
561
+ })
562
+ } catch (err) {
563
+ // 调用方主动取消:原样抛出 AbortError,别包成 HttpError 让人误判成服务端问题
564
+ if (req.signal?.aborted) throw new AbortError(`请求已被取消:${req.method} ${safeUrl}`)
565
+ if (timer?.aborted) {
566
+ throw new HttpError(`请求超时(${req.timeout}ms):${req.method} ${safeUrl}`, {
567
+ status: 0,
568
+ method: req.method,
569
+ url: safeUrl,
570
+ cause: err
571
+ })
572
+ }
573
+ throw new HttpError(`请求失败:${req.method} ${safeUrl} —— ${(err as Error).message}`, {
574
+ status: 0,
575
+ method: req.method,
576
+ url: safeUrl,
577
+ cause: err
578
+ })
579
+ }
580
+
581
+ const status = res.statusCode
582
+ const headers = flattenHeaders(res.headers)
583
+ const ok = status >= 200 && status < 300
584
+
585
+ /**
586
+ * 组装响应对象
587
+ * @param data 解析后的数据
588
+ * @returns 响应
589
+ */
590
+ const wrap = (data: unknown): HttpResponse<T> => ({
591
+ status,
592
+ statusText: headers["x-status-text"] ?? "",
593
+ headers,
594
+ data: data as T,
595
+ ok,
596
+ url: safeUrl,
597
+ cost: Date.now() - startedAt
598
+ })
599
+
600
+ if (req.responseType === "none") {
601
+ // 用原始 res.body:dump() 是 undici 挂在它身上的方法,
602
+ // 套一层解压流就没有了,连接也就还不回池子
603
+ discard(res.body)
604
+ return wrap(undefined)
605
+ }
606
+
607
+ // 往下的分支都要真读内容,先按 Content-Encoding 套上解压
608
+ const body = decodeStream(res.body, headers["content-encoding"])
609
+
610
+ if (req.responseType === "stream") {
611
+ // 流式:body 交给调用方,读完/销毁的责任也一并交出去
612
+ return wrap(body)
613
+ }
614
+
615
+ /**
616
+ * 超限时的错误
617
+ * @param read 已读字节数
618
+ * @returns 错误
619
+ */
620
+ const overflow = (read: number): HttpError =>
621
+ new HttpError(
622
+ `响应体超过上限 ${maxBodySize} 字节(已读 ${read}):${req.method} ${safeUrl}。` +
623
+ `大文件请用 download() 直接落盘,不要读进内存`,
624
+ { status, method: req.method, url: safeUrl }
625
+ )
626
+
627
+ const bytes = await readBounded(body, maxBodySize, overflow)
628
+
629
+ if (req.responseType === "buffer") return wrap(bytes)
630
+
631
+ const text = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8")
632
+ if (req.responseType === "text") return wrap(text)
633
+
634
+ // json:空体按 undefined 处理;解析失败时把片段带上,
635
+ // 否则只看到 "Unexpected token <" 完全不知道对方回了什么
636
+ if (text.length === 0) return wrap(undefined)
637
+ try {
638
+ return wrap(JSON.parse(text))
639
+ } catch (err) {
640
+ // 非 2xx 就退回文本,不在这里抛:网关返回 503 + 一张 HTML 错误页是常态,
641
+ // 在这儿抛掉的话 request() 里"按状态码重试"的判断永远轮不到执行 ——
642
+ // 症状是"明明配了 retry,日志里一次重试都没有"。
643
+ // 这种响应里状态码才是主要信息,正文只当片段用
644
+ if (!ok) return wrap(text)
645
+ throw new HttpError(
646
+ `响应不是合法 JSON:${req.method} ${safeUrl}(状态 ${status})。片段:${text.slice(0, 200)}`,
647
+ { status, method: req.method, url: safeUrl, body: text.slice(0, 2000), cause: err }
648
+ )
649
+ }
650
+ }
651
+
652
+ /**
653
+ * 带重试地发请求
654
+ * @param url 地址
655
+ * @param options 选项
656
+ * @returns 完整响应
657
+ * @throws HttpError / AbortError
658
+ */
659
+ async function request<T>(url: string, options?: HttpRequestOptions): Promise<HttpResponse<T>> {
660
+ if (closed) {
661
+ throw new Error(
662
+ "HTTP 客户端已关闭,不能再发请求。注意 extend() 派生出的客户端与根客户端共享连接池,关一个等于全关"
663
+ )
664
+ }
665
+ const req = resolve(url, options)
666
+ const times = Math.max(0, req.retry.times)
667
+ const statuses = req.retry.statuses ?? RETRY_STATUSES
668
+ const baseDelay = req.retry.delay === undefined ? 500 : parseDuration(req.retry.delay, 500)
669
+
670
+ for (let attempt = 0; ; attempt++) {
671
+ /** 本轮是否还能再试 */
672
+ const canRetry = attempt < times
673
+
674
+ /**
675
+ * 等待下一轮
676
+ * @param wait 等待毫秒
677
+ * @param why 记录用的原因
678
+ */
679
+ const waitThen = async (wait: number, why: string): Promise<void> => {
680
+ logger?.debug(`${req.method} ${sanitizeUrl(req.url)} ${why},${wait}ms 后重试(第 ${attempt + 1} 次)`)
681
+ await sleep(Math.min(wait, MAX_RETRY_DELAY), req.signal)
682
+ }
683
+
684
+ let res: HttpResponse<T>
685
+ try {
686
+ res = await once<T>(req)
687
+ } catch (err) {
688
+ const custom = req.retry.when?.(err)
689
+ const retryable = custom ?? isRetryableNetworkError((err as { cause?: unknown }).cause ?? err)
690
+ if (!canRetry || !retryable) throw err
691
+ await waitThen(backoffDelay(attempt + 1, { baseDelay }), "网络出错")
692
+ continue
693
+ }
694
+
695
+ const byStatus = statuses.includes(res.status)
696
+ const custom = req.retry.when?.(undefined, res)
697
+ if ((custom ?? byStatus) && canRetry) {
698
+ // 重试前必须把 body 处理掉,否则连接不还池
699
+ if (req.responseType === "stream") discard(res.data as unknown as Readable)
700
+ const hinted = parseRetryAfter(res.headers["retry-after"])
701
+ await waitThen(hinted ?? backoffDelay(attempt + 1, { baseDelay }), `返回 ${res.status}`)
702
+ continue
703
+ }
704
+
705
+ if (!res.ok && req.throwOnError) {
706
+ const snippet = typeof res.data === "string" ? res.data.slice(0, 200) : ""
707
+ throw new HttpError(`${req.method} ${res.url} 返回 ${res.status}${snippet ? `:${snippet}` : ""}`, {
708
+ status: res.status,
709
+ method: req.method,
710
+ url: res.url,
711
+ ...(snippet ? { body: snippet } : {})
712
+ })
713
+ }
714
+ return res
715
+ }
716
+ }
717
+
718
+ const client: ManagedHttpClient = {
719
+ request,
720
+
721
+ get: async <T>(url: string, options?: Omit<HttpRequestOptions, "method">): Promise<T> =>
722
+ (await request<T>(url, { ...options, method: "GET" })).data,
723
+
724
+ post: async <T>(
725
+ url: string,
726
+ json?: unknown,
727
+ options?: Omit<HttpRequestOptions, "method" | "json">
728
+ ): Promise<T> => {
729
+ const merged: HttpRequestOptions = { ...options, method: "POST" }
730
+ if (json !== undefined) merged.json = json
731
+ return (await request<T>(url, merged)).data
732
+ },
733
+
734
+ // 实际返回 Node 的 Buffer(本身就是 Uint8Array 的子类)。契约上写 Uint8Array
735
+ // 是为了不把类型包绑到 Node,但这里刻意不降级成裸 Uint8Array ——
736
+ // 适配器发图片要 toString("base64"),那是 Buffer 才有的
737
+ buffer: async (url: string, options?: HttpRequestOptions): Promise<Uint8Array> =>
738
+ (await request<Uint8Array>(url, { ...options, responseType: "buffer" })).data,
739
+
740
+ download: async (url: string, dest: string, options?: HttpRequestOptions): Promise<string> => {
741
+ await ensureDir(dirname(dest))
742
+ // 先写 .part 再改名:残缺文件若留在目标路径上,下次会被当作有效缓存,
743
+ // 症状是某张图片始终损坏
744
+ const temp = `${dest}.part`
745
+ const res = await request<Readable>(url, { ...options, responseType: "stream" })
746
+ try {
747
+ await pipeline(res.data, createWriteStream(temp))
748
+ await rename(temp, dest)
749
+ return dest
750
+ } catch (err) {
751
+ await unlink(temp).catch(() => undefined)
752
+ throw err
753
+ }
754
+ },
755
+
756
+ extend: (override: HttpDefaults): HttpClient => build({ ...defaults, ...override }),
757
+
758
+ close: async (): Promise<void> => {
759
+ closed = true
760
+ dispatchers.clear()
761
+ const closing = [...agents.values()].map(agent => agent.close().catch(() => undefined))
762
+ agents.clear()
763
+ await Promise.all(closing)
764
+ }
765
+ }
766
+ return client
767
+ }
768
+
769
+ const rootDefaults: HttpDefaults = {}
770
+ if (opts.baseUrl !== undefined) rootDefaults.baseUrl = opts.baseUrl
771
+ if (opts.headers !== undefined) rootDefaults.headers = opts.headers
772
+ if (opts.timeout !== undefined) rootDefaults.timeout = opts.timeout
773
+ if (opts.retry !== undefined) rootDefaults.retry = opts.retry
774
+ if (opts.proxy !== undefined) rootDefaults.proxy = opts.proxy
775
+
776
+ return build(rootDefaults)
777
+ }