@homurajs/core 0.0.0-stage → 26.10.13
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/README.md +27 -2
- package/dist/index.d.ts +88 -0
- package/dist/index.js +164 -0
- package/package.json +27 -3
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @homurajs/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
基座包(零依赖):运行环境探测、配置装载、结构化日志、TTL-KV。
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { detectRuntime, defineConfig, parses, createLogger, createMemoryTtlKv } from '@homurajs/core'
|
|
7
|
+
|
|
8
|
+
// 1. 环境:HOMURA_RUNTIME 显式优先,QW_PAGES* 标记启发式
|
|
9
|
+
const { target } = detectRuntime(process.env) // 'standalone' | 'qw-pages'
|
|
10
|
+
|
|
11
|
+
// 2. 配置即文档:load() + help()(生成 .env.example 片段)
|
|
12
|
+
const config = defineConfig({
|
|
13
|
+
port: { env: 'PORT', default: 3000, parse: parses.int, description: '监听端口' },
|
|
14
|
+
secret: { env: 'APP_SECRET', parse: parses.string, required: true, secret: true },
|
|
15
|
+
})
|
|
16
|
+
const cfg = config.load()
|
|
17
|
+
|
|
18
|
+
// 3. 日志:JSON lines、级别过滤、敏感字段打码、child scope
|
|
19
|
+
const log = createLogger().child('auth')
|
|
20
|
+
log.info('登录成功', { userId: 'u1' })
|
|
21
|
+
|
|
22
|
+
// 4. TTL-KV:一次性消费语义(OAuth state 等)
|
|
23
|
+
const kv = createMemoryTtlKv()
|
|
24
|
+
await kv.set(state, value, 5 * 60_000)
|
|
25
|
+
const taken = await kv.take(state) // 取出即删;过期 null
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
多实例部署改用 `@homurajs/db` 的 `createDbTtlKv`。使用示范见 `templates/hono-react/src/server/app.ts`(buildAuth 里 `stateKv: createMemoryTtlKv()`)。
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/runtime.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 运行环境探测。
|
|
4
|
+
*
|
|
5
|
+
* 显式配置(HOMURA_RUNTIME)永远优先;探测仅为零配置体验的缺省,
|
|
6
|
+
* 标记列表集中在 QW_PAGES_ENV_MARKERS,属启发式,可被显式值覆盖。
|
|
7
|
+
*/
|
|
8
|
+
export type RuntimeTarget = 'standalone' | 'qw-pages';
|
|
9
|
+
export interface RuntimeInfo {
|
|
10
|
+
target: RuntimeTarget;
|
|
11
|
+
/** true = 来自显式 env;false = 启发式探测 */
|
|
12
|
+
explicit: boolean;
|
|
13
|
+
/** 探测依据(explicit=false 时给出命中的标记) */
|
|
14
|
+
reasons: string[];
|
|
15
|
+
}
|
|
16
|
+
/** qw-pages 环境的启发式标记(文档标注:启发式、可被 HOMURA_RUNTIME 覆盖) */
|
|
17
|
+
export declare const QW_PAGES_ENV_MARKERS: readonly string[];
|
|
18
|
+
export declare function detectRuntime(env: NodeJS.ProcessEnv): RuntimeInfo;
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/config.d.ts
|
|
21
|
+
/**
|
|
22
|
+
* 类型安全的配置装载:配置即文档。
|
|
23
|
+
*
|
|
24
|
+
* defineConfig(specs) 返回 { load, help }:
|
|
25
|
+
* - load(env?) 解析并校验环境变量(未显式传 env 时自动 process.loadEnvFile())
|
|
26
|
+
* - help() 生成 .env.example 片段
|
|
27
|
+
*/
|
|
28
|
+
export interface ConfigSpec<T> {
|
|
29
|
+
/** 环境变量名 */
|
|
30
|
+
env: string;
|
|
31
|
+
default?: T;
|
|
32
|
+
parse: (raw: string) => T;
|
|
33
|
+
/** true = 缺失且无 default 即抛错 */
|
|
34
|
+
required?: boolean;
|
|
35
|
+
/** true = help() 输出时打码 */
|
|
36
|
+
secret?: boolean;
|
|
37
|
+
/** 人类可读说明(help() 输出与 .env.example 注释) */
|
|
38
|
+
description?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface LoadedConfig<S extends Record<string, ConfigSpec<unknown>>> {
|
|
41
|
+
/** 按 spec key 取已解析值 */
|
|
42
|
+
get<K extends keyof S & string>(key: K): S[K] extends ConfigSpec<infer T> ? T : never;
|
|
43
|
+
/** 原始解析结果(测试用) */
|
|
44
|
+
raw(): { [K in keyof S]: S[K] extends ConfigSpec<infer T> ? T | undefined : never; };
|
|
45
|
+
}
|
|
46
|
+
export declare function defineConfig<S extends Record<string, ConfigSpec<unknown>>>(specs: S): {
|
|
47
|
+
load(env?: NodeJS.ProcessEnv): LoadedConfig<S>;
|
|
48
|
+
/** 生成 .env.example 片段——配置即文档 */
|
|
49
|
+
help(): string;
|
|
50
|
+
};
|
|
51
|
+
/** 常用 parse 助手 */
|
|
52
|
+
export declare const parses: {
|
|
53
|
+
readonly string: (raw: string) => string;
|
|
54
|
+
readonly int: (raw: string) => number;
|
|
55
|
+
readonly boolean: (raw: string) => boolean;
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/logger.d.ts
|
|
59
|
+
/**
|
|
60
|
+
* 结构化日志(轻量零依赖实现):JSON lines 输出,便于 qw-pages 等平台收集。
|
|
61
|
+
*/
|
|
62
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
63
|
+
export interface Logger {
|
|
64
|
+
debug(msg: string, fields?: Record<string, unknown>): void;
|
|
65
|
+
info(msg: string, fields?: Record<string, unknown>): void;
|
|
66
|
+
warn(msg: string, fields?: Record<string, unknown>): void;
|
|
67
|
+
error(msg: string, fields?: Record<string, unknown>): void;
|
|
68
|
+
/** 派生带固定 scope 的子 logger */
|
|
69
|
+
child(scope: string): Logger;
|
|
70
|
+
}
|
|
71
|
+
export declare function createLogger(options?: {
|
|
72
|
+
level?: LogLevel;
|
|
73
|
+
/** 输出中打码的字段名(默认 ['secret','password','token','authorization','apikey']) */
|
|
74
|
+
redactKeys?: string[];
|
|
75
|
+
}): Logger;
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/ttlkv.d.ts
|
|
78
|
+
export interface TtlKv {
|
|
79
|
+
set(key: string, value: string, ttlMs: number): Promise<void>;
|
|
80
|
+
/** 一次性取出并删除(CSRF state 消费语义);过期返回 null */
|
|
81
|
+
take(key: string): Promise<string | null>;
|
|
82
|
+
}
|
|
83
|
+
export declare function createMemoryTtlKv(options?: {
|
|
84
|
+
maxEntries?: number;
|
|
85
|
+
}): TtlKv;
|
|
86
|
+
/** 加密随机串(base64url),state/token 生成用 */
|
|
87
|
+
export declare function randomToken(bytes: number): string;
|
|
88
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
//#region src/runtime.ts
|
|
3
|
+
/** qw-pages 环境的启发式标记(文档标注:启发式、可被 HOMURA_RUNTIME 覆盖) */
|
|
4
|
+
const QW_PAGES_ENV_MARKERS = ["QW_PAGES", "QW_PAGES_APP_ID"];
|
|
5
|
+
function detectRuntime(env) {
|
|
6
|
+
const explicit = env["HOMURA_RUNTIME"];
|
|
7
|
+
if (explicit !== void 0) {
|
|
8
|
+
if (explicit !== "standalone" && explicit !== "qw-pages") throw new Error(`HOMURA_RUNTIME 非法值: ${explicit}(允许 'standalone' | 'qw-pages')`);
|
|
9
|
+
return {
|
|
10
|
+
target: explicit,
|
|
11
|
+
explicit: true,
|
|
12
|
+
reasons: [`HOMURA_RUNTIME=${explicit}`]
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const hits = QW_PAGES_ENV_MARKERS.filter((m) => env[m] !== void 0 && env[m] !== "");
|
|
16
|
+
if (hits.length > 0) return {
|
|
17
|
+
target: "qw-pages",
|
|
18
|
+
explicit: false,
|
|
19
|
+
reasons: hits
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
target: "standalone",
|
|
23
|
+
explicit: false,
|
|
24
|
+
reasons: []
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/config.ts
|
|
29
|
+
function defineConfig(specs) {
|
|
30
|
+
const loadFrom = (env) => {
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
33
|
+
const raw = env[spec.env];
|
|
34
|
+
if (raw !== void 0 && raw !== "") out[key] = spec.parse(raw);
|
|
35
|
+
else if (spec.default !== void 0) out[key] = spec.default;
|
|
36
|
+
else if (spec.required) throw new Error(`缺少必需的环境变量 ${spec.env}(配置项 ${key})`);
|
|
37
|
+
else out[key] = void 0;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
load(env) {
|
|
43
|
+
let source = env;
|
|
44
|
+
if (!source) {
|
|
45
|
+
try {
|
|
46
|
+
process.loadEnvFile();
|
|
47
|
+
} catch {}
|
|
48
|
+
source = process.env;
|
|
49
|
+
}
|
|
50
|
+
const resolved = loadFrom(source);
|
|
51
|
+
return {
|
|
52
|
+
get: ((key) => {
|
|
53
|
+
if (!(key in resolved)) throw new Error(`未知配置项: ${key}`);
|
|
54
|
+
return resolved[key];
|
|
55
|
+
}),
|
|
56
|
+
raw: () => resolved
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
help() {
|
|
60
|
+
const lines = ["# 由 @homurajs/core defineConfig 生成(配置即文档)", ""];
|
|
61
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
62
|
+
if (spec.description) lines.push(`# ${spec.description}`);
|
|
63
|
+
const example = spec.secret ? `${spec.env}=<redacted>` : `${spec.env}=${spec.default ?? ""}`;
|
|
64
|
+
lines.push(`${example}`);
|
|
65
|
+
lines.push("");
|
|
66
|
+
}
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** 常用 parse 助手 */
|
|
72
|
+
const parses = {
|
|
73
|
+
string: (raw) => raw,
|
|
74
|
+
int: (raw) => {
|
|
75
|
+
const n = Number.parseInt(raw, 10);
|
|
76
|
+
if (Number.isNaN(n)) throw new Error(`not an integer: ${raw}`);
|
|
77
|
+
return n;
|
|
78
|
+
},
|
|
79
|
+
boolean: (raw) => raw === "1" || raw.toLowerCase() === "true"
|
|
80
|
+
};
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/logger.ts
|
|
83
|
+
const LEVEL_ORDER = {
|
|
84
|
+
debug: 10,
|
|
85
|
+
info: 20,
|
|
86
|
+
warn: 30,
|
|
87
|
+
error: 40
|
|
88
|
+
};
|
|
89
|
+
const DEFAULT_REDACT = [
|
|
90
|
+
"secret",
|
|
91
|
+
"password",
|
|
92
|
+
"token",
|
|
93
|
+
"authorization",
|
|
94
|
+
"apikey"
|
|
95
|
+
];
|
|
96
|
+
function createLogger(options) {
|
|
97
|
+
const level = options?.level ?? "info";
|
|
98
|
+
const redactKeys = options?.redactKeys ?? DEFAULT_REDACT;
|
|
99
|
+
const redactSet = new Set(redactKeys.map((k) => k.toLowerCase()));
|
|
100
|
+
const emit = (lvl, scopes, msg, fields) => {
|
|
101
|
+
if (LEVEL_ORDER[lvl] < LEVEL_ORDER[level]) return;
|
|
102
|
+
const safeFields = {};
|
|
103
|
+
for (const [k, v] of Object.entries(fields ?? {})) safeFields[k] = redactSet.has(k.toLowerCase()) ? "[REDACTED]" : v;
|
|
104
|
+
const line = {
|
|
105
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
106
|
+
level: lvl,
|
|
107
|
+
...scopes.length > 0 ? { scope: scopes.join(":") } : {},
|
|
108
|
+
msg,
|
|
109
|
+
...safeFields
|
|
110
|
+
};
|
|
111
|
+
const out = JSON.stringify(line);
|
|
112
|
+
if (lvl === "error") console.error(out);
|
|
113
|
+
else if (lvl === "warn") console.warn(out);
|
|
114
|
+
else console.log(out);
|
|
115
|
+
};
|
|
116
|
+
const make = (scopes) => ({
|
|
117
|
+
debug: (m, f) => emit("debug", scopes, m, f),
|
|
118
|
+
info: (m, f) => emit("info", scopes, m, f),
|
|
119
|
+
warn: (m, f) => emit("warn", scopes, m, f),
|
|
120
|
+
error: (m, f) => emit("error", scopes, m, f),
|
|
121
|
+
child: (scope) => make([...scopes, scope])
|
|
122
|
+
});
|
|
123
|
+
return make([]);
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/ttlkv.ts
|
|
127
|
+
/**
|
|
128
|
+
* TTL-KV 抽象:OAuth state 等「一次性消费」语义的最小存储。
|
|
129
|
+
* 内存实现为缺省;多实例部署用 @homurajs/db 的 createDbTtlKv 兜底。
|
|
130
|
+
*/
|
|
131
|
+
function createMemoryTtlKv(options) {
|
|
132
|
+
const maxEntries = options?.maxEntries ?? 1e4;
|
|
133
|
+
const store = /* @__PURE__ */ new Map();
|
|
134
|
+
const sweep = (now) => {
|
|
135
|
+
for (const [k, e] of store) if (e.expiresAt <= now) store.delete(k);
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
async set(key, value, ttlMs) {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
if (store.size >= maxEntries) sweep(now);
|
|
141
|
+
if (store.size >= maxEntries) {
|
|
142
|
+
const oldest = store.keys().next().value;
|
|
143
|
+
if (oldest !== void 0) store.delete(oldest);
|
|
144
|
+
}
|
|
145
|
+
store.set(key, {
|
|
146
|
+
value,
|
|
147
|
+
expiresAt: now + ttlMs
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
async take(key) {
|
|
151
|
+
const entry = store.get(key);
|
|
152
|
+
if (!entry) return null;
|
|
153
|
+
store.delete(key);
|
|
154
|
+
if (entry.expiresAt <= Date.now()) return null;
|
|
155
|
+
return entry.value;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** 加密随机串(base64url),state/token 生成用 */
|
|
160
|
+
function randomToken(bytes) {
|
|
161
|
+
return randomBytes(bytes).toString("base64url");
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
export { QW_PAGES_ENV_MARKERS, createLogger, createMemoryTtlKv, defineConfig, detectRuntime, parses, randomToken };
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homurajs/core",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "26.10.13",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "homura 基座:运行环境探测、配置装载、日志、TTL-KV",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.5"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org/"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"tsdown": "^0.23.1"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsdown",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
}
|
|
6
30
|
}
|