@homurajs/core 26.10.13 → 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 CHANGED
@@ -1,28 +1,53 @@
1
1
  # @homurajs/core
2
2
 
3
- 基座包(零依赖):运行环境探测、配置装载、结构化日志、TTL-KV。
3
+ 基座包(零依赖):运行环境探测、形态相关常量、配置装载、部署期配置文件、结构化日志、TTL-KV。
4
4
 
5
5
  ```ts
6
- import { detectRuntime, defineConfig, parses, createLogger, createMemoryTtlKv } from '@homurajs/core'
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'
7
18
 
8
- // 1. 环境:HOMURA_RUNTIME 显式优先,QW_PAGES* 标记启发式
19
+ // 1. 形态探测:HOMURA_RUNTIME 显式优先,否则按平台实测注入的标记启发式判断
9
20
  const { target } = detectRuntime(process.env) // 'standalone' | 'qw-pages'
21
+ // qw-pages 的标记是 QWENWORK_WRITABLE_DIR / SUPABASE_INSTANCE_ID(线上实测);
22
+ // 平台并不注入任何 QW_PAGES* 变量,别用 HOST/PORT 当标记(standalone 也常设)
10
23
 
11
- // 2. 配置即文档:load() + help()(生成 .env.example 片段)
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)
12
29
  const config = defineConfig({
13
30
  port: { env: 'PORT', default: 3000, parse: parses.int, description: '监听端口' },
14
31
  secret: { env: 'APP_SECRET', parse: parses.string, required: true, secret: true },
15
32
  })
16
33
  const cfg = config.load()
17
34
 
18
- // 3. 日志:JSON lines、级别过滤、敏感字段打码、child scope
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
19
42
  const log = createLogger().child('auth')
20
43
  log.info('登录成功', { userId: 'u1' })
21
44
 
22
- // 4. TTL-KV:一次性消费语义(OAuth state 等)
45
+ // 6. TTL-KV:一次性消费语义(OAuth state 等)
23
46
  const kv = createMemoryTtlKv()
24
47
  await kv.set(state, value, 5 * 60_000)
25
48
  const taken = await kv.take(state) // 取出即删;过期 null
26
49
  ```
27
50
 
28
- 多实例部署改用 `@homurajs/db` 的 `createDbTtlKv`。使用示范见 `templates/hono-react/src/server/app.ts`(buildAuth 里 `stateKv: createMemoryTtlKv()`)。
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 CHANGED
@@ -1,9 +1,15 @@
1
1
  //#region src/runtime.d.ts
2
2
  /**
3
- * 运行环境探测。
3
+ * 运行环境探测 + 形态相关的运行时常量。
4
4
  *
5
5
  * 显式配置(HOMURA_RUNTIME)永远优先;探测仅为零配置体验的缺省,
6
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 项。
7
13
  */
8
14
  export type RuntimeTarget = 'standalone' | 'qw-pages';
9
15
  export interface RuntimeInfo {
@@ -13,8 +19,24 @@ export interface RuntimeInfo {
13
19
  /** 探测依据(explicit=false 时给出命中的标记) */
14
20
  reasons: string[];
15
21
  }
16
- /** qw-pages 环境的启发式标记(文档标注:启发式、可被 HOMURA_RUNTIME 覆盖) */
22
+ /**
23
+ * qw-pages 环境的启发式标记(实测注入,可被 HOMURA_RUNTIME 覆盖)。
24
+ * - QWENWORK_WRITABLE_DIR:平台必注入的唯一可写目录(实测 /tmp/qwenwork-page,跨发布清空)
25
+ * - SUPABASE_INSTANCE_ID:仅在 Page 绑定数据库后注入,故只作辅助标记
26
+ * 不纳入 HOST/PORT:standalone 部署也常设这两个变量,会造成误判。
27
+ */
17
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;
18
40
  export declare function detectRuntime(env: NodeJS.ProcessEnv): RuntimeInfo;
19
41
  //#endregion
20
42
  //#region src/config.d.ts
@@ -55,6 +77,52 @@ export declare const parses: {
55
77
  readonly boolean: (raw: string) => boolean;
56
78
  };
57
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
58
126
  //#region src/logger.d.ts
59
127
  /**
60
128
  * 结构化日志(轻量零依赖实现):JSON lines 输出,便于 qw-pages 等平台收集。
package/dist/index.js CHANGED
@@ -1,7 +1,30 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
1
3
  import { randomBytes } from "node:crypto";
2
4
  //#region src/runtime.ts
3
- /** qw-pages 环境的启发式标记(文档标注:启发式、可被 HOMURA_RUNTIME 覆盖) */
4
- const QW_PAGES_ENV_MARKERS = ["QW_PAGES", "QW_PAGES_APP_ID"];
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
+ }
5
28
  function detectRuntime(env) {
6
29
  const explicit = env["HOMURA_RUNTIME"];
7
30
  if (explicit !== void 0) {
@@ -79,6 +102,105 @@ const parses = {
79
102
  boolean: (raw) => raw === "1" || raw.toLowerCase() === "true"
80
103
  };
81
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
82
204
  //#region src/logger.ts
83
205
  const LEVEL_ORDER = {
84
206
  debug: 10,
@@ -161,4 +283,4 @@ function randomToken(bytes) {
161
283
  return randomBytes(bytes).toString("base64url");
162
284
  }
163
285
  //#endregion
164
- export { QW_PAGES_ENV_MARKERS, createLogger, createMemoryTtlKv, defineConfig, detectRuntime, parses, randomToken };
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,6 @@
1
1
  {
2
2
  "name": "@homurajs/core",
3
- "version": "26.10.13",
3
+ "version": "26.10.14",
4
4
  "type": "module",
5
5
  "description": "homura 基座:运行环境探测、配置装载、日志、TTL-KV",
6
6
  "engines": {