@ubean/shared 0.6.3 → 0.6.4

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/dist/index.d.ts CHANGED
@@ -380,22 +380,121 @@ export declare function isUbeanError(err: unknown): err is UbeanError;
380
380
  export declare function errorToResponse(c: unknown, err?: unknown): Response;
381
381
  //#endregion
382
382
  //#region src/env.d.ts
383
- export type EnvSchema = Record<string, StandardSchemaV1 | GenericSchema | {
383
+ export type EnvSchema = Record<string, EnvFieldDef>;
384
+ /**
385
+ * 环境变量的**归属维度**(借用 Astro `astro:env` 的模型,见 docs/contracts/env-v1.md)。
386
+ *
387
+ * - `context`: 值在哪一侧可用。`client` → 构建期内联进客户端产物(服务端同样可读);
388
+ * `server` → 只在服务端运行时读取。
389
+ * - `access`: 值是否敏感。`secret` 永不进客户端产物;`public` 可以(但 `client` 侧的
390
+ * public 才会被内联)。
391
+ *
392
+ * 合法组合只有三种:`client+public`、`server+public`、`server+secret`。
393
+ * `client+secret` 在建 schema 时直接抛错 —— 这是 Astro 的硬约束,也是本设计里唯一
394
+ * 「配置期就失败」的规则。
395
+ */
396
+ export type EnvContext = 'client' | 'server';
397
+ export type EnvAccess = 'public' | 'secret';
398
+ export interface EnvFieldMeta<T = unknown> {
399
+ /** 判别标记:把 `envField.*()` 的产物与 legacy 三形态区分开。 */
400
+ readonly __ubeanEnvField: true;
401
+ readonly type: 'string' | 'number' | 'boolean' | 'enum';
402
+ readonly context: EnvContext;
403
+ readonly access: EnvAccess;
404
+ /** 仅 `type: 'enum'` 有值。 */
405
+ readonly values?: readonly string[];
406
+ readonly optional?: boolean;
407
+ readonly default?: T;
408
+ /** 纯类型标记(运行时不写),让 `InferEnvOutput` 能推出字面量联合。 */
409
+ readonly _output?: T;
410
+ }
411
+ /**
412
+ * 一个 schema 字段:新形态(`envField.*()`)或 legacy 形态。
413
+ *
414
+ * legacy 形态保留是为了不破坏既有 `defineEnv({ server, public })` 调用 —— 现有仓库
415
+ * 与示例都在用,`0.6.x` 内不能变语义。
416
+ */
417
+ export type EnvFieldDef = StandardSchemaV1 | GenericSchema | EnvFieldMeta | {
384
418
  type: StringConstructor | NumberConstructor | BooleanConstructor;
385
419
  default?: unknown;
386
420
  required?: boolean;
387
- }>;
388
- export type InferEnvOutput<S extends EnvSchema> = { [K in keyof S]: S[K] extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S[K]> : S[K] extends GenericSchema<infer O> ? O : S[K] extends {
421
+ };
422
+ interface EnvFieldBase {
423
+ context: EnvContext;
424
+ access: EnvAccess;
425
+ optional?: boolean;
426
+ }
427
+ export interface EnvStringFieldOptions extends EnvFieldBase {
428
+ default?: string;
429
+ }
430
+ export interface EnvNumberFieldOptions extends EnvFieldBase {
431
+ default?: number;
432
+ }
433
+ export interface EnvBooleanFieldOptions extends EnvFieldBase {
434
+ default?: boolean;
435
+ }
436
+ export interface EnvEnumFieldOptions<V extends readonly string[]> extends EnvFieldBase {
437
+ values: V;
438
+ default?: V[number];
439
+ }
440
+ /**
441
+ * 声明一个环境变量字段(Astro `envField` 的等价物)。
442
+ *
443
+ * @example
444
+ * ```ts
445
+ * // ubean.config.ts
446
+ * export default defineConfig({
447
+ * env: {
448
+ * schema: {
449
+ * PUBLIC_API_BASE: envField.string({ context: 'client', access: 'public', default: '/api' }),
450
+ * DATABASE_URL: envField.string({ context: 'server', access: 'secret', optional: true }),
451
+ * PORT: envField.number({ context: 'server', access: 'public', default: 9527 })
452
+ * }
453
+ * }
454
+ * });
455
+ * ```
456
+ */
457
+ export declare const envField: {
458
+ string(options: EnvStringFieldOptions): EnvFieldMeta<string>;
459
+ number(options: EnvNumberFieldOptions): EnvFieldMeta<number>;
460
+ boolean(options: EnvBooleanFieldOptions): EnvFieldMeta<boolean>;
461
+ enum<const V extends readonly string[]>(options: EnvEnumFieldOptions<V>): EnvFieldMeta<V[number]>;
462
+ };
463
+ export declare function isEnvFieldMeta(value: unknown): value is EnvFieldMeta;
464
+ export type InferEnvOutput<S extends EnvSchema> = { [K in keyof S]: S[K] extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S[K]> : S[K] extends GenericSchema<infer O> ? O : S[K] extends EnvFieldMeta<infer O> ? O : S[K] extends {
389
465
  type: StringConstructor;
390
466
  } ? string : S[K] extends {
391
467
  type: NumberConstructor;
392
468
  } ? number : S[K] extends {
393
469
  type: BooleanConstructor;
394
470
  } ? boolean : string; };
471
+ /**
472
+ * 规范化后的字段:把 legacy 的 `server:` / `public:` 分组糖与显式 `context`/`access`
473
+ * 统一成同一形状。codegen 与虚拟模块都只消费这个形状。
474
+ */
475
+ export interface NormalizedEnvField {
476
+ key: string;
477
+ context: EnvContext;
478
+ access: EnvAccess;
479
+ def: EnvFieldDef;
480
+ }
395
481
  export interface EnvConfig<S extends EnvSchema = EnvSchema> {
396
- /** Server-only env variables (not exposed to client) */
482
+ /**
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
+ */
397
492
  server?: S;
398
- /** Public env variables (exposed to client via import.meta.env) */
493
+ /**
494
+ * @deprecated 用 `schema` + `envField.*()` 代替。
495
+ *
496
+ * 分组糖:组内字段默认 `context: 'client', access: 'public'`。
497
+ */
399
498
  public?: S;
400
499
  /** Validation mode - 'warn' logs errors, 'throw' throws on validation failure */
401
500
  mode?: 'warn' | 'throw';
@@ -413,6 +512,24 @@ export interface EnvValidationError {
413
512
  message: string;
414
513
  value: unknown;
415
514
  }
515
+ /**
516
+ * 把用户 schema(含 legacy 分组糖)摊平成 `NormalizedEnvField[]`。
517
+ *
518
+ * 字段自带的 `context`/`access` 优先于分组默认值 —— 这样用户可以在 legacy 的
519
+ * `server:` 组里单独把一个字段标成 `public`,不必整体重写。
520
+ */
521
+ export declare function normalizeEnvSchema(schema: EnvSchema | EnvConfig): NormalizedEnvField[];
522
+ /**
523
+ * 服务端运行时校验:按规范化字段读 `source`(默认 `process.env`),产出强类型对象。
524
+ *
525
+ * 与 `defineEnv()` 的区别:这里只处理「已经归好维度」的字段,不碰 legacy 分组糖 ——
526
+ * 虚拟模块 `ubean:env/server` 的运行时校验走它。
527
+ */
528
+ export declare function resolveEnvSchema(schema: EnvSchema | EnvConfig, source?: Record<string, string | undefined>): {
529
+ success: boolean;
530
+ errors: EnvValidationError[];
531
+ data: Record<string, unknown>;
532
+ };
416
533
  export declare function defineEnv<S extends EnvSchema>(config: EnvConfig<S>): DefineEnvResult<S>;
417
534
  export declare function setRuntimeEnv(env: Record<string, unknown>): void;
418
535
  export declare function useRuntimeEnv<T = string>(key: string, defaultValue?: T): T;
@@ -475,4 +592,5 @@ export declare function matchAnyGlob(route: string, patterns: string[]): boolean
475
592
  * 将字符串首字母大写。
476
593
  */
477
594
  export declare function capitalize(str: string): string;
478
- //#endregion
595
+ //#endregion
596
+ export {};
package/dist/index.js CHANGED
@@ -113,11 +113,86 @@ function errorToResponse(c, err) {
113
113
  }
114
114
  //#endregion
115
115
  //#region src/env.ts
116
+ function assertEnvFieldCombo(options) {
117
+ if (options.context === "client" && options.access === "secret") throw new Error("Invalid env field: context 'client' cannot be combined with access 'secret'. Secrets are never sent to the client; use `{ context: 'server', access: 'secret' }`.");
118
+ }
119
+ /**
120
+ * 声明一个环境变量字段(Astro `envField` 的等价物)。
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * // ubean.config.ts
125
+ * export default defineConfig({
126
+ * env: {
127
+ * schema: {
128
+ * PUBLIC_API_BASE: envField.string({ context: 'client', access: 'public', default: '/api' }),
129
+ * DATABASE_URL: envField.string({ context: 'server', access: 'secret', optional: true }),
130
+ * PORT: envField.number({ context: 'server', access: 'public', default: 9527 })
131
+ * }
132
+ * }
133
+ * });
134
+ * ```
135
+ */
136
+ const envField = {
137
+ string(options) {
138
+ assertEnvFieldCombo(options);
139
+ return {
140
+ __ubeanEnvField: true,
141
+ type: "string",
142
+ ...options
143
+ };
144
+ },
145
+ number(options) {
146
+ assertEnvFieldCombo(options);
147
+ return {
148
+ __ubeanEnvField: true,
149
+ type: "number",
150
+ ...options
151
+ };
152
+ },
153
+ boolean(options) {
154
+ assertEnvFieldCombo(options);
155
+ return {
156
+ __ubeanEnvField: true,
157
+ type: "boolean",
158
+ ...options
159
+ };
160
+ },
161
+ enum(options) {
162
+ assertEnvFieldCombo(options);
163
+ return {
164
+ __ubeanEnvField: true,
165
+ type: "enum",
166
+ ...options
167
+ };
168
+ }
169
+ };
170
+ function isEnvFieldMeta(value) {
171
+ return typeof value === "object" && value !== null && value.__ubeanEnvField === true;
172
+ }
173
+ /**
174
+ * legacy 分组 → 维度映射。
175
+ *
176
+ * `server:` 组历史语义是「只在服务端读」→ `server + secret`。
177
+ * `public:` 组历史语义是「不敏感、客户端也能拿」→ `client + public`(`client` 侧在服务端
178
+ * 同样可读,所以 examples 里在 API handler 中读 `env.API_BASE_URL` 的行为不变)。
179
+ */
180
+ const LEGACY_GROUP_DIMENSIONS = {
181
+ server: {
182
+ context: "server",
183
+ access: "secret"
184
+ },
185
+ public: {
186
+ context: "client",
187
+ access: "public"
188
+ }
189
+ };
116
190
  function parseSchemaValue(schema, rawValue, key) {
117
191
  if (!schema) return {
118
192
  ok: true,
119
193
  value: rawValue
120
194
  };
195
+ if (isEnvFieldMeta(schema)) return parseEnvFieldValue(schema, rawValue, key);
121
196
  if ("~standard" in schema && typeof schema["~standard"] === "object" && schema["~standard"]) return {
122
197
  ok: false,
123
198
  error: "Standard schema validation requires async validate, use validate() instead"
@@ -203,6 +278,135 @@ function parseSchemaValue(schema, rawValue, key) {
203
278
  value: rawValue
204
279
  };
205
280
  }
281
+ /** 缺失判定用内联比较而非 `isMissing()` 谓词函数 —— 后者是 boolean 返回,
282
+ * TypeScript 不会据此收窄 `string | undefined`,下游的 `values.includes()` 就会报错。 */
283
+ /**
284
+ * 解析 `envField.*()` 声明的字段。
285
+ *
286
+ * 三种类型各自的行为与 legacy 形态保持一致(缺失 → `default` → `optional` 放行 → 报错),
287
+ * 多出来的是 `enum` 的成员校验:值不在 `values` 里直接失败,不做静默回落 ——
288
+ * 枚举变量的拼写错误在启动期暴露比在运行期暴露便宜得多。
289
+ */
290
+ function parseEnvFieldValue(meta, rawValue, key) {
291
+ if (rawValue === void 0 || rawValue === "") {
292
+ if (meta.default !== void 0) return {
293
+ ok: true,
294
+ value: meta.default
295
+ };
296
+ if (meta.optional === true) return {
297
+ ok: true,
298
+ value: void 0
299
+ };
300
+ return {
301
+ ok: false,
302
+ error: `Missing required env: ${key}`
303
+ };
304
+ }
305
+ if (meta.type === "string") return {
306
+ ok: true,
307
+ value: rawValue
308
+ };
309
+ if (meta.type === "number") {
310
+ const n = Number(rawValue);
311
+ if (Number.isNaN(n)) return {
312
+ ok: false,
313
+ error: `Env ${key} must be a number, got ${rawValue}`
314
+ };
315
+ return {
316
+ ok: true,
317
+ value: n
318
+ };
319
+ }
320
+ if (meta.type === "boolean") return {
321
+ ok: true,
322
+ value: rawValue === "true" || rawValue === "1"
323
+ };
324
+ const values = meta.values ?? [];
325
+ if (!values.includes(rawValue)) return {
326
+ ok: false,
327
+ error: `Env ${key} must be one of ${values.join(" | ")}, got ${rawValue}`
328
+ };
329
+ return {
330
+ ok: true,
331
+ value: rawValue
332
+ };
333
+ }
334
+ /**
335
+ * 把用户 schema(含 legacy 分组糖)摊平成 `NormalizedEnvField[]`。
336
+ *
337
+ * 字段自带的 `context`/`access` 优先于分组默认值 —— 这样用户可以在 legacy 的
338
+ * `server:` 组里单独把一个字段标成 `public`,不必整体重写。
339
+ */
340
+ function normalizeEnvSchema(schema) {
341
+ 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'.`);
348
+ fields.push({
349
+ key,
350
+ context: dimensions.context,
351
+ access: dimensions.access,
352
+ def
353
+ });
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
+ }
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
+ return fields;
375
+ }
376
+ /**
377
+ * 服务端运行时校验:按规范化字段读 `source`(默认 `process.env`),产出强类型对象。
378
+ *
379
+ * 与 `defineEnv()` 的区别:这里只处理「已经归好维度」的字段,不碰 legacy 分组糖 ——
380
+ * 虚拟模块 `ubean:env/server` 的运行时校验走它。
381
+ */
382
+ function resolveEnvSchema(schema, source) {
383
+ const fields = normalizeEnvSchema(schema);
384
+ const resolvedSource = source ?? readProcessEnv();
385
+ const errors = [];
386
+ const data = {};
387
+ for (const field of fields) {
388
+ const rawValue = resolvedSource[field.key];
389
+ const result = parseEnvFieldOrLegacy(field.def, rawValue, field.key);
390
+ if (!result.ok) errors.push({
391
+ key: field.key,
392
+ message: result.error || "invalid",
393
+ value: rawValue
394
+ });
395
+ else data[field.key] = result.value;
396
+ }
397
+ return {
398
+ success: errors.length === 0,
399
+ errors,
400
+ data
401
+ };
402
+ }
403
+ function parseEnvFieldOrLegacy(def, rawValue, key) {
404
+ return parseSchemaValue(def, rawValue, key);
405
+ }
406
+ function readProcessEnv() {
407
+ if (typeof process !== "undefined" && process.env) return process.env;
408
+ return globalThis.process?.env || {};
409
+ }
206
410
  function validateSchemaSync(schema, source) {
207
411
  const errors = [];
208
412
  const data = {};
@@ -223,10 +427,11 @@ function validateSchemaSync(schema, source) {
223
427
  };
224
428
  }
225
429
  function defineEnv(config) {
226
- const serverSource = typeof process !== "undefined" ? process.env : globalThis.process?.env || {};
430
+ const serverSource = readProcessEnv();
227
431
  const mergedSchema = {
228
432
  ...config.public,
229
- ...config.server
433
+ ...config.server,
434
+ ...config.schema
230
435
  };
231
436
  const initial = validateSchemaSync(mergedSchema, serverSource);
232
437
  if (!initial.success && config.mode === "throw") {
@@ -351,4 +556,4 @@ function matchAnyGlob(route, patterns) {
351
556
  return patterns.some((p) => matchGlob(route, p));
352
557
  }
353
558
  //#endregion
354
- export { ACTION_BRAND, ActionError, UbeanError, capitalize, createError, defineEnv, errorToResponse, fail, getBasename, getDirname, getExtension, getStem, isActionFailure, isNodeRuntime, isServerAction, isUbeanError, matchAnyGlob, matchGlob, normalizePath, pathToTitle, setRuntimeEnv, useRuntimeEnv };
559
+ export { ACTION_BRAND, ActionError, UbeanError, capitalize, createError, defineEnv, envField, errorToResponse, fail, getBasename, getDirname, getExtension, getStem, isActionFailure, isEnvFieldMeta, isNodeRuntime, isServerAction, isUbeanError, matchAnyGlob, matchGlob, normalizeEnvSchema, normalizePath, pathToTitle, resolveEnvSchema, setRuntimeEnv, useRuntimeEnv };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ubean/shared",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Shared protocol types, errors, env and universal utilities for ubean",
5
5
  "files": [
6
6
  "dist"