@ubean/shared 0.6.7 → 0.6.9

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
@@ -73,18 +73,21 @@ isNodeRuntime(); // false on Cloudflare Workers
73
73
 
74
74
  ## Environment Variables
75
75
 
76
- `defineEnv(config)` validates a constructor-based schema and returns `{ env, validate }`. `server` keys are never exposed to the client; `public` keys are exposed via `import.meta.env`.
76
+ `defineEnv(config)` validates a schema and returns `{ env, validate }`. Every field declares its own `context` (`client` | `server`) and `access` (`public` | `secret`) via `envField.*()` — the same shape `ubean.config.ts` accepts under `env.schema`.
77
77
 
78
78
  ```ts
79
- import { defineEnv } from '@ubean/shared';
79
+ import { defineEnv, envField } from '@ubean/shared';
80
80
 
81
81
  export const { env, validate } = defineEnv({
82
- server: {
83
- DATABASE_URL: { type: String, required: true },
84
- PORT: { type: Number, default: 9527 }
85
- },
86
- public: {
87
- APP_NAME: { type: String, default: 'ubean-app' }
82
+ schema: {
83
+ DATABASE_URL: envField.string({ context: 'server', access: 'secret', optional: true }),
84
+ PORT: envField.number({ context: 'server', access: 'public', default: 9527 }),
85
+ APP_NAME: envField.string({
86
+ context: 'client',
87
+ access: 'public',
88
+ default: 'ubean-app',
89
+ description: 'Shown in the header'
90
+ })
88
91
  },
89
92
  mode: 'warn' // 'warn' logs; 'throw' rethrows on validation failure
90
93
  });
@@ -93,6 +96,10 @@ env.PORT; // number
93
96
  validate(); // { success, errors, data }
94
97
  ```
95
98
 
99
+ Every builder also accepts `description`, emitted as JSDoc in the generated `.ubean/env.d.ts`. Grouped `server:` / `public:` schemas are gone: dimensions live on the field, so nothing silently changes meaning when an entry moves between groups. `client` + `secret` throws at declaration time. The constructor shape (`{ type: String }`) still parses under `schema`, but it has no dimension to read from, so it is treated as `server + secret` (the conservative choice).
100
+
101
+ For zod / valibot-grade validation, `defineEnv()` also accepts Standard Schema and `safeParse` shapes — it shares a process with the validator, unlike the generated env modules. Validation must be synchronous.
102
+
96
103
  | API | Description |
97
104
  | ------------------------------------------------ | ------------------------------------------------------------------------------------- |
98
105
  | `defineEnv(config)` | Validate a schema and return `{ env, validate }` (`DefineEnvResult<S>`) |
package/README.zh-CN.md CHANGED
@@ -73,18 +73,21 @@ isNodeRuntime(); // Cloudflare Workers 上为 false
73
73
 
74
74
  ## 环境变量
75
75
 
76
- `defineEnv(config)` 校验基于构造函数的 schema,返回 `{ env, validate }`。`server` 键永不暴露给客户端;`public` 键通过 `import.meta.env` 暴露。
76
+ `defineEnv(config)` 校验 schema 并返回 `{ env, validate }`。每个字段用 `envField.*()` 自带 `context`(`client` | `server`)与 `access`(`public` | `secret`)—— 与 `ubean.config.ts` 的 `env.schema` 完全同形。
77
77
 
78
78
  ```ts
79
- import { defineEnv } from '@ubean/shared';
79
+ import { defineEnv, envField } from '@ubean/shared';
80
80
 
81
81
  export const { env, validate } = defineEnv({
82
- server: {
83
- DATABASE_URL: { type: String, required: true },
84
- PORT: { type: Number, default: 9527 }
85
- },
86
- public: {
87
- APP_NAME: { type: String, default: 'ubean-app' }
82
+ schema: {
83
+ DATABASE_URL: envField.string({ context: 'server', access: 'secret', optional: true }),
84
+ PORT: envField.number({ context: 'server', access: 'public', default: 9527 }),
85
+ APP_NAME: envField.string({
86
+ context: 'client',
87
+ access: 'public',
88
+ default: 'ubean-app',
89
+ description: '页头展示的名称'
90
+ })
88
91
  },
89
92
  mode: 'warn' // 'warn' 记录日志;'throw' 在校验失败时抛出
90
93
  });
@@ -93,6 +96,10 @@ env.PORT; // number
93
96
  validate(); // { success, errors, data }
94
97
  ```
95
98
 
99
+ 每个 builder 还接受 `description`,它会以 JSDoc 形式写进生成的 `.ubean/env.d.ts`。分组糖(`server:` / `public:`)已删除:维度写在字段上,同一个字段挪位置不会静默换语义。`client` + `secret` 在声明时直接抛错。构造函数形态(`{ type: String }`)仍可在 `schema` 下解析,但它没有维度可读,按 `server + secret` 处理(最保守的取舍)。
100
+
101
+ 需要 zod / valibot 级校验时,`defineEnv()` 也接受 Standard Schema 与 `safeParse` 形态——它与校验器同进程,而生成的 env 模块装不下函数。校验必须同步。
102
+
96
103
  | API | 说明 |
97
104
  | ------------------------------------------------ | ------------------------------------------------------------------------------------- |
98
105
  | `defineEnv(config)` | 校验 schema 并返回 `{ env, validate }`(`DefineEnvResult<S>`) |
package/dist/index.d.ts CHANGED
@@ -396,7 +396,7 @@ export type EnvSchema = Record<string, EnvFieldDef>;
396
396
  export type EnvContext = 'client' | 'server';
397
397
  export type EnvAccess = 'public' | 'secret';
398
398
  export interface EnvFieldMeta<T = unknown> {
399
- /** 判别标记:把 `envField.*()` 的产物与 legacy 三形态区分开。 */
399
+ /** 判别标记:把 `envField.*()` 的产物与构造函数形态区分开。 */
400
400
  readonly __ubeanEnvField: true;
401
401
  readonly type: 'string' | 'number' | 'boolean' | 'enum';
402
402
  readonly context: EnvContext;
@@ -405,14 +405,18 @@ export interface EnvFieldMeta<T = unknown> {
405
405
  readonly values?: readonly string[];
406
406
  readonly optional?: boolean;
407
407
  readonly default?: T;
408
+ /**
409
+ * 字段说明。写进 `.ubean/env.d.ts` 的 JSDoc,编辑器里 hover 具名导入时直接可见 ——
410
+ * 这是唯一能跟着变量走到消费端的地方(`.env.example` 里的注释不会)。
411
+ */
412
+ readonly description?: string;
408
413
  /** 纯类型标记(运行时不写),让 `InferEnvOutput` 能推出字面量联合。 */
409
414
  readonly _output?: T;
410
415
  }
411
416
  /**
412
- * 一个 schema 字段:新形态(`envField.*()`)或 legacy 形态。
417
+ * 一个 schema 字段:`envField.*()` 产物、运行期 schema,或构造函数形态(`{ type: String }`)。
413
418
  *
414
- * legacy 形态保留是为了不破坏既有 `defineEnv({ server, public })` 调用 —— 现有仓库
415
- * 与示例都在用,`0.6.x` 内不能变语义。
419
+ * 构造函数形态没有维度信息,按 `server + secret` 处理(见 `DEFAULT_FIELD_DIMENSIONS`)。
416
420
  */
417
421
  export type EnvFieldDef = StandardSchemaV1 | GenericSchema | EnvFieldMeta | {
418
422
  type: StringConstructor | NumberConstructor | BooleanConstructor;
@@ -423,6 +427,13 @@ interface EnvFieldBase {
423
427
  context: EnvContext;
424
428
  access: EnvAccess;
425
429
  optional?: boolean;
430
+ /**
431
+ * 字段说明,会作为 JSDoc 出现在生成的 `.ubean/env.d.ts` 里。
432
+ *
433
+ * 与 `.env.example` 的注释分工:那边是给运维看「这个值填什么」,这里是给写代码的人
434
+ * 看「这个变量是什么」—— 且会跟着类型走到每一处 import。
435
+ */
436
+ description?: string;
426
437
  }
427
438
  export interface EnvStringFieldOptions extends EnvFieldBase {
428
439
  default?: string;
@@ -469,8 +480,8 @@ export type InferEnvOutput<S extends EnvSchema> = { [K in keyof S]: S[K] extends
469
480
  type: BooleanConstructor;
470
481
  } ? boolean : string; };
471
482
  /**
472
- * 规范化后的字段:把 legacy 的 `server:` / `public:` 分组糖与显式 `context`/`access`
473
- * 统一成同一形状。codegen 与虚拟模块都只消费这个形状。
483
+ * 规范化后的字段:每个都带着确定的 `context` / `access`。codegen 与虚拟模块都只
484
+ * 消费这个形状。
474
485
  */
475
486
  export interface NormalizedEnvField {
476
487
  key: string;
@@ -480,22 +491,14 @@ export interface NormalizedEnvField {
480
491
  }
481
492
  export interface EnvConfig<S extends EnvSchema = EnvSchema> {
482
493
  /**
483
- * 扁平 schema —— 每个字段自带 `context` / `access`(`envField.*()` 声明)。
484
- * 这是新写法,也是 `ubean.config.ts` 的 `env.schema` 直通入口。
485
- */
486
- schema?: S;
487
- /**
488
- * @deprecated 用 `schema` + `envField.*()` 代替。
489
- *
490
- * 分组糖:组内字段默认 `context: 'server', access: 'secret'`。
491
- */
492
- server?: S;
493
- /**
494
- * @deprecated 用 `schema` + `envField.*()` 代替。
494
+ * 扁平 schema —— 每个字段自带 `context` / `access`(用 `envField.*()` 声明)。
495
+ * `ubean.config.ts` 的 `env.schema` 与 `defineEnv({ schema })` 都是这个形状。
495
496
  *
496
- * 分组糖:组内字段默认 `context: 'client', access: 'public'`。
497
+ * 分组糖(`server:` / `public:`)已删除:维度**必须**写在字段上。旧写法靠组名
498
+ * 隐式决定 `context`/`access`,同一个字段换个位置就换了语义,且 `client+secret`
499
+ * 这类非法组合只能靠组合规则兜。
497
500
  */
498
- public?: S;
501
+ schema?: S;
499
502
  /** Validation mode - 'warn' logs errors, 'throw' throws on validation failure */
500
503
  mode?: 'warn' | 'throw';
501
504
  }
@@ -513,17 +516,22 @@ export interface EnvValidationError {
513
516
  value: unknown;
514
517
  }
515
518
  /**
516
- * 把用户 schema(含 legacy 分组糖)摊平成 `NormalizedEnvField[]`。
519
+ * 把用户 schema 摊平成 `NormalizedEnvField[]`。
520
+ *
521
+ * 字段自带 `context`/`access`(`envField.*()`)时用它;构造函数形态(`{ type: String }`)
522
+ * 没有维度信息,按「最保守」处理 —— 当 `server + secret`,绝不进客户端产物。要跳侧可见
523
+ * 就显式用 `envField.*()`。
517
524
  *
518
- * 字段自带的 `context`/`access` 优先于分组默认值 —— 这样用户可以在 legacy 的
519
- * `server:` 组里单独把一个字段标成 `public`,不必整体重写。
525
+ * 分组糖(`server:` / `public:`)已删除:维度必须写在字段上。旧写法靠组名隐式决定
526
+ * `context`/`access`,同一字段换个位置就换了语义,而混用(分组里再放 envField)只能靠
527
+ * 优先级规则解释。现在传入分组语法会直接抛错。
520
528
  */
521
529
  export declare function normalizeEnvSchema(schema: EnvSchema | EnvConfig): NormalizedEnvField[];
522
530
  /**
523
531
  * 服务端运行时校验:按规范化字段读 `source`(默认 `process.env`),产出强类型对象。
524
532
  *
525
- * 与 `defineEnv()` 的区别:这里只处理「已经归好维度」的字段,不碰 legacy 分组糖 ——
526
- * 虚拟模块 `ubean:env/server` 的运行时校验走它。
533
+ * 与 `defineEnv()` 的区别:这里只做「已归好维度」的字段到运行时值的解析 —— 虚拟模块
534
+ * `ubean:env/server` 的运行时校验走它。
527
535
  */
528
536
  export declare function resolveEnvSchema(schema: EnvSchema | EnvConfig, source?: Record<string, string | undefined>): {
529
537
  success: boolean;
package/dist/index.js CHANGED
@@ -171,32 +171,69 @@ function isEnvFieldMeta(value) {
171
171
  return typeof value === "object" && value !== null && value.__ubeanEnvField === true;
172
172
  }
173
173
  /**
174
- * legacy 分组 → 维度映射。
174
+ * 构造函数形态(无维度信息)的默认维度:按「最保守」处理 —— 当服务端 secret,
175
+ * 绝不进客户端产物。要跳侧可见就显式用 `envField.*()`。
176
+ */
177
+ const DEFAULT_FIELD_DIMENSIONS = {
178
+ context: "server",
179
+ access: "secret"
180
+ };
181
+ /**
182
+ * 判断一个值是否符合 Standard Schema 接口(zod / valibot / arktype 等)。
175
183
  *
176
- * `server:` 组历史语义是「只在服务端读」→ `server + secret`。
177
- * `public:` 组历史语义是「不敏感、客户端也能拿」→ `client + public`(`client` 侧在服务端
178
- * 同样可读,所以 examples 里在 API handler 中读 `env.API_BASE_URL` 的行为不变)。
184
+ * 只看 `~standard.validate` 是不是函数,不看 `vendor` / `version` —— 各家实现这几个字段
185
+ * 的值不一致(valibot 是 `vendor: 'valibot'`),但**调用面**统一。
179
186
  */
180
- const LEGACY_GROUP_DIMENSIONS = {
181
- server: {
182
- context: "server",
183
- access: "secret"
184
- },
185
- public: {
186
- context: "client",
187
- access: "public"
187
+ function isStandardSchema(value) {
188
+ if (typeof value !== "object" || value === null) return false;
189
+ const standard = value["~standard"];
190
+ return typeof standard === "object" && standard !== null && typeof standard.validate === "function";
191
+ }
192
+ /**
193
+ * 用 Standard Schema 校验一个环境变量。
194
+ *
195
+ * **`~standard.validate()` 必须同步返回。** 大多数 schema 都是同步的(zod 的 `.min()`
196
+ * 一类、valibot 的 `v.pipe()`),但 zod 的 `.refine(async …)` 会返回 Promise:那种 schema
197
+ * 在环境变量上无法支持 —— 生成的 server 模块在模块顶层同步执行,而
198
+ * `loadUbeanConfigSync()` 又把配置编译成 CJS(顶层 await 在那里是语法错误),
199
+ * 两边都没有 await 的位置。
200
+ *
201
+ * 早先这里写的是「看到 `~standard` 就判失败」并提示「请用 validate()」—— 可它从未
202
+ * 调用过 `validate()`,于是**任何**标准 schema 都必定校验失败,而且报错指向一个同样会
203
+ * 失败的方向。现在真的调用它了。
204
+ */
205
+ function parseStandardSchemaValue(schema, rawValue, key) {
206
+ const input = rawValue === "" ? void 0 : rawValue;
207
+ let result;
208
+ try {
209
+ result = schema["~standard"].validate(input);
210
+ } catch (error) {
211
+ return {
212
+ ok: false,
213
+ error: error instanceof Error ? error.message : String(error)
214
+ };
188
215
  }
189
- };
216
+ if (typeof result?.then === "function") return {
217
+ ok: false,
218
+ error: `Env ${key}: async Standard Schema validation is not supported (env validation must be synchronous)`
219
+ };
220
+ const settled = result;
221
+ if (settled.issues && settled.issues.length > 0) return {
222
+ ok: false,
223
+ error: settled.issues[0]?.message ?? `Env ${key} is invalid`
224
+ };
225
+ return {
226
+ ok: true,
227
+ value: settled.value
228
+ };
229
+ }
190
230
  function parseSchemaValue(schema, rawValue, key) {
191
231
  if (!schema) return {
192
232
  ok: true,
193
233
  value: rawValue
194
234
  };
195
235
  if (isEnvFieldMeta(schema)) return parseEnvFieldValue(schema, rawValue, key);
196
- if ("~standard" in schema && typeof schema["~standard"] === "object" && schema["~standard"]) return {
197
- ok: false,
198
- error: "Standard schema validation requires async validate, use validate() instead"
199
- };
236
+ if (isStandardSchema(schema)) return parseStandardSchemaValue(schema, rawValue, key);
200
237
  if (typeof schema.safeParse === "function") {
201
238
  const result = schema.safeParse(rawValue);
202
239
  if (!result.success) return {
@@ -283,7 +320,7 @@ function parseSchemaValue(schema, rawValue, key) {
283
320
  /**
284
321
  * 解析 `envField.*()` 声明的字段。
285
322
  *
286
- * 三种类型各自的行为与 legacy 形态保持一致(缺失 → `default` → `optional` 放行 → 报错),
323
+ * 三种类型的行为与构造函数形态一致(缺失 → `default` → `optional` 放行 → 报错),
287
324
  * 多出来的是 `enum` 的成员校验:值不在 `values` 里直接失败,不做静默回落 ——
288
325
  * 枚举变量的拼写错误在启动期暴露比在运行期暴露便宜得多。
289
326
  */
@@ -332,52 +369,47 @@ function parseEnvFieldValue(meta, rawValue, key) {
332
369
  };
333
370
  }
334
371
  /**
335
- * 把用户 schema(含 legacy 分组糖)摊平成 `NormalizedEnvField[]`。
372
+ * 把用户 schema 摊平成 `NormalizedEnvField[]`。
373
+ *
374
+ * 字段自带 `context`/`access`(`envField.*()`)时用它;构造函数形态(`{ type: String }`)
375
+ * 没有维度信息,按「最保守」处理 —— 当 `server + secret`,绝不进客户端产物。要跳侧可见
376
+ * 就显式用 `envField.*()`。
336
377
  *
337
- * 字段自带的 `context`/`access` 优先于分组默认值 —— 这样用户可以在 legacy 的
338
- * `server:` 组里单独把一个字段标成 `public`,不必整体重写。
378
+ * 分组糖(`server:` / `public:`)已删除:维度必须写在字段上。旧写法靠组名隐式决定
379
+ * `context`/`access`,同一字段换个位置就换了语义,而混用(分组里再放 envField)只能靠
380
+ * 优先级规则解释。现在传入分组语法会直接抛错。
339
381
  */
340
382
  function normalizeEnvSchema(schema) {
341
383
  const fields = [];
342
- const push = (key, def, fallback) => {
343
- const dimensions = (isEnvFieldMeta(def) ? {
344
- context: def.context,
345
- access: def.access
346
- } : null) ?? fallback;
347
- if (dimensions.context === "client" && dimensions.access === "secret") throw new Error(`Invalid env field "${key}": context 'client' cannot be combined with access 'secret'.`);
384
+ const config = schema;
385
+ for (const groupKey of ["server", "public"]) if (groupKey in schema) throw new Error(`Invalid env config: grouped \`${groupKey}:\` schemas were removed.\nDeclare every field under \`schema:\` with explicit dimensions, e.g. \`${groupKey.toUpperCase()}_KEY: envField.string({ context: '${groupKey}', access: 'public' })\`.`);
386
+ const raw = config.schema ?? schema;
387
+ if (!raw || typeof raw !== "object") return fields;
388
+ for (const [key, def] of Object.entries(raw)) {
389
+ if (def === void 0 || def === null) continue;
390
+ if (!isEnvFieldMeta(def)) {
391
+ fields.push({
392
+ key,
393
+ ...DEFAULT_FIELD_DIMENSIONS,
394
+ def
395
+ });
396
+ continue;
397
+ }
398
+ if (def.context === "client" && def.access === "secret") throw new Error(`Invalid env field "${key}": context 'client' cannot be combined with access 'secret'.`);
348
399
  fields.push({
349
400
  key,
350
- context: dimensions.context,
351
- access: dimensions.access,
401
+ context: def.context,
402
+ access: def.access,
352
403
  def
353
404
  });
354
- };
355
- const record = schema;
356
- if (!("server" in record) && !("public" in record)) {
357
- const flat = "schema" in record ? record.schema : record;
358
- if (!flat || typeof flat !== "object") return fields;
359
- for (const [key, def] of Object.entries(flat)) {
360
- if (!isEnvFieldMeta(def)) {
361
- push(key, def, LEGACY_GROUP_DIMENSIONS.server);
362
- continue;
363
- }
364
- push(key, def, {
365
- context: def.context,
366
- access: def.access
367
- });
368
- }
369
- return fields;
370
405
  }
371
- const grouped = schema;
372
- for (const [key, def] of Object.entries(grouped.server ?? {})) push(key, def, LEGACY_GROUP_DIMENSIONS.server);
373
- for (const [key, def] of Object.entries(grouped.public ?? {})) push(key, def, LEGACY_GROUP_DIMENSIONS.public);
374
406
  return fields;
375
407
  }
376
408
  /**
377
409
  * 服务端运行时校验:按规范化字段读 `source`(默认 `process.env`),产出强类型对象。
378
410
  *
379
- * 与 `defineEnv()` 的区别:这里只处理「已经归好维度」的字段,不碰 legacy 分组糖 ——
380
- * 虚拟模块 `ubean:env/server` 的运行时校验走它。
411
+ * 与 `defineEnv()` 的区别:这里只做「已归好维度」的字段到运行时值的解析 —— 虚拟模块
412
+ * `ubean:env/server` 的运行时校验走它。
381
413
  */
382
414
  function resolveEnvSchema(schema, source) {
383
415
  const fields = normalizeEnvSchema(schema);
@@ -428,11 +460,7 @@ function validateSchemaSync(schema, source) {
428
460
  }
429
461
  function defineEnv(config) {
430
462
  const serverSource = readProcessEnv();
431
- const mergedSchema = {
432
- ...config.public,
433
- ...config.server,
434
- ...config.schema
435
- };
463
+ const mergedSchema = config.schema ?? {};
436
464
  const initial = validateSchemaSync(mergedSchema, serverSource);
437
465
  if (!initial.success && config.mode === "throw") {
438
466
  const messages = initial.errors.map((e) => ` - ${e.key}: ${e.message}`).join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ubean/shared",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "Shared protocol types, errors, env and universal utilities for ubean",
5
5
  "files": [
6
6
  "dist"