@ubean/shared 0.6.2 → 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/README.md ADDED
@@ -0,0 +1,255 @@
1
+ # @ubean/shared
2
+
3
+ **English | [中文](./README.zh-CN.md)**
4
+
5
+ Shared protocol types, errors, environment handling and universal utilities for ubean.
6
+
7
+ `@ubean/shared` is the foundation leaf of the monorepo: every other `@ubean/*` package may depend on it, and it depends on **no** `@ubean/*` package. Its main entry stays browser-safe — Node-only helpers live behind the `/node` subpath, and the tslog-based logger behind `/logger`.
8
+
9
+ ```ts
10
+ import { defineEnv, createError, matchGlob } from '@ubean/shared';
11
+ import { logger } from '@ubean/shared/logger';
12
+ import { findAvailablePort } from '@ubean/shared/node';
13
+ ```
14
+
15
+ ## Table of Contents
16
+
17
+ - [Package Positioning](#package-positioning)
18
+ - [Install](#install)
19
+ - [Quick Start](#quick-start)
20
+ - [Environment Variables](#environment-variables)
21
+ - [Errors](#errors)
22
+ - [Route Rules & Shared Types](#route-rules--shared-types)
23
+ - [Server Actions Shared Types](#server-actions-shared-types)
24
+ - [Glob Matching](#glob-matching)
25
+ - [Path & String Utilities](#path--string-utilities)
26
+ - [Logging](#logging)
27
+ - [Node Utilities](#node-utilities)
28
+ - [Type Safety](#type-safety)
29
+ - [Architecture Notes](#architecture-notes)
30
+
31
+ ## Package Positioning
32
+
33
+ | What this package is responsible for | What it is not — owner instead |
34
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
35
+ | Protocol **types** shared across packages: `PageHead`, `RouteMeta` / `BaseRouteMeta` / `RateLimitMeta` / `CacheMeta`, `RouteRule` / `IsrRule`, `Span*`, `UbeanEnv` / `UbeanContext` / `UbeanHandler` / `UbeanMiddleware` / `UbeanVariables` / `UbeanBindings` / `UbeanMiddlewareStep`, `GenericSchema` / `Input` | Page data protocol at runtime → `@ubean/pages`; handler runtime → `@ubean/routes`; SEO metadata construction → `@ubean/seo` |
36
+ | Dependency-free universal **utilities**: `normalizePath`, `getDirname`, `getBasename`, `getExtension`, `getStem`, `pathToTitle`, `capitalize`, `matchGlob`, `matchAnyGlob` | Route scanning → `@ubean/scan`; prerender link discovery → `@ubean/builder` |
37
+ | **Error primitives**: `UbeanError`, `createError`, `isUbeanError`, `errorToResponse` | Hono `onError` wiring and the `handleError` hook → `@ubean/app` |
38
+ | **Environment** helpers: `defineEnv`, `setRuntimeEnv`, `useRuntimeEnv`, `isNodeRuntime` + the `EnvSchema` / `InferEnvOutput` / `EnvValidationError` types | `ubean.config.ts` loading and module resolution → `@ubean/config` |
39
+ | **Logging** facade (`/logger`, `/logger/hono`): `createUbeanLogger` / `logger` / `getLogger` / `setMinLevel` / `setDebugLogging` and the Hono request-logger middleware, wrapping tslog@5 | Server-side request lifecycle, cache, and tracing → `@ubean/server`; DevTools log UI → `@ubean/devtools` |
40
+ | **Node-only** helpers (`/node`): port probing (`findAvailablePort`, `waitForPort`, `isPortReachable`, `sleep`), LAN address detection (`getLanAddresses`, `isLoopbackHost`, `isWildcardHost`), Vite config lookup (`findUserViteConfig`) | Process lifecycle, dev server bootstrap and CLI output → `@ubean/cli` / `@ubean/builder` |
41
+
42
+ **Runtime dependency whitelist (main entry): `@standard-schema/spec`, `hono` (type-only), `pathe`, `scule`** — no `tslog`, no `node:*`. The `/logger` subpath exists precisely so that tslog never enters the browser-safe main barrel; `/node` exists for the same reason with `node:net` / `node:os` / `node:fs`.
43
+
44
+ `@ubean/shared` declares **no peer dependencies** — nothing degrades when a peer is absent, because there is none.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pnpm add @ubean/shared
50
+ ```
51
+
52
+ Transitively installed by every other `@ubean/*` package; add it directly when you only need the shared types or utilities.
53
+
54
+ ## Quick Start
55
+
56
+ ```ts
57
+ import { UbeanError, createError, errorToResponse, defineEnv, isNodeRuntime, matchGlob } from '@ubean/shared';
58
+
59
+ // Typed error with a status code
60
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
61
+
62
+ // Turn it into a JSON Response (works for unknown errors too)
63
+ try {
64
+ await handler();
65
+ } catch (err) {
66
+ return errorToResponse(err);
67
+ }
68
+
69
+ // '**' matches any depth, '*' matches a single segment
70
+ matchGlob('/blog/a/b', '/blog/**'); // true
71
+ isNodeRuntime(); // false on Cloudflare Workers
72
+ ```
73
+
74
+ ## Environment Variables
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`.
77
+
78
+ ```ts
79
+ import { defineEnv } from '@ubean/shared';
80
+
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' }
88
+ },
89
+ mode: 'warn' // 'warn' logs; 'throw' rethrows on validation failure
90
+ });
91
+
92
+ env.PORT; // number
93
+ validate(); // { success, errors, data }
94
+ ```
95
+
96
+ | API | Description |
97
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------- |
98
+ | `defineEnv(config)` | Validate a schema and return `{ env, validate }` (`DefineEnvResult<S>`) |
99
+ | `setRuntimeEnv(env)` / `useRuntimeEnv(key, def)` | Merge / read runtime environment values (worker bindings, injected secrets) |
100
+ | `isNodeRuntime()` | True on Node / Bun / Deno (checked via `process.versions.node`, not `typeof process`) |
101
+ | `EnvSchema` / `InferEnvOutput<S>` | Schema map type and its inferred output type |
102
+ | `EnvValidationError` | `{ key, message, value }` for each failed entry |
103
+
104
+ `isNodeRuntime()` exists to gate Node-only capabilities behind a **dynamic** `import()`: bundlers keep statically imported `node:*` modules in the artifact, and workerd fails at module instantiation (`No such module "node:fs/promises"`) before any branch is evaluated.
105
+
106
+ ## Errors
107
+
108
+ ```ts
109
+ import { UbeanError, createError, isUbeanError, errorToResponse } from '@ubean/shared';
110
+
111
+ try {
112
+ throw createError({ statusCode: 400, statusMessage: 'Bad Request', data: { field: 'email' } });
113
+ } catch (err) {
114
+ if (isUbeanError(err)) {
115
+ err.statusCode; // 400
116
+ }
117
+ return errorToResponse(err); // JSON: { error, statusCode, data }
118
+ }
119
+ ```
120
+
121
+ - `UbeanError extends Error` carries `statusCode` / `statusMessage` / `data`; the default message comes from a status-code table (400/401/403/404/405/408/409/422/429/500/501/502/503), falling back to `'Error'`.
122
+ - `isUbeanError(err)` accepts both real instances and errors crossing a module boundary (checks `err.name === 'UbeanError'` plus a numeric `statusCode`).
123
+ - `errorToResponse(err)` renders a `UbeanError` with its own status code; any other error becomes `500` with `{ error: 'Internal Server Error', message }`.
124
+
125
+ ## Route Rules & Shared Types
126
+
127
+ These types are the shared vocabulary of the routing, caching, and prerender layers. They live here to break cycles — `@ubean/routes` and `@ubean/config` both need them, and neither may depend on the other.
128
+
129
+ | Type | Shape / meaning |
130
+ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
131
+ | `BaseRouteMeta` | `{ requiresAuth?: boolean; [key: string]: unknown }` |
132
+ | `RateLimitMeta` / `CacheMeta` | `{ maxRequests, windowSeconds }` / `{ ttl, swr? }` |
133
+ | `RouteMeta extends BaseRouteMeta` | Adds `rateLimit` and `cache` |
134
+ | `IsrRule` | `{ ttl: number; swr?: boolean }` — the object form of a `routeRules.isr` entry |
135
+ | `RouteRule` | Per-route `redirect` / `rewrite` / `proxy` / `headers` / `cache` / `ssr` / `prerender` / `isr` / `ppr` |
136
+ | `PageHead` | Head metadata consumed by `@ubean/pages` and `@ubean/seo` |
137
+ | `Span`, `SpanOptions`, `SpanContext`, `SpanAttributes`, `SpanEvent`, `SpanEndOptions`, `SpanStatus` | Tracing primitives consumed by `@ubean/server` observability |
138
+ | `UbeanVariables` / `UbeanBindings` / `UbeanEnv` | Hono context variables, platform bindings, and the composed `UbeanEnv` |
139
+ | `UbeanContext` / `UbeanHandler` / `UbeanMiddleware` / `ComposedHandler` / `Input` / `GenericSchema` | Handler- and middleware-level types for `@ubean/routes` |
140
+ | `UbeanMiddlewareStep` | Union of middleware step names (`handle`, `requestId`, `securityHeaders`, `csrf`, `cacheStore`, `i18n`, `routeRules`, …) |
141
+
142
+ ## Server Actions Shared Types
143
+
144
+ Server Actions are defined in `@ubean/routes`, but their wire types are shared so that the client runtime can consume them without importing server code.
145
+
146
+ ```ts
147
+ import { ACTION_BRAND, ActionError, fail, isServerAction, isActionFailure } from '@ubean/shared';
148
+
149
+ fail(400, { email: 'Invalid email' }); // { status: 400, errors: { email: '…' } }
150
+ ```
151
+
152
+ | API | Description |
153
+ | -------------------------------------------------- | ------------------------------------------------------------------------- |
154
+ | `ACTION_BRAND` | Well-known symbol (`Symbol.for('ubean.action')`) marking a `ServerAction` |
155
+ | `ServerAction<TInput, TOutput>` | Action descriptor; `ActionHandler` is its implementation signature |
156
+ | `ActionContext` / `ActionResult<T>` | Handler context and success envelope |
157
+ | `ActionFailure<T>` | `{ status, errors }` field-level failure envelope |
158
+ | `fail(status, errors)` | Build an `ActionFailure` |
159
+ | `ActionError` | User-readable error class with `code` / `status`, thrown from a handler |
160
+ | `isServerAction(value)` / `isActionFailure(value)` | Type guards |
161
+ | `ActionSchema<TOutput>` | Standard-Schema / `safeParse` input contract |
162
+
163
+ ## Glob Matching
164
+
165
+ ```ts
166
+ import { matchGlob, matchAnyGlob } from '@ubean/shared';
167
+
168
+ matchGlob('/blog', '/blog/**'); // true ('**' also matches the bare prefix)
169
+ matchGlob('/blog/a', '/blog/*'); // true ('*' is a single segment)
170
+ matchGlob('/blog/a/b', '/blog/*'); // false
171
+ matchAnyGlob('/admin/x', ['/blog/**', '/admin/**']); // true
172
+ ```
173
+
174
+ `matchGlob(route, pattern)` supports `**` (recursive), `*` (single segment), and literals; `pattern === '**'` or `'/**'` matches everything. It is the matcher behind SSR `exclude`, prerender `include`/`exclude`, and route-rule patterns.
175
+
176
+ ## Path & String Utilities
177
+
178
+ ```ts
179
+ import { normalizePath, getDirname, getBasename, getExtension, getStem, pathToTitle, capitalize } from '@ubean/shared';
180
+
181
+ normalizePath('//a\\b/c/'); // '/a/b/c'
182
+ getExtension('Post.MD'); // 'md'
183
+ getStem('Post.md'); // 'Post'
184
+ pathToTitle('blog/getting-started.md'); // 'Getting Started'
185
+ pathToTitle('blog/index.md'); // 'Blog' (index resolves to the parent directory name)
186
+ ```
187
+
188
+ `pathToTitle` delegates to `scule`'s `kebabCase` plus `capitalize` — the title-case rule used by `@ubean/content` when a document has no explicit `title`.
189
+
190
+ ## Logging
191
+
192
+ `/logger` is the only public logging entry. It is a **separate subpath on purpose**: re-exporting it from the main entry would pull tslog into the browser-safe barrel.
193
+
194
+ ```ts
195
+ import { logger, getLogger, createUbeanLogger, setMinLevel, setDebugLogging } from '@ubean/shared/logger';
196
+
197
+ logger.info('booted');
198
+ const http = getLogger('http'); // child logger named `ubean:http`
199
+
200
+ setMinLevel('debug'); // global level switch (affects existing and future instances)
201
+ ```
202
+
203
+ | API | Description |
204
+ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
205
+ | `logger` | Default global tslog instance named `ubean` |
206
+ | `createUbeanLogger(options)` | Create a named instance; `options` are tslog v5 settings (`type` / `pretty` / `json` / `mask` / `stack` / `meta` / `minLevel`) |
207
+ | `getLogger(scope?, options?)` | Scope child logger; inherits settings and `minLevel` from the parent; no scope → global `logger` |
208
+ | `setMinLevel(level)` | Global level: `silly` / `trace` / `debug` / `info` / `warn` / `error` / `fatal` |
209
+ | `setDebugLogging(enabled)` | Convenience toggle for the debug level |
210
+ | `UbeanLogger` / `UbeanLoggerOptions` / `LogLevelName` | Logger instance type, settings, and level name union |
211
+
212
+ The Hono request logger lives behind `/logger/hono`, again to keep Hono middleware out of the shared barrel:
213
+
214
+ ```ts
215
+ import { createRequestLoggerMiddleware } from '@ubean/shared/logger/hono';
216
+
217
+ app.use('*', createRequestLoggerMiddleware({ slowThreshold: 1000, exclude: ['/_health', '/api/**'] }));
218
+ ```
219
+
220
+ `RequestLoggerOptions`: `logger` (default `getLogger('http')`), `exclude` (glob strings, `RegExp`, or a `(path) => boolean` predicate), `slowThreshold` (ms, default `1000`), `logQuery` (log the full URL including the query string, default `false`). Errors captured by Hono's `onError` are detected through `c.error` and logged with their status.
221
+
222
+ ## Node Utilities
223
+
224
+ `/node` bundles everything that imports `node:net`, `node:os`, or `node:fs`. It never enters the main entry.
225
+
226
+ ```ts
227
+ import { findAvailablePort, waitForPort, isPortReachable, sleep } from '@ubean/shared/node';
228
+ import { getLanAddresses, isLoopbackHost, isWildcardHost } from '@ubean/shared/node';
229
+ import { findUserViteConfig } from '@ubean/shared/node';
230
+
231
+ const port = await findAvailablePort(3000, { strictPort: false }); // walks 3001, 3002… like Vite
232
+ await waitForPort(port, { timeout: 5000 });
233
+ getLanAddresses(); // ['192.168.1.20', …] — IPv4 only, interface-name sorted
234
+ findUserViteConfig(process.cwd()); // '/…/vite.config.ts' | null
235
+ ```
236
+
237
+ | Group | API |
238
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
239
+ | Port probing | `findAvailablePort(port, options)`, `waitForPort(port, options)`, `isPortReachable(port, host)`, `sleep(ms)`; options types `FindPortOptions`, `WaitForPortOptions` |
240
+ | Network addresses | `isLoopbackHost(host)`, `isWildcardHost(host)`, `getLanAddresses()` |
241
+ | Vite config | `findUserViteConfig(cwd)` — scans `vite.config.ts` / `.mts` / `.js` / `.mjs`, returns the first hit or `null` |
242
+
243
+ ## Type Safety
244
+
245
+ - The package is written in strict TypeScript and ships `dist/*.d.ts` for every subpath; `EnvSchema` + `InferEnvOutput<S>` make `defineEnv({...}).env` fully inferred — `PORT` is `number`, not `string`.
246
+ - `GenericSchema<O = unknown>` accepts Standard Schema objects; `validate()` is the async/sync-agnostic entry point, while only schemas with a sync `safeParse` work as direct schema entries.
247
+ - Error narrowing is type-safe through `isUbeanError` and `isActionFailure`, so `statusCode` / `errors` are only reachable after the guard.
248
+ - No global `declare module` augmentation is performed — the types are plain exports.
249
+
250
+ ## Architecture Notes
251
+
252
+ - **Dependency direction.** `@ubean/shared` is the root leaf: it imports **no** `@ubean/*` package. Everything else may import it. Types that previously caused cycles (`BaseRouteMeta` from `@ubean/routing`, `Span*` from `@ubean/server`, `PageHead` from `@ubean/pages`) were deliberately pushed down here rather than duplicated.
253
+ - **Subpath split is a browser-safety boundary, not a convenience.** `tslog` lives behind `/logger`; `node:net` / `node:os` / `node:fs` behind `/node`; Hono middleware behind `/logger/hono`. The main barrel (`src/index.ts`) only re-exports `types` / `error` / `env` / `path` / `glob` / `string`, so client bundles never pick up a logger or a Node built-in.
254
+ - **No `globalThis` registry of its own.** The data and cache registries of sibling packages need cross-instance sharing, but `@ubean/shared` itself holds only `_runtimeEnv` — a module-level merge target set explicitly by `setRuntimeEnv`, which is also available through the app runtime.
255
+ - **No side effects on import.** Every module is a pure collection of exports; importing `@ubean/shared` installs nothing globally and binds no listeners.
@@ -0,0 +1,255 @@
1
+ # @ubean/shared
2
+
3
+ **[English](./README.md) | 中文**
4
+
5
+ ubean 的共享协议类型、错误、环境变量处理与通用工具。
6
+
7
+ `@ubean/shared` 是 monorepo 的基础 leaf 包:其他所有 `@ubean/*` 包都可以依赖它,而它**不依赖**任何 `@ubean/*` 包。主入口保持浏览器安全 —— Node 专用工具放在 `/node` 子路径,基于 tslog 的 logger 放在 `/logger`。
8
+
9
+ ```ts
10
+ import { defineEnv, createError, matchGlob } from '@ubean/shared';
11
+ import { logger } from '@ubean/shared/logger';
12
+ import { findAvailablePort } from '@ubean/shared/node';
13
+ ```
14
+
15
+ ## 目录
16
+
17
+ - [包定位](#包定位)
18
+ - [安装](#安装)
19
+ - [快速开始](#快速开始)
20
+ - [环境变量](#环境变量)
21
+ - [错误](#错误)
22
+ - [路由规则与共享类型](#路由规则与共享类型)
23
+ - [Server Actions 共享类型](#server-actions-共享类型)
24
+ - [Glob 匹配](#glob-匹配)
25
+ - [路径与字符串工具](#路径与字符串工具)
26
+ - [日志](#日志)
27
+ - [Node 工具](#node-工具)
28
+ - [类型安全](#类型安全)
29
+ - [架构说明](#架构说明)
30
+
31
+ ## 包定位
32
+
33
+ | 本包负责 | 本包不负责 —— 对应归属 |
34
+ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
35
+ | 跨包共享的协议**类型**:`PageHead`、`RouteMeta` / `BaseRouteMeta` / `RateLimitMeta` / `CacheMeta`、`RouteRule` / `IsrRule`、`Span*`、`UbeanEnv` / `UbeanContext` / `UbeanHandler` / `UbeanMiddleware` / `UbeanVariables` / `UbeanBindings` / `UbeanMiddlewareStep`、`GenericSchema` / `Input` | 页面数据协议的运行时 → `@ubean/pages`;handler 运行时 → `@ubean/routes`;SEO 元数据构造 → `@ubean/seo` |
36
+ | 零依赖的通用**工具**:`normalizePath`、`getDirname`、`getBasename`、`getExtension`、`getStem`、`pathToTitle`、`capitalize`、`matchGlob`、`matchAnyGlob` | 路由扫描 → `@ubean/scan`;预渲染链接发现 → `@ubean/builder` |
37
+ | **错误原语**:`UbeanError`、`createError`、`isUbeanError`、`errorToResponse` | Hono `onError` 接线与 `handleError` hook → `@ubean/app` |
38
+ | **环境变量**辅助:`defineEnv`、`setRuntimeEnv`、`useRuntimeEnv`、`isNodeRuntime` 以及 `EnvSchema` / `InferEnvOutput` / `EnvValidationError` 类型 | `ubean.config.ts` 加载与模块解析 → `@ubean/config` |
39
+ | **日志**门面(`/logger`、`/logger/hono`):`createUbeanLogger` / `logger` / `getLogger` / `setMinLevel` / `setDebugLogging` 与 Hono 请求日志中间件,封装 tslog@5 | 服务端请求生命周期、缓存与 tracing → `@ubean/server`;DevTools 日志 UI → `@ubean/devtools` |
40
+ | **Node 专用**工具(`/node`):端口探测(`findAvailablePort`、`waitForPort`、`isPortReachable`、`sleep`)、局域网地址探测(`getLanAddresses`、`isLoopbackHost`、`isWildcardHost`)、Vite 配置查找(`findUserViteConfig`) | 进程生命周期、dev server 自举与 CLI 输出 → `@ubean/cli` / `@ubean/builder` |
41
+
42
+ **主入口的运行时依赖白名单:`@standard-schema/spec`、`hono`(仅类型)、`pathe`、`scule`** —— 不含 `tslog`,不含 `node:*`。`/logger` 子路径的存在意义就是让 tslog 永不进入浏览器安全的主 barrel;`/node` 出于同样理由隔离 `node:net` / `node:os` / `node:fs`。
43
+
44
+ `@ubean/shared` **没有任何 peer 依赖** —— 没有 peer,也就不存在「缺少 peer 时能力降级」的情况。
45
+
46
+ ## 安装
47
+
48
+ ```bash
49
+ pnpm add @ubean/shared
50
+ ```
51
+
52
+ 其他每个 `@ubean/*` 包都会传递安装它;只需要共享类型或工具时可以直接安装。
53
+
54
+ ## 快速开始
55
+
56
+ ```ts
57
+ import { UbeanError, createError, errorToResponse, defineEnv, isNodeRuntime, matchGlob } from '@ubean/shared';
58
+
59
+ // 带状态码的类型化错误
60
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
61
+
62
+ // 转成 JSON Response(未知错误同样适用)
63
+ try {
64
+ await handler();
65
+ } catch (err) {
66
+ return errorToResponse(err);
67
+ }
68
+
69
+ // '**' 匹配任意层级,'*' 只匹配单段
70
+ matchGlob('/blog/a/b', '/blog/**'); // true
71
+ isNodeRuntime(); // Cloudflare Workers 上为 false
72
+ ```
73
+
74
+ ## 环境变量
75
+
76
+ `defineEnv(config)` 校验基于构造函数的 schema,返回 `{ env, validate }`。`server` 键永不暴露给客户端;`public` 键通过 `import.meta.env` 暴露。
77
+
78
+ ```ts
79
+ import { defineEnv } from '@ubean/shared';
80
+
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' }
88
+ },
89
+ mode: 'warn' // 'warn' 记录日志;'throw' 在校验失败时抛出
90
+ });
91
+
92
+ env.PORT; // number
93
+ validate(); // { success, errors, data }
94
+ ```
95
+
96
+ | API | 说明 |
97
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------- |
98
+ | `defineEnv(config)` | 校验 schema 并返回 `{ env, validate }`(`DefineEnvResult<S>`) |
99
+ | `setRuntimeEnv(env)` / `useRuntimeEnv(key, def)` | 合并 / 读取运行态环境变量(worker bindings、注入的密钥) |
100
+ | `isNodeRuntime()` | Node / Bun / Deno 上为 true(用 `process.versions.node` 判定,而非 `typeof process`) |
101
+ | `EnvSchema` / `InferEnvOutput<S>` | schema 映射类型及其推导出的输出类型 |
102
+ | `EnvValidationError` | 每个失败项的 `{ key, message, value }` |
103
+
104
+ `isNodeRuntime()` 的用途是把 Node 专用能力挡在**动态** `import()` 之后:打包器会把静态 import 的 `node:*` 留在产物里,workerd 在模块实例化阶段就失败(`No such module "node:fs/promises"`),根本轮不到分支判断。
105
+
106
+ ## 错误
107
+
108
+ ```ts
109
+ import { UbeanError, createError, isUbeanError, errorToResponse } from '@ubean/shared';
110
+
111
+ try {
112
+ throw createError({ statusCode: 400, statusMessage: 'Bad Request', data: { field: 'email' } });
113
+ } catch (err) {
114
+ if (isUbeanError(err)) {
115
+ err.statusCode; // 400
116
+ }
117
+ return errorToResponse(err); // JSON: { error, statusCode, data }
118
+ }
119
+ ```
120
+
121
+ - `UbeanError extends Error` 携带 `statusCode` / `statusMessage` / `data`;默认 message 来自状态码表(400/401/403/404/405/408/409/422/429/500/501/502/503),未命中回退到 `'Error'`。
122
+ - `isUbeanError(err)` 同时接受真实实例与跨模块边界传来的错误(检查 `err.name === 'UbeanError'` 且 `statusCode` 为数字)。
123
+ - `errorToResponse(err)` 用 `UbeanError` 自身的状态码渲染;其他错误一律变成 `500` + `{ error: 'Internal Server Error', message }`。
124
+
125
+ ## 路由规则与共享类型
126
+
127
+ 这些类型是路由、缓存与预渲染层的共享词汇。它们放在这里是为了打破循环依赖 —— `@ubean/routes` 与 `@ubean/config` 都需要它们,而两者不能互相依赖。
128
+
129
+ | 类型 | 形状 / 含义 |
130
+ | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
131
+ | `BaseRouteMeta` | `{ requiresAuth?: boolean; [key: string]: unknown }` |
132
+ | `RateLimitMeta` / `CacheMeta` | `{ maxRequests, windowSeconds }` / `{ ttl, swr? }` |
133
+ | `RouteMeta extends BaseRouteMeta` | 增加 `rateLimit` 与 `cache` |
134
+ | `IsrRule` | `{ ttl: number; swr?: boolean }` —— `routeRules.isr` 的对象形式 |
135
+ | `RouteRule` | 单路由的 `redirect` / `rewrite` / `proxy` / `headers` / `cache` / `ssr` / `prerender` / `isr` / `ppr` |
136
+ | `PageHead` | 被 `@ubean/pages` 与 `@ubean/seo` 消费的 head 元数据 |
137
+ | `Span`、`SpanOptions`、`SpanContext`、`SpanAttributes`、`SpanEvent`、`SpanEndOptions`、`SpanStatus` | 被 `@ubean/server` 可观测性消费的 tracing 原语 |
138
+ | `UbeanVariables` / `UbeanBindings` / `UbeanEnv` | Hono context variables、平台 bindings 与组合出的 `UbeanEnv` |
139
+ | `UbeanContext` / `UbeanHandler` / `UbeanMiddleware` / `ComposedHandler` / `Input` / `GenericSchema` | `@ubean/routes` 的 handler 与 middleware 层类型 |
140
+ | `UbeanMiddlewareStep` | 中间件步骤名的联合类型(`handle`、`requestId`、`securityHeaders`、`csrf`、`cacheStore`、`i18n`、`routeRules` 等) |
141
+
142
+ ## Server Actions 共享类型
143
+
144
+ Server Actions 定义在 `@ubean/routes`,但它们的协议类型放在这里共享,这样客户端运行时无需引入服务端代码即可消费。
145
+
146
+ ```ts
147
+ import { ACTION_BRAND, ActionError, fail, isServerAction, isActionFailure } from '@ubean/shared';
148
+
149
+ fail(400, { email: 'Invalid email' }); // { status: 400, errors: { email: '…' } }
150
+ ```
151
+
152
+ | API | 说明 |
153
+ | -------------------------------------------------- | ------------------------------------------------------------------------ |
154
+ | `ACTION_BRAND` | 标记 `ServerAction` 的 well-known symbol(`Symbol.for('ubean.action')`) |
155
+ | `ServerAction<TInput, TOutput>` | action 描述符;`ActionHandler` 是其实现签名 |
156
+ | `ActionContext` / `ActionResult<T>` | handler 上下文与成功信封 |
157
+ | `ActionFailure<T>` | `{ status, errors }` 字段级失败信封 |
158
+ | `fail(status, errors)` | 构造 `ActionFailure` |
159
+ | `ActionError` | 可在 handler 中抛出的用户可读错误类,含 `code` / `status` |
160
+ | `isServerAction(value)` / `isActionFailure(value)` | 类型守卫 |
161
+ | `ActionSchema<TOutput>` | Standard Schema / `safeParse` 输入契约 |
162
+
163
+ ## Glob 匹配
164
+
165
+ ```ts
166
+ import { matchGlob, matchAnyGlob } from '@ubean/shared';
167
+
168
+ matchGlob('/blog', '/blog/**'); // true ('**' 也匹配裸前缀)
169
+ matchGlob('/blog/a', '/blog/*'); // true ('*' 是单段)
170
+ matchGlob('/blog/a/b', '/blog/*'); // false
171
+ matchAnyGlob('/admin/x', ['/blog/**', '/admin/**']); // true
172
+ ```
173
+
174
+ `matchGlob(route, pattern)` 支持 `**`(递归)、`*`(单段)与字面量;`pattern === '**'` 或 `'/**'` 匹配一切。SSR `exclude`、预渲染 `include`/`exclude` 与路由规则 pattern 都走这个匹配器。
175
+
176
+ ## 路径与字符串工具
177
+
178
+ ```ts
179
+ import { normalizePath, getDirname, getBasename, getExtension, getStem, pathToTitle, capitalize } from '@ubean/shared';
180
+
181
+ normalizePath('//a\\b/c/'); // '/a/b/c'
182
+ getExtension('Post.MD'); // 'md'
183
+ getStem('Post.md'); // 'Post'
184
+ pathToTitle('blog/getting-started.md'); // 'Getting Started'
185
+ pathToTitle('blog/index.md'); // 'Blog' (index 解析为父目录名)
186
+ ```
187
+
188
+ `pathToTitle` 委托 `scule` 的 `kebabCase` 加 `capitalize` —— 这就是 `@ubean/content` 在文档没有显式 `title` 时使用的标题化规则。
189
+
190
+ ## 日志
191
+
192
+ `/logger` 是唯一的公开日志入口。它**刻意做成独立子路径**:从主入口 re-export 会把 tslog 拖进浏览器安全的 barrel。
193
+
194
+ ```ts
195
+ import { logger, getLogger, createUbeanLogger, setMinLevel, setDebugLogging } from '@ubean/shared/logger';
196
+
197
+ logger.info('booted');
198
+ const http = getLogger('http'); // 名为 `ubean:http` 的子 logger
199
+
200
+ setMinLevel('debug'); // 全局级别切换(对已创建与后续创建的实例都生效)
201
+ ```
202
+
203
+ | API | 说明 |
204
+ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
205
+ | `logger` | 名为 `ubean` 的默认全局 tslog 实例 |
206
+ | `createUbeanLogger(options)` | 创建命名实例;`options` 为 tslog v5 settings(`type` / `pretty` / `json` / `mask` / `stack` / `meta` / `minLevel`) |
207
+ | `getLogger(scope?, options?)` | scope 子 logger;继承父实例的 settings 与 `minLevel`;不传 scope 返回全局 `logger` |
208
+ | `setMinLevel(level)` | 全局级别:`silly` / `trace` / `debug` / `info` / `warn` / `error` / `fatal` |
209
+ | `setDebugLogging(enabled)` | debug 级别的便捷开关 |
210
+ | `UbeanLogger` / `UbeanLoggerOptions` / `LogLevelName` | logger 实例类型、settings 与级别名联合类型 |
211
+
212
+ Hono 请求日志放在 `/logger/hono`,同样是为了让 Hono 中间件不进入 shared barrel:
213
+
214
+ ```ts
215
+ import { createRequestLoggerMiddleware } from '@ubean/shared/logger/hono';
216
+
217
+ app.use('*', createRequestLoggerMiddleware({ slowThreshold: 1000, exclude: ['/_health', '/api/**'] }));
218
+ ```
219
+
220
+ `RequestLoggerOptions`:`logger`(默认 `getLogger('http')`)、`exclude`(glob 字符串、`RegExp` 或 `(path) => boolean` 谓词)、`slowThreshold`(毫秒,默认 `1000`)、`logQuery`(记录含 query string 的完整 URL,默认 `false`)。Hono `onError` 捕获的错误通过 `c.error` 识别,并连同状态码一起记录。
221
+
222
+ ## Node 工具
223
+
224
+ `/node` 汇集所有 import `node:net`、`node:os`、`node:fs` 的实现,永不进入主入口。
225
+
226
+ ```ts
227
+ import { findAvailablePort, waitForPort, isPortReachable, sleep } from '@ubean/shared/node';
228
+ import { getLanAddresses, isLoopbackHost, isWildcardHost } from '@ubean/shared/node';
229
+ import { findUserViteConfig } from '@ubean/shared/node';
230
+
231
+ const port = await findAvailablePort(3000, { strictPort: false }); // 像 Vite 一样依次探 3001、3002…
232
+ await waitForPort(port, { timeout: 5000 });
233
+ getLanAddresses(); // ['192.168.1.20', …] —— 仅 IPv4,按网卡名排序
234
+ findUserViteConfig(process.cwd()); // '/…/vite.config.ts' | null
235
+ ```
236
+
237
+ | 分组 | API |
238
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
239
+ | 端口探测 | `findAvailablePort(port, options)`、`waitForPort(port, options)`、`isPortReachable(port, host)`、`sleep(ms)`;选项类型 `FindPortOptions`、`WaitForPortOptions` |
240
+ | 网络地址 | `isLoopbackHost(host)`、`isWildcardHost(host)`、`getLanAddresses()` |
241
+ | Vite 配置 | `findUserViteConfig(cwd)` —— 依次查找 `vite.config.ts` / `.mts` / `.js` / `.mjs`,返回首个命中或 `null` |
242
+
243
+ ## 类型安全
244
+
245
+ - 本包用严格 TypeScript 编写,每个子路径都产出 `dist/*.d.ts`;`EnvSchema` + `InferEnvOutput<S>` 让 `defineEnv({...}).env` 完全推导 —— `PORT` 是 `number` 而非 `string`。
246
+ - `GenericSchema<O = unknown>` 接受 Standard Schema 对象;`validate()` 是同步/异步无关的入口,而只有带同步 `safeParse` 的 schema 能作为直接 schema 条目使用。
247
+ - 错误收窄通过 `isUbeanError` 与 `isActionFailure` 保证类型安全,`statusCode` / `errors` 只有过了守卫才能访问。
248
+ - 不做任何全局 `declare module` 增强 —— 这些类型都是普通导出。
249
+
250
+ ## 架构说明
251
+
252
+ - **依赖方向。** `@ubean/shared` 是根 leaf:**不** import 任何 `@ubean/*` 包,其余包都可以 import 它。历史上造成环依赖的类型(来自 `@ubean/routing` 的 `BaseRouteMeta`、来自 `@ubean/server` 的 `Span*`、来自 `@ubean/pages` 的 `PageHead`)都是有意下沉到这里的,而不是各自复制一份。
253
+ - **子路径划分是浏览器安全边界,不是便利性分组。** `tslog` 在 `/logger` 之后,`node:net` / `node:os` / `node:fs` 在 `/node` 之后,Hono 中间件在 `/logger/hono` 之后。主 barrel(`src/index.ts`)只 re-export `types` / `error` / `env` / `path` / `glob` / `string`,因此客户端产物永远不会捎上 logger 或 Node 内建模块。
254
+ - **刻意不依赖 `globalThis`。** 兄弟包的数据与缓存注册表需要跨实例共享,但 `@ubean/shared` 自身只持有 `_runtimeEnv` —— 一个由 `setRuntimeEnv` 显式写入的模块级合并目标,它同样可以通过 app runtime 访问。
255
+ - **import 无副作用。** 每个模块都是纯导出集合;import `@ubean/shared` 不会全局安装任何东西,也不会绑定任何监听器。
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 };
@@ -1,2 +1 @@
1
- import { a as setMinLevel, i as setDebugLogging, n as getLogger, r as logger, t as createUbeanLogger } from "../logger-NMOHjTCB.js";
2
- export { createUbeanLogger, getLogger, logger, setDebugLogging, setMinLevel };
1
+ export { t as createUbeanLogger, n as getLogger, r as logger, i as setDebugLogging, a as setMinLevel } from "../logger-NMOHjTCB.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ubean/shared",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Shared protocol types, errors, env and universal utilities for ubean",
5
5
  "files": [
6
6
  "dist"
@@ -37,7 +37,7 @@
37
37
  "devDependencies": {
38
38
  "@types/node": "^26.6.5",
39
39
  "typescript": "npm:typescript-native-bridge@latest",
40
- "vite-plus": "1.1.0"
40
+ "vite-plus": "1.3.0"
41
41
  },
42
42
  "scripts": {
43
43
  "build": "vp pack",