@homurajs/core 0.0.0-stage → 26.10.14
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 +52 -2
- package/dist/index.d.ts +156 -0
- package/dist/index.js +286 -0
- package/package.json +27 -3
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @homurajs/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
基座包(零依赖):运行环境探测、形态相关常量、配置装载、部署期配置文件、结构化日志、TTL-KV。
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
detectRuntime,
|
|
8
|
+
healthzPathFor,
|
|
9
|
+
HEALTHZ_PATH,
|
|
10
|
+
defineConfig,
|
|
11
|
+
parses,
|
|
12
|
+
applyEnvFile,
|
|
13
|
+
resolveConfigFilePath,
|
|
14
|
+
parseEnvFile,
|
|
15
|
+
createLogger,
|
|
16
|
+
createMemoryTtlKv,
|
|
17
|
+
} from '@homurajs/core'
|
|
18
|
+
|
|
19
|
+
// 1. 形态探测:HOMURA_RUNTIME 显式优先,否则按平台实测注入的标记启发式判断
|
|
20
|
+
const { target } = detectRuntime(process.env) // 'standalone' | 'qw-pages'
|
|
21
|
+
// qw-pages 的标记是 QWENWORK_WRITABLE_DIR / SUPABASE_INSTANCE_ID(线上实测);
|
|
22
|
+
// 平台并不注入任何 QW_PAGES* 变量,别用 HOST/PORT 当标记(standalone 也常设)
|
|
23
|
+
|
|
24
|
+
// 2. 形态相关路径常量:app-kit 注册路由与 license guard 豁免清单必须同源读它
|
|
25
|
+
healthzPathFor(target) // standalone → '/healthz';qw-pages → '/_healthz'
|
|
26
|
+
// 原因:'/healthz' 被 qw-pages 的 Page Gateway 保留,网关自答 ok 且请求不下发到 App
|
|
27
|
+
|
|
28
|
+
// 3. 配置即文档:load() + help()(help 用于生成 .env.example)
|
|
29
|
+
const config = defineConfig({
|
|
30
|
+
port: { env: 'PORT', default: 3000, parse: parses.int, description: '监听端口' },
|
|
31
|
+
secret: { env: 'APP_SECRET', parse: parses.string, required: true, secret: true },
|
|
32
|
+
})
|
|
33
|
+
const cfg = config.load()
|
|
34
|
+
|
|
35
|
+
// 4. 部署期配置文件:qw-pages 无自定义 env 注入口,密钥只能随产物携带
|
|
36
|
+
// 优先级 HOMURA_CONFIG_FILE → <cwd>/homura.config.env → QWENWORK_WRITABLE_DIR/homura.config.env
|
|
37
|
+
// 真实 env 优先于文件;必须在 defineConfig().load() 之前调用
|
|
38
|
+
applyEnvFile(process.env)
|
|
39
|
+
parseEnvFile('A=1\n# 注释\nexport B="x\\ny"\n') // → { A: '1', B: 'x\ny' }
|
|
40
|
+
|
|
41
|
+
// 5. 日志:JSON lines、级别过滤、敏感字段打码、child scope
|
|
42
|
+
const log = createLogger().child('auth')
|
|
43
|
+
log.info('登录成功', { userId: 'u1' })
|
|
44
|
+
|
|
45
|
+
// 6. TTL-KV:一次性消费语义(OAuth state 等)
|
|
46
|
+
const kv = createMemoryTtlKv()
|
|
47
|
+
await kv.set(state, value, 5 * 60_000)
|
|
48
|
+
const taken = await kv.take(state) // 取出即删;过期 null
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
TTL-KV 的持久化版本在 `@homurajs/db`:SQL 形态用 `createDbTtlKv`,**qw-pages(REST 形态)用 `createRestTtlKv`**——内存版在 Pages 上进程重启即清空(跨发布必换实例),OAuth state 会断在「跳转 → 回调」两条腿之间。
|
|
52
|
+
|
|
53
|
+
使用示范见 `templates/hono-react/src/server/env.ts`(`loadConfig` 里 `applyEnvFile` + `assertSecretsOverridden`)与 `app.ts`(`stateKv` 按形态选择)。
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
//#region src/runtime.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 运行环境探测 + 形态相关的运行时常量。
|
|
4
|
+
*
|
|
5
|
+
* 显式配置(HOMURA_RUNTIME)永远优先;探测仅为零配置体验的缺省,
|
|
6
|
+
* 标记列表集中在 QW_PAGES_ENV_MARKERS,属启发式,可被显式值覆盖。
|
|
7
|
+
*
|
|
8
|
+
* 标记来源:2026-10-13 在真实 qw-pages 动态页上实测(线上探针回读 process.env 全集)。
|
|
9
|
+
* 平台注入集只有 12 个变量:PORT / HOST / NODE_ENV / HOME / TMPDIR / XDG_* /
|
|
10
|
+
* QWENWORK_WRITABLE_DIR / SUPABASE_URL / SUPABASE_ANON_KEY / SUPABASE_INSTANCE_ID。
|
|
11
|
+
* 不存在任何 QW_PAGES* 命名的变量——旧标记 ['QW_PAGES','QW_PAGES_APP_ID'] 已证伪移除,
|
|
12
|
+
* 定论见 docs/specs/module-qw-pages.md 第 5 项。
|
|
13
|
+
*/
|
|
14
|
+
export type RuntimeTarget = 'standalone' | 'qw-pages';
|
|
15
|
+
export interface RuntimeInfo {
|
|
16
|
+
target: RuntimeTarget;
|
|
17
|
+
/** true = 来自显式 env;false = 启发式探测 */
|
|
18
|
+
explicit: boolean;
|
|
19
|
+
/** 探测依据(explicit=false 时给出命中的标记) */
|
|
20
|
+
reasons: string[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* qw-pages 环境的启发式标记(实测注入,可被 HOMURA_RUNTIME 覆盖)。
|
|
24
|
+
* - QWENWORK_WRITABLE_DIR:平台必注入的唯一可写目录(实测 /tmp/qwenwork-page,跨发布清空)
|
|
25
|
+
* - SUPABASE_INSTANCE_ID:仅在 Page 绑定数据库后注入,故只作辅助标记
|
|
26
|
+
* 不纳入 HOST/PORT:standalone 部署也常设这两个变量,会造成误判。
|
|
27
|
+
*/
|
|
28
|
+
export declare const QW_PAGES_ENV_MARKERS: readonly string[];
|
|
29
|
+
/**
|
|
30
|
+
* 健康检查路径按形态取值——同源常量:app-kit 注册路由、license guard 豁免清单、
|
|
31
|
+
* 模板 SPA fallback 的保留前缀都读它,避免三处各写一份而漂移。
|
|
32
|
+
*
|
|
33
|
+
* qw-pages 上 '/healthz' 被 Page Gateway 保留:实测网关自答 `ok`(text/plain),
|
|
34
|
+
* 请求不下发到 App 进程(探针 serve 计数不增),因此 App 注册在 '/healthz'
|
|
35
|
+
* 的检查项在 Pages 上永远不可达。
|
|
36
|
+
*/
|
|
37
|
+
export declare const HEALTHZ_PATH: Readonly<Record<RuntimeTarget, string>>;
|
|
38
|
+
/** 取指定形态的健康检查路径 */
|
|
39
|
+
export declare function healthzPathFor(target: RuntimeTarget): string;
|
|
40
|
+
export declare function detectRuntime(env: NodeJS.ProcessEnv): RuntimeInfo;
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/config.d.ts
|
|
43
|
+
/**
|
|
44
|
+
* 类型安全的配置装载:配置即文档。
|
|
45
|
+
*
|
|
46
|
+
* defineConfig(specs) 返回 { load, help }:
|
|
47
|
+
* - load(env?) 解析并校验环境变量(未显式传 env 时自动 process.loadEnvFile())
|
|
48
|
+
* - help() 生成 .env.example 片段
|
|
49
|
+
*/
|
|
50
|
+
export interface ConfigSpec<T> {
|
|
51
|
+
/** 环境变量名 */
|
|
52
|
+
env: string;
|
|
53
|
+
default?: T;
|
|
54
|
+
parse: (raw: string) => T;
|
|
55
|
+
/** true = 缺失且无 default 即抛错 */
|
|
56
|
+
required?: boolean;
|
|
57
|
+
/** true = help() 输出时打码 */
|
|
58
|
+
secret?: boolean;
|
|
59
|
+
/** 人类可读说明(help() 输出与 .env.example 注释) */
|
|
60
|
+
description?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface LoadedConfig<S extends Record<string, ConfigSpec<unknown>>> {
|
|
63
|
+
/** 按 spec key 取已解析值 */
|
|
64
|
+
get<K extends keyof S & string>(key: K): S[K] extends ConfigSpec<infer T> ? T : never;
|
|
65
|
+
/** 原始解析结果(测试用) */
|
|
66
|
+
raw(): { [K in keyof S]: S[K] extends ConfigSpec<infer T> ? T | undefined : never; };
|
|
67
|
+
}
|
|
68
|
+
export declare function defineConfig<S extends Record<string, ConfigSpec<unknown>>>(specs: S): {
|
|
69
|
+
load(env?: NodeJS.ProcessEnv): LoadedConfig<S>;
|
|
70
|
+
/** 生成 .env.example 片段——配置即文档 */
|
|
71
|
+
help(): string;
|
|
72
|
+
};
|
|
73
|
+
/** 常用 parse 助手 */
|
|
74
|
+
export declare const parses: {
|
|
75
|
+
readonly string: (raw: string) => string;
|
|
76
|
+
readonly int: (raw: string) => number;
|
|
77
|
+
readonly boolean: (raw: string) => boolean;
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/env-file.d.ts
|
|
81
|
+
/** 配置文件名(缺省);产物根 = 运行时 cwd,故 <cwd>/homura.config.env 即随产物携带的那份 */
|
|
82
|
+
export declare const CONFIG_FILE_NAME = "homura.config.env";
|
|
83
|
+
/**
|
|
84
|
+
* 解析 KEY=VALUE 行格式。
|
|
85
|
+
* - 跳过空行与 `#` 注释行;支持 `export KEY=VALUE`
|
|
86
|
+
* - 单引号:字面量,不解析转义;双引号:解析 \n \t \r \" \\ 转义
|
|
87
|
+
* - 无引号:去掉行尾 ` #...` 内联注释(前必须有空白),并 trim
|
|
88
|
+
* - 同名键后者覆盖前者
|
|
89
|
+
* - 非法行抛错并带上行号(fail fast:配置写错不该静默忽略)
|
|
90
|
+
*/
|
|
91
|
+
export declare function parseEnvFile(text: string): Record<string, string>;
|
|
92
|
+
export interface ResolveConfigFileOptions {
|
|
93
|
+
/** 显式路径(绝对或相对 cwd);缺省读 env.HOMURA_CONFIG_FILE */
|
|
94
|
+
path?: string;
|
|
95
|
+
cwd?: string;
|
|
96
|
+
/** 额外候选目录(缺省会追加 QWENWORK_WRITABLE_DIR,即平台唯一可写区) */
|
|
97
|
+
extraDirs?: string[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 定位配置文件,按优先级返回第一个存在的:
|
|
101
|
+
* 1. 显式 path / `HOMURA_CONFIG_FILE`
|
|
102
|
+
* 2. `<cwd>/homura.config.env`(= 部署产物根,随产物携带的那份)
|
|
103
|
+
* 3. `<QWENWORK_WRITABLE_DIR>/homura.config.env`(平台可写区:可在不改产物的情况下投放/轮换)
|
|
104
|
+
* 4. extraDirs 下的同名文件
|
|
105
|
+
* 都不存在返回 null(零配置也能跑——渐进式)。
|
|
106
|
+
*/
|
|
107
|
+
export declare function resolveConfigFilePath(env: NodeJS.ProcessEnv, opts?: ResolveConfigFileOptions): string | null;
|
|
108
|
+
export interface ApplyEnvFileOptions extends ResolveConfigFileOptions {
|
|
109
|
+
/** true 时文件值覆盖已有 env(缺省 false:真实 env 优先) */
|
|
110
|
+
override?: boolean;
|
|
111
|
+
}
|
|
112
|
+
export interface ApplyEnvFileResult {
|
|
113
|
+
/** 实际使用的文件路径;null = 没有配置文件(零配置运行) */
|
|
114
|
+
path: string | null;
|
|
115
|
+
/** 写入 env 的变量名 */
|
|
116
|
+
applied: string[];
|
|
117
|
+
/** 因已有真实 env 而跳过的变量名 */
|
|
118
|
+
skipped: string[];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 把配置文件并入 env(就地修改传入对象,通常就是 process.env)。
|
|
122
|
+
* 必须在 defineConfig(...).load(env) **之前**调用。
|
|
123
|
+
*/
|
|
124
|
+
export declare function applyEnvFile(env: NodeJS.ProcessEnv, opts?: ApplyEnvFileOptions): ApplyEnvFileResult;
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/logger.d.ts
|
|
127
|
+
/**
|
|
128
|
+
* 结构化日志(轻量零依赖实现):JSON lines 输出,便于 qw-pages 等平台收集。
|
|
129
|
+
*/
|
|
130
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
131
|
+
export interface Logger {
|
|
132
|
+
debug(msg: string, fields?: Record<string, unknown>): void;
|
|
133
|
+
info(msg: string, fields?: Record<string, unknown>): void;
|
|
134
|
+
warn(msg: string, fields?: Record<string, unknown>): void;
|
|
135
|
+
error(msg: string, fields?: Record<string, unknown>): void;
|
|
136
|
+
/** 派生带固定 scope 的子 logger */
|
|
137
|
+
child(scope: string): Logger;
|
|
138
|
+
}
|
|
139
|
+
export declare function createLogger(options?: {
|
|
140
|
+
level?: LogLevel;
|
|
141
|
+
/** 输出中打码的字段名(默认 ['secret','password','token','authorization','apikey']) */
|
|
142
|
+
redactKeys?: string[];
|
|
143
|
+
}): Logger;
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/ttlkv.d.ts
|
|
146
|
+
export interface TtlKv {
|
|
147
|
+
set(key: string, value: string, ttlMs: number): Promise<void>;
|
|
148
|
+
/** 一次性取出并删除(CSRF state 消费语义);过期返回 null */
|
|
149
|
+
take(key: string): Promise<string | null>;
|
|
150
|
+
}
|
|
151
|
+
export declare function createMemoryTtlKv(options?: {
|
|
152
|
+
maxEntries?: number;
|
|
153
|
+
}): TtlKv;
|
|
154
|
+
/** 加密随机串(base64url),state/token 生成用 */
|
|
155
|
+
export declare function randomToken(bytes: number): string;
|
|
156
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
//#region src/runtime.ts
|
|
5
|
+
/**
|
|
6
|
+
* qw-pages 环境的启发式标记(实测注入,可被 HOMURA_RUNTIME 覆盖)。
|
|
7
|
+
* - QWENWORK_WRITABLE_DIR:平台必注入的唯一可写目录(实测 /tmp/qwenwork-page,跨发布清空)
|
|
8
|
+
* - SUPABASE_INSTANCE_ID:仅在 Page 绑定数据库后注入,故只作辅助标记
|
|
9
|
+
* 不纳入 HOST/PORT:standalone 部署也常设这两个变量,会造成误判。
|
|
10
|
+
*/
|
|
11
|
+
const QW_PAGES_ENV_MARKERS = ["QWENWORK_WRITABLE_DIR", "SUPABASE_INSTANCE_ID"];
|
|
12
|
+
/**
|
|
13
|
+
* 健康检查路径按形态取值——同源常量:app-kit 注册路由、license guard 豁免清单、
|
|
14
|
+
* 模板 SPA fallback 的保留前缀都读它,避免三处各写一份而漂移。
|
|
15
|
+
*
|
|
16
|
+
* qw-pages 上 '/healthz' 被 Page Gateway 保留:实测网关自答 `ok`(text/plain),
|
|
17
|
+
* 请求不下发到 App 进程(探针 serve 计数不增),因此 App 注册在 '/healthz'
|
|
18
|
+
* 的检查项在 Pages 上永远不可达。
|
|
19
|
+
*/
|
|
20
|
+
const HEALTHZ_PATH = {
|
|
21
|
+
standalone: "/healthz",
|
|
22
|
+
"qw-pages": "/_healthz"
|
|
23
|
+
};
|
|
24
|
+
/** 取指定形态的健康检查路径 */
|
|
25
|
+
function healthzPathFor(target) {
|
|
26
|
+
return HEALTHZ_PATH[target];
|
|
27
|
+
}
|
|
28
|
+
function detectRuntime(env) {
|
|
29
|
+
const explicit = env["HOMURA_RUNTIME"];
|
|
30
|
+
if (explicit !== void 0) {
|
|
31
|
+
if (explicit !== "standalone" && explicit !== "qw-pages") throw new Error(`HOMURA_RUNTIME 非法值: ${explicit}(允许 'standalone' | 'qw-pages')`);
|
|
32
|
+
return {
|
|
33
|
+
target: explicit,
|
|
34
|
+
explicit: true,
|
|
35
|
+
reasons: [`HOMURA_RUNTIME=${explicit}`]
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const hits = QW_PAGES_ENV_MARKERS.filter((m) => env[m] !== void 0 && env[m] !== "");
|
|
39
|
+
if (hits.length > 0) return {
|
|
40
|
+
target: "qw-pages",
|
|
41
|
+
explicit: false,
|
|
42
|
+
reasons: hits
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
target: "standalone",
|
|
46
|
+
explicit: false,
|
|
47
|
+
reasons: []
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/config.ts
|
|
52
|
+
function defineConfig(specs) {
|
|
53
|
+
const loadFrom = (env) => {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
56
|
+
const raw = env[spec.env];
|
|
57
|
+
if (raw !== void 0 && raw !== "") out[key] = spec.parse(raw);
|
|
58
|
+
else if (spec.default !== void 0) out[key] = spec.default;
|
|
59
|
+
else if (spec.required) throw new Error(`缺少必需的环境变量 ${spec.env}(配置项 ${key})`);
|
|
60
|
+
else out[key] = void 0;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
load(env) {
|
|
66
|
+
let source = env;
|
|
67
|
+
if (!source) {
|
|
68
|
+
try {
|
|
69
|
+
process.loadEnvFile();
|
|
70
|
+
} catch {}
|
|
71
|
+
source = process.env;
|
|
72
|
+
}
|
|
73
|
+
const resolved = loadFrom(source);
|
|
74
|
+
return {
|
|
75
|
+
get: ((key) => {
|
|
76
|
+
if (!(key in resolved)) throw new Error(`未知配置项: ${key}`);
|
|
77
|
+
return resolved[key];
|
|
78
|
+
}),
|
|
79
|
+
raw: () => resolved
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
help() {
|
|
83
|
+
const lines = ["# 由 @homurajs/core defineConfig 生成(配置即文档)", ""];
|
|
84
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
85
|
+
if (spec.description) lines.push(`# ${spec.description}`);
|
|
86
|
+
const example = spec.secret ? `${spec.env}=<redacted>` : `${spec.env}=${spec.default ?? ""}`;
|
|
87
|
+
lines.push(`${example}`);
|
|
88
|
+
lines.push("");
|
|
89
|
+
}
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** 常用 parse 助手 */
|
|
95
|
+
const parses = {
|
|
96
|
+
string: (raw) => raw,
|
|
97
|
+
int: (raw) => {
|
|
98
|
+
const n = Number.parseInt(raw, 10);
|
|
99
|
+
if (Number.isNaN(n)) throw new Error(`not an integer: ${raw}`);
|
|
100
|
+
return n;
|
|
101
|
+
},
|
|
102
|
+
boolean: (raw) => raw === "1" || raw.toLowerCase() === "true"
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/env-file.ts
|
|
106
|
+
/**
|
|
107
|
+
* 部署期配置文件加载(KEY=VALUE 行格式,dotenv 语义子集,零依赖)。
|
|
108
|
+
*
|
|
109
|
+
* 为什么需要它:qw-pages 平台只注入固定 12 个 env,且**没有自定义 env/secret 注入口**
|
|
110
|
+
* (实测),故 SESSION_SECRET / OAUTH_CLIENT_SECRET 这类值无法用环境变量投递。
|
|
111
|
+
* 而部署产物本身不可被公网读取(实测:请求 /server/*.mjs、/homura.manifest.json 一律被
|
|
112
|
+
* SPA fallback 兜成 index.html,%2e%2e 变体被网关 400,而真实 /assets/*.js 正常返回),
|
|
113
|
+
* 所以「随产物携带一个配置文件」是可行的投递通道,其信任边界与代码本身相同。
|
|
114
|
+
*
|
|
115
|
+
* 为什么是 env 行格式而不是 yaml/json:整个配置面已经是 env 形状(configSpecs +
|
|
116
|
+
* defineConfig),把文件内容并进 process.env 后能力包零改动,也不需要引解析器。
|
|
117
|
+
* 需要 yaml 时再加一个 source 适配即可,属加法而非改造。
|
|
118
|
+
*
|
|
119
|
+
* 优先级:**真实 env > 文件**(override=false,缺省)。standalone 仍可用环境变量覆盖,
|
|
120
|
+
* 与 12-factor 习惯一致;也便于本地调试时临时改一个值而不编辑文件。
|
|
121
|
+
*/
|
|
122
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
123
|
+
/** 配置文件名(缺省);产物根 = 运行时 cwd,故 <cwd>/homura.config.env 即随产物携带的那份 */
|
|
124
|
+
const CONFIG_FILE_NAME = "homura.config.env";
|
|
125
|
+
/**
|
|
126
|
+
* 解析 KEY=VALUE 行格式。
|
|
127
|
+
* - 跳过空行与 `#` 注释行;支持 `export KEY=VALUE`
|
|
128
|
+
* - 单引号:字面量,不解析转义;双引号:解析 \n \t \r \" \\ 转义
|
|
129
|
+
* - 无引号:去掉行尾 ` #...` 内联注释(前必须有空白),并 trim
|
|
130
|
+
* - 同名键后者覆盖前者
|
|
131
|
+
* - 非法行抛错并带上行号(fail fast:配置写错不该静默忽略)
|
|
132
|
+
*/
|
|
133
|
+
function parseEnvFile(text) {
|
|
134
|
+
const out = {};
|
|
135
|
+
const lines = text.split(/\r?\n/);
|
|
136
|
+
for (let i = 0; i < lines.length; i++) {
|
|
137
|
+
const raw = lines[i] ?? "";
|
|
138
|
+
const line = raw.trim();
|
|
139
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
140
|
+
const body = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
141
|
+
const eq = body.indexOf("=");
|
|
142
|
+
if (eq <= 0) throw new Error(`配置文件第 ${i + 1} 行不是 KEY=VALUE:${raw.slice(0, 80)}`);
|
|
143
|
+
const key = body.slice(0, eq).trim();
|
|
144
|
+
if (!KEY_RE.test(key)) throw new Error(`配置文件第 ${i + 1} 行的变量名非法:${key}`);
|
|
145
|
+
out[key] = unquote(body.slice(eq + 1).trim());
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function unquote(value) {
|
|
150
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
|
|
151
|
+
if (value.length >= 2 && value.startsWith("\"") && value.endsWith("\"")) return value.slice(1, -1).replace(/\\([nrt"\\])/g, (_m, c) => c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? " " : c);
|
|
152
|
+
const hash = value.search(/\s#/);
|
|
153
|
+
return (hash === -1 ? value : value.slice(0, hash)).trim();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 定位配置文件,按优先级返回第一个存在的:
|
|
157
|
+
* 1. 显式 path / `HOMURA_CONFIG_FILE`
|
|
158
|
+
* 2. `<cwd>/homura.config.env`(= 部署产物根,随产物携带的那份)
|
|
159
|
+
* 3. `<QWENWORK_WRITABLE_DIR>/homura.config.env`(平台可写区:可在不改产物的情况下投放/轮换)
|
|
160
|
+
* 4. extraDirs 下的同名文件
|
|
161
|
+
* 都不存在返回 null(零配置也能跑——渐进式)。
|
|
162
|
+
*/
|
|
163
|
+
function resolveConfigFilePath(env, opts = {}) {
|
|
164
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
165
|
+
const explicit = opts.path ?? env["HOMURA_CONFIG_FILE"];
|
|
166
|
+
const candidates = [];
|
|
167
|
+
if (explicit) candidates.push(isAbsolute(explicit) ? explicit : resolve(cwd, explicit));
|
|
168
|
+
candidates.push(resolve(cwd, CONFIG_FILE_NAME));
|
|
169
|
+
const writable = env["QWENWORK_WRITABLE_DIR"];
|
|
170
|
+
if (writable) candidates.push(resolve(writable, CONFIG_FILE_NAME));
|
|
171
|
+
for (const dir of opts.extraDirs ?? []) candidates.push(resolve(dir, CONFIG_FILE_NAME));
|
|
172
|
+
return candidates.find((p) => existsSync(p)) ?? null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 把配置文件并入 env(就地修改传入对象,通常就是 process.env)。
|
|
176
|
+
* 必须在 defineConfig(...).load(env) **之前**调用。
|
|
177
|
+
*/
|
|
178
|
+
function applyEnvFile(env, opts = {}) {
|
|
179
|
+
const path = resolveConfigFilePath(env, opts);
|
|
180
|
+
if (!path) return {
|
|
181
|
+
path: null,
|
|
182
|
+
applied: [],
|
|
183
|
+
skipped: []
|
|
184
|
+
};
|
|
185
|
+
const values = parseEnvFile(readFileSync(path, "utf8"));
|
|
186
|
+
const applied = [];
|
|
187
|
+
const skipped = [];
|
|
188
|
+
for (const [key, value] of Object.entries(values)) {
|
|
189
|
+
const existing = env[key];
|
|
190
|
+
if (!opts.override && existing !== void 0 && existing !== "") {
|
|
191
|
+
skipped.push(key);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
env[key] = value;
|
|
195
|
+
applied.push(key);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
path,
|
|
199
|
+
applied,
|
|
200
|
+
skipped
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/logger.ts
|
|
205
|
+
const LEVEL_ORDER = {
|
|
206
|
+
debug: 10,
|
|
207
|
+
info: 20,
|
|
208
|
+
warn: 30,
|
|
209
|
+
error: 40
|
|
210
|
+
};
|
|
211
|
+
const DEFAULT_REDACT = [
|
|
212
|
+
"secret",
|
|
213
|
+
"password",
|
|
214
|
+
"token",
|
|
215
|
+
"authorization",
|
|
216
|
+
"apikey"
|
|
217
|
+
];
|
|
218
|
+
function createLogger(options) {
|
|
219
|
+
const level = options?.level ?? "info";
|
|
220
|
+
const redactKeys = options?.redactKeys ?? DEFAULT_REDACT;
|
|
221
|
+
const redactSet = new Set(redactKeys.map((k) => k.toLowerCase()));
|
|
222
|
+
const emit = (lvl, scopes, msg, fields) => {
|
|
223
|
+
if (LEVEL_ORDER[lvl] < LEVEL_ORDER[level]) return;
|
|
224
|
+
const safeFields = {};
|
|
225
|
+
for (const [k, v] of Object.entries(fields ?? {})) safeFields[k] = redactSet.has(k.toLowerCase()) ? "[REDACTED]" : v;
|
|
226
|
+
const line = {
|
|
227
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
228
|
+
level: lvl,
|
|
229
|
+
...scopes.length > 0 ? { scope: scopes.join(":") } : {},
|
|
230
|
+
msg,
|
|
231
|
+
...safeFields
|
|
232
|
+
};
|
|
233
|
+
const out = JSON.stringify(line);
|
|
234
|
+
if (lvl === "error") console.error(out);
|
|
235
|
+
else if (lvl === "warn") console.warn(out);
|
|
236
|
+
else console.log(out);
|
|
237
|
+
};
|
|
238
|
+
const make = (scopes) => ({
|
|
239
|
+
debug: (m, f) => emit("debug", scopes, m, f),
|
|
240
|
+
info: (m, f) => emit("info", scopes, m, f),
|
|
241
|
+
warn: (m, f) => emit("warn", scopes, m, f),
|
|
242
|
+
error: (m, f) => emit("error", scopes, m, f),
|
|
243
|
+
child: (scope) => make([...scopes, scope])
|
|
244
|
+
});
|
|
245
|
+
return make([]);
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/ttlkv.ts
|
|
249
|
+
/**
|
|
250
|
+
* TTL-KV 抽象:OAuth state 等「一次性消费」语义的最小存储。
|
|
251
|
+
* 内存实现为缺省;多实例部署用 @homurajs/db 的 createDbTtlKv 兜底。
|
|
252
|
+
*/
|
|
253
|
+
function createMemoryTtlKv(options) {
|
|
254
|
+
const maxEntries = options?.maxEntries ?? 1e4;
|
|
255
|
+
const store = /* @__PURE__ */ new Map();
|
|
256
|
+
const sweep = (now) => {
|
|
257
|
+
for (const [k, e] of store) if (e.expiresAt <= now) store.delete(k);
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
async set(key, value, ttlMs) {
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
if (store.size >= maxEntries) sweep(now);
|
|
263
|
+
if (store.size >= maxEntries) {
|
|
264
|
+
const oldest = store.keys().next().value;
|
|
265
|
+
if (oldest !== void 0) store.delete(oldest);
|
|
266
|
+
}
|
|
267
|
+
store.set(key, {
|
|
268
|
+
value,
|
|
269
|
+
expiresAt: now + ttlMs
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
async take(key) {
|
|
273
|
+
const entry = store.get(key);
|
|
274
|
+
if (!entry) return null;
|
|
275
|
+
store.delete(key);
|
|
276
|
+
if (entry.expiresAt <= Date.now()) return null;
|
|
277
|
+
return entry.value;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** 加密随机串(base64url),state/token 生成用 */
|
|
282
|
+
function randomToken(bytes) {
|
|
283
|
+
return randomBytes(bytes).toString("base64url");
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
export { CONFIG_FILE_NAME, HEALTHZ_PATH, QW_PAGES_ENV_MARKERS, applyEnvFile, createLogger, createMemoryTtlKv, defineConfig, detectRuntime, healthzPathFor, parseEnvFile, parses, randomToken, resolveConfigFilePath };
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homurajs/core",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "26.10.14",
|
|
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
|
}
|