@ubean/shared 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soybean
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,447 @@
1
+ import { Context, Env, Handler, MiddlewareHandler } from "hono";
2
+ import { RequestIdVariables } from "hono/request-id";
3
+ import { HandlerResponse, Input as Input$1 } from "hono/types";
4
+ import { StandardSchemaV1 } from "@standard-schema/spec";
5
+ //#region src/types.d.ts
6
+ /**
7
+ * 基础路由元数据,由 @ubean/scan 使用。
8
+ * 扩展的 RouteMeta 在此处定义,增加了 rateLimit 和 cache 字段。
9
+ */
10
+ interface BaseRouteMeta {
11
+ requiresAuth?: boolean;
12
+ [key: string]: unknown;
13
+ }
14
+ interface RateLimitMeta {
15
+ maxRequests: number;
16
+ windowSeconds: number;
17
+ }
18
+ interface CacheMeta {
19
+ ttl: number;
20
+ swr?: boolean;
21
+ }
22
+ interface RouteMeta extends BaseRouteMeta {
23
+ rateLimit?: RateLimitMeta;
24
+ cache?: CacheMeta;
25
+ }
26
+ /**
27
+ * ISR (Incremental Static Regeneration) 规则。
28
+ *
29
+ * - `number` → `{ ttl: <秒数>, swr: false }` 的简写
30
+ * - 对象形式可显式指定 `swr` 启用 stale-while-revalidate 语义
31
+ * (返回过期内容 + 后台异步重新生成)
32
+ */
33
+ interface IsrRule {
34
+ /** 缓存有效期(秒)。过期后下次请求触发重新生成 */
35
+ ttl: number;
36
+ /** 是否启用 stale-while-revalidate:过期时先返回旧内容,后台异步刷新 */
37
+ swr?: boolean;
38
+ }
39
+ /**
40
+ * 路由规则,由 @ubean/routes (route-rules) 和 @ubean/server (cache) 共享。
41
+ * 完整的 ResolvedConfig 在 @ubean/config 中定义。
42
+ *
43
+ * P9-03 扩展:支持 per-route 渲染规则(`ssr`/`prerender`/`isr`)。
44
+ * 这些字段为运行时 / 构建时的渲染提示,与原有的 `cache`/`headers`/`redirect`/
45
+ * `rewrite`/`proxy` 等运行时规则并存:
46
+ *
47
+ * - `ssr: true` 强制该路由走 SSR(覆盖全局 `ssr.exclude`)
48
+ * - `ssr: false` 强制该路由跳过 SSR(走 CSR shell)
49
+ * - `ssr: 'streaming'` 强制该路由走流式 SSR(覆盖全局 `ssr.streaming`)
50
+ * - `prerender: true` 该路由加入 SSG 预渲染队列(由 `@ubean/prerender` 自动发现)
51
+ * - `isr: 60` 或 `isr: { ttl: 60, swr: true }` 启用 ISR,以 TTL 秒缓存渲染 HTML
52
+ *
53
+ * P9-04 扩展:支持 Partial Prerendering(`ppr`)。
54
+ * - `ppr: true` 启用 PPR:静态壳预渲染 + Suspense 流式动态内容
55
+ * (对齐 Next.js 16 PPR / Astro `server:defer`)。运行时强制流式 SSR,
56
+ * 覆盖全局 `ssr.exclude`;同时该路由加入预渲染队列(等价于 `prerender: true`),
57
+ * 预渲染产物作为静态壳供运行时复用。
58
+ *
59
+ * 注意:构建时预渲染策略已迁移至 `PrerenderConfig`(由 `ubean.config.ts` 的
60
+ * `prerender` 字段统一管理),`RouteRule.prerender` 仅作为自动发现标记 ——
61
+ * `prerender: true` 的路由会被 `collectPrerenderRoutes` 加入队列(受
62
+ * `PrerenderConfig.exclude` 过滤)。`ppr: true` 隐含 `prerender: true`。
63
+ */
64
+ interface RouteRule {
65
+ cache?: {
66
+ ttl?: number;
67
+ swr?: boolean;
68
+ };
69
+ headers?: Record<string, string>;
70
+ redirect?: string | {
71
+ to: string;
72
+ statusCode?: number;
73
+ };
74
+ rewrite?: string;
75
+ proxy?: string;
76
+ /**
77
+ * Per-route SSR 渲染策略(P9-03)。覆盖全局 `ssr.exclude` / `ssr.streaming`。
78
+ *
79
+ * - `true` 强制 SSR(即使命中全局 exclude)
80
+ * - `false` 强制 CSR(即使全局未排除)
81
+ * - `'streaming'` 强制流式 SSR(等同于 `ssr: true` + 流式输出)
82
+ */
83
+ ssr?: boolean | 'streaming';
84
+ /**
85
+ * 标记该路由加入 SSG 预渲染队列(P9-03)。
86
+ * 由 `collectPrerenderRoutes` 自动从 `routeRules` 中扫描发现。
87
+ */
88
+ prerender?: boolean;
89
+ /**
90
+ * 启用 ISR(增量静态再生,P9-03)。
91
+ *
92
+ * 服务端将渲染后的 HTML 以 TTL 秒缓存;过期后下次请求触发重新生成。
93
+ * `swr: true` 时,过期窗口内先返回旧 HTML,同时后台异步刷新。
94
+ */
95
+ isr?: number | IsrRule;
96
+ /**
97
+ * 启用 Partial Prerendering / Server Islands(P9-04)。
98
+ *
99
+ * - `true` 该路由启用 PPR:静态壳预渲染 + Suspense 流式动态内容
100
+ *
101
+ * 运行时行为:
102
+ * - 强制流式 SSR(覆盖 `ssr.exclude` / 全局 `streaming`),等价于 `ssr: 'streaming'`
103
+ * - 隐含 `prerender: true`,该路由会被预渲染(产物作为静态壳)
104
+ * - 页面内带 `server:defer` 指令的组件在预渲染时仅渲染 fallback,
105
+ * 在流式 SSR 时通过 Suspense 边界流式输出实际内容
106
+ *
107
+ * 对齐:Next.js 16 PPR(稳定)、Astro 5 `server:defer`。
108
+ */
109
+ ppr?: boolean;
110
+ }
111
+ type SpanStatus = 'ok' | 'error' | 'cancelled';
112
+ interface SpanAttributes {
113
+ [key: string]: string | number | boolean | undefined | null;
114
+ }
115
+ interface SpanContext {
116
+ traceId: string;
117
+ spanId: string;
118
+ parentSpanId?: string;
119
+ }
120
+ interface SpanEndOptions {
121
+ status?: SpanStatus;
122
+ error?: Error;
123
+ attributes?: SpanAttributes;
124
+ }
125
+ interface SpanEvent {
126
+ name: string;
127
+ timestamp: number;
128
+ attributes?: SpanAttributes;
129
+ }
130
+ interface SpanOptions {
131
+ name: string;
132
+ attributes?: SpanAttributes;
133
+ parent?: Span | SpanContext;
134
+ }
135
+ interface Span {
136
+ readonly name: string;
137
+ readonly context: SpanContext;
138
+ readonly startTime: number;
139
+ endTime?: number;
140
+ status: SpanStatus;
141
+ attributes: SpanAttributes;
142
+ events: SpanEvent[];
143
+ error?: Error;
144
+ parent?: Span;
145
+ end(options?: SpanEndOptions): void;
146
+ setAttribute(key: string, value: string | number | boolean | undefined | null): void;
147
+ setAttributes(attrs: SpanAttributes): void;
148
+ addEvent(name: string, attributes?: SpanAttributes): void;
149
+ isRecording(): boolean;
150
+ duration(): number | undefined;
151
+ }
152
+ interface PageHead {
153
+ title?: string;
154
+ meta?: Array<Record<string, string>>;
155
+ link?: Array<Record<string, string>>;
156
+ script?: Array<Record<string, string>>;
157
+ htmlAttrs?: Record<string, string>;
158
+ bodyAttrs?: Record<string, string>;
159
+ }
160
+ interface UbeanVariables extends RequestIdVariables {
161
+ route: {
162
+ meta: RouteMeta;
163
+ path: string;
164
+ method: string;
165
+ };
166
+ span?: Span;
167
+ locale?: string;
168
+ pathWithoutLocale?: string;
169
+ /**
170
+ * 匹配到的路由规则(P9-03)。由 `createRouteRulesMiddleware` 在请求开始时
171
+ * 写入,供页面渲染器读取 per-route 的 `ssr`/`isr`/`prerender` 等字段。
172
+ * 未启用 routeRules 中间件时为 `undefined`。
173
+ */
174
+ routeRule?: RouteRule;
175
+ }
176
+ interface UbeanBindings {}
177
+ interface UbeanEnv extends Env {
178
+ Variables: UbeanVariables;
179
+ Bindings: UbeanBindings;
180
+ }
181
+ type UbeanContext = Context<UbeanEnv>;
182
+ type Input = Input$1;
183
+ interface GenericSchema<O = unknown> {
184
+ safeParse?(value: unknown): {
185
+ success: boolean;
186
+ data?: O;
187
+ error?: {
188
+ issues?: Array<{
189
+ message?: string;
190
+ }>;
191
+ };
192
+ };
193
+ parse?(value: unknown): O;
194
+ _output?: O;
195
+ }
196
+ type UbeanMiddleware<I extends Input = {}> = MiddlewareHandler<UbeanEnv, any, I>;
197
+ type UbeanHandler<I extends Input = {}, R extends HandlerResponse<any> = HandlerResponse<any>> = Handler<UbeanEnv, any, I, R>;
198
+ type ComposedHandler = MiddlewareHandler<UbeanEnv>;
199
+ /**
200
+ * Action ID — a stable string identifier for a server action.
201
+ *
202
+ * Generated from the action's source location (file path + export name) so
203
+ * the client and server sides agree on the same ID without runtime
204
+ * coordination. Format: `<base32(sha1(relPath:exportName))>`.
205
+ */
206
+ type ActionId = string;
207
+ /**
208
+ * Context passed to every server action handler.
209
+ *
210
+ * - `request`: the underlying `Request` (for headers, cookies, etc.)
211
+ * - `context`: the Hono context (for `c.set`, `c.get`, `c.req`, etc.)
212
+ * - `params`: route params extracted from the URL (for page-level actions)
213
+ */
214
+ interface ActionContext {
215
+ request: Request;
216
+ context: Context<UbeanEnv>;
217
+ params: Record<string, string>;
218
+ }
219
+ /**
220
+ * Result returned by a server action.
221
+ *
222
+ * - `data`: the action's return value (serializable)
223
+ * - `error`: an `ActionError` instance thrown by the handler, or `null`
224
+ * - `errors`: per-field validation errors (SvelteKit-style `ActionFailure<{ fields }>`),
225
+ * populated when the handler returns `fail()` with field errors
226
+ * - `response`: a raw `Response` returned/thrown by the handler (e.g. a
227
+ * redirect) — passed through verbatim by the dispatcher instead of being
228
+ * JSON-serialized
229
+ *
230
+ * Either `data` (success), `error`/`errors` (failure), or `response`
231
+ * (passthrough) is set; never more than one.
232
+ */
233
+ interface ActionResult<T = unknown> {
234
+ data?: T;
235
+ error?: {
236
+ message: string;
237
+ code?: string;
238
+ };
239
+ errors?: Record<string, string> | null;
240
+ response?: Response;
241
+ status: number;
242
+ }
243
+ /**
244
+ * A handler that runs server-side when an action is invoked.
245
+ *
246
+ * - When `schema` is provided to `defineAction`, the handler receives the
247
+ * parsed/validated `data` (typed by the schema's output).
248
+ * - Without a schema, the handler receives the raw `input` (FormData or
249
+ * parsed JSON object).
250
+ *
251
+ * The handler may return any serializable value, throw an `ActionError`,
252
+ * or call `fail()` to return field-level validation errors.
253
+ */
254
+ type ActionHandler<TInput = unknown, TOutput = unknown> = (input: TInput, ctx: ActionContext) => Promise<TOutput | ActionFailure> | TOutput | ActionFailure;
255
+ /**
256
+ * Field-level validation failure (SvelteKit-style).
257
+ *
258
+ * Returned by `fail()` inside an action handler to signal validation errors
259
+ * back to the form without throwing. The page can read `result.errors` to
260
+ * display per-field messages.
261
+ */
262
+ interface ActionFailure<T = Record<string, string>> {
263
+ __actionFailure: true;
264
+ status: number;
265
+ errors: T;
266
+ }
267
+ /**
268
+ * Schema accepted by `defineAction` for input validation.
269
+ *
270
+ * Any Standard Schema (`safeParse`/`parse`) or a function
271
+ * `(value: unknown) => { success: true; data } | { success: false; error }`.
272
+ */
273
+ interface ActionSchema<TOutput = unknown> {
274
+ safeParse?(value: unknown): {
275
+ success: boolean;
276
+ data?: TOutput;
277
+ error?: {
278
+ issues?: Array<{
279
+ message?: string;
280
+ }>;
281
+ };
282
+ };
283
+ parse?(value: unknown): TOutput;
284
+ _output?: TOutput;
285
+ }
286
+ /**
287
+ * A registered server action — the runtime representation produced by
288
+ * `defineAction`. Carries the action ID and the original handler.
289
+ *
290
+ * The function is callable server-side (direct invocation with typed input)
291
+ * and is replaced by an RPC stub on the client (POST to `/__actions`).
292
+ */
293
+ interface ServerAction<TInput = unknown, TOutput = unknown> {
294
+ /** Stable action ID (file path + export name hash). */
295
+ id: ActionId;
296
+ /** The handler that runs server-side. */
297
+ handler: ActionHandler<TInput, TOutput>;
298
+ /** Optional schema for input validation. */
299
+ schema?: ActionSchema<TOutput>;
300
+ /** Original function name (for error messages / debugging). */
301
+ name: string;
302
+ /** Source file path (project-relative, for debugging). */
303
+ filePath?: string;
304
+ }
305
+ /**
306
+ * Brand used to identify a server action at runtime (`Symbol`).
307
+ */
308
+ declare const ACTION_BRAND: unique symbol;
309
+ /**
310
+ * Type guard: is the value a registered `ServerAction`?
311
+ */
312
+ declare function isServerAction(value: unknown): value is ServerAction;
313
+ /**
314
+ * Error thrown by action handlers to signal a user-facing error.
315
+ *
316
+ * The `code` field can be used for programmatic error handling on the client
317
+ * (e.g. `err.code === 'INVALID_CREDENTIALS'`).
318
+ */
319
+ declare class ActionError extends Error {
320
+ code?: string;
321
+ status: number;
322
+ constructor(message: string, opts?: {
323
+ code?: string;
324
+ status?: number;
325
+ });
326
+ }
327
+ /**
328
+ * Mark a return value as a field-level validation failure.
329
+ *
330
+ * Used inside `defineAction` handlers to signal validation errors back to
331
+ * the form without throwing. Mirrors SvelteKit's `fail()` helper.
332
+ *
333
+ * ```ts
334
+ * export const login = defineAction(async (input) => {
335
+ * if (!input.email) return fail(400, { email: 'Email is required' });
336
+ * return { user: input.email };
337
+ * });
338
+ * ```
339
+ */
340
+ declare function fail<T extends Record<string, string>>(status: number, errors: T): ActionFailure<T>;
341
+ /**
342
+ * Type guard: is the value an `ActionFailure` (returned by `fail()`)?
343
+ */
344
+ declare function isActionFailure(value: unknown): value is ActionFailure;
345
+ //#endregion
346
+ //#region src/error.d.ts
347
+ declare class UbeanError extends Error {
348
+ statusCode: number;
349
+ statusMessage: string;
350
+ data?: unknown;
351
+ constructor(statusCode: number, statusMessage?: string, data?: unknown);
352
+ }
353
+ declare function createError(options: {
354
+ statusCode: number;
355
+ statusMessage?: string;
356
+ data?: unknown;
357
+ message?: string;
358
+ }): UbeanError;
359
+ declare function isUbeanError(err: unknown): err is UbeanError;
360
+ declare function errorToResponse(c: unknown, err?: unknown): Response;
361
+ //#endregion
362
+ //#region src/env.d.ts
363
+ type EnvSchema = Record<string, StandardSchemaV1 | GenericSchema | {
364
+ type: StringConstructor | NumberConstructor | BooleanConstructor;
365
+ default?: unknown;
366
+ required?: boolean;
367
+ }>;
368
+ 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 {
369
+ type: StringConstructor;
370
+ } ? string : S[K] extends {
371
+ type: NumberConstructor;
372
+ } ? number : S[K] extends {
373
+ type: BooleanConstructor;
374
+ } ? boolean : string; };
375
+ interface EnvConfig<S extends EnvSchema = EnvSchema> {
376
+ /** Server-only env variables (not exposed to client) */
377
+ server?: S;
378
+ /** Public env variables (exposed to client via import.meta.env) */
379
+ public?: S;
380
+ /** Validation mode - 'warn' logs errors, 'throw' throws on validation failure */
381
+ mode?: 'warn' | 'throw';
382
+ }
383
+ interface DefineEnvResult<S extends EnvSchema> {
384
+ readonly env: InferEnvOutput<S>;
385
+ validate(source?: Record<string, string | undefined>): {
386
+ success: boolean;
387
+ errors: EnvValidationError[];
388
+ data: InferEnvOutput<S>;
389
+ };
390
+ }
391
+ interface EnvValidationError {
392
+ key: string;
393
+ message: string;
394
+ value: unknown;
395
+ }
396
+ declare function defineEnv<S extends EnvSchema>(config: EnvConfig<S>): DefineEnvResult<S>;
397
+ declare function setRuntimeEnv(env: Record<string, unknown>): void;
398
+ declare function useRuntimeEnv<T = string>(key: string, defaultValue?: T): T;
399
+ //#endregion
400
+ //#region src/path.d.ts
401
+ /**
402
+ * 标准化路径:替换反斜杠、去除首尾多余斜杠、合并连续斜杠。
403
+ */
404
+ declare function normalizePath(path: string): string;
405
+ /**
406
+ * 获取路径的目录部分(不含文件名)。
407
+ */
408
+ declare function getDirname(path: string): string;
409
+ /**
410
+ * 获取路径的文件名部分(含扩展名)。
411
+ */
412
+ declare function getBasename(path: string): string;
413
+ /**
414
+ * 获取文件名的扩展名(小写,不含点)。
415
+ */
416
+ declare function getExtension(filename: string): string;
417
+ /**
418
+ * 获取文件名(不含扩展名)。
419
+ */
420
+ declare function getStem(filename: string): string;
421
+ /**
422
+ * 将文件路径转换为人类可读的标题。
423
+ */
424
+ declare function pathToTitle(path: string): string;
425
+ //#endregion
426
+ //#region src/glob.d.ts
427
+ /**
428
+ * 通配符匹配,支持:
429
+ * - `**` 多段递归 ('/blog/**' 匹配 '/blog/a/b/c' 与 '/blog')
430
+ * - `*` 单段 ('/blog/*' 匹配 '/blog/a' 不匹配 '/blog/a/b')
431
+ * - 字面量 ('/about' 仅匹配 '/about')
432
+ *
433
+ * 提取自 `@ubean/prerender`,供 SSR exclude / prerender / 其他需要 glob 匹配的模块共享。
434
+ */
435
+ declare function matchGlob(route: string, pattern: string): boolean;
436
+ /**
437
+ * 检查 route 是否匹配任意一个 patterns。
438
+ */
439
+ declare function matchAnyGlob(route: string, patterns: string[]): boolean;
440
+ //#endregion
441
+ //#region src/string.d.ts
442
+ /**
443
+ * 将字符串首字母大写。
444
+ */
445
+ declare function capitalize(str: string): string;
446
+ //#endregion
447
+ export { ACTION_BRAND, ActionContext, ActionError, ActionFailure, ActionHandler, ActionId, ActionResult, ActionSchema, BaseRouteMeta, CacheMeta, ComposedHandler, DefineEnvResult, EnvConfig, EnvSchema, EnvValidationError, GenericSchema, InferEnvOutput, Input, IsrRule, PageHead, RateLimitMeta, RouteMeta, RouteRule, ServerAction, Span, SpanAttributes, SpanContext, SpanEndOptions, SpanEvent, SpanOptions, SpanStatus, UbeanBindings, UbeanContext, UbeanEnv, UbeanError, UbeanHandler, UbeanMiddleware, UbeanVariables, capitalize, createError, defineEnv, errorToResponse, fail, getBasename, getDirname, getExtension, getStem, isActionFailure, isServerAction, isUbeanError, matchAnyGlob, matchGlob, normalizePath, pathToTitle, setRuntimeEnv, useRuntimeEnv };
package/dist/index.js ADDED
@@ -0,0 +1,339 @@
1
+ import { kebabCase } from "scule";
2
+ //#region src/types.ts
3
+ /**
4
+ * Brand used to identify a server action at runtime (`Symbol`).
5
+ */
6
+ const ACTION_BRAND = Symbol.for("ubean.action");
7
+ /**
8
+ * Type guard: is the value a registered `ServerAction`?
9
+ */
10
+ function isServerAction(value) {
11
+ return typeof value === "object" && value !== null && value[ACTION_BRAND] === true && typeof value.id === "string" && typeof value.handler === "function";
12
+ }
13
+ /**
14
+ * Error thrown by action handlers to signal a user-facing error.
15
+ *
16
+ * The `code` field can be used for programmatic error handling on the client
17
+ * (e.g. `err.code === 'INVALID_CREDENTIALS'`).
18
+ */
19
+ var ActionError = class extends Error {
20
+ code;
21
+ status;
22
+ constructor(message, opts = {}) {
23
+ super(message);
24
+ this.name = "ActionError";
25
+ this.code = opts.code;
26
+ this.status = opts.status ?? 400;
27
+ }
28
+ };
29
+ /**
30
+ * Mark a return value as a field-level validation failure.
31
+ *
32
+ * Used inside `defineAction` handlers to signal validation errors back to
33
+ * the form without throwing. Mirrors SvelteKit's `fail()` helper.
34
+ *
35
+ * ```ts
36
+ * export const login = defineAction(async (input) => {
37
+ * if (!input.email) return fail(400, { email: 'Email is required' });
38
+ * return { user: input.email };
39
+ * });
40
+ * ```
41
+ */
42
+ function fail(status, errors) {
43
+ return {
44
+ __actionFailure: true,
45
+ status,
46
+ errors
47
+ };
48
+ }
49
+ /**
50
+ * Type guard: is the value an `ActionFailure` (returned by `fail()`)?
51
+ */
52
+ function isActionFailure(value) {
53
+ return typeof value === "object" && value !== null && value.__actionFailure === true;
54
+ }
55
+ //#endregion
56
+ //#region src/error.ts
57
+ var UbeanError = class extends Error {
58
+ statusCode;
59
+ statusMessage;
60
+ data;
61
+ constructor(statusCode, statusMessage, data) {
62
+ super(statusMessage);
63
+ this.name = "UbeanError";
64
+ this.statusCode = statusCode;
65
+ this.statusMessage = statusMessage || statusCodeToMessage(statusCode);
66
+ this.data = data;
67
+ }
68
+ };
69
+ function createError(options) {
70
+ return new UbeanError(options.statusCode, options.statusMessage || options.message, options.data);
71
+ }
72
+ function statusCodeToMessage(code) {
73
+ return {
74
+ 400: "Bad Request",
75
+ 401: "Unauthorized",
76
+ 403: "Forbidden",
77
+ 404: "Not Found",
78
+ 405: "Method Not Allowed",
79
+ 408: "Request Timeout",
80
+ 409: "Conflict",
81
+ 422: "Unprocessable Entity",
82
+ 429: "Too Many Requests",
83
+ 500: "Internal Server Error",
84
+ 501: "Not Implemented",
85
+ 502: "Bad Gateway",
86
+ 503: "Service Unavailable"
87
+ }[code] || "Error";
88
+ }
89
+ function isErrorWithStatusCode(err) {
90
+ return err instanceof Error && "statusCode" in err && typeof err.statusCode === "number";
91
+ }
92
+ function isUbeanError(err) {
93
+ return err instanceof UbeanError || err instanceof Error && err.name === "UbeanError" && isErrorWithStatusCode(err);
94
+ }
95
+ function errorToResponse(c, err) {
96
+ const error = err ?? c;
97
+ if (isUbeanError(error)) return new Response(JSON.stringify({
98
+ error: error.statusMessage,
99
+ statusCode: error.statusCode,
100
+ data: error.data
101
+ }), {
102
+ status: error.statusCode,
103
+ headers: { "Content-Type": "application/json" }
104
+ });
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ return new Response(JSON.stringify({
107
+ error: "Internal Server Error",
108
+ message
109
+ }), {
110
+ status: 500,
111
+ headers: { "Content-Type": "application/json" }
112
+ });
113
+ }
114
+ //#endregion
115
+ //#region src/env.ts
116
+ function parseSchemaValue(schema, rawValue, key) {
117
+ if (!schema) return {
118
+ ok: true,
119
+ value: rawValue
120
+ };
121
+ if ("~standard" in schema && typeof schema["~standard"] === "object" && schema["~standard"]) return {
122
+ ok: false,
123
+ error: "Standard schema validation requires async validate, use validate() instead"
124
+ };
125
+ if (typeof schema.safeParse === "function") {
126
+ const result = schema.safeParse(rawValue);
127
+ if (!result.success) return {
128
+ ok: false,
129
+ error: result.error?.issues?.[0]?.message || "invalid"
130
+ };
131
+ return {
132
+ ok: true,
133
+ value: result.data
134
+ };
135
+ }
136
+ if (schema.type === String) {
137
+ if (rawValue === void 0 || rawValue === "") {
138
+ if (schema.default !== void 0) return {
139
+ ok: true,
140
+ value: schema.default
141
+ };
142
+ if (schema.required === false) return {
143
+ ok: true,
144
+ value: void 0
145
+ };
146
+ return {
147
+ ok: false,
148
+ error: `Missing required env: ${key}`
149
+ };
150
+ }
151
+ return {
152
+ ok: true,
153
+ value: rawValue
154
+ };
155
+ }
156
+ if (schema.type === Number) {
157
+ if (rawValue === void 0 || rawValue === "") {
158
+ if (schema.default !== void 0) return {
159
+ ok: true,
160
+ value: schema.default
161
+ };
162
+ if (schema.required === false) return {
163
+ ok: true,
164
+ value: void 0
165
+ };
166
+ return {
167
+ ok: false,
168
+ error: `Missing required env: ${key}`
169
+ };
170
+ }
171
+ const n = Number(rawValue);
172
+ if (Number.isNaN(n)) return {
173
+ ok: false,
174
+ error: `Env ${key} must be a number, got ${rawValue}`
175
+ };
176
+ return {
177
+ ok: true,
178
+ value: n
179
+ };
180
+ }
181
+ if (schema.type === Boolean) {
182
+ if (rawValue === void 0 || rawValue === "") {
183
+ if (schema.default !== void 0) return {
184
+ ok: true,
185
+ value: schema.default
186
+ };
187
+ if (schema.required === false) return {
188
+ ok: true,
189
+ value: void 0
190
+ };
191
+ return {
192
+ ok: false,
193
+ error: `Missing required env: ${key}`
194
+ };
195
+ }
196
+ return {
197
+ ok: true,
198
+ value: rawValue === "true" || rawValue === "1"
199
+ };
200
+ }
201
+ return {
202
+ ok: true,
203
+ value: rawValue
204
+ };
205
+ }
206
+ function validateSchemaSync(schema, source) {
207
+ const errors = [];
208
+ const data = {};
209
+ for (const [key, def] of Object.entries(schema)) {
210
+ const rawValue = source[key];
211
+ const result = parseSchemaValue(def, rawValue, key);
212
+ if (!result.ok) errors.push({
213
+ key,
214
+ message: result.error || "invalid",
215
+ value: rawValue
216
+ });
217
+ else data[key] = result.value;
218
+ }
219
+ return {
220
+ success: errors.length === 0,
221
+ errors,
222
+ data
223
+ };
224
+ }
225
+ function defineEnv(config) {
226
+ const serverSource = typeof process !== "undefined" ? process.env : globalThis.process?.env || {};
227
+ const mergedSchema = {
228
+ ...config.public,
229
+ ...config.server
230
+ };
231
+ const initial = validateSchemaSync(mergedSchema, serverSource);
232
+ if (!initial.success && config.mode === "throw") {
233
+ const messages = initial.errors.map((e) => ` - ${e.key}: ${e.message}`).join("\n");
234
+ throw new Error(`Environment validation failed:\n${messages}`);
235
+ }
236
+ return {
237
+ env: new Proxy(initial.data, { get(target, prop) {
238
+ if (typeof prop === "string") return target[prop];
239
+ } }),
240
+ validate(source) {
241
+ return validateSchemaSync(mergedSchema, source || serverSource);
242
+ }
243
+ };
244
+ }
245
+ let _runtimeEnv = {};
246
+ function setRuntimeEnv(env) {
247
+ _runtimeEnv = {
248
+ ..._runtimeEnv,
249
+ ...env
250
+ };
251
+ }
252
+ function useRuntimeEnv(key, defaultValue) {
253
+ return _runtimeEnv[key] ?? defaultValue;
254
+ }
255
+ //#endregion
256
+ //#region src/string.ts
257
+ /**
258
+ * 将字符串首字母大写。
259
+ */
260
+ function capitalize(str) {
261
+ return str.charAt(0).toUpperCase() + str.slice(1);
262
+ }
263
+ //#endregion
264
+ //#region src/path.ts
265
+ /**
266
+ * 标准化路径:替换反斜杠、去除首尾多余斜杠、合并连续斜杠。
267
+ */
268
+ function normalizePath(path) {
269
+ return `/${path.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/+/g, "/")}`;
270
+ }
271
+ /**
272
+ * 获取路径的目录部分(不含文件名)。
273
+ */
274
+ function getDirname(path) {
275
+ const parts = path.split("/");
276
+ parts.pop();
277
+ return parts.join("/") || "/";
278
+ }
279
+ /**
280
+ * 获取路径的文件名部分(含扩展名)。
281
+ */
282
+ function getBasename(path) {
283
+ const parts = path.split("/");
284
+ return parts[parts.length - 1];
285
+ }
286
+ /**
287
+ * 获取文件名的扩展名(小写,不含点)。
288
+ */
289
+ function getExtension(filename) {
290
+ const match = filename.match(/\.([^.]+)$/);
291
+ return match ? match[1].toLowerCase() : "";
292
+ }
293
+ /**
294
+ * 获取文件名(不含扩展名)。
295
+ */
296
+ function getStem(filename) {
297
+ return filename.replace(/\.[^.]+$/, "");
298
+ }
299
+ /**
300
+ * 将文件路径转换为人类可读的标题。
301
+ */
302
+ function pathToTitle(path) {
303
+ const stem = getStem(getBasename(path));
304
+ if (stem === "index") return kebabCase(getBasename(getDirname(path)) || "home").split("-").map(capitalize).join(" ");
305
+ return kebabCase(stem).split("-").map(capitalize).join(" ");
306
+ }
307
+ //#endregion
308
+ //#region src/glob.ts
309
+ /**
310
+ * 通配符匹配,支持:
311
+ * - `**` 多段递归 ('/blog/**' 匹配 '/blog/a/b/c' 与 '/blog')
312
+ * - `*` 单段 ('/blog/*' 匹配 '/blog/a' 不匹配 '/blog/a/b')
313
+ * - 字面量 ('/about' 仅匹配 '/about')
314
+ *
315
+ * 提取自 `@ubean/prerender`,供 SSR exclude / prerender / 其他需要 glob 匹配的模块共享。
316
+ */
317
+ function matchGlob(route, pattern) {
318
+ if (pattern === route) return true;
319
+ if (pattern === "**") return true;
320
+ if (pattern.endsWith("/**")) {
321
+ const prefix = pattern.slice(0, -3);
322
+ return route === prefix || route.startsWith(`${prefix}/`);
323
+ }
324
+ if (pattern.endsWith("/*")) {
325
+ const prefix = pattern.slice(0, -2);
326
+ return route.startsWith(`${prefix}/`) && !route.slice(prefix.length + 1).includes("/");
327
+ }
328
+ if (pattern === "/**") return true;
329
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*");
330
+ return new RegExp(`^${escaped}$`).test(route);
331
+ }
332
+ /**
333
+ * 检查 route 是否匹配任意一个 patterns。
334
+ */
335
+ function matchAnyGlob(route, patterns) {
336
+ return patterns.some((p) => matchGlob(route, p));
337
+ }
338
+ //#endregion
339
+ export { ACTION_BRAND, ActionError, UbeanError, capitalize, createError, defineEnv, errorToResponse, fail, getBasename, getDirname, getExtension, getStem, isActionFailure, isServerAction, isUbeanError, matchAnyGlob, matchGlob, normalizePath, pathToTitle, setRuntimeEnv, useRuntimeEnv };
package/dist/node.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ //#region src/port.d.ts
2
+ interface FindPortOptions {
3
+ host?: string;
4
+ strictPort?: boolean;
5
+ }
6
+ /**
7
+ * Tries to listen on `port` at `host`. If the port is already in use and
8
+ * `strictPort` is false, recursively tries `port + 1` until an available
9
+ * port is found (mirrors Vite's behaviour).
10
+ *
11
+ * Resolves with the available port (which may differ from the requested one
12
+ * when not in strict mode). Rejects with the original error when the port is
13
+ * in use and `strictPort` is true, or for any non-EADDRINUSE error.
14
+ */
15
+ declare function findAvailablePort(port: number, options?: FindPortOptions): Promise<number>;
16
+ interface WaitForPortOptions {
17
+ host?: string;
18
+ retries?: number;
19
+ delay?: number;
20
+ }
21
+ /**
22
+ * Polls `port` until a TCP connection can be established, indicating the
23
+ * spawned preview server is ready to accept requests.
24
+ *
25
+ * Rejects when the port does not become available within `retries` attempts.
26
+ */
27
+ declare function waitForPort(port: number, options?: WaitForPortOptions): Promise<void>;
28
+ /**
29
+ * Returns true when something is actively listening on `port` at `host`.
30
+ */
31
+ declare function isPortReachable(port: number, host: string): Promise<boolean>;
32
+ declare function sleep(ms: number): Promise<void>;
33
+ //#endregion
34
+ //#region src/vite-config.d.ts
35
+ /**
36
+ * 检测项目根目录下是否存在 vite 配置文件。
37
+ * 返回找到的第一个文件的完整路径,如果都不存在则返回 null。
38
+ */
39
+ declare function findUserViteConfig(cwd: string): string | null;
40
+ //#endregion
41
+ export { FindPortOptions, WaitForPortOptions, findAvailablePort, findUserViteConfig, isPortReachable, sleep, waitForPort };
package/dist/node.js ADDED
@@ -0,0 +1,92 @@
1
+ import { createConnection, createServer } from "node:net";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "pathe";
4
+ //#region src/port.ts
5
+ /**
6
+ * Tries to listen on `port` at `host`. If the port is already in use and
7
+ * `strictPort` is false, recursively tries `port + 1` until an available
8
+ * port is found (mirrors Vite's behaviour).
9
+ *
10
+ * Resolves with the available port (which may differ from the requested one
11
+ * when not in strict mode). Rejects with the original error when the port is
12
+ * in use and `strictPort` is true, or for any non-EADDRINUSE error.
13
+ */
14
+ async function findAvailablePort(port, options = {}) {
15
+ const host = options.host ?? "localhost";
16
+ const strictPort = options.strictPort ?? false;
17
+ return new Promise((resolve, reject) => {
18
+ const server = createServer();
19
+ server.once("error", (err) => {
20
+ if (err.code === "EADDRINUSE" && !strictPort) {
21
+ server.close();
22
+ resolve(findAvailablePort(port + 1, options));
23
+ } else reject(err);
24
+ });
25
+ server.listen(port, host, () => {
26
+ const addr = server.address();
27
+ const actual = typeof addr === "object" && addr ? addr.port : port;
28
+ server.close(() => resolve(actual));
29
+ });
30
+ });
31
+ }
32
+ /**
33
+ * Polls `port` until a TCP connection can be established, indicating the
34
+ * spawned preview server is ready to accept requests.
35
+ *
36
+ * Rejects when the port does not become available within `retries` attempts.
37
+ */
38
+ async function waitForPort(port, options = {}) {
39
+ const host = options.host ?? "localhost";
40
+ const retries = options.retries ?? 30;
41
+ const delay = options.delay ?? 200;
42
+ for (let attempt = 0; attempt < retries; attempt++) {
43
+ if (await isPortReachable(port, host)) return;
44
+ await sleep(delay);
45
+ }
46
+ throw new Error(`Port ${port} did not become ready after ${retries} attempts (${host}).`);
47
+ }
48
+ /**
49
+ * Returns true when something is actively listening on `port` at `host`.
50
+ */
51
+ function isPortReachable(port, host) {
52
+ return new Promise((resolve) => {
53
+ const socket = createConnection({
54
+ port,
55
+ host
56
+ });
57
+ let settled = false;
58
+ const done = (result) => {
59
+ if (settled) return;
60
+ settled = true;
61
+ socket.destroy();
62
+ resolve(result);
63
+ };
64
+ socket.once("connect", () => done(true));
65
+ socket.once("error", () => done(false));
66
+ setTimeout(() => done(false), 1e3);
67
+ });
68
+ }
69
+ function sleep(ms) {
70
+ return new Promise((resolve) => setTimeout(resolve, ms));
71
+ }
72
+ //#endregion
73
+ //#region src/vite-config.ts
74
+ const VITE_CONFIG_FILES = [
75
+ "vite.config.ts",
76
+ "vite.config.mts",
77
+ "vite.config.js",
78
+ "vite.config.mjs"
79
+ ];
80
+ /**
81
+ * 检测项目根目录下是否存在 vite 配置文件。
82
+ * 返回找到的第一个文件的完整路径,如果都不存在则返回 null。
83
+ */
84
+ function findUserViteConfig(cwd) {
85
+ for (const file of VITE_CONFIG_FILES) {
86
+ const fullPath = join(cwd, file);
87
+ if (existsSync(fullPath)) return fullPath;
88
+ }
89
+ return null;
90
+ }
91
+ //#endregion
92
+ export { findAvailablePort, findUserViteConfig, isPortReachable, sleep, waitForPort };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@ubean/shared",
3
+ "version": "0.2.0",
4
+ "description": "Shared protocol types, errors, env and universal utilities for ubean",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./node": {
18
+ "types": "./dist/node.d.ts",
19
+ "import": "./dist/node.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "@standard-schema/spec": "1.1.0",
24
+ "hono": "4.13.2",
25
+ "pathe": "^2.0.3",
26
+ "scule": "^1.3.0",
27
+ "ufo": "1.6.4"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^26.2.0",
31
+ "typescript": "7.0.2",
32
+ "vite-plus": "0.2.9"
33
+ },
34
+ "scripts": {
35
+ "build": "vp pack",
36
+ "dev": "vp pack --watch",
37
+ "test": "vp test",
38
+ "typecheck": "tsc --noEmit --skipLibCheck"
39
+ }
40
+ }