@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,82 @@
1
+ /**
2
+ * 模块职责:时长表达式解析
3
+ * 依赖方向:仅依赖类型包
4
+ * 生命周期:纯函数
5
+ * 注意事项:一律写 `"30d"` 而不是 `60 * 60 * 24 * 30`:手工算出来的秒数改一处要通查全部
6
+ * 调用点,而配置文件里也读不出那串数字是多久。
7
+ */
8
+ import type { DurationLike } from "@yunzai-ng/types"
9
+
10
+ /** 各单位对应的毫秒数 */
11
+ const UNIT_MS: Record<string, number> = {
12
+ ms: 1,
13
+ s: 1000,
14
+ m: 60_000,
15
+ h: 3_600_000,
16
+ d: 86_400_000
17
+ }
18
+
19
+ const DURATION_RE = /^(-?\d+(?:\.\d+)?)(ms|s|m|h|d)$/
20
+
21
+ /**
22
+ * 把时长表达式解析为毫秒
23
+ * @param value 毫秒数字,或形如 `"5s"` / `"30d"` 的字符串
24
+ * @param fallback 无法解析时返回的默认值,缺省 0
25
+ * @returns 毫秒数
26
+ */
27
+ export function parseDuration(value: DurationLike | string | undefined | null, fallback = 0): number {
28
+ if (value === undefined || value === null) return fallback
29
+ if (typeof value === "number") return Number.isFinite(value) ? value : fallback
30
+
31
+ const trimmed = value.trim()
32
+ if (trimmed === "") return fallback
33
+
34
+ // 纯数字串按毫秒处理,兼容从 YAML 里读到的 "5000"
35
+ if (/^-?\d+$/.test(trimmed)) return Number(trimmed)
36
+
37
+ const m = DURATION_RE.exec(trimmed)
38
+ if (!m) return fallback
39
+ const [, num, unit] = m
40
+ return Number(num) * (UNIT_MS[unit as string] ?? 1)
41
+ }
42
+
43
+ /**
44
+ * 把毫秒格式化为人类可读的中文时长
45
+ * @param ms 毫秒数
46
+ * @returns 形如 `"1天2小时3分"` 的字符串;小于 1 秒时返回 `"<1秒"`
47
+ */
48
+ export function formatDuration(ms: number): string {
49
+ if (!Number.isFinite(ms) || ms < 0) return "-"
50
+ if (ms < 1000) return "<1秒"
51
+
52
+ const d = Math.floor(ms / UNIT_MS.d!)
53
+ const h = Math.floor((ms % UNIT_MS.d!) / UNIT_MS.h!)
54
+ const m = Math.floor((ms % UNIT_MS.h!) / UNIT_MS.m!)
55
+ const s = Math.floor((ms % UNIT_MS.m!) / 1000)
56
+
57
+ const parts: string[] = []
58
+ if (d) parts.push(`${d}天`)
59
+ if (h) parts.push(`${h}小时`)
60
+ if (m) parts.push(`${m}分`)
61
+ // 只有在没有更大单位时才显示秒,避免 "1天0小时0分3秒" 这种噪音
62
+ if (s && !d && !h) parts.push(`${s}秒`)
63
+ return parts.join("") || "<1秒"
64
+ }
65
+
66
+ /**
67
+ * 把字节数格式化为可读大小
68
+ * @param bytes 字节数
69
+ * @returns 形如 `"12.34MB"` 的字符串
70
+ */
71
+ export function formatBytes(bytes: number): string {
72
+ if (!Number.isFinite(bytes)) return "-"
73
+ const units = ["B", "KB", "MB", "GB", "TB"]
74
+ let value = Math.abs(bytes)
75
+ let i = 0
76
+ while (value >= 1024 && i < units.length - 1) {
77
+ value /= 1024
78
+ i++
79
+ }
80
+ const sign = bytes < 0 ? "-" : ""
81
+ return `${sign}${i === 0 ? value : value.toFixed(2)}${units[i]}`
82
+ }
package/src/util/fs.ts ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * 模块职责:文件系统助手(原子写、目录保证、路径越界防护)
3
+ * 依赖方向:仅依赖 node:fs / node:path 与 util/defer
4
+ * 生命周期:纯函数
5
+ * 注意事项:**原子写是必需品**,一律走"写临时文件 → rename"。直接 `writeFileSync` 时,
6
+ * 进程恰在此刻被杀(Windows 关窗口、安卓被系统回收)会留下一个截断的配置文件,
7
+ * 下次启动即崩。
8
+ *
9
+ * Windows 上 rename 会因杀软/资源管理器短暂占用而抛 EPERM
10
+ * (本项目 pnpm install 就撞过一次),所以带退避重试。
11
+ */
12
+ import { constants as fsConstants } from "node:fs"
13
+ import {
14
+ access,
15
+ mkdir,
16
+ readFile,
17
+ readdir,
18
+ rename,
19
+ rm,
20
+ stat,
21
+ unlink,
22
+ writeFile
23
+ } from "node:fs/promises"
24
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
25
+ import { randomId } from "./id.js"
26
+ import { sleep } from "./defer.js"
27
+
28
+ /** rename 遇到占用类错误时的重试次数 */
29
+ const RENAME_RETRIES = 5
30
+
31
+ /** 判定为"临时占用、可重试"的错误码 */
32
+ const TRANSIENT_CODES = new Set(["EPERM", "EACCES", "EBUSY"])
33
+
34
+ /**
35
+ * 从未知错误里取出 errno code
36
+ * @param err 错误
37
+ * @returns 错误码,取不到时 undefined
38
+ */
39
+ export function errorCode(err: unknown): string | undefined {
40
+ if (err !== null && typeof err === "object" && "code" in err) {
41
+ const code = (err as { code?: unknown }).code
42
+ if (typeof code === "string") return code
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ /**
48
+ * 路径是否存在
49
+ * @param path 路径
50
+ * @returns 是否存在
51
+ */
52
+ export async function pathExists(path: string): Promise<boolean> {
53
+ try {
54
+ await access(path, fsConstants.F_OK)
55
+ return true
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 是否是目录
63
+ * @param path 路径
64
+ * @returns 是目录则 true;不存在或不是目录则 false
65
+ */
66
+ export async function isDirectory(path: string): Promise<boolean> {
67
+ try {
68
+ return (await stat(path)).isDirectory()
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 是否是文件
76
+ * @param path 路径
77
+ * @returns 是文件则 true
78
+ */
79
+ export async function isFile(path: string): Promise<boolean> {
80
+ try {
81
+ return (await stat(path)).isFile()
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 确保目录存在
89
+ * @param path 目录路径
90
+ * @returns 目录路径本身,便于链式使用
91
+ */
92
+ export async function ensureDir(path: string): Promise<string> {
93
+ await mkdir(path, { recursive: true })
94
+ return path
95
+ }
96
+
97
+ /**
98
+ * 带重试的 rename
99
+ *
100
+ * Windows 下杀软扫描、编辑器索引会短暂锁住新建文件,导致 rename 抛 EPERM。
101
+ * 这类错误重试即可,不该让一次配置保存失败。
102
+ * @param from 源路径
103
+ * @param to 目标路径
104
+ * @throws 重试用尽后抛出最后一次错误
105
+ */
106
+ async function renameWithRetry(from: string, to: string): Promise<void> {
107
+ for (let attempt = 1; ; attempt++) {
108
+ try {
109
+ await rename(from, to)
110
+ return
111
+ } catch (err) {
112
+ const code = errorCode(err)
113
+ if (attempt >= RENAME_RETRIES || !code || !TRANSIENT_CODES.has(code)) throw err
114
+ await sleep(20 * attempt)
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 原子写文件
121
+ *
122
+ * 先写同目录下的 `.tmp-*` 文件再 rename 覆盖 —— 同目录是关键,
123
+ * 跨分区 rename 不是原子操作。
124
+ * @param path 目标文件路径
125
+ * @param data 内容
126
+ * @throws 写入或替换失败时抛出;此时原文件保持不变
127
+ */
128
+ export async function atomicWrite(path: string, data: string | Uint8Array): Promise<void> {
129
+ const dir = dirname(path)
130
+ await ensureDir(dir)
131
+ const tmp = join(dir, `.tmp-${randomId(8)}`)
132
+ try {
133
+ await writeFile(tmp, data)
134
+ await renameWithRetry(tmp, path)
135
+ } catch (err) {
136
+ // 清理临时文件,失败也不掩盖原始错误
137
+ await unlink(tmp).catch(() => undefined)
138
+ throw err
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 读文本文件
144
+ * @param path 路径
145
+ * @returns 文件内容;不存在时 undefined
146
+ * @throws 除"不存在"以外的读取错误
147
+ */
148
+ export async function readText(path: string): Promise<string | undefined> {
149
+ try {
150
+ return await readFile(path, "utf8")
151
+ } catch (err) {
152
+ if (errorCode(err) === "ENOENT") return undefined
153
+ throw err
154
+ }
155
+ }
156
+
157
+ /**
158
+ * 读 JSON 文件
159
+ * @param path 路径
160
+ * @returns 解析结果;文件不存在时 undefined
161
+ * @throws JSON 解析失败时抛出,错误信息带上文件路径
162
+ */
163
+ export async function readJson<T = unknown>(path: string): Promise<T | undefined> {
164
+ const text = await readText(path)
165
+ if (text === undefined) return undefined
166
+ try {
167
+ return JSON.parse(text) as T
168
+ } catch (err) {
169
+ throw new Error(`解析 JSON 失败:${path} —— ${err instanceof Error ? err.message : String(err)}`)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * 原子写 JSON 文件
175
+ * @param path 路径
176
+ * @param value 数据
177
+ * @param pretty 是否缩进,缺省 true(配置类文件要能手改)
178
+ */
179
+ export async function writeJson(path: string, value: unknown, pretty = true): Promise<void> {
180
+ await atomicWrite(path, JSON.stringify(value, null, pretty ? 2 : undefined))
181
+ }
182
+
183
+ /**
184
+ * 列出目录下的子目录名
185
+ * @param path 目录路径
186
+ * @returns 子目录名数组;目录不存在时空数组
187
+ */
188
+ export async function listDirs(path: string): Promise<string[]> {
189
+ try {
190
+ const entries = await readdir(path, { withFileTypes: true })
191
+ return entries.filter(e => e.isDirectory()).map(e => e.name)
192
+ } catch (err) {
193
+ if (errorCode(err) === "ENOENT") return []
194
+ throw err
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 列出目录下的文件名
200
+ * @param path 目录路径
201
+ * @param ext 可选扩展名过滤,如 `".yaml"`
202
+ * @returns 文件名数组;目录不存在时空数组
203
+ */
204
+ export async function listFiles(path: string, ext?: string): Promise<string[]> {
205
+ try {
206
+ const entries = await readdir(path, { withFileTypes: true })
207
+ const files = entries.filter(e => e.isFile()).map(e => e.name)
208
+ return ext ? files.filter(name => name.endsWith(ext)) : files
209
+ } catch (err) {
210
+ if (errorCode(err) === "ENOENT") return []
211
+ throw err
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 删除文件或目录(不存在时静默)
217
+ * @param path 路径
218
+ */
219
+ export async function remove(path: string): Promise<void> {
220
+ await rm(path, { recursive: true, force: true })
221
+ }
222
+
223
+ /**
224
+ * 取文件修改时间
225
+ * @param path 路径
226
+ * @returns 毫秒时间戳;文件不存在时 undefined
227
+ */
228
+ export async function mtimeOf(path: string): Promise<number | undefined> {
229
+ try {
230
+ return (await stat(path)).mtimeMs
231
+ } catch {
232
+ return undefined
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 取文件大小
238
+ * @param path 路径
239
+ * @returns 字节数;文件不存在时 undefined
240
+ */
241
+ export async function sizeOf(path: string): Promise<number | undefined> {
242
+ try {
243
+ return (await stat(path)).size
244
+ } catch {
245
+ return undefined
246
+ }
247
+ }
248
+
249
+ /**
250
+ * 在根目录内安全拼接路径,阻止 `../` 越界
251
+ *
252
+ * WebUI 的静态资源、插件模板资源都要按外部传入的相对路径取文件,
253
+ * 不做这层校验就是任意文件读取漏洞。
254
+ * @param root 允许访问的根目录
255
+ * @param requested 外部传入的相对路径
256
+ * @returns 解析后的绝对路径
257
+ * @throws 当结果落在 root 之外,或传入了绝对路径时
258
+ */
259
+ export function safeJoin(root: string, requested: string): string {
260
+ if (isAbsolute(requested)) throw new Error(`路径越界:不接受绝对路径 ${requested}`)
261
+ const rootAbs = resolve(root)
262
+ const target = resolve(rootAbs, requested)
263
+ const rel = relative(rootAbs, target)
264
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
265
+ throw new Error(`路径越界:${requested}`)
266
+ }
267
+ return target
268
+ }
269
+
270
+ /**
271
+ * 判断路径是否在某目录内
272
+ * @param root 根目录
273
+ * @param target 待判定路径
274
+ * @returns 是否在根目录内(含自身)
275
+ */
276
+ export function isInside(root: string, target: string): boolean {
277
+ const rel = relative(resolve(root), resolve(target))
278
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))
279
+ }
package/src/util/id.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 模块职责:id 生成与哈希
3
+ * 依赖方向:仅依赖 node:crypto
4
+ * 生命周期:纯函数
5
+ * 注意事项:事件 id 采用"时间前缀 + 随机后缀"而非纯 uuid —— 排障时可直接
6
+ * 按时间排序,且在日志中可直接辨识所属批次。
7
+ * 刻意不引入 nanoid:一个 60 行的功能不值得增加一个依赖,在
8
+ * Termux 上安装依赖时尤为如此。
9
+ */
10
+ import { createHash, randomBytes, randomUUID } from "node:crypto"
11
+
12
+ /** id 字符表:去掉了易混淆的 0/O/1/l/I */
13
+ const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"
14
+
15
+ /**
16
+ * 生成随机字符串
17
+ * @param size 长度,缺省 12
18
+ * @returns 随机 id
19
+ */
20
+ export function randomId(size = 12): string {
21
+ const bytes = randomBytes(size)
22
+ let out = ""
23
+ for (let i = 0; i < size; i++) {
24
+ // 取模引入的偏差在 56/256 上可忽略;这里不用于密码学场景
25
+ out += ALPHABET[bytes[i]! % ALPHABET.length]
26
+ }
27
+ return out
28
+ }
29
+
30
+ /**
31
+ * 生成事件 id
32
+ *
33
+ * 形如 `"m9x2k1qa-7Kd3"`:前段是 36 进制毫秒时间戳,后段随机。
34
+ * @param prefix 可选前缀,如 `"evt"`
35
+ * @returns 事件 id
36
+ */
37
+ export function createEventId(prefix?: string): string {
38
+ const stamp = Date.now().toString(36)
39
+ const tail = randomId(4)
40
+ return prefix ? `${prefix}-${stamp}-${tail}` : `${stamp}-${tail}`
41
+ }
42
+
43
+ /**
44
+ * 生成 UUID v4
45
+ * @returns 标准 uuid 字符串
46
+ */
47
+ export function uuid(): string {
48
+ return randomUUID()
49
+ }
50
+
51
+ /**
52
+ * 单调递增的自增 id 工厂
53
+ *
54
+ * 用于适配器的 `echo` 序号等只需进程内唯一的场景,比随机串更省。
55
+ * @param prefix 前缀
56
+ * @returns 每次调用返回下一个 id
57
+ */
58
+ export function createSeqFactory(prefix = ""): () => string {
59
+ let seq = 0
60
+ // 加一个进程内随机段,避免重启后与对端残留的旧 echo 撞号
61
+ const salt = randomId(4)
62
+ return () => `${prefix}${salt}${(++seq).toString(36)}`
63
+ }
64
+
65
+ /**
66
+ * 计算字符串的 sha256 十六进制摘要
67
+ * @param input 输入
68
+ * @param length 截断长度,缺省完整 64 位
69
+ * @returns 十六进制摘要
70
+ */
71
+ export function sha256(input: string, length?: number): string {
72
+ const hex = createHash("sha256").update(input, "utf8").digest("hex")
73
+ return length ? hex.slice(0, length) : hex
74
+ }
75
+
76
+ /**
77
+ * 计算字符串的 md5 十六进制摘要
78
+ *
79
+ * 仅用于兼容米游社等平台的签名算法,**不要用于任何安全用途**。
80
+ * @param input 输入
81
+ * @returns 十六进制摘要
82
+ */
83
+ export function md5(input: string): string {
84
+ return createHash("md5").update(input, "utf8").digest("hex")
85
+ }
86
+
87
+ /**
88
+ * 稳定的短哈希,用于给任意字符串生成缓存键
89
+ * @param input 输入
90
+ * @returns 12 位十六进制
91
+ */
92
+ export function shortHash(input: string): string {
93
+ return sha256(input, 12)
94
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 模块职责:LRU + TTL 缓存,带并发去重
3
+ * 依赖方向:仅依赖类型包
4
+ * 生命周期:随创建者(插件 / 账号)一起被回收
5
+ * 注意事项:三项机制合起来使缓存的内存占用具备上界 —— 裸 Map 存群成员表且从不淘汰时,
6
+ * 数十个千人群即是数百 MB 常驻:
7
+ * 1) 硬性上限 + LRU 淘汰;
8
+ * 2) TTL 惰性过期;
9
+ * 3) single-flight 合并同 key 的并发请求,避免一条群消息中多处判定权限时
10
+ * 各发一次平台请求。
11
+ *
12
+ * **不采用定时器扫描**:定时器或造成泄漏,或需额外的生命周期管理。改为每次写入时
13
+ * 清理队首若干过期项,摊还成本 O(1)。
14
+ */
15
+ import type { ContactCache, ContactCacheOptions } from "@yunzai-ng/types"
16
+ import { parseDuration } from "./duration.js"
17
+
18
+ /** 缓存条目 */
19
+ interface Entry<V> {
20
+ /** 缓存的值 */
21
+ value: V
22
+ /** 过期时间点(毫秒),Infinity 表示不过期 */
23
+ expireAt: number
24
+ }
25
+
26
+ /** 每次写入时顺带检查的队首条目数 */
27
+ const SWEEP_PER_WRITE = 4
28
+
29
+ /**
30
+ * LRU + TTL 缓存
31
+ *
32
+ * 利用 `Map` 的插入顺序实现 LRU:读命中时把条目挪到队尾,
33
+ * 于是队首恒为最久未使用者,淘汰只需 `keys().next()`。
34
+ */
35
+ export class LruCache<V> implements ContactCache<V> {
36
+ /** 主存储,键序即 LRU 序(队首最久未用) */
37
+ readonly #map = new Map<string, Entry<V>>()
38
+ /** 进行中的 loader,用于并发去重 */
39
+ readonly #pending = new Map<string, Promise<V | undefined>>()
40
+ /** 条目上限 */
41
+ readonly #max: number
42
+ /** 默认存活毫秒 */
43
+ readonly #ttl: number
44
+
45
+ /** 命中次数(诊断用) */
46
+ #hits = 0
47
+ /** 未命中次数(诊断用) */
48
+ #misses = 0
49
+
50
+ /**
51
+ * @param opts 缓存参数;`max` 至少为 1,`ttl` 为 0 表示不过期
52
+ */
53
+ constructor(opts: ContactCacheOptions) {
54
+ this.#max = Math.max(1, Math.floor(opts.max))
55
+ const ttl = parseDuration(opts.ttl, 0)
56
+ this.#ttl = ttl > 0 ? ttl : Number.POSITIVE_INFINITY
57
+ }
58
+
59
+ /** 当前条目数(含尚未被清理的过期项) */
60
+ get size(): number {
61
+ return this.#map.size
62
+ }
63
+
64
+ /** 命中率统计,供 `/api/diagnostics` 展示缓存是否配得合理 */
65
+ get stats(): { hits: number; misses: number; size: number; max: number } {
66
+ return { hits: this.#hits, misses: this.#misses, size: this.#map.size, max: this.#max }
67
+ }
68
+
69
+ /**
70
+ * 取缓存
71
+ * @param key 键
72
+ * @returns 值;未命中或已过期时 undefined
73
+ */
74
+ get(key: string): V | undefined {
75
+ const entry = this.#map.get(key)
76
+ if (!entry) {
77
+ this.#misses++
78
+ return undefined
79
+ }
80
+ if (entry.expireAt <= Date.now()) {
81
+ this.#map.delete(key)
82
+ this.#misses++
83
+ return undefined
84
+ }
85
+ // 命中即挪到队尾:delete + set 是 Map 上唯一能改插入序的手段
86
+ this.#map.delete(key)
87
+ this.#map.set(key, entry)
88
+ this.#hits++
89
+ return entry.value
90
+ }
91
+
92
+ /**
93
+ * 写缓存
94
+ * @param key 键
95
+ * @param value 值
96
+ * @param ttl 覆盖默认存活时长(毫秒)
97
+ */
98
+ set(key: string, value: V, ttl?: number): void {
99
+ this.#sweep()
100
+ const expireAt = ttl === undefined ? this.#ttl : ttl
101
+ this.#map.delete(key)
102
+ this.#map.set(key, {
103
+ value,
104
+ expireAt: Number.isFinite(expireAt) ? Date.now() + expireAt : Number.POSITIVE_INFINITY
105
+ })
106
+ // 超限时从队首淘汰,直到回到上限内
107
+ while (this.#map.size > this.#max) {
108
+ const oldest = this.#map.keys().next()
109
+ if (oldest.done) break
110
+ this.#map.delete(oldest.value)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 删除
116
+ * @param key 键
117
+ */
118
+ delete(key: string): void {
119
+ this.#map.delete(key)
120
+ }
121
+
122
+ /**
123
+ * 取缓存,未命中时用 loader 拉取并回填
124
+ *
125
+ * 同一 key 的并发调用共享同一个 loader promise。loader 抛错时不缓存,
126
+ * 但会把错误传播给所有等待者——避免"一次网络抖动被缓存成永久失败"。
127
+ * @param key 键
128
+ * @param loader 拉取函数
129
+ * @returns 值;loader 返回 undefined 时不缓存
130
+ */
131
+ async fetch(key: string, loader: (key: string) => Promise<V | undefined>): Promise<V | undefined> {
132
+ const cached = this.get(key)
133
+ if (cached !== undefined) return cached
134
+
135
+ const inflight = this.#pending.get(key)
136
+ if (inflight) return inflight
137
+
138
+ const task = (async () => {
139
+ try {
140
+ const value = await loader(key)
141
+ if (value !== undefined) this.set(key, value)
142
+ return value
143
+ } finally {
144
+ this.#pending.delete(key)
145
+ }
146
+ })()
147
+
148
+ this.#pending.set(key, task)
149
+ return task
150
+ }
151
+
152
+ /** 清空全部条目与进行中的 loader 记录 */
153
+ clear(): void {
154
+ this.#map.clear()
155
+ this.#pending.clear()
156
+ this.#hits = 0
157
+ this.#misses = 0
158
+ }
159
+
160
+ /**
161
+ * 清理队首若干个过期条目
162
+ *
163
+ * 只看队首是因为 LRU 序下队首最可能已经过期;全量扫描在千级条目上
164
+ * 会让每次写入都变成 O(n)。
165
+ */
166
+ #sweep(): void {
167
+ if (this.#ttl === Number.POSITIVE_INFINITY) return
168
+ const now = Date.now()
169
+ let checked = 0
170
+ for (const [key, entry] of this.#map) {
171
+ if (checked++ >= SWEEP_PER_WRITE) break
172
+ if (entry.expireAt <= now) this.#map.delete(key)
173
+ else break // 队首未过期时后续项更不可能过期(近似成立,满足本场景需要)
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 创建 LRU + TTL 缓存
180
+ * @param opts 缓存参数
181
+ * @returns 缓存实例
182
+ */
183
+ export function createCache<V>(opts: ContactCacheOptions): LruCache<V> {
184
+ return new LruCache<V>(opts)
185
+ }