@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,265 @@
1
+ /**
2
+ * 模块职责:日志文件的滚动写入与保留策略
3
+ * 依赖方向:仅依赖 node:fs / node:path
4
+ * 生命周期:由 LoggerHub 创建与关闭;`close()` 前必须 `flushSync()`
5
+ * 注意事项:刻意**不用** `pino-roll` / `pino/file` 这类 worker 线程 transport。
6
+ * worker transport 在两个场景下存在缺陷:
7
+ * 1) 单文件打包(Windows 安装包)里 worker 入口路径解析不到;
8
+ * 2) 进程被强杀时 worker 尚未落盘的日志直接丢失 —— 而崩溃前最后
9
+ * 几行日志恰恰是排障最需要的。
10
+ *
11
+ * 因此此处自行维护一个 fd 与内存缓冲:常态下批量异步刷盘,退出与致命
12
+ * 错误时 `flushSync()` 同步落盘。写入用 `writeSync` 循环,处理
13
+ * 部分写入(管道/网络盘上会发生)。
14
+ */
15
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, statSync, unlinkSync, writeSync } from "node:fs"
16
+ import { join } from "node:path"
17
+
18
+ /** 滚动写入参数 */
19
+ export interface RotateOptions {
20
+ /** 日志目录 */
21
+ dir: string
22
+ /** 文件名前缀,缺省 `"app"` */
23
+ basename?: string
24
+ /** 单文件字节上限,缺省 8MB */
25
+ maxSize?: number
26
+ /** 保留天数,缺省 14 */
27
+ keepDays?: number
28
+ /** 文件数上限,缺省 100(防止同一天大量写入日志将磁盘写满) */
29
+ maxFiles?: number
30
+ /** 批量刷盘间隔毫秒,缺省 200 */
31
+ flushInterval?: number
32
+ /** 缓冲字节数上限,超过立即刷盘,缺省 32KB */
33
+ bufferLimit?: number
34
+ }
35
+
36
+ /** 默认单文件上限:8MB */
37
+ const DEFAULT_MAX_SIZE = 8 * 1024 * 1024
38
+
39
+ /**
40
+ * 取本地日期字符串
41
+ * @param date 时间
42
+ * @returns 形如 `"2026-08-21"`
43
+ */
44
+ function localDay(date: Date): string {
45
+ const y = date.getFullYear()
46
+ const m = String(date.getMonth() + 1).padStart(2, "0")
47
+ const d = String(date.getDate()).padStart(2, "0")
48
+ return `${y}-${m}-${d}`
49
+ }
50
+
51
+ /**
52
+ * 滚动日志写入器
53
+ *
54
+ * 触发滚动的两个条件:跨自然日、当前文件超过 `maxSize`。
55
+ * 同一天内超限则追加序号:`app-2026-08-21.1.log`。
56
+ */
57
+ export class RotatingFileWriter {
58
+ /** 日志目录 */
59
+ readonly #dir: string
60
+ /** 文件名前缀 */
61
+ readonly #basename: string
62
+ /** 单文件上限 */
63
+ readonly #maxSize: number
64
+ /** 保留天数 */
65
+ readonly #keepDays: number
66
+ /** 文件数上限 */
67
+ readonly #maxFiles: number
68
+ /** 刷盘间隔 */
69
+ readonly #flushInterval: number
70
+ /** 缓冲上限 */
71
+ readonly #bufferLimit: number
72
+
73
+ /** 当前文件描述符 */
74
+ #fd = -1
75
+ /** 当前文件路径 */
76
+ #file = ""
77
+ /** 当前文件已写字节数 */
78
+ #size = 0
79
+ /** 当前文件对应的日期 */
80
+ #day = ""
81
+ /** 待刷盘缓冲 */
82
+ #buffer: string[] = []
83
+ /** 缓冲字节数(按 UTF-8 估算) */
84
+ #bufferBytes = 0
85
+ /** 刷盘定时器 */
86
+ #timer: NodeJS.Timeout | undefined
87
+ /** 是否已关闭 */
88
+ #closed = false
89
+
90
+ /**
91
+ * @param opts 滚动参数
92
+ */
93
+ constructor(opts: RotateOptions) {
94
+ this.#dir = opts.dir
95
+ this.#basename = opts.basename ?? "app"
96
+ this.#maxSize = Math.max(64 * 1024, opts.maxSize ?? DEFAULT_MAX_SIZE)
97
+ this.#keepDays = Math.max(1, opts.keepDays ?? 14)
98
+ this.#maxFiles = Math.max(2, opts.maxFiles ?? 100)
99
+ this.#flushInterval = Math.max(50, opts.flushInterval ?? 200)
100
+ this.#bufferLimit = Math.max(4096, opts.bufferLimit ?? 32 * 1024)
101
+ }
102
+
103
+ /** 当前正在写入的文件路径 */
104
+ get file(): string {
105
+ return this.#file
106
+ }
107
+
108
+ /**
109
+ * 写入一行(调用方需自带换行符,pino 已带)
110
+ * @param line 日志行
111
+ */
112
+ write(line: string): void {
113
+ if (this.#closed) return
114
+ this.#buffer.push(line)
115
+ this.#bufferBytes += Buffer.byteLength(line)
116
+
117
+ if (this.#bufferBytes >= this.#bufferLimit) {
118
+ this.flushSync()
119
+ return
120
+ }
121
+ if (!this.#timer) {
122
+ this.#timer = setTimeout(() => {
123
+ this.#timer = undefined
124
+ this.flushSync()
125
+ }, this.#flushInterval)
126
+ // unref:日志缓冲不该拖着进程不让退出
127
+ if (typeof this.#timer.unref === "function") this.#timer.unref()
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 同步刷盘
133
+ *
134
+ * 进程退出、`uncaughtException`、`fatal` 级日志之后都应调用,
135
+ * 否则最关键的几行日志会随进程一起消失。
136
+ */
137
+ flushSync(): void {
138
+ if (this.#buffer.length === 0) return
139
+ const payload = this.#buffer.join("")
140
+ this.#buffer = []
141
+ this.#bufferBytes = 0
142
+
143
+ try {
144
+ this.#ensureTarget(Buffer.byteLength(payload))
145
+ if (this.#fd < 0) return
146
+ const data = Buffer.from(payload, "utf8")
147
+ let offset = 0
148
+ // writeSync 可能只写了一部分,必须循环
149
+ while (offset < data.length) {
150
+ offset += writeSync(this.#fd, data, offset, data.length - offset)
151
+ }
152
+ this.#size += data.length
153
+ } catch {
154
+ // 日志系统本身不能抛错,否则会把"记录错误"变成"制造错误"。
155
+ // 磁盘满或权限问题只能静默丢弃,控制台流仍然可用。
156
+ }
157
+ }
158
+
159
+ /** 刷盘并关闭文件 */
160
+ close(): void {
161
+ if (this.#closed) return
162
+ this.flushSync()
163
+ this.#closed = true
164
+ if (this.#timer) {
165
+ clearTimeout(this.#timer)
166
+ this.#timer = undefined
167
+ }
168
+ if (this.#fd >= 0) {
169
+ try {
170
+ closeSync(this.#fd)
171
+ } catch {
172
+ // 关闭失败无从处理
173
+ }
174
+ this.#fd = -1
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 确保当前文件可写且容量足够
180
+ * @param incoming 即将写入的字节数
181
+ */
182
+ #ensureTarget(incoming: number): void {
183
+ const day = localDay(new Date())
184
+ const needRotate = this.#fd < 0 || day !== this.#day || this.#size + incoming > this.#maxSize
185
+ if (!needRotate) return
186
+
187
+ if (this.#fd >= 0) {
188
+ try {
189
+ closeSync(this.#fd)
190
+ } catch {
191
+ // 忽略
192
+ }
193
+ this.#fd = -1
194
+ }
195
+
196
+ mkdirSync(this.#dir, { recursive: true })
197
+ this.#day = day
198
+
199
+ // 找当天第一个未超限的序号
200
+ let index = 0
201
+ let file = join(this.#dir, `${this.#basename}-${day}.log`)
202
+ let size = this.#sizeOf(file)
203
+ while (size + incoming > this.#maxSize && index < 1000) {
204
+ index++
205
+ file = join(this.#dir, `${this.#basename}-${day}.${index}.log`)
206
+ size = this.#sizeOf(file)
207
+ }
208
+
209
+ this.#file = file
210
+ this.#size = size
211
+ this.#fd = openSync(file, "a")
212
+ this.#cleanup()
213
+ }
214
+
215
+ /**
216
+ * 取文件大小
217
+ * @param file 文件路径
218
+ * @returns 字节数;不存在时 0
219
+ */
220
+ #sizeOf(file: string): number {
221
+ try {
222
+ return existsSync(file) ? statSync(file).size : 0
223
+ } catch {
224
+ return 0
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 按保留策略删除旧日志
230
+ *
231
+ * 只在滚动时调用(低频),因此用同步 API 无妨。
232
+ */
233
+ #cleanup(): void {
234
+ try {
235
+ const pattern = new RegExp(`^${this.#basename}-(\\d{4}-\\d{2}-\\d{2})(?:\\.(\\d+))?\\.log$`)
236
+ const files: { name: string; day: string; index: number }[] = []
237
+ for (const name of readdirSync(this.#dir)) {
238
+ const m = pattern.exec(name)
239
+ if (m) files.push({ name, day: m[1]!, index: Number(m[2] ?? 0) })
240
+ }
241
+
242
+ // 按日期倒序、同日按序号倒序 → 数组头部是最新的
243
+ files.sort((a, b) => (a.day === b.day ? b.index - a.index : a.day < b.day ? 1 : -1))
244
+
245
+ const cutoff = new Date()
246
+ cutoff.setDate(cutoff.getDate() - this.#keepDays)
247
+ const cutoffDay = localDay(cutoff)
248
+
249
+ for (let i = 0; i < files.length; i++) {
250
+ const entry = files[i]!
251
+ const tooOld = entry.day < cutoffDay
252
+ const tooMany = i >= this.#maxFiles
253
+ if (!tooOld && !tooMany) continue
254
+ if (join(this.#dir, entry.name) === this.#file) continue
255
+ try {
256
+ unlinkSync(join(this.#dir, entry.name))
257
+ } catch {
258
+ // 文件被占用(Windows 常见)时留到下次滚动再试
259
+ }
260
+ }
261
+ } catch {
262
+ // 清理失败不影响写入
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * 模块职责:消息段编解码的单元测试 —— 归一化、媒体引用、纯文本视图、日志摘要
3
+ * 依赖方向:测试文件,只依赖 message/segment
4
+ * 生命周期:纯函数,无夹具
5
+ * 注意事项:这里盯的是文件头那三条取舍能不能一直守住:
6
+ * 媒体**不**擅自转 base64、相邻文本段合并、假值被丢弃。
7
+ * 三条都是"写错了也能跑"的那种缺陷 —— 图片照样发得出去,只是多占几倍
8
+ * 内存;回复照样收得到,只是被平台当成三条内容计费。
9
+ *
10
+ * 路径断言一律以 `resolve()` + `pathToFileURL()` 实时计算,不硬编码
11
+ * `C:\` 或 `/tmp`:这套代码 Windows 与 Termux 双端都要跑,
12
+ * 把盘符写进测试等于把测试绑死在一个平台上。
13
+ */
14
+ import { resolve } from "node:path"
15
+ import { pathToFileURL } from "node:url"
16
+ import { describe, expect, it } from "vitest"
17
+ import type { MediaRef, MessageContent, Segment } from "@yunzai-ng/types"
18
+ import {
19
+ atUsersOf,
20
+ countByType,
21
+ describeMedia,
22
+ describeMessage,
23
+ hasAtAll,
24
+ imagesOf,
25
+ isSegment,
26
+ quotedIdOf,
27
+ seg,
28
+ textOf,
29
+ toMediaRef,
30
+ toSegments
31
+ } from "./segment.js"
32
+
33
+ /** 本机上一个真实存在写法的绝对路径 */
34
+ const ABS = resolve("temp", "示例 图.png")
35
+
36
+ describe("toSegments 归一化", () => {
37
+ it("裸字符串与数字都变成文本段", () => {
38
+ expect(toSegments("你好")).toEqual([{ type: "text", text: "你好" }])
39
+ expect(toSegments(160)).toEqual([{ type: "text", text: "160" }])
40
+ })
41
+
42
+ it("相邻文本段合并成一段", () => {
43
+ // 原样产出三段的话,部分平台会当成三条内容拼接甚至分别计费
44
+ expect(toSegments(["你的体力:", 160, "/160"])).toEqual([{ type: "text", text: "你的体力:160/160" }])
45
+ })
46
+
47
+ it("非相邻的文本段各自保留", () => {
48
+ expect(toSegments(["前", seg.at("20000"), "后"])).toEqual([
49
+ { type: "text", text: "前" },
50
+ { type: "at", uid: "20000" },
51
+ { type: "text", text: "后" }
52
+ ])
53
+ })
54
+
55
+ it("任意层嵌套数组都被展平", () => {
56
+ expect(toSegments(["a", ["b", ["c", [["d"]]]]])).toEqual([{ type: "text", text: "abcd" }])
57
+ })
58
+
59
+ it("null / undefined / false / 空串被丢弃", () => {
60
+ // 于是条件拼装可以直接写 `[e.isGroup && seg.at(uid), "内容"]`
61
+ expect(toSegments([null, undefined, false, "", "正文"])).toEqual([{ type: "text", text: "正文" }])
62
+ expect(toSegments(null)).toEqual([])
63
+ expect(toSegments([])).toEqual([])
64
+ })
65
+
66
+ it("数字 0 是有意义的文本,不能跟着假值一起丢", () => {
67
+ expect(toSegments(["剩余 ", 0, " 次"])).toEqual([{ type: "text", text: "剩余 0 次" }])
68
+ })
69
+
70
+ it("已经是段的对象原样透传", () => {
71
+ const image = seg.image("https://example.com/a.png")
72
+ const out = toSegments(["看图", image])
73
+ expect(out[1]).toBe(image)
74
+ })
75
+
76
+ it("文本段对象也参与合并", () => {
77
+ expect(toSegments([seg.text("甲"), { type: "text", text: "乙" }])).toEqual([{ type: "text", text: "甲乙" }])
78
+ })
79
+
80
+ it("空文本段不会额外产生一个段", () => {
81
+ expect(toSegments([seg.text(""), seg.at("1")])).toEqual([{ type: "at", uid: "1" }])
82
+ })
83
+
84
+ it("认不出来的对象被丢掉而不是抛错", () => {
85
+ // 平台或插件传入非法数据时,宁可少一个段,亦不应使整条回复无法发出
86
+ expect(toSegments([{ foo: 1 }, "正文"] as unknown as MessageContent)).toEqual([{ type: "text", text: "正文" }])
87
+ })
88
+ })
89
+
90
+ describe("toMediaRef 媒体归一化", () => {
91
+ it("http(s) 链接归到 url", () => {
92
+ expect(toMediaRef("https://example.com/a.png")).toEqual({ kind: "url", url: "https://example.com/a.png" })
93
+ expect(toMediaRef("http://example.com/a.png")).toEqual({ kind: "url", url: "http://example.com/a.png" })
94
+ })
95
+
96
+ it("file:// 与绝对路径都归到 path", () => {
97
+ // 经 fileURLToPath 而不是字符串切片:Windows 盘符与 %20 转义都要正确还原
98
+ expect(toMediaRef(pathToFileURL(ABS).href)).toEqual({ kind: "path", path: ABS })
99
+ expect(toMediaRef(pathToFileURL(ABS))).toEqual({ kind: "path", path: ABS })
100
+ expect(toMediaRef(ABS)).toEqual({ kind: "path", path: ABS })
101
+ })
102
+
103
+ it("URL 对象按协议分流", () => {
104
+ expect(toMediaRef(new URL("https://example.com/a.png"))).toEqual({
105
+ kind: "url",
106
+ url: "https://example.com/a.png"
107
+ })
108
+ })
109
+
110
+ it("base64:// 前缀归到 base64", () => {
111
+ expect(toMediaRef("base64://QUJD")).toEqual({ kind: "base64", base64: "QUJD" })
112
+ })
113
+
114
+ it("data URI 归到 base64 并带上 mime", () => {
115
+ expect(toMediaRef("data:image/png;base64,QUJD")).toEqual({ kind: "base64", base64: "QUJD", mime: "image/png" })
116
+ expect(toMediaRef("data:text/plain;charset=utf-8;base64,QUJD")).toEqual({
117
+ kind: "base64",
118
+ base64: "QUJD",
119
+ mime: "text/plain"
120
+ })
121
+ // 未标注 mime 的 data URI 不应写入一个空串 mime
122
+ expect(toMediaRef("data:;base64,QUJD")).toEqual({ kind: "base64", base64: "QUJD" })
123
+ })
124
+
125
+ it("字节直接归到 buffer,不做 base64 编码", () => {
126
+ const bytes = new Uint8Array([1, 2, 3])
127
+ const ref = toMediaRef(bytes)
128
+ expect(ref).toEqual({ kind: "buffer", data: bytes })
129
+ // 同一块内存,不做拷贝:base64 会让 2MB 的图多占 2.7MB
130
+ if (ref.kind === "buffer") expect(ref.data).toBe(bytes)
131
+ })
132
+
133
+ it("ArrayBuffer 包成 Uint8Array 视图", () => {
134
+ const buf = new ArrayBuffer(2)
135
+ new Uint8Array(buf).set([7, 8])
136
+ expect(toMediaRef(buf)).toEqual({ kind: "buffer", data: new Uint8Array([7, 8]) })
137
+ })
138
+
139
+ it("已归一化的引用原样返回,不重复包装", () => {
140
+ const ref: MediaRef = { kind: "id", id: "file-123" }
141
+ expect(toMediaRef(ref)).toBe(ref)
142
+ })
143
+
144
+ it("相对路径被拒绝,并指出该用 ctx.resource", () => {
145
+ // 内核无从判定其解析基准,判定错误的表现为"图片无法发出但未产生报错"
146
+ expect(() => toMediaRef("./resources/a.png")).toThrow(/ctx\.resource/)
147
+ expect(() => toMediaRef("a.png")).toThrow(/无法识别的媒体引用/)
148
+ })
149
+
150
+ it("空串被拒绝", () => {
151
+ expect(() => toMediaRef("")).toThrow("媒体引用不能为空串")
152
+ })
153
+ })
154
+
155
+ describe("seg 构造器", () => {
156
+ it("可选参数没传时不留 undefined 键", () => {
157
+ // 留着的话适配器 `if ("name" in s)` 之类的判断会误判,序列化出去也多一个 null
158
+ expect(seg.at("20000")).toEqual({ type: "at", uid: "20000" })
159
+ expect(Object.keys(seg.at("20000"))).toEqual(["type", "uid"])
160
+ expect(Object.keys(seg.face(1))).toEqual(["type", "id"])
161
+ expect(Object.keys(seg.poke())).toEqual(["type"])
162
+ expect(Object.keys(seg.dice())).toEqual(["type"])
163
+ expect(Object.keys(seg.video("https://e.com/v.mp4"))).toEqual(["type", "file"])
164
+ })
165
+
166
+ it("可选参数传了就带上", () => {
167
+ expect(seg.at("20000", "阿测")).toEqual({ type: "at", uid: "20000", name: "阿测" })
168
+ expect(seg.face(4, true)).toEqual({ type: "face", id: 4, big: true })
169
+ expect(seg.poke("20000", 2)).toEqual({ type: "poke", uid: "20000", pokeType: 2 })
170
+ expect(seg.dice(6)).toEqual({ type: "dice", result: 6 })
171
+ })
172
+
173
+ it("媒体类构造器把来源归一化,附加字段照原样带上", () => {
174
+ expect(seg.image(ABS, { summary: "面板图", width: 800 })).toEqual({
175
+ type: "image",
176
+ file: { kind: "path", path: ABS },
177
+ summary: "面板图",
178
+ width: 800
179
+ })
180
+ expect(seg.file("base64://QUJD", { name: "a.txt" })).toEqual({
181
+ type: "file",
182
+ file: { kind: "base64", base64: "QUJD" },
183
+ name: "a.txt"
184
+ })
185
+ expect(seg.video("https://e.com/v.mp4", ABS)).toEqual({
186
+ type: "video",
187
+ file: { kind: "url", url: "https://e.com/v.mp4" },
188
+ thumb: { kind: "path", path: ABS }
189
+ })
190
+ })
191
+
192
+ it("json 接对象时自动序列化", () => {
193
+ expect(seg.json({ app: "com.tencent.miniapp" })).toEqual({
194
+ type: "json",
195
+ data: '{"app":"com.tencent.miniapp"}'
196
+ })
197
+ expect(seg.json('{"raw":1}')).toEqual({ type: "json", data: '{"raw":1}' })
198
+ })
199
+
200
+ it("转发节点的内容也走归一化", () => {
201
+ const node = seg.node(["第", 1, "条"], { name: "阿测", uid: "20000" })
202
+ expect(node).toEqual({ message: [{ type: "text", text: "第1条" }], name: "阿测", uid: "20000" })
203
+
204
+ expect(seg.forward([node], { summary: "查看 1 条转发" })).toEqual({
205
+ type: "forward",
206
+ nodes: [node],
207
+ summary: "查看 1 条转发"
208
+ })
209
+ })
210
+
211
+ it("音乐分两种:平台歌曲与自定义卡片", () => {
212
+ expect(seg.music("163", "123")).toEqual({ type: "music", platform: "163", id: "123" })
213
+ expect(seg.musicCustom({ title: "曲名", url: "https://e.com/s", audio: "https://e.com/a.mp3" })).toEqual({
214
+ type: "music",
215
+ platform: "custom",
216
+ title: "曲名",
217
+ url: "https://e.com/s",
218
+ audio: "https://e.com/a.mp3"
219
+ })
220
+ })
221
+
222
+ it("raw 段为平台私有内容的兼容出口", () => {
223
+ expect(seg.raw("napcat", "mface", { emoji_id: "x" })).toEqual({
224
+ type: "raw",
225
+ platform: "napcat",
226
+ platformType: "mface",
227
+ data: { emoji_id: "x" }
228
+ })
229
+ })
230
+ })
231
+
232
+ describe("派生视图", () => {
233
+ /** 一条什么都有的消息 */
234
+ const rich: Segment[] = [
235
+ seg.reply("m-1"),
236
+ seg.at("10000", "机器人"),
237
+ seg.at("20000"),
238
+ seg.at("20000"),
239
+ seg.at(""),
240
+ seg.text(" 查一下 "),
241
+ seg.atAll(),
242
+ seg.image("https://e.com/1.png"),
243
+ seg.image(ABS),
244
+ seg.text("角色 ")
245
+ ]
246
+
247
+ it("textOf 只拼文本段并 trim", () => {
248
+ expect(textOf(rich)).toBe("查一下 角色")
249
+ expect(textOf([])).toBe("")
250
+ expect(textOf([seg.image(ABS)])).toBe("")
251
+ })
252
+
253
+ it("imagesOf 按出现顺序取全部图片", () => {
254
+ expect(imagesOf(rich).map(i => i.file.kind)).toEqual(["url", "path"])
255
+ })
256
+
257
+ it("atUsersOf 去重保序并忽略空 uid", () => {
258
+ expect(atUsersOf(rich)).toEqual(["10000", "20000"])
259
+ })
260
+
261
+ it("hasAtAll 认 atAll 段", () => {
262
+ expect(hasAtAll(rich)).toBe(true)
263
+ expect(hasAtAll([seg.at("20000")])).toBe(false)
264
+ })
265
+
266
+ it("quotedIdOf 取第一个 reply 段", () => {
267
+ expect(quotedIdOf(rich)).toBe("m-1")
268
+ expect(quotedIdOf([seg.reply("a"), seg.reply("b")])).toBe("a")
269
+ expect(quotedIdOf([seg.text("无引用")])).toBeUndefined()
270
+ })
271
+
272
+ it("countByType 统计各类型条数", () => {
273
+ expect(countByType(rich)).toEqual({ reply: 1, at: 4, text: 2, atAll: 1, image: 2 })
274
+ expect(countByType([])).toEqual({})
275
+ })
276
+
277
+ it("isSegment 只认有 type 字符串的对象", () => {
278
+ expect(isSegment(seg.text("x"))).toBe(true)
279
+ expect(isSegment({ type: "谁知道是什么" })).toBe(true)
280
+ expect(isSegment({ type: 1 })).toBe(false)
281
+ expect(isSegment(null)).toBe(false)
282
+ expect(isSegment("text")).toBe(false)
283
+ })
284
+ })
285
+
286
+ describe("日志摘要", () => {
287
+ it("describeMedia 对 base64 与字节只报长度", () => {
288
+ // 把 2MB 的 base64 写进日志既没用又会把轮转打满,混在里面的 CK 还会泄露
289
+ expect(describeMedia({ kind: "base64", base64: "QUJD" })).toBe("base64 4 字符")
290
+ expect(describeMedia({ kind: "buffer", data: new Uint8Array(2048) })).toBe("2048 字节")
291
+ expect(describeMedia({ kind: "id", id: "f-1" })).toBe("id:f-1")
292
+ expect(describeMedia({ kind: "path", path: ABS })).toBe(ABS)
293
+ })
294
+
295
+ it("describeMedia 把超长 url 截断", () => {
296
+ const long = `https://example.com/${"a".repeat(80)}.png`
297
+ const out = describeMedia({ kind: "url", url: long })
298
+ expect([...out]).toHaveLength(61)
299
+ expect(out.endsWith("…")).toBe(true)
300
+ })
301
+
302
+ it("describeMessage 贴着聊天窗口的样子拼一行", () => {
303
+ expect(
304
+ describeMessage([
305
+ seg.reply("m-1"),
306
+ seg.at("20000", "阿测"),
307
+ seg.at("30000"),
308
+ seg.atAll(),
309
+ seg.text("查\n角色"),
310
+ seg.face(4),
311
+ seg.image("https://e.com/1.png"),
312
+ seg.record(ABS),
313
+ seg.video(ABS),
314
+ seg.file(ABS, { name: "a.txt" }),
315
+ seg.forward([seg.node("一条")]),
316
+ seg.json("{}"),
317
+ seg.markdown("# 标题"),
318
+ seg.keyboard([[{ label: "按我", action: "input", data: "#帮助" }]]),
319
+ seg.raw("napcat", "mface", {})
320
+ ])
321
+ ).toBe("[回复m-1][@阿测][@30000][@全体]查 角色[表情4][图片 https://e.com/1.png][语音][视频][文件 a.txt][合并转发 1][卡片][Markdown][按钮][napcat:mface]")
322
+ })
323
+
324
+ it("describeMessage 里的媒体只出摘要,不出正文", () => {
325
+ const out = describeMessage([seg.image(`base64://${"Q".repeat(200)}`)])
326
+ expect(out).toBe("[图片 base64 200 字符]")
327
+ expect(out).not.toContain("QQQ")
328
+ })
329
+
330
+ it("describeMessage 把超长文本截断", () => {
331
+ const out = describeMessage([seg.text("字".repeat(50))])
332
+ expect([...out]).toHaveLength(41)
333
+ expect(out.endsWith("…")).toBe(true)
334
+ })
335
+ })