@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.
- package/LICENSE +661 -0
- package/README.md +51 -0
- package/dist/adapter/accounts.d.ts +174 -0
- package/dist/adapter/accounts.d.ts.map +1 -0
- package/dist/adapter/accounts.js +653 -0
- package/dist/adapter/accounts.js.map +1 -0
- package/dist/adapter/bots.d.ts +142 -0
- package/dist/adapter/bots.d.ts.map +1 -0
- package/dist/adapter/bots.js +454 -0
- package/dist/adapter/bots.js.map +1 -0
- package/dist/adapter/host.d.ts +114 -0
- package/dist/adapter/host.d.ts.map +1 -0
- package/dist/adapter/host.js +90 -0
- package/dist/adapter/host.js.map +1 -0
- package/dist/adapter/login.d.ts +174 -0
- package/dist/adapter/login.d.ts.map +1 -0
- package/dist/adapter/login.js +345 -0
- package/dist/adapter/login.js.map +1 -0
- package/dist/adapter/registry.d.ts +102 -0
- package/dist/adapter/registry.d.ts.map +1 -0
- package/dist/adapter/registry.js +150 -0
- package/dist/adapter/registry.js.map +1 -0
- package/dist/config/core-config.d.ts +151 -0
- package/dist/config/core-config.d.ts.map +1 -0
- package/dist/config/core-config.js +338 -0
- package/dist/config/core-config.js.map +1 -0
- package/dist/config/schema.d.ts +449 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +871 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/store.d.ts +184 -0
- package/dist/config/store.d.ts.map +1 -0
- package/dist/config/store.js +428 -0
- package/dist/config/store.js.map +1 -0
- package/dist/config/yaml.d.ts +23 -0
- package/dist/config/yaml.d.ts.map +1 -0
- package/dist/config/yaml.js +108 -0
- package/dist/config/yaml.js.map +1 -0
- package/dist/http/client.d.ts +82 -0
- package/dist/http/client.d.ts.map +1 -0
- package/dist/http/client.js +658 -0
- package/dist/http/client.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +94 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/app.d.ts +244 -0
- package/dist/kernel/app.d.ts.map +1 -0
- package/dist/kernel/app.js +654 -0
- package/dist/kernel/app.js.map +1 -0
- package/dist/kernel/kv-sink.d.ts +34 -0
- package/dist/kernel/kv-sink.d.ts.map +1 -0
- package/dist/kernel/kv-sink.js +25 -0
- package/dist/kernel/kv-sink.js.map +1 -0
- package/dist/kernel/policy.d.ts +77 -0
- package/dist/kernel/policy.d.ts.map +1 -0
- package/dist/kernel/policy.js +99 -0
- package/dist/kernel/policy.js.map +1 -0
- package/dist/kernel/runtime.d.ts +117 -0
- package/dist/kernel/runtime.d.ts.map +1 -0
- package/dist/kernel/runtime.js +197 -0
- package/dist/kernel/runtime.js.map +1 -0
- package/dist/kernel/sql-sink.d.ts +36 -0
- package/dist/kernel/sql-sink.d.ts.map +1 -0
- package/dist/kernel/sql-sink.js +114 -0
- package/dist/kernel/sql-sink.js.map +1 -0
- package/dist/logger/format.d.ts +67 -0
- package/dist/logger/format.d.ts.map +1 -0
- package/dist/logger/format.js +218 -0
- package/dist/logger/format.js.map +1 -0
- package/dist/logger/index.d.ts +97 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +363 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/rotate.d.ts +47 -0
- package/dist/logger/rotate.d.ts.map +1 -0
- package/dist/logger/rotate.js +242 -0
- package/dist/logger/rotate.js.map +1 -0
- package/dist/message/segment.d.ts +255 -0
- package/dist/message/segment.d.ts.map +1 -0
- package/dist/message/segment.js +510 -0
- package/dist/message/segment.js.map +1 -0
- package/dist/message/split.d.ts +34 -0
- package/dist/message/split.d.ts.map +1 -0
- package/dist/message/split.js +79 -0
- package/dist/message/split.js.map +1 -0
- package/dist/message/target.d.ts +29 -0
- package/dist/message/target.d.ts.map +1 -0
- package/dist/message/target.js +34 -0
- package/dist/message/target.js.map +1 -0
- package/dist/pipeline/cooldown.d.ts +84 -0
- package/dist/pipeline/cooldown.d.ts.map +1 -0
- package/dist/pipeline/cooldown.js +94 -0
- package/dist/pipeline/cooldown.js.map +1 -0
- package/dist/pipeline/dispatch.d.ts +98 -0
- package/dist/pipeline/dispatch.d.ts.map +1 -0
- package/dist/pipeline/dispatch.js +323 -0
- package/dist/pipeline/dispatch.js.map +1 -0
- package/dist/pipeline/event.d.ts +130 -0
- package/dist/pipeline/event.d.ts.map +1 -0
- package/dist/pipeline/event.js +538 -0
- package/dist/pipeline/event.js.map +1 -0
- package/dist/pipeline/middleware.d.ts +63 -0
- package/dist/pipeline/middleware.d.ts.map +1 -0
- package/dist/pipeline/middleware.js +174 -0
- package/dist/pipeline/middleware.js.map +1 -0
- package/dist/pipeline/prompt.d.ts +80 -0
- package/dist/pipeline/prompt.d.ts.map +1 -0
- package/dist/pipeline/prompt.js +155 -0
- package/dist/pipeline/prompt.js.map +1 -0
- package/dist/pipeline/router.d.ts +98 -0
- package/dist/pipeline/router.d.ts.map +1 -0
- package/dist/pipeline/router.js +489 -0
- package/dist/pipeline/router.js.map +1 -0
- package/dist/platform/detect.d.ts +24 -0
- package/dist/platform/detect.d.ts.map +1 -0
- package/dist/platform/detect.js +178 -0
- package/dist/platform/detect.js.map +1 -0
- package/dist/platform/paths.d.ts +38 -0
- package/dist/platform/paths.d.ts.map +1 -0
- package/dist/platform/paths.js +129 -0
- package/dist/platform/paths.js.map +1 -0
- package/dist/platform/system.d.ts +71 -0
- package/dist/platform/system.d.ts.map +1 -0
- package/dist/platform/system.js +242 -0
- package/dist/platform/system.js.map +1 -0
- package/dist/plugin/context.d.ts +92 -0
- package/dist/plugin/context.d.ts.map +1 -0
- package/dist/plugin/context.js +573 -0
- package/dist/plugin/context.js.map +1 -0
- package/dist/plugin/define.d.ts +92 -0
- package/dist/plugin/define.d.ts.map +1 -0
- package/dist/plugin/define.js +82 -0
- package/dist/plugin/define.js.map +1 -0
- package/dist/plugin/discover.d.ts +106 -0
- package/dist/plugin/discover.d.ts.map +1 -0
- package/dist/plugin/discover.js +254 -0
- package/dist/plugin/discover.js.map +1 -0
- package/dist/plugin/events.d.ts +109 -0
- package/dist/plugin/events.d.ts.map +1 -0
- package/dist/plugin/events.js +209 -0
- package/dist/plugin/events.js.map +1 -0
- package/dist/plugin/hooks.d.ts +266 -0
- package/dist/plugin/hooks.d.ts.map +1 -0
- package/dist/plugin/hooks.js +84 -0
- package/dist/plugin/hooks.js.map +1 -0
- package/dist/plugin/host.d.ts +160 -0
- package/dist/plugin/host.d.ts.map +1 -0
- package/dist/plugin/host.js +546 -0
- package/dist/plugin/host.js.map +1 -0
- package/dist/plugin/market.d.ts +264 -0
- package/dist/plugin/market.d.ts.map +1 -0
- package/dist/plugin/market.js +700 -0
- package/dist/plugin/market.js.map +1 -0
- package/dist/plugin/services.d.ts +116 -0
- package/dist/plugin/services.d.ts.map +1 -0
- package/dist/plugin/services.js +213 -0
- package/dist/plugin/services.js.map +1 -0
- package/dist/plugin/tar.d.ts +65 -0
- package/dist/plugin/tar.d.ts.map +1 -0
- package/dist/plugin/tar.js +277 -0
- package/dist/plugin/tar.js.map +1 -0
- package/dist/render/registry.d.ts +127 -0
- package/dist/render/registry.d.ts.map +1 -0
- package/dist/render/registry.js +306 -0
- package/dist/render/registry.js.map +1 -0
- package/dist/scheduler/index.d.ts +67 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +279 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/server/api.d.ts +179 -0
- package/dist/server/api.d.ts.map +1 -0
- package/dist/server/api.js +602 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/auth.d.ts +83 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +178 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/browse.d.ts +60 -0
- package/dist/server/browse.d.ts.map +1 -0
- package/dist/server/browse.js +187 -0
- package/dist/server/browse.js.map +1 -0
- package/dist/server/files.d.ts +27 -0
- package/dist/server/files.d.ts.map +1 -0
- package/dist/server/files.js +76 -0
- package/dist/server/files.js.map +1 -0
- package/dist/server/index.d.ts +189 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1123 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/table.d.ts +105 -0
- package/dist/server/table.d.ts.map +1 -0
- package/dist/server/table.js +289 -0
- package/dist/server/table.js.map +1 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +120 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/json.d.ts +73 -0
- package/dist/store/json.d.ts.map +1 -0
- package/dist/store/json.js +174 -0
- package/dist/store/json.js.map +1 -0
- package/dist/store/kv.d.ts +126 -0
- package/dist/store/kv.d.ts.map +1 -0
- package/dist/store/kv.js +232 -0
- package/dist/store/kv.js.map +1 -0
- package/dist/store/level.d.ts +103 -0
- package/dist/store/level.d.ts.map +1 -0
- package/dist/store/level.js +119 -0
- package/dist/store/level.js.map +1 -0
- package/dist/store/memory.d.ts +61 -0
- package/dist/store/memory.d.ts.map +1 -0
- package/dist/store/memory.js +94 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/store/sql.d.ts +142 -0
- package/dist/store/sql.d.ts.map +1 -0
- package/dist/store/sql.js +246 -0
- package/dist/store/sql.js.map +1 -0
- package/dist/testing/fake.d.ts +109 -0
- package/dist/testing/fake.d.ts.map +1 -0
- package/dist/testing/fake.js +196 -0
- package/dist/testing/fake.js.map +1 -0
- package/dist/testing/index.d.ts +16 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +16 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/mock-adapter.d.ts +205 -0
- package/dist/testing/mock-adapter.d.ts.map +1 -0
- package/dist/testing/mock-adapter.js +309 -0
- package/dist/testing/mock-adapter.js.map +1 -0
- package/dist/util/deep.d.ts +108 -0
- package/dist/util/deep.d.ts.map +1 -0
- package/dist/util/deep.js +275 -0
- package/dist/util/deep.js.map +1 -0
- package/dist/util/defer.d.ts +107 -0
- package/dist/util/defer.d.ts.map +1 -0
- package/dist/util/defer.js +163 -0
- package/dist/util/defer.js.map +1 -0
- package/dist/util/dispose.d.ts +88 -0
- package/dist/util/dispose.d.ts.map +1 -0
- package/dist/util/dispose.js +143 -0
- package/dist/util/dispose.js.map +1 -0
- package/dist/util/duration.d.ts +28 -0
- package/dist/util/duration.d.ts.map +1 -0
- package/dist/util/duration.js +77 -0
- package/dist/util/duration.js.map +1 -0
- package/dist/util/fs.d.ts +110 -0
- package/dist/util/fs.d.ts.map +1 -0
- package/dist/util/fs.js +269 -0
- package/dist/util/fs.js.map +1 -0
- package/dist/util/id.d.ts +49 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +87 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/lru.d.ts +74 -0
- package/dist/util/lru.d.ts.map +1 -0
- package/dist/util/lru.js +156 -0
- package/dist/util/lru.js.map +1 -0
- package/dist/util/queue.d.ts +64 -0
- package/dist/util/queue.d.ts.map +1 -0
- package/dist/util/queue.js +147 -0
- package/dist/util/queue.js.map +1 -0
- package/dist/util/text.d.ts +86 -0
- package/dist/util/text.d.ts.map +1 -0
- package/dist/util/text.js +182 -0
- package/dist/util/text.js.map +1 -0
- package/package.json +44 -0
- package/src/adapter/accounts.ts +758 -0
- package/src/adapter/bots.ts +582 -0
- package/src/adapter/host.ts +224 -0
- package/src/adapter/login.ts +481 -0
- package/src/adapter/registry.ts +202 -0
- package/src/config/core-config.test.ts +60 -0
- package/src/config/core-config.ts +401 -0
- package/src/config/schema.test.ts +179 -0
- package/src/config/schema.ts +1036 -0
- package/src/config/store.test.ts +245 -0
- package/src/config/store.ts +506 -0
- package/src/config/yaml.ts +119 -0
- package/src/http/client.test.ts +568 -0
- package/src/http/client.ts +777 -0
- package/src/index.ts +106 -0
- package/src/kernel/app.test.ts +267 -0
- package/src/kernel/app.ts +843 -0
- package/src/kernel/kv-sink.ts +58 -0
- package/src/kernel/policy.ts +120 -0
- package/src/kernel/runtime.test.ts +542 -0
- package/src/kernel/runtime.ts +307 -0
- package/src/kernel/sql-sink.ts +154 -0
- package/src/logger/format.ts +259 -0
- package/src/logger/index.ts +427 -0
- package/src/logger/rotate.ts +265 -0
- package/src/message/segment.test.ts +335 -0
- package/src/message/segment.ts +569 -0
- package/src/message/split.ts +97 -0
- package/src/message/target.ts +48 -0
- package/src/pipeline/cooldown.ts +136 -0
- package/src/pipeline/dispatch.ts +419 -0
- package/src/pipeline/event.ts +735 -0
- package/src/pipeline/middleware.test.ts +426 -0
- package/src/pipeline/middleware.ts +199 -0
- package/src/pipeline/prompt.ts +212 -0
- package/src/pipeline/router.test.ts +432 -0
- package/src/pipeline/router.ts +575 -0
- package/src/platform/detect.ts +178 -0
- package/src/platform/paths.ts +147 -0
- package/src/platform/system.test.ts +102 -0
- package/src/platform/system.ts +289 -0
- package/src/plugin/context.test.ts +459 -0
- package/src/plugin/context.ts +757 -0
- package/src/plugin/define.test.ts +120 -0
- package/src/plugin/define.ts +142 -0
- package/src/plugin/discover.test.ts +253 -0
- package/src/plugin/discover.ts +353 -0
- package/src/plugin/events.test.ts +162 -0
- package/src/plugin/events.ts +266 -0
- package/src/plugin/hooks.ts +373 -0
- package/src/plugin/host.test.ts +696 -0
- package/src/plugin/host.ts +738 -0
- package/src/plugin/market.test.ts +781 -0
- package/src/plugin/market.ts +870 -0
- package/src/plugin/services.test.ts +135 -0
- package/src/plugin/services.ts +261 -0
- package/src/plugin/tar.test.ts +158 -0
- package/src/plugin/tar.ts +309 -0
- package/src/render/registry.test.ts +127 -0
- package/src/render/registry.ts +439 -0
- package/src/scheduler/index.ts +357 -0
- package/src/server/api.test.ts +802 -0
- package/src/server/api.ts +869 -0
- package/src/server/auth.ts +201 -0
- package/src/server/browse.test.ts +193 -0
- package/src/server/browse.ts +240 -0
- package/src/server/files.ts +97 -0
- package/src/server/index.test.ts +566 -0
- package/src/server/index.ts +1386 -0
- package/src/server/table.ts +351 -0
- package/src/store/index.ts +156 -0
- package/src/store/json.ts +198 -0
- package/src/store/kv.test.ts +307 -0
- package/src/store/kv.ts +256 -0
- package/src/store/level.ts +167 -0
- package/src/store/memory.ts +109 -0
- package/src/store/sql.test.ts +204 -0
- package/src/store/sql.ts +324 -0
- package/src/testing/fake.ts +297 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +588 -0
- package/src/util/deep.ts +272 -0
- package/src/util/defer.ts +211 -0
- package/src/util/dispose.ts +169 -0
- package/src/util/duration.ts +82 -0
- package/src/util/fs.ts +279 -0
- package/src/util/id.ts +94 -0
- package/src/util/lru.ts +185 -0
- package/src/util/queue.ts +167 -0
- 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
|
+
}
|
package/src/util/lru.ts
ADDED
|
@@ -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
|
+
}
|