@ubean/shared 0.6.3 → 0.6.5
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 +124 -6
- package/dist/index.js +208 -3
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 =
|
|
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 };
|