@planet-matrix/mobius-model 0.6.0 → 0.9.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/CHANGELOG.md +37 -0
- package/dist/index.js +706 -36
- package/dist/index.js.map +855 -59
- package/package.json +28 -16
- package/src/ai/README.md +1 -0
- package/src/ai/ai.ts +107 -0
- package/src/ai/chat-completion-ai/aihubmix-chat-completion.ts +78 -0
- package/src/ai/chat-completion-ai/chat-completion-ai.ts +270 -0
- package/src/ai/chat-completion-ai/chat-completion.ts +189 -0
- package/src/ai/chat-completion-ai/index.ts +7 -0
- package/src/ai/chat-completion-ai/lingyiwanwu-chat-completion.ts +78 -0
- package/src/ai/chat-completion-ai/ohmygpt-chat-completion.ts +78 -0
- package/src/ai/chat-completion-ai/openai-next-chat-completion.ts +78 -0
- package/src/ai/embedding-ai/embedding-ai.ts +63 -0
- package/src/ai/embedding-ai/embedding.ts +50 -0
- package/src/ai/embedding-ai/index.ts +4 -0
- package/src/ai/embedding-ai/openai-next-embedding.ts +23 -0
- package/src/ai/index.ts +4 -0
- package/src/aio/README.md +100 -0
- package/src/aio/content.ts +141 -0
- package/src/aio/index.ts +3 -0
- package/src/aio/json.ts +127 -0
- package/src/aio/prompt.ts +246 -0
- package/src/basic/README.md +20 -15
- package/src/basic/error.ts +19 -5
- package/src/basic/function.ts +2 -2
- package/src/basic/index.ts +1 -0
- package/src/basic/schedule.ts +111 -0
- package/src/basic/stream.ts +135 -25
- package/src/credential/README.md +107 -0
- package/src/credential/api-key.ts +158 -0
- package/src/credential/bearer.ts +73 -0
- package/src/credential/index.ts +4 -0
- package/src/credential/json-web-token.ts +96 -0
- package/src/credential/password.ts +170 -0
- package/src/cron/README.md +86 -0
- package/src/cron/cron.ts +87 -0
- package/src/cron/index.ts +1 -0
- package/src/drizzle/README.md +1 -0
- package/src/drizzle/drizzle.ts +1 -0
- package/src/drizzle/helper.ts +47 -0
- package/src/drizzle/index.ts +5 -0
- package/src/drizzle/infer.ts +52 -0
- package/src/drizzle/kysely.ts +8 -0
- package/src/drizzle/pagination.ts +200 -0
- package/src/email/README.md +1 -0
- package/src/email/index.ts +1 -0
- package/src/email/resend.ts +25 -0
- package/src/event/class-event-proxy.ts +6 -5
- package/src/event/common.ts +13 -3
- package/src/event/event-manager.ts +3 -3
- package/src/event/instance-event-proxy.ts +6 -5
- package/src/event/internal.ts +4 -4
- package/src/form/README.md +25 -0
- package/src/form/index.ts +1 -0
- package/src/form/inputor-controller/base.ts +874 -0
- package/src/form/inputor-controller/boolean.ts +39 -0
- package/src/form/inputor-controller/file.ts +39 -0
- package/src/form/inputor-controller/form.ts +181 -0
- package/src/form/inputor-controller/helper.ts +117 -0
- package/src/form/inputor-controller/index.ts +17 -0
- package/src/form/inputor-controller/multi-select.ts +99 -0
- package/src/form/inputor-controller/number.ts +116 -0
- package/src/form/inputor-controller/select.ts +109 -0
- package/src/form/inputor-controller/text.ts +82 -0
- package/src/http/READMD.md +1 -0
- package/src/http/api/api-core.ts +84 -0
- package/src/http/api/api-handler.ts +79 -0
- package/src/http/api/api-host.ts +47 -0
- package/src/http/api/api-result.ts +56 -0
- package/src/http/api/api-schema.ts +154 -0
- package/src/http/api/api-server.ts +130 -0
- package/src/http/api/api-test.ts +142 -0
- package/src/http/api/api-type.ts +37 -0
- package/src/http/api/api.ts +81 -0
- package/src/http/api/index.ts +11 -0
- package/src/http/api-adapter/api-core-node-http.ts +260 -0
- package/src/http/api-adapter/api-host-node-http.ts +156 -0
- package/src/http/api-adapter/api-result-arktype.ts +297 -0
- package/src/http/api-adapter/api-result-zod.ts +286 -0
- package/src/http/api-adapter/index.ts +5 -0
- package/src/http/bin/gen-api-list/gen-api-list.ts +126 -0
- package/src/http/bin/gen-api-list/index.ts +1 -0
- package/src/http/bin/gen-api-test/gen-api-test.ts +136 -0
- package/src/http/bin/gen-api-test/index.ts +1 -0
- package/src/http/bin/gen-api-type/calc-code.ts +25 -0
- package/src/http/bin/gen-api-type/gen-api-type.ts +127 -0
- package/src/http/bin/gen-api-type/index.ts +2 -0
- package/src/http/bin/index.ts +2 -0
- package/src/http/index.ts +3 -0
- package/src/huawei/README.md +1 -0
- package/src/huawei/index.ts +2 -0
- package/src/huawei/moderation/index.ts +1 -0
- package/src/huawei/moderation/moderation.ts +355 -0
- package/src/huawei/obs/esdk-obs-nodejs.d.ts +87 -0
- package/src/huawei/obs/index.ts +1 -0
- package/src/huawei/obs/obs.ts +42 -0
- package/src/index.ts +19 -2
- package/src/json/README.md +92 -0
- package/src/json/index.ts +1 -0
- package/src/json/repair.ts +18 -0
- package/src/log/logger.ts +15 -4
- package/src/openai/README.md +1 -0
- package/src/openai/index.ts +1 -0
- package/src/openai/openai.ts +510 -0
- package/src/orchestration/README.md +9 -7
- package/src/orchestration/dispatching/dispatcher.ts +83 -0
- package/src/orchestration/dispatching/index.ts +2 -0
- package/src/orchestration/dispatching/selector/base-selector.ts +39 -0
- package/src/orchestration/dispatching/selector/down-count-selector.ts +119 -0
- package/src/orchestration/dispatching/selector/index.ts +2 -0
- package/src/orchestration/index.ts +2 -0
- package/src/orchestration/scheduling/index.ts +2 -0
- package/src/orchestration/scheduling/scheduler.ts +103 -0
- package/src/orchestration/scheduling/task.ts +32 -0
- package/src/random/README.md +8 -7
- package/src/random/base.ts +66 -0
- package/src/random/index.ts +5 -1
- package/src/random/random-boolean.ts +40 -0
- package/src/random/random-integer.ts +60 -0
- package/src/random/random-number.ts +72 -0
- package/src/random/random-string.ts +66 -0
- package/src/request/README.md +108 -0
- package/src/request/fetch/base.ts +108 -0
- package/src/request/fetch/browser.ts +285 -0
- package/src/request/fetch/general.ts +20 -0
- package/src/request/fetch/index.ts +4 -0
- package/src/request/fetch/nodejs.ts +285 -0
- package/src/request/index.ts +2 -0
- package/src/request/request/base.ts +250 -0
- package/src/request/request/general.ts +64 -0
- package/src/request/request/index.ts +3 -0
- package/src/request/request/resource.ts +68 -0
- package/src/result/README.md +4 -0
- package/src/result/controller.ts +54 -0
- package/src/result/either.ts +193 -0
- package/src/result/index.ts +2 -0
- package/src/route/README.md +105 -0
- package/src/route/adapter/browser.ts +122 -0
- package/src/route/adapter/driver.ts +56 -0
- package/src/route/adapter/index.ts +2 -0
- package/src/route/index.ts +3 -0
- package/src/route/router/index.ts +2 -0
- package/src/route/router/route.ts +630 -0
- package/src/route/router/router.ts +1642 -0
- package/src/route/uri/hash.ts +308 -0
- package/src/route/uri/index.ts +7 -0
- package/src/route/uri/pathname.ts +376 -0
- package/src/route/uri/search.ts +413 -0
- package/src/socket/README.md +105 -0
- package/src/socket/client/index.ts +2 -0
- package/src/socket/client/socket-unit.ts +660 -0
- package/src/socket/client/socket.ts +203 -0
- package/src/socket/common/index.ts +2 -0
- package/src/socket/common/socket-unit-common.ts +23 -0
- package/src/socket/common/socket-unit-heartbeat.ts +427 -0
- package/src/socket/index.ts +3 -0
- package/src/socket/server/index.ts +3 -0
- package/src/socket/server/server.ts +183 -0
- package/src/socket/server/socket-unit.ts +449 -0
- package/src/socket/server/socket.ts +264 -0
- package/src/storage/table.ts +3 -3
- package/src/timer/expiration/expiration-manager.ts +3 -3
- package/src/timer/expiration/remaining-manager.ts +3 -3
- package/src/tube/README.md +99 -0
- package/src/tube/helper.ts +138 -0
- package/src/tube/index.ts +2 -0
- package/src/tube/tube.ts +880 -0
- package/src/weixin/README.md +1 -0
- package/src/weixin/index.ts +2 -0
- package/src/weixin/official-account/authorization.ts +159 -0
- package/src/weixin/official-account/index.ts +2 -0
- package/src/weixin/official-account/js-api.ts +134 -0
- package/src/weixin/open/index.ts +1 -0
- package/src/weixin/open/oauth2.ts +133 -0
- package/tests/unit/ai/ai.spec.ts +85 -0
- package/tests/unit/aio/content.spec.ts +105 -0
- package/tests/unit/aio/json.spec.ts +147 -0
- package/tests/unit/aio/prompt.spec.ts +111 -0
- package/tests/unit/basic/error.spec.ts +16 -4
- package/tests/unit/basic/schedule.spec.ts +74 -0
- package/tests/unit/basic/stream.spec.ts +90 -37
- package/tests/unit/credential/api-key.spec.ts +37 -0
- package/tests/unit/credential/bearer.spec.ts +23 -0
- package/tests/unit/credential/json-web-token.spec.ts +23 -0
- package/tests/unit/credential/password.spec.ts +41 -0
- package/tests/unit/cron/cron.spec.ts +84 -0
- package/tests/unit/event/class-event-proxy.spec.ts +3 -3
- package/tests/unit/event/event-manager.spec.ts +3 -3
- package/tests/unit/event/instance-event-proxy.spec.ts +3 -3
- package/tests/unit/form/inputor-controller/base.spec.ts +458 -0
- package/tests/unit/form/inputor-controller/boolean.spec.ts +30 -0
- package/tests/unit/form/inputor-controller/file.spec.ts +27 -0
- package/tests/unit/form/inputor-controller/form.spec.ts +120 -0
- package/tests/unit/form/inputor-controller/helper.spec.ts +67 -0
- package/tests/unit/form/inputor-controller/multi-select.spec.ts +34 -0
- package/tests/unit/form/inputor-controller/number.spec.ts +36 -0
- package/tests/unit/form/inputor-controller/select.spec.ts +49 -0
- package/tests/unit/form/inputor-controller/text.spec.ts +34 -0
- package/tests/unit/http/api/api-core-host.spec.ts +207 -0
- package/tests/unit/http/api/api-schema.spec.ts +120 -0
- package/tests/unit/http/api/api-server.spec.ts +363 -0
- package/tests/unit/http/api/api-test.spec.ts +117 -0
- package/tests/unit/http/api/api.spec.ts +121 -0
- package/tests/unit/http/api-adapter/node-http.spec.ts +191 -0
- package/tests/unit/json/repair.spec.ts +11 -0
- package/tests/unit/log/logger.spec.ts +19 -4
- package/tests/unit/openai/openai.spec.ts +64 -0
- package/tests/unit/orchestration/dispatching/dispatcher.spec.ts +41 -0
- package/tests/unit/orchestration/dispatching/selector/down-count-selector.spec.ts +81 -0
- package/tests/unit/orchestration/scheduling/scheduler.spec.ts +103 -0
- package/tests/unit/random/base.spec.ts +58 -0
- package/tests/unit/random/random-boolean.spec.ts +25 -0
- package/tests/unit/random/random-integer.spec.ts +32 -0
- package/tests/unit/random/random-number.spec.ts +33 -0
- package/tests/unit/random/random-string.spec.ts +22 -0
- package/tests/unit/request/fetch/browser.spec.ts +222 -0
- package/tests/unit/request/fetch/general.spec.ts +43 -0
- package/tests/unit/request/fetch/nodejs.spec.ts +225 -0
- package/tests/unit/request/request/base.spec.ts +385 -0
- package/tests/unit/request/request/general.spec.ts +161 -0
- package/tests/unit/route/router/route.spec.ts +431 -0
- package/tests/unit/route/router/router.spec.ts +407 -0
- package/tests/unit/route/uri/hash.spec.ts +72 -0
- package/tests/unit/route/uri/pathname.spec.ts +147 -0
- package/tests/unit/route/uri/search.spec.ts +107 -0
- package/tests/unit/socket/client.spec.ts +208 -0
- package/tests/unit/socket/server.spec.ts +135 -0
- package/tests/unit/socket/socket-unit-heartbeat.spec.ts +214 -0
- package/tests/unit/tube/helper.spec.ts +139 -0
- package/tests/unit/tube/tube.spec.ts +501 -0
- package/src/random/string.ts +0 -35
- package/tests/unit/random/string.spec.ts +0 -11
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const internalApiKeyAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
2
|
+
const internalApiKeyPrefix = "sk-"
|
|
3
|
+
const internalApiKeyBodyLength = 48
|
|
4
|
+
|
|
5
|
+
interface InternalApiKeyFormat {
|
|
6
|
+
prefix: string
|
|
7
|
+
alphabet: string
|
|
8
|
+
bodyLength: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// API key 的生成依赖安全随机源。
|
|
12
|
+
// 这个辅助函数同时负责长度校验和随机字节填充,使 API key 生成逻辑可以直接建立在“安全随机字节”之上。
|
|
13
|
+
const internalGetApiKeyRandomValues = (length: number): Uint8Array => {
|
|
14
|
+
if (Number.isInteger(length) === false || length <= 0) {
|
|
15
|
+
throw new RangeError(`Expected length to be a positive integer, got: ${length}`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (globalThis.crypto === undefined || typeof globalThis.crypto.getRandomValues !== "function") {
|
|
19
|
+
throw new Error("Credential utilities require crypto.getRandomValues")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const buffer = new Uint8Array(length)
|
|
23
|
+
globalThis.crypto.getRandomValues(buffer)
|
|
24
|
+
return buffer
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// API key 的生成与校验必须共享同一份格式定义。
|
|
28
|
+
// 这个辅助函数负责补齐默认值并统一校验前缀、字符集和主体长度,避免生成与遮罩对“合法 key”的理解出现分叉。
|
|
29
|
+
const internalResolveApiKeyFormat = (options: GenerateApiKeyOptions | undefined): InternalApiKeyFormat => {
|
|
30
|
+
const prefix = options?.prefix ?? internalApiKeyPrefix
|
|
31
|
+
const alphabet = options?.alphabet ?? internalApiKeyAlphabet
|
|
32
|
+
const bodyLength = options?.bodyLength ?? internalApiKeyBodyLength
|
|
33
|
+
|
|
34
|
+
if (prefix.length === 0) {
|
|
35
|
+
throw new RangeError("Expected prefix to contain at least one character")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (alphabet.length === 0) {
|
|
39
|
+
throw new RangeError("Expected alphabet to contain at least one character")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Number.isInteger(bodyLength) === false || bodyLength <= 0) {
|
|
43
|
+
throw new RangeError(`Expected bodyLength to be a positive integer, got: ${bodyLength}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
prefix,
|
|
48
|
+
alphabet,
|
|
49
|
+
bodyLength,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Step 1: 校验输入值是否满足 API key 格式约定。
|
|
54
|
+
//
|
|
55
|
+
// 遮罩 API key 时需要按固定位置保留前缀和尾部字符,因此必须先确认输入值符合预期长度和前缀规则。
|
|
56
|
+
const internalAssertApiKey = (apiKey: string, format: InternalApiKeyFormat): void => {
|
|
57
|
+
const expectedLength = format.prefix.length + format.bodyLength
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
apiKey.startsWith(format.prefix) === false
|
|
61
|
+
|| apiKey.length !== expectedLength
|
|
62
|
+
) {
|
|
63
|
+
throw new Error("Invalid API key format")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const body = apiKey.slice(format.prefix.length)
|
|
67
|
+
|
|
68
|
+
for (const character of body) {
|
|
69
|
+
if (format.alphabet.includes(character) === false) {
|
|
70
|
+
throw new Error("Invalid API key format")
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Step 2: 生成 API key 的随机主体。
|
|
76
|
+
//
|
|
77
|
+
// 该实现直接把安全随机字节映射为 API key 字符,避免把凭据格式语义拆散到通用随机能力之外。
|
|
78
|
+
// 读者只需要理解一个规则:每个随机字节都会被转换成允许字符集中的一个字符。
|
|
79
|
+
const internalGenerateApiKeyBody = (alphabet: string, bodyLength: number): string => {
|
|
80
|
+
const randomValues = internalGetApiKeyRandomValues(bodyLength)
|
|
81
|
+
let result = ""
|
|
82
|
+
|
|
83
|
+
for (const value of randomValues) {
|
|
84
|
+
result = result + alphabet[value % alphabet.length]!
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* API key 生成选项。
|
|
92
|
+
*
|
|
93
|
+
* `prefix` 用于指定凭据前缀,`alphabet` 用于指定主体字符集,`bodyLength` 用于指定主体长度。
|
|
94
|
+
* 未提供的字段会分别回退到默认前缀、默认字符集和默认主体长度。
|
|
95
|
+
*/
|
|
96
|
+
export interface GenerateApiKeyOptions {
|
|
97
|
+
prefix?: string | undefined
|
|
98
|
+
alphabet?: string | undefined
|
|
99
|
+
bodyLength?: number | undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 生成 API key 字符串,并允许调用方覆写前缀、字符集和主体长度。
|
|
104
|
+
*
|
|
105
|
+
* 该函数使用运行时提供的安全随机源生成固定长度的凭据文本,适合在需要不透明访问凭据的边界层生成新 key。
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```
|
|
109
|
+
* const apiKey = generateApiKey({})
|
|
110
|
+
* // Expect: true
|
|
111
|
+
* const example1 = apiKey.startsWith("sk-")
|
|
112
|
+
* // Expect: 51
|
|
113
|
+
* const example2 = apiKey.length
|
|
114
|
+
* const customApiKey = generateApiKey({ prefix: "pk_", alphabet: "ABC123", bodyLength: 8 })
|
|
115
|
+
* // Expect: true
|
|
116
|
+
* const example3 = customApiKey.startsWith("pk_")
|
|
117
|
+
* // Expect: 11
|
|
118
|
+
* const example4 = customApiKey.length
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export const generateApiKey = (options: GenerateApiKeyOptions): string => {
|
|
122
|
+
const { prefix, alphabet, bodyLength } = internalResolveApiKeyFormat(options)
|
|
123
|
+
|
|
124
|
+
return `${prefix}${internalGenerateApiKeyBody(alphabet, bodyLength)}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 遮罩 API key 的中间部分,保留前缀与末尾少量可见字符。
|
|
129
|
+
*
|
|
130
|
+
* 当输入值不满足当前 API key 格式约定时,该函数会抛出 Error。
|
|
131
|
+
* 若 API key 使用了非默认前缀、字符集或主体长度,应传入同一份格式选项以保持生成与遮罩语义一致。
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```
|
|
135
|
+
* // Expect: "sk-ab********************************************yz"
|
|
136
|
+
* const example1 = maskApiKey("sk-ab0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklyz")
|
|
137
|
+
* const customOptions = { prefix: "pk_", alphabet: "ABC123", bodyLength: 6 }
|
|
138
|
+
* // Expect: "pk_AB**23"
|
|
139
|
+
* const example2 = maskApiKey("pk_ABC123", customOptions)
|
|
140
|
+
* // Expect: throws Error
|
|
141
|
+
* const example3 = () => maskApiKey("not-an-api-key")
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export const maskApiKey = (apiKey: string, options?: GenerateApiKeyOptions): string => {
|
|
145
|
+
const format = internalResolveApiKeyFormat(options)
|
|
146
|
+
|
|
147
|
+
internalAssertApiKey(apiKey, format)
|
|
148
|
+
|
|
149
|
+
// 保留前缀可以帮助识别凭据类型,保留末尾少量字符可以帮助区分不同的 key。
|
|
150
|
+
// 遮罩中间主体可以降低 API key 在日志、报错信息或界面中被完整泄露的风险。
|
|
151
|
+
const visibleBodyStartLength = Math.min(2, format.bodyLength)
|
|
152
|
+
const visibleBodyEndLength = Math.min(2, Math.max(0, format.bodyLength - visibleBodyStartLength))
|
|
153
|
+
const visibleStart = apiKey.slice(0, format.prefix.length + visibleBodyStartLength)
|
|
154
|
+
const visibleEnd = visibleBodyEndLength === 0 ? "" : apiKey.slice(-visibleBodyEndLength)
|
|
155
|
+
const maskedPart = "*".repeat(Math.max(0, format.bodyLength - visibleBodyStartLength - visibleBodyEndLength))
|
|
156
|
+
|
|
157
|
+
return `${visibleStart}${maskedPart}${visibleEnd}`
|
|
158
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const internalCredentialBearerRegexp = /^Bearer\s+(.+)$/i
|
|
2
|
+
|
|
3
|
+
// Bearer credential 的标准文本形态是 "Bearer <token>"。
|
|
4
|
+
// 这个辅助函数统一去掉多余空白和 Bearer 前缀,使判断、格式化和解析共享同一条规范化规则。
|
|
5
|
+
const internalNormalizeBearerToken = (token: string): string => {
|
|
6
|
+
return token.trim().replace(/^Bearer\s+/i, "")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 判断输入值是否为 Bearer 凭据字符串。
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```
|
|
14
|
+
* // Expect: true
|
|
15
|
+
* const example1 = isBearerCredential("Bearer token-value")
|
|
16
|
+
* // Expect: false
|
|
17
|
+
* const example2 = isBearerCredential("Basic token-value")
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export const isBearerCredential = (input: string): boolean => {
|
|
21
|
+
return internalCredentialBearerRegexp.test(input.trim())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 把原始 token 格式化为 Bearer 凭据字符串。
|
|
26
|
+
*
|
|
27
|
+
* 当输入值只包含空白字符时,该函数会抛出 RangeError,避免产出一个连 Bearer 校验器都不会接受的无效凭据字符串。
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```
|
|
31
|
+
* // Expect: "Bearer token-value"
|
|
32
|
+
* const example1 = formatBearerCredential("token-value")
|
|
33
|
+
* // Expect: "Bearer token-value"
|
|
34
|
+
* const example2 = formatBearerCredential(" token-value ")
|
|
35
|
+
* // Expect: throws RangeError
|
|
36
|
+
* const example3 = () => formatBearerCredential(" ")
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export const formatBearerCredential = (token: string): string => {
|
|
40
|
+
const normalizedToken = internalNormalizeBearerToken(token)
|
|
41
|
+
|
|
42
|
+
if (normalizedToken.length === 0) {
|
|
43
|
+
throw new RangeError("Expected token to contain at least one non-whitespace character")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return `Bearer ${normalizedToken}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 从 Bearer 凭据字符串中提取原始 token。
|
|
51
|
+
*
|
|
52
|
+
* 当输入值不是 Bearer 格式时,该函数返回 `undefined`。
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```
|
|
56
|
+
* // Expect: "token-value"
|
|
57
|
+
* const example1 = parseBearerCredential("Bearer token-value")
|
|
58
|
+
* // Expect: undefined
|
|
59
|
+
* const example2 = parseBearerCredential("Basic token-value")
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export const parseBearerCredential = (input: string | undefined): string | undefined => {
|
|
63
|
+
if (input === undefined) {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isBearerCredential(input) === false) {
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const normalizedToken = internalNormalizeBearerToken(input)
|
|
72
|
+
return normalizedToken.length === 0 ? undefined : normalizedToken
|
|
73
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { jwtVerify, SignJWT } from "jose"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON Web Token(JWT)中当前对外承诺的业务载荷。
|
|
5
|
+
*/
|
|
6
|
+
export interface JsonWebTokenPayload {
|
|
7
|
+
userId: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// JWT 的签发和校验都要求把共享密钥表示为字节序列。
|
|
11
|
+
// 这个辅助函数明确说明:传入的密钥字符串会按 UTF-8 编码后再交给签名与验签逻辑。
|
|
12
|
+
const internalJsonWebTokenTextToBytes = (text: string): Uint8Array => {
|
|
13
|
+
return new TextEncoder().encode(text)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// JWT 的过期时间既可以用持续时间字符串表示,也可以用相对秒数表示。
|
|
17
|
+
// 这个辅助函数统一把 number 解释为“若干秒后过期”,避免把相对时长误当成绝对 Unix 时间。
|
|
18
|
+
const internalNormalizeJsonWebTokenExpiresIn = (expiresIn: string | number): string | number => {
|
|
19
|
+
return typeof expiresIn === "number"
|
|
20
|
+
? `${expiresIn}s`
|
|
21
|
+
: expiresIn
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 创建 JsonWebToken 实例时需要提供的配置。
|
|
26
|
+
*
|
|
27
|
+
* `secret` 表示对称签名密钥,`expiresIn` 表示 token 的相对过期时间。
|
|
28
|
+
*/
|
|
29
|
+
export interface JsonWebTokenOptions {
|
|
30
|
+
secret: string
|
|
31
|
+
expiresIn: string | number
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 提供围绕 JSON Web Token(JWT)进行签发与解析的基础能力。
|
|
35
|
+
*
|
|
36
|
+
* 当前实现使用对称密钥的 HS256 算法,并把 `userId` 作为默认业务载荷写入 token。
|
|
37
|
+
* `expiresIn` 表示相对时长:若传入字符串,可使用 `1h`、`30m` 之类的持续时间文本;若传入数字,则表示多少秒后过期。
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```
|
|
41
|
+
* const jwt = new JsonWebToken({ secret: "secret", expiresIn: "1h" })
|
|
42
|
+
* const token = await jwt.signToken("user-1")
|
|
43
|
+
* // Expect: 3
|
|
44
|
+
* const example1 = token.split(".").length
|
|
45
|
+
* // Expect: "user-1"
|
|
46
|
+
* const example2 = await jwt.parseToken(token)
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class JsonWebToken {
|
|
50
|
+
private options: JsonWebTokenOptions
|
|
51
|
+
|
|
52
|
+
constructor(options: JsonWebTokenOptions) {
|
|
53
|
+
this.options = options
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 为给定用户标识签发 JWT 字符串。
|
|
58
|
+
*/
|
|
59
|
+
async signToken(userId: string): Promise<string> {
|
|
60
|
+
const { secret, expiresIn } = this.options
|
|
61
|
+
return await new SignJWT({ userId } satisfies JsonWebTokenPayload)
|
|
62
|
+
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
|
63
|
+
.setIssuedAt()
|
|
64
|
+
.setExpirationTime(internalNormalizeJsonWebTokenExpiresIn(expiresIn))
|
|
65
|
+
.sign(internalJsonWebTokenTextToBytes(secret))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 解析原始 JWT 字符串,并返回其中的用户标识。
|
|
70
|
+
*/
|
|
71
|
+
async parseToken(token: string | undefined): Promise<string | undefined> {
|
|
72
|
+
if (token === undefined) {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const { secret } = this.options
|
|
78
|
+
const { payload } = await jwtVerify<JsonWebTokenPayload>(
|
|
79
|
+
token,
|
|
80
|
+
internalJsonWebTokenTextToBytes(secret),
|
|
81
|
+
{
|
|
82
|
+
algorithms: ["HS256"],
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if (typeof payload.userId !== "string") {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return payload.userId
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const internalPasswordPbkdf2IterationCount = 210_000
|
|
2
|
+
const internalPasswordKeyLengthBits = 512
|
|
3
|
+
const internalPasswordHexRegexp = /^[0-9A-F]+$/i
|
|
4
|
+
|
|
5
|
+
// Password 的散列推导依赖 SubtleCrypto。
|
|
6
|
+
// 这个辅助函数显式检查运行时能力,避免在不支持 PBKDF2 的环境里产生看似成功但实际不可用的结果。
|
|
7
|
+
const internalGetSubtleCrypto = (): SubtleCrypto => {
|
|
8
|
+
if (globalThis.crypto === undefined || globalThis.crypto.subtle === undefined) {
|
|
9
|
+
throw new Error("Password utilities require crypto.subtle")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return globalThis.crypto.subtle
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Password 的盐值生成从随机字节开始。
|
|
16
|
+
// 这个辅助函数同时负责长度校验和随机字节填充,让后续逻辑只需要处理已经生成好的安全随机输入。
|
|
17
|
+
const internalPasswordGetRandomValues = (length: number): Uint8Array => {
|
|
18
|
+
if (Number.isInteger(length) === false || length <= 0) {
|
|
19
|
+
throw new RangeError(`Expected length to be a positive integer, got: ${length}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (globalThis.crypto === undefined || typeof globalThis.crypto.getRandomValues !== "function") {
|
|
23
|
+
throw new Error("Credential utilities require crypto.getRandomValues")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const buffer = new Uint8Array(length)
|
|
27
|
+
globalThis.crypto.getRandomValues(buffer)
|
|
28
|
+
return buffer
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 十六进制文本是一种稳定、可打印、便于传输的字节表示方式。
|
|
32
|
+
// 这个辅助函数会把每个字节编码成固定两位字符,保证相同字节长度总是得到相同文本长度。
|
|
33
|
+
const internalPasswordBytesToHex = (bytes: Uint8Array): string => {
|
|
34
|
+
return Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// PBKDF2 接收的 salt 最终必须是原始字节序列。
|
|
38
|
+
// 这个辅助函数会先校验十六进制文本是否合法,再按两位一组恢复成真实字节,避免把坏输入静默带入密码推导流程。
|
|
39
|
+
const internalPasswordHexToBytes = (hex: string): Uint8Array => {
|
|
40
|
+
if (hex.length === 0 || hex.length % 2 !== 0 || internalPasswordHexRegexp.test(hex) === false) {
|
|
41
|
+
throw new TypeError(`Expected an even-length hexadecimal string, got: ${hex}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const bytes = new Uint8Array(hex.length / 2)
|
|
45
|
+
|
|
46
|
+
for (let index = 0; index < hex.length; index = index + 2) {
|
|
47
|
+
bytes[index / 2] = Number.parseInt(hex.slice(index, index + 2), 16)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return bytes
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Web Crypto 处理密码时接收的是字节而不是普通字符串。
|
|
54
|
+
// 这个辅助函数负责使用 UTF-8 把文本密码编码成稳定字节序列,使同一段文本在相同环境下得到一致输入。
|
|
55
|
+
const internalPasswordTextToBytes = (text: string): Uint8Array => {
|
|
56
|
+
return new TextEncoder().encode(text)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 某些 Web Crypto 方法要求输入值是 ArrayBuffer。
|
|
60
|
+
// 这个辅助函数会复制一份字节数据再取出 buffer,避免把原始视图的偏移量和切片状态带进底层 API。
|
|
61
|
+
const internalPasswordBytesToArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {
|
|
62
|
+
return Uint8Array.from(bytes).buffer
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 散列比较不能只依赖普通字符串相等判断。
|
|
66
|
+
// 这个辅助函数会遍历完整字符串并累计差异,避免在第一个不同字符处提前返回而暴露可观察的比较差异。
|
|
67
|
+
const internalPasswordSafeEqual = (left: string, right: string): boolean => {
|
|
68
|
+
if (left.length !== right.length) {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let difference = 0
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index < left.length; index = index + 1) {
|
|
75
|
+
const leftCodePoint = left.codePointAt(index) ?? 0
|
|
76
|
+
const rightCodePoint = right.codePointAt(index) ?? 0
|
|
77
|
+
difference = difference | (leftCodePoint ^ rightCodePoint)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return difference === 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Step 1: 校验盐值长度。
|
|
84
|
+
//
|
|
85
|
+
// `length` 表示盐值的字节数;由于结果会编码成十六进制文本,因此结果字符串长度始终是 `length * 2`。
|
|
86
|
+
const internalAssertPasswordSaltLength = (length: number): void => {
|
|
87
|
+
if (Number.isInteger(length) === false || length <= 0) {
|
|
88
|
+
throw new RangeError(`Expected length to be a positive integer, got: ${length}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Step 2: 生成十六进制盐值。
|
|
93
|
+
//
|
|
94
|
+
// 盐值先以随机字节形式生成,再编码成十六进制文本。
|
|
95
|
+
// 这种表示方式既适合传输和存储,也能和后续 PBKDF2 所需的原始字节输入建立清楚的一一对应关系。
|
|
96
|
+
const internalGeneratePasswordSalt = (length: number): string => {
|
|
97
|
+
internalAssertPasswordSaltLength(length)
|
|
98
|
+
return internalPasswordBytesToHex(internalPasswordGetRandomValues(length))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Step 3: 用 PBKDF2-SHA-512 从“明文密码 + 盐值”导出稳定散列。
|
|
102
|
+
//
|
|
103
|
+
// 该过程先把密码编码成字节,再把十六进制盐值还原成字节,最后交给 Web Crypto 推导固定长度的散列结果。
|
|
104
|
+
const internalDerivePasswordHash = async (password: string, salt: string): Promise<string> => {
|
|
105
|
+
const subtleCrypto = internalGetSubtleCrypto()
|
|
106
|
+
const importedKey = await subtleCrypto.importKey("raw", internalPasswordBytesToArrayBuffer(internalPasswordTextToBytes(password)), "PBKDF2", false, ["deriveBits"])
|
|
107
|
+
const derivedBits = await subtleCrypto.deriveBits({
|
|
108
|
+
name: "PBKDF2",
|
|
109
|
+
hash: "SHA-512",
|
|
110
|
+
salt: internalPasswordBytesToArrayBuffer(internalPasswordHexToBytes(salt)),
|
|
111
|
+
iterations: internalPasswordPbkdf2IterationCount,
|
|
112
|
+
}, importedKey, internalPasswordKeyLengthBits)
|
|
113
|
+
|
|
114
|
+
return internalPasswordBytesToHex(new Uint8Array(derivedBits))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 提供围绕密码盐值生成、带盐散列与比较的基础能力。
|
|
119
|
+
*/
|
|
120
|
+
export const Password = {
|
|
121
|
+
/**
|
|
122
|
+
* 生成指定字节长度的随机盐,并以十六进制字符串返回。
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```
|
|
126
|
+
* const salt = Password.generateSalt(16)
|
|
127
|
+
* // Expect: 32
|
|
128
|
+
* const example1 = salt.length
|
|
129
|
+
* // Expect: true
|
|
130
|
+
* const example2 = /^[0-9A-F]+$/i.test(salt)
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
generateSalt(length: number): string {
|
|
134
|
+
return internalGeneratePasswordSalt(length)
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 使用 PBKDF2-SHA-512 与盐值生成密码散列。
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```
|
|
142
|
+
* const hash = await Password.hashPasswordWithSalt("secret", "00112233445566778899AABBCCDDEEFF")
|
|
143
|
+
* // Expect: 128
|
|
144
|
+
* const example1 = hash.length
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
async hashPasswordWithSalt(password: string, salt: string): Promise<string> {
|
|
148
|
+
return await internalDerivePasswordHash(password, salt)
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 比较明文密码与既有散列是否匹配。
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```
|
|
156
|
+
* const salt = "00112233445566778899AABBCCDDEEFF"
|
|
157
|
+
* const hash = await Password.hashPasswordWithSalt("secret", salt)
|
|
158
|
+
* // Expect: true
|
|
159
|
+
* const example1 = await Password.comparePassword("secret", hash, salt)
|
|
160
|
+
* // Expect: false
|
|
161
|
+
* const example2 = await Password.comparePassword("other", hash, salt)
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
async comparePassword(password: string, hashedPassword: string, salt: string): Promise<boolean> {
|
|
165
|
+
// 比较密码时不能直接比较明文,而是要先按同样规则重新计算散列。
|
|
166
|
+
// 固定时序比较可以减少在第一个不同字符处提前返回所带来的可观察差异。
|
|
167
|
+
const recalculatedHash = await internalDerivePasswordHash(password, salt)
|
|
168
|
+
return internalPasswordSafeEqual(recalculatedHash, hashedPassword)
|
|
169
|
+
},
|
|
170
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Cron
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
Cron 模块提供围绕 Cron 表达式与计划触发时点计算的基础建模能力,用于在不执行具体任务的前提下稳定描述某个计划何时匹配、何时已经触发以及何时将要触发。
|
|
6
|
+
|
|
7
|
+
它关注的是“计划语义如何被抽象为稳定模型”,而不是把某个运行时里的定时任务 API 直接包一层后暴露出去。对于使用者而言,这个模块更适合承载计划匹配、运行点推导与计划边界判断,而不是任务执行、副作用调度或分布式作业管理。
|
|
8
|
+
|
|
9
|
+
## For Understanding
|
|
10
|
+
|
|
11
|
+
理解 Cron 模块时,重点应放在“一个计划本身如何被表达与判断”,而不是“某段任务代码何时被调度执行”。Cron 在这里表达的是一种时间计划模型:给定某个计划模式后,外部可以稳定地询问它是否匹配某个时间点、它最近一次计划运行点是什么、以及未来的计划运行点是什么。
|
|
12
|
+
|
|
13
|
+
因此,这个模块适合放在以下边界中:
|
|
14
|
+
|
|
15
|
+
- 你需要复用一类与计划时点推导相关的基础能力,而不想在业务代码里反复处理 Cron 表达式解析与时间匹配。
|
|
16
|
+
- 你需要把“计划何时成立”与“计划成立后业务要做什么”明确分离,让上层自行决定副作用。
|
|
17
|
+
- 你需要一个环境中立的计划模型,以便在不同运行时或不同任务系统之间复用相同的计划语义。
|
|
18
|
+
|
|
19
|
+
同时也要守住几个边界:
|
|
20
|
+
|
|
21
|
+
- Cron 模块不负责执行任务,也不负责重试、并发控制、分布式锁、任务持久化或调度生命周期管理。
|
|
22
|
+
- 某个能力如果主要表达的是“任务如何运行”,通常更适合放在调度器、作业系统或编排模块中,而不是放在 Cron 模块中。
|
|
23
|
+
- 如果某种时间逻辑不以 Cron 计划语义为中心,而更偏向广义时间状态或过期管理,则通常应放在其它时间相关模块边界内理解。
|
|
24
|
+
|
|
25
|
+
## For Using
|
|
26
|
+
|
|
27
|
+
当你已经明确需要的是“计划时点模型”,而不是一个直接帮你启动任务的执行器时,可以接入 Cron 模块。它更适合被用来完成三类工作:一类是定义一个长期可复用的计划模式;一类是围绕该计划推导过去、当前与未来的运行点;另一类是判断某个给定时点是否满足该计划。
|
|
28
|
+
|
|
29
|
+
在实际接入时,更合理的方式是把它视为上层调度逻辑的基础判断器。业务系统、任务系统或编排系统可以依赖这里提供的计划语义来决定何时启动、跳过、补偿或展示某个任务,但这些决策本身不应内置在 Cron 模块里。
|
|
30
|
+
|
|
31
|
+
如果你的需求已经超出了“计划表达与时点判断”,例如需要内建回调执行、失败重试、任务注册表或长期运行的后台调度循环,那么应当在更上层的模块中组合这个计划模型,而不是把这些职责继续压进 Cron 模块。
|
|
32
|
+
|
|
33
|
+
## For Contributing
|
|
34
|
+
|
|
35
|
+
贡献 Cron 模块时,应优先确认新增内容表达的是不是稳定的计划语义,而不是某个调度实现中的临时便利逻辑。这里的核心问题始终是:某种能力是否仍然围绕 Cron 计划本身展开,还是已经开始转向任务执行与调度控制。如果是后者,就不应继续塞进这个模块。
|
|
36
|
+
|
|
37
|
+
扩展时尤其要避免两类偏差。一类是把 Cron 模块做成“带回调的任务执行器”,让它逐步承担不属于计划模型的职责;另一类是为了方便而把底层解析器的细碎内部能力直接公开,导致外部接入依赖不稳定的内部结构。更稳妥的做法,是围绕计划模式、计划匹配和计划运行点这些稳定语义组织公共 API,并把解析器适配细节保持在内部。
|
|
38
|
+
|
|
39
|
+
### JSDoc 注释格式要求
|
|
40
|
+
|
|
41
|
+
- 每个公开导出的目标(类型、函数、变量、类等)都应包含 JSDoc 注释,让人在不跳转实现的情况下就能理解用途。
|
|
42
|
+
- JSDoc 注释第一行应为清晰且简洁的描述,该描述优先使用中文(英文也可以)。
|
|
43
|
+
- 如果描述后还有其他内容,应在描述后加一个空行。
|
|
44
|
+
- 如果有示例,应使用 `@example` 标签,后接三重反引号代码块(不带语言标识)。
|
|
45
|
+
- 如果有示例,应包含多个场景,展示不同用法,尤其要覆盖常见组合方式或边界输入。
|
|
46
|
+
- 如果有示例,应使用注释格式说明每个场景:`// Expect: <result>`。
|
|
47
|
+
- 如果有示例,应将结果赋值给 `example1`、`example2` 之类的变量,以保持示例易读。
|
|
48
|
+
- 如果有示例,`// Expect: <result>` 应该位于 `example1`、`example2` 之前,以保持示例的逻辑清晰。
|
|
49
|
+
- 如果有示例,应优先使用确定性示例;避免断言精确的随机输出。
|
|
50
|
+
- 如果函数返回结构化字符串,应展示其预期格式特征。
|
|
51
|
+
- 如果有参考资料,应将 `@see` 放在 `@example` 代码块之后,并用一个空行分隔。
|
|
52
|
+
|
|
53
|
+
### 实现规范要求
|
|
54
|
+
|
|
55
|
+
- 不同程序元素之间使用一个空行分隔,保持结构清楚。这里的程序元素,通常指函数、类型、常量,以及直接服务于它们的辅助元素。
|
|
56
|
+
- 某程序元素独占的辅助元素与该程序元素本身视为一个整体,不要在它们之间添加空行。
|
|
57
|
+
- 程序元素的辅助元素应该放置在该程序元素的上方,以保持阅读时的逻辑顺序。
|
|
58
|
+
- 若辅助元素被多个程序元素共享,则应将其视为独立的程序元素,放在这些程序元素中第一个相关目标的上方,并与后续程序元素之间保留一个空行。
|
|
59
|
+
- 辅助元素也应该像其它程序元素一样,保持清晰的命名和适当的注释,以便在需要阅读实现细节时能够快速理解它们的作用和使用方式。
|
|
60
|
+
- 辅助元素的命名必须以前缀 `internal` 开头(或 `Internal`,大小写不敏感)。
|
|
61
|
+
- 辅助元素永远不要公开导出。
|
|
62
|
+
- 被模块内多个不同文件中的程序元素共享的辅助元素,应该放在一个单独的文件中,例如 `./src/cron/internal.ts`。
|
|
63
|
+
- 模块内可以包含子模块。只有当某个子目录表达一个稳定、可单独理解、且可能被父模块重导出的子问题域时,才应将其视为子模块。
|
|
64
|
+
- 子模块包含多个文件时,应该为其单独创建子文件夹,并为其创建单独的 Barrel 文件;父模块的 Barrel 文件再重导出子模块的 Barrel 文件。
|
|
65
|
+
- 子模块不需要有自己的 `README.md`。
|
|
66
|
+
- 子模块可以有自己的 `internal.ts` 文件,多个子模块共享的辅助元素应该放在父模块的 `internal.ts` 文件中,单个子模块共享的辅助元素应该放在该子模块的 `internal.ts` 文件中。
|
|
67
|
+
- 对模块依赖关系的要求(通常是不循环依赖或不反向依赖)与对 DRY 的要求可能产生冲突。此时,若复用的代码数量不大,可以适当牺牲 DRY,复制粘贴并保留必要的注释说明;若复用的代码数量较大,则可以将其抽象到新的文件或子模块中,如 `common.ts`,并在需要的地方导入使用。
|
|
68
|
+
- 与 Cron 相关的实现应优先围绕计划语义、计划匹配与计划运行点组织,避免把任务执行、副作用调度或运行时专属控制逻辑直接提升为 Cron 的长期公共语义。
|
|
69
|
+
|
|
70
|
+
### 导出策略要求
|
|
71
|
+
|
|
72
|
+
- 保持内部辅助项和内部符号为私有,不要让外部接入依赖临时性的内部结构。
|
|
73
|
+
- 每个模块都应有一个用于重导出所有公共 API 的 Barrel 文件。
|
|
74
|
+
- Barrel 文件应命名为 `index.ts`,放在模块目录根部,并且所有公共 API 都应从该文件导出。
|
|
75
|
+
- 新增公共能力时,应优先检查它是否表达稳定、清楚且值得长期维护的计划语义,而不是某段实现细节的便捷暴露;仅在确认需要长期对外承诺时再加入 Barrel 导出。
|
|
76
|
+
|
|
77
|
+
### 测试要求
|
|
78
|
+
|
|
79
|
+
- 若程序元素是函数,则只为该函数编写一个测试,如果该函数需要测试多个用例,应放在同一个测试中。
|
|
80
|
+
- 若程序元素是类,则至少要为该类的每一个方法编写一个测试,如果该方法需要测试多个用例,应放在同一个测试中。
|
|
81
|
+
- 若程序元素是类,除了为该类的每一个方法编写至少一个测试之外,还可以为该类编写任意多个测试,以覆盖该类的不同使用场景或边界情况。
|
|
82
|
+
- 若编写测试时需要用到辅助元素(Mock 或 Spy 等),可以在测试文件中直接定义这些辅助元素。若辅助元素较为简单,则可以直接放在每一个测试内部,优先保证每个测试的独立性,而不是追求极致 DRY;若辅助元素较为复杂或需要在多个测试中复用,则可以放在测试文件顶部,供该测试文件中的所有测试使用。
|
|
83
|
+
- 测试顺序应与源文件中被测试目标的原始顺序保持一致。
|
|
84
|
+
- 若该模块不需要测试,必须在说明文件中明确说明该模块不需要测试,并说明理由。一般来说,只有在该模块没有可执行的公共函数、只承载类型层表达,或其语义已被上层模块的测试完整覆盖且重复测试几乎不再带来额外价值时,才适合这样处理。
|
|
85
|
+
- 模块的单元测试文件目录是 `./tests/unit/cron`。
|
|
86
|
+
- 对 Cron 相关能力,应优先覆盖计划参数传递、过去与未来运行点推导、空结果规范化以及时间匹配判断等场景。
|
package/src/cron/cron.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Cron as CronerCron } from "croner"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cron 计划模型的配置项。
|
|
5
|
+
*/
|
|
6
|
+
export interface CronOptions {
|
|
7
|
+
/**
|
|
8
|
+
* 用于描述计划的 Cron 表达式或单次触发时点。
|
|
9
|
+
*/
|
|
10
|
+
pattern: string | Date
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @default false
|
|
14
|
+
*/
|
|
15
|
+
domAndDow?: boolean | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 用于围绕 Cron 计划计算运行时点并判断时间匹配关系的模型。
|
|
20
|
+
*/
|
|
21
|
+
export class Cron {
|
|
22
|
+
protected options: CronOptions
|
|
23
|
+
protected croner: CronerCron
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 使用给定配置创建一个 Cron 计划模型。
|
|
27
|
+
*/
|
|
28
|
+
constructor(options: CronOptions) {
|
|
29
|
+
this.options = options
|
|
30
|
+
this.croner = new CronerCron(options.pattern, {
|
|
31
|
+
domAndDow: options.domAndDow ?? false,
|
|
32
|
+
alternativeWeekdays: false,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 获取该计划最近一次运行时点。
|
|
38
|
+
*/
|
|
39
|
+
prevRun(): Date | undefined {
|
|
40
|
+
const prev = this.croner.previousRun()
|
|
41
|
+
return prev === null ? undefined : prev
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 获取该计划最近若干次运行时点。
|
|
46
|
+
*/
|
|
47
|
+
prevRuns(n: number): Date[] {
|
|
48
|
+
const prevRuns = this.croner.previousRuns(n)
|
|
49
|
+
return prevRuns
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 获取该计划当前运行时点。
|
|
54
|
+
*/
|
|
55
|
+
currentRun(): Date | undefined {
|
|
56
|
+
const current = this.croner.currentRun()
|
|
57
|
+
return current === null ? undefined : current
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 获取该计划下一次运行时点。
|
|
62
|
+
*/
|
|
63
|
+
nextRun(): Date | undefined {
|
|
64
|
+
const next = this.croner.nextRun()
|
|
65
|
+
return next === null ? undefined : next
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 获取该计划接下来若干次运行时点。
|
|
70
|
+
*/
|
|
71
|
+
nextRuns(n: number): Date[] {
|
|
72
|
+
const nextRuns = this.croner.nextRuns(n)
|
|
73
|
+
return nextRuns
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 判断给定时间点是否匹配该计划。
|
|
78
|
+
*/
|
|
79
|
+
match(date: Date): boolean {
|
|
80
|
+
const isMatch = this.croner.match(date)
|
|
81
|
+
return isMatch
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const isCron = (value: unknown): value is Cron => {
|
|
86
|
+
return value instanceof Cron
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cron.ts"
|