运行时与开发体验 (defineApp)

与 void 硬编码 createSSRApp(App) 不同,ubean 提供 defineApp 函数让用户完全控制 Vue 应用实例的创建和配置,支持注册插件、全局组件、指令、provide/inject 等。

应用根组件 (App.vue / appRoot)

ubean 的应用外壳由框架内部生成(UbeanAppRoot → 布局链 → PageView → 页面)。如果需要在布局之上再包一层全局内容(全局 ConfigProvider/主题上下文、覆盖布局自身的错误边界、全局过渡容器等),可以提供一个"应用根组件"——经典 Vue App.vue 的入口组件模式(对应 Nuxt 的 app.vue)。

两种提供方式(优先级:defineApp({ appRoot }) > src/app.vue > src/App.vue):

<!-- src/app.vue — 自动检测的应用根组件(小写优先;也可用大写 src/App.vue) -->
<script setup lang="ts">
import { SConfigProvider } from '@vean/ui';
</script>

<template>
  <SConfigProvider>
    <!-- 框架出口(布局链 + 页面)经默认 slot 注入 —— 用 <slot /> 声明渲染位置 -->
    <slot />
  </SConfigProvider>
</template>
// app.ts — 编程式等价物(显式配置优先于文件自动检测)
import { defineApp } from 'ubean';
import AppRoot from './src/components/AppRoot.vue';

export default defineApp({
  appRoot: AppRoot
});

组件渲染在框架根组件之上,因此布局系统、KeepAlive、错误边界、水合全部原样保留。注意:不要在根组件里渲染 <PageView />/<RouterView /> —— 那会绕过布局链;出口始终是 <slot />。文件命名支持小写 src/app.vue(对齐 app.ts 配置入口,优先)与大写 src/App.vue(经典 Vue 约定);仅 .vue 扩展参与检测,app.ts 等 ts/js 命名归 defineApp 配置入口。

设计理念

用户在项目根目录(或 srcDir)创建 app.ts(可选 app.server.ts / app.client.ts 区分服务端/客户端),通过 defineApp(options) 导出一个配置对象(不是工厂函数)。ubean 在创建 Vue 实例后通过 applyAppConfig(app, config, mode) 将该配置应用到 app 上,从而支持注册插件、全局组件、provide/inject、错误组件、View Transitions 等。

// app.ts
import { defineApp } from 'ubean';
import { createPinia } from 'pinia';
import GlobalComponent from './src/components/GlobalComponent.vue';

export default defineApp({
  // 注册 Vue 插件(可附带 options 数组或 { plugin, mode } 配置)
  plugins: [
    createPinia(),
    [SomePlugin, { option: true }] // 等价于 app.use(SomePlugin, { option: true })
  ],

  // 全局组件注册
  globalComponents: {
    GlobalComponent
  },

  // provide/inject
  provides: {
    appVersion: '1.0.0'
  },

  // <head> 默认内容
  head: {
    title: 'My ubean App',
    meta: [{ name: 'description', content: 'Built with ubean' }]
  },

  // 根元素属性(#app)
  rootId: 'app',
  rootAttrs: { 'data-app': 'true' },

  // 路由钩子 — 注册 vue-router 的导航守卫
  // 在 router 实例创建后、app.use(router) 之前调用,
  // 因此守卫能拦截首次导航(包括 SSR 的初始 URL push)。
  // Client 和 SSR 都会执行;setup 必须同步注册守卫(守卫本身可返回 Promise)。
  router: {
    setup(router) {
      router.beforeEach((to, from) => {
        if (to.meta.requiresAuth && !isAuthenticated()) {
          return '/login';
        }
      });
      router.afterEach((to) => {
        // 埋点、设置文档标题等
        if (typeof document !== 'undefined' && to.meta?.title) {
          document.title = String(to.meta.title);
        }
      });
    }
  },

  // 命令式访问 app 实例
  onAppCreated(app) {
    // 注册全局指令
    app.directive('focus', {
      mounted(el) {
        el.focus();
      }
    });

    // 全局错误处理
    app.config.errorHandler = (err, _instance, info) => {
      console.error('[Vue Error]', err, info);
    };
  },

  // 客户端就绪回调(仅客户端执行)
  onClientReady(app) {
    // PWA 注册、分析埋点等
  },

  // 自定义错误/加载组件(覆盖 pages/error.vue 和 pages/loading.vue 自动检测)
  errorComponent: () => import('./src/components/ErrorBoundary.vue'),
  loadingComponent: () => import('./src/components/LoadingSpinner.vue'),

  // 启用 View Transitions
  viewTransitions: true
});

服务端/客户端分离

app.server.ts 和 app.client.ts 导出独立的 defineApp(options) 配置。ubean 在构建时合并:

  • 服务端:app.ts + app.server.ts(仅 mode: 'server' | 'all' 的插件生效)
  • 客户端:app.ts + app.client.ts(仅 mode: 'client' | 'all' 的插件生效)
// app.server.ts — 仅在 SSR 时合并
import { defineApp } from 'ubean';

export default defineApp({
  provides: {
    isSSR: true
  },
  onAppCreated(app) {
    // SSR 特有逻辑,如注入 SSR 状态
  }
});

// app.client.ts — 仅在客户端水合时合并
import { defineApp } from 'ubean';

export default defineApp({
  plugins: [/* 仅客户端需要的插件 */],
  onClientReady(app) {
    // PWA 注册、客户端埋点等
  }
});

defineApp 参数类型

// packages/client/src/define-app.ts
import type { App as VueApp, Component, Plugin } from 'vue';
import type { Router } from 'vue-router';
import type { PageHead } from '../pages/protocol';
import type { ViewTransitionOptions } from './view-transitions';

export interface AppPluginConfig {
  plugin: Plugin | [Plugin, ...any[]];
  mode?: 'all' | 'client' | 'server';
}

/**
 * 路由钩子配置 — 暴露 vue-router 的导航守卫注册入口。
 * `setup(router)` 在 router 创建后、`app.use(router)` 之前调用,
 * 因此守卫能拦截首次导航(包括 SSR 的初始 URL push)。
 * Client 和 SSR 都会执行。
 */
export interface RouterConfig {
  /** 在 router 创建后、初始导航前同步注册守卫(守卫本身可返回 Promise) */
  setup?: (router: Router) => void;
}

export interface DefineAppOptions {
  /** Vue 插件列表(支持 Plugin、[Plugin, ...opts]、或 { plugin, mode }) */
  plugins?: Array<Plugin | [Plugin, ...any[]] | AppPluginConfig>;
  /** 全局组件(key → 组件) */
  globalComponents?: Record<string, Component>;
  /** provide/inject 的键值对 */
  provides?: Record<string | symbol, unknown>;
  /** 默认 <head> 内容 */
  head?: PageHead;
  /** 根元素 id(默认 'app') */
  rootId?: string;
  /** 根元素额外属性 */
  rootAttrs?: Record<string, string>;
  /**
   * 应用根组件(包装组件) — 经典 Vue `App.vue` / Nuxt `app.vue` 的编程式等价物。
   * 框架出口(布局链 + 页面)经默认 slot 注入,组件内用 `<slot />` 声明位置。
   * 优先级:defineApp({ appRoot }) > src/app.vue > src/App.vue。
   */
  appRoot?: Component;
  /** 路由钩子配置 — 注册 beforeEach/beforeResolve/afterEach 等导航守卫 */
  router?: RouterConfig;
  /** App 创建后回调 */
  onAppCreated?: (app: VueApp) => void | Promise<void>;
  /** 客户端 mount 完成后回调 */
  onClientReady?: (app: VueApp) => void | Promise<void>;
  /** 自定义错误边界组件(覆盖 pages/error.vue 自动检测) */
  errorComponent?: Component;
  /** 异步路由的加载占位组件(覆盖 pages/loading.vue 自动检测) */
  loadingComponent?: Component;
  /** 启用/配置 View Transitions */
  viewTransitions?: boolean | ViewTransitionOptions;
  /**
   * SSR 状态序列化钩子 — 在 renderToString 完成后调用。
   * 返回的对象会被序列化到 HTML 的 `__UBEAN_STATE__` script 标签中。
   * 配合 @ubean/integrations/pinia 等状态管理扩展使用。
   */
  serializeState?: (app: VueApp) => Record<string, unknown> | Promise<Record<string, unknown>>;
  /**
   * 客户端状态水合钩子 — 在 applyAppConfig(注册插件)之后、app.mount() 之前调用。
   * 接收从 `__UBEAN_STATE__` 反序列化的状态对象(或 null)。
   * 必须在 mount 前执行,否则 store 已用默认值初始化,水合无效。
   */
  hydrateState?: (app: VueApp, state: Record<string, unknown> | null) => void;
}

export interface ResolvedAppConfig {
  plugins: AppPluginConfig[];
  globalComponents: Record<string, Component>;
  provides: Record<string | symbol, unknown>;
  head?: PageHead;
  rootId: string;
  rootAttrs: Record<string, string>;
  appRoot?: Component;
  router?: RouterConfig;
  onAppCreated?: (app: VueApp) => void | Promise<void>;
  onClientReady?: (app: VueApp) => void | Promise<void>;
  errorComponent?: Component;
  loadingComponent?: Component;
  viewTransitions?: boolean | ViewTransitionOptions;
  serializeState?: (app: VueApp) => Record<string, unknown> | Promise<Record<string, unknown>>;
  hydrateState?: (app: VueApp, state: Record<string, unknown> | null) => void;
}

export function defineApp(options: DefineAppOptions): ResolvedAppConfig;

入口生成流程

  1. 构建时扫描 srcDir 是否存在 app.ts / app.server.ts / app.client.ts
  2. 如果不存在,使用默认入口(createDefaultAppConfig() + 自动 mount/hydrate)
  3. 如果存在,生成虚拟入口模块:
    • 服务端: import appConfig from '<app.ts>'; const app = createSSRApp(RootComponent); applyAppConfig(app, appConfig, 'server'); renderToString(app)
    • 客户端: import appConfig from '<app.ts>'; const app = createSSRApp(RootComponent); applyAppConfig(app, appConfig, 'client'); app.mount('#' + appConfig.rootId)
  4. 支持 app.server.ts / app.client.ts 的条件合并:服务端入口合并 app.ts + app.server.ts,客户端入口合并 app.ts + app.client.ts(plugins 中带 mode 的项按上下文过滤)

与 void 对比

方面void (硬编码)ubean (defineApp)
App 实例创建内部硬编码 createSSRApp(App)用户通过 defineApp(options) 配置
插件注册不支持(需手动改入口)原生支持 plugins 数组
全局组件不支持支持 globalComponents
SSR 上下文无法访问通过 onAppCreated 在 SSR 阶段访问
服务端/客户端分离不支持app.server.ts / app.client.ts 分离
路由守卫不支持router.setup 注册 beforeEach 等
默认行为固定模板无 app.ts 时自动降级为默认行为
View Transitions不支持viewTransitions 选项
错误边界不支持errorComponent / loadingComponent

路由钩子(Navigation Guards)

ubean 通过 defineApp({ router }) 暴露 vue-router 的全局导航守卫注册入口,典型场景包括鉴权重定向、页面访问埋点、NProgress 进度条、根据 to.meta.requiresAuth 做登录校验等。

执行时机:router.setup(router) 在 router 实例创建后、app.use(router) 之前调用,因此守卫能拦截首次导航(包括 SSR 的 router.push(initialUrl))。

执行环境:Client 和 SSR 都会执行。在 app.ts + app.server.ts / app.client.ts 中各自定义的 setup 会累加执行(顺序:shared 先,client/server 后),因此 shared 可放通用守卫(如埋点),client/server 可放环境专用守卫(如 SSR 鉴权重定向)。

注册约束:setup 函数本身必须同步完成守卫注册(虽然守卫函数体可以返回 Promise)。异步逻辑(如 API 请求)放在守卫函数体内:

// app.ts
import { defineApp } from 'ubean';

export default defineApp({
  router: {
    setup(router) {
      // ✅ 同步注册守卫(守卫本身可异步)
      router.beforeEach(async (to, from) => {
        // 异步逻辑放在守卫函数体内,这里可以 await
        if (to.meta.requiresAuth) {
          const user = await fetchCurrentUser();
          if (!user) return '/login';
        }
      });

      router.afterEach((to) => {
        // 埋点、设置文档标题等
        if (typeof document !== 'undefined' && to.meta?.title) {
          document.title = String(to.meta.title);
        }
      });
    }
  }
});

与组件内 useRouter().beforeEach 的区别:defineApp({ router }) 中的守卫是全局的,在应用启动时注册一次,作用于所有路由;而 useRouter().beforeEach 通常在组件 setup 中调用,如果组件被多次挂载会重复注册。生产环境推荐使用 defineApp({ router }) 注册全局守卫。

与后端 middleware 的区别:前端路由守卫只在客户端导航(以及 SSR 渲染当前 URL)时触发,不经过网络;后端 src/middleware/ 是 Hono 中间件,在每个 HTTP 请求时触发。鉴权等需要查 cookie/header 的逻辑,通常在后端 middleware 中处理并将结果注入 context;前端守卫则用于根据已注入的状态做路由级决策(如未登录跳转)。

4.8 类型安全请求客户端

ubean 不自研 HTTP 客户端。类型化 HTTP 请求由 @soybeanjs/fetch 提供:createRequest / toFlatRequest 创建请求实例,@soybeanjs/fetch/openapi 子路径的 createTypedClient / toFlatTypedClient 在其上叠加 OpenAPI paths 类型,使路径、参数、请求体和返回值全部类型安全。页面数据获取优先使用内置数据层(useData / useAsyncData / useFetch);下文的 typed client 面向直接的浏览器/服务端请求。

自动类型生成

dev server 启动时自动从 /_openapi.json 获取 schema,用 openapi-typescript 生成 .ubean/openapi.d.ts:

  • OpenAPI Operation 定义由 hono-openapi 的 describeRoute 中间件收集
  • 从 validator(target, schema) 使用的 Standard Schema 推导请求参数类型
  • 从 describeRoute 的 responses 中通过 resolver(schema) 推导响应类型
  • 开发模式下 HMR 自动更新类型
// .ubean/openapi.d.ts (自动生成)
export interface paths {
  '/api/users/{id}': {
    get: {
      parameters: { path: { id: string } };
      responses: { 200: { content: { 'application/json': User } } };
    };
  };
}

推荐用法:在 src/request/ 集中创建 typed client

ubean init 会在项目 src/request/ 下生成两个模板文件,将项目 paths 类型绑定到 typed client,后续无需重复传递泛型:

// src/request/client.ts — 浏览器端
import { createRequest } from '@soybeanjs/fetch';
import { createTypedClient, toFlatTypedClient } from '@soybeanjs/fetch/openapi';
import type { paths } from '../../.ubean/openapi';

const request = createRequest({});

/**
 * 浏览器端类型化 HTTP 客户端(抛异常模式)
 * 路径、参数、请求体和返回值类型均从 OpenAPI schema 自动推断。
 */
export const api = createTypedClient<paths, '/api'>(request, '/api');

/**
 * 类型化扁平客户端(不抛异常)
 * 返回 `{ data, error, response }`,由调用方决定如何处理失败。
 */
export const flatApi = toFlatTypedClient<paths, '/api'>(request, '/api');
// src/request/internal.ts — server 端进程内 fetch
import { createRequest } from '@soybeanjs/fetch';
import { createTypedClient } from '@soybeanjs/fetch/openapi';
import { createInternalAdapter } from 'ubean/server';
import type { paths } from '../../.ubean/openapi';

/**
 * API 路由 / loader 中使用的类型化进程内客户端。
 * `createInternalAdapter(context)` 将注册的 Hono `app.fetch` 包装为
 * `@soybeanjs/fetch` 的 adapter:请求进程内分发(不发起新的网络请求),
 * 并自动转发当前请求的 cookie/authorization 等请求头。
 */
export function createServerApi(context: Parameters<typeof createInternalAdapter>[0]) {
  const adapter = createInternalAdapter(context);
  const request = createRequest({ adapter });
  return createTypedClient<paths, '/api'>(request, '/api');
}

在组件或 API 路由中使用:

// 浏览器端
import { api, flatApi } from '../request/client';

const user = await api.get('/api/users/{id}', {
  params: { path: { id: '123' }, query: { include: 'posts' } }
});
// user 的类型自动从 OpenAPI schema 推导

const { data, error } = await flatApi.get('/api/users/{id}', {
  params: { path: { id: '123' } }
});
if (error) {
  console.error('请求失败:', error.message);
}
// server 端
import { defineHandler } from 'ubean/server';
import { createServerApi } from '../request/internal';

export const GET = defineHandler(async c => {
  const api = createServerApi(c);
  const user = await api.get('/api/users/{id}', { params: { path: { id: '1' } } });
  return c.json(user);
});

API 速查

所有 HTTP 客户端 API 均来自 @soybeanjs/fetch(需在项目中声明该依赖);ubean 只贡献进程内 adapter 与 OpenAPI 类型生成。

API来源说明返回值
createRequest(options?, hooks?)@soybeanjs/fetch创建请求实例(baseURL、timeout、重试、拦截器)RequestInstance
toFlatRequest(request)@soybeanjs/fetch将同一实例包装为扁平模式(不抛异常)FlatRequestInstance
createTypedClient<paths, Prefix>(request, prefix?)@soybeanjs/fetch/openapi绑定 OpenAPI paths 类型的类型化客户端,失败抛异常typed client
toFlatTypedClient<paths, Prefix>(request, prefix?)@soybeanjs/fetch/openapi类型化扁平客户端,返回 { data, error, response }typed flat client
createInternalAdapter(c?, options?)ubean/server将 Hono app 包装为 @soybeanjs/fetch adapter,进程内分发并转发请求头FetchAdapter
setDefaultFetch(request)ubean注入页面数据层使用的 fetch 实例void

每个实例都提供 get / post / put / patch / delete 快捷方法。params 包含 path / query / header,body 携带请求体:

api.post('/api/users', {
  params: {
    path: { id: 1 },              // 替换 URL 中的 {id}
    query: { page: 1 },            // query string
    header: { Authorization: '' }  // 请求头
  },
  body: { name: 'test' }           // 请求体(POST/PUT/PATCH)
});

非 JSON 响应通过 responseType 选项选择('json' | 'text' | 'blob' | 'arraybuffer' | 'stream' | 'auto')——完整选项集(扁平模式、hooks、重试、auth 刷新等)见 @soybeanjs/fetch 文档。

页面数据获取

页面/loader 数据优先使用内置数据层而非直接 HTTP 调用:useData(options)、useAsyncData(key, fn, options?) 与 useFetch(key, url, options?)(useAsyncData 的薄封装)。它们提供 SSR payload 序列化、按 key 失效(invalidateData / invalidateAll)与 SPA 导航缓存。底层 fetch 实例可注入——setDefaultFetch(createRequest()) 将页面数据层接到与上文 typed client 相同的 @soybeanjs/fetch 管线。

4.9 定时任务系统 (Cron Jobs)

参考 void 的 defineScheduled 设计,定时任务定义在 crons/ 目录下(文件名的数字前缀控制注册顺序):

  • 在 crons/ 目录下定义定时任务
  • 使用 defineScheduled({ name, schedule }, handler) 声明任务与 cron 表达式
  • 开发态加载任务后自动调用 startCronScheduler() 启动调度器
  • 生产构建通过 eager glob 将任务打进 server entry;node/bun/deno/standard 预设启动进程内调度器
  • serverless/edge 预设不安装进程内调度器——改用平台触发(Cloudflare Workers Cron Triggers、Vercel Cron、Netlify scheduled functions)
// crons/daily-cleanup.ts
import { defineScheduled } from 'ubean/server';

export default defineScheduled(
  {
    name: 'daily-cleanup',
    schedule: '0 0 * * *', // 每天凌晨执行
    timeout: 30_000, // 可选:单次运行超时(ms)
    runOnStart: false // 可选:启动时也执行一次
  },
  async ({ name, schedule, timestamp, runCount }) => {
    // 清理过期数据、发送日报等
    console.log(`[Cron] ${name} (${schedule}) fired at ${timestamp}, run #${runCount}`);
  }
);

手动与编程式触发

不存在 /_cron/<name> HTTP 端点,也没有顶层 cron 配置;触发通过代码完成:

import { runScheduledTask, getScheduledTasks, startCronScheduler } from 'ubean/server';

// 立即运行某个任务
await runScheduledTask('daily-cleanup'); // { ok: true, duration: 12, error?: Error }

// 查看已注册任务
const tasks = getScheduledTasks(); // ScheduledTask[]

// 完整调度器控制(通常由框架自动启动)
const scheduler = startCronScheduler({
  timezone: 'UTC',
  defaultTimeout: 30_000,
  onTaskError: (task, error) => console.error(task.name, error)
});
// scheduler.stop() / scheduler.runTask(name) / scheduler.getNextRuns()

4.10 环境变量系统

defineEnv() 来自 @ubean/shared(由 ubean 主入口 re-export),使用构造器式 schema 校验环境变量,返回 { env, validate }:

// env.ts
import { defineEnv } from 'ubean';

export const { env, validate } = defineEnv({
  // 服务端密钥
  server: {
    DATABASE_URL: { type: String, required: true },
    API_SECRET: { type: String, required: true },
    PORT: { type: Number, default: 9527 },
    DEBUG: { type: Boolean, default: false }
  },
  // 公共变量 (通过 import.meta.env 暴露到客户端)
  public: {
    APP_NAME: { type: String, default: 'My App' },
    API_URL: { type: String, default: '/api' }
  },
  // 'warn' (默认) 只记录校验错误; 'throw' 在启动时抛错
  mode: 'throw'
});
  • schema 条目通过构造器(String / Number / Boolean)声明类型,外加可选的 default / required——不存在链式 builder API(string().secret() 等)
  • env 代理是全类型化的(InferEnvOutput<S> 从每个构造器推导 string / number / boolean),env.DATABASE_URL 即 string
  • 仅 UBEAN_PUBLIC_、VITE_ 或 PUBLIC_ 前缀的变量会通过 import.meta.env 暴露给客户端
  • 用 validate(source) 校验自定义来源(测试 fixture、请求级 env);返回 { success, errors }
  • ubean env CLI(init / list / add / remove)管理 .env 文件——完整 API 见 Env 参考

4.11 Preset 系统设计

Preset 位于 @ubean/preset(由 ubean/build 聚合)。Preset 是「定义对象 + 元数据」,通过 definePreset(definition, meta?) 创建:

import { definePreset } from '@ubean/preset';
import type { PresetDefinition, PresetMeta } from '@ubean/preset';

const nodePreset = definePreset(
  {
    name: 'node',
    extends: 'standard', // 继承并合并基础 preset
    entry: './runtime/node',
    build: { outputDir: '.output', format: 'esm' },
    commands: {
      preview: 'node .output/server/index.mjs'
    }
  } satisfies PresetDefinition,
  {
    name: 'node',
    aliases: ['node-server', 'nodedev'],
    stdName: 'node'
  } satisfies PresetMeta
);
  • PresetDefinition 字段:name、extends、entry、exportConditions、serve、build(outputDir/format/externals/minify/rollupConfig)、output、runtime、devServer、capabilities、capabilityInfo、hooks、alias、wasm、unenv、commands、nitro
  • PresetMeta:name、aliases、stdName、static、dev、compatibilityDate、url
  • 注册表 API:registerPreset / resolvePreset / getRegisteredPresets / getPresetNames / getPresetAliases
  • 各平台配置生成器:generateWranglerConfig(Cloudflare)、generateVercelConfig、generateNetlifyConfig、generateBunfigConfig、generateDenoConfig,以及 AWS SAM / Azure SWA 等价物

内置 11 个 preset,由 registerBuiltinPresets() 注册:

Preset别名
standarddefault
nodenode-server、nodedev
cloudflarecloudflare-pages、cloudflare-module、cf、wrangler、workers
cloudflare-devcf-dev、wrangler-dev
vercelvercel-serverless、vercel-node
vercel-edgevercel-edge-function
netlifynetlify-functions、netlify-node
bunbun-runtime
denodeno-deploy、deno-runtime
awsaws-lambda、lambda、amazon、sam
azureazure-swa、azure-static-web-apps、swa、azure-functions

Preset 自动解析由 detectPreset(hints?) 完成,优先级为 explicit > config-file > environment > default:

  1. 通过 resolvePresetWithDetection(name) / 配置的 preset 选项显式指定
  2. 配置文件与依赖:wrangler.toml/wrangler.json → cloudflare,vercel.json → vercel,netlify.toml → netlify,deno.json → deno,template.yaml(AWS SAM)→ aws,staticwebapp.config.json → azure;package.json 中的 wrangler/vercel/netlify-cli/aws-cdk/@azure/functions 等依赖
  3. 环境变量与运行时全局:VERCEL、NETLIFY、AWS_LAMBDA_FUNCTION_NAME、AZURE_FUNCTIONS_ENVIRONMENT、globalThis.Deno、globalThis.Bun、process.versions.node
  4. 回退到 standard preset

detectPreset() 返回 { preset, source: 'explicit' | 'config-file' | 'environment' | 'default', reason }。不存在 UBEAN_SERVER_PRESET 环境变量。

4.12 DevTools 开发工具面板

参考 Nuxt DevTools 的 iframe + RPC 架构模式,为 ubean 内置可视化开发工具面板,提供配置/环境变量/页面路由/API 接口的 UI 化管理,并暴露钩子函数支持数据持久化到数据库,同时集成 AI 大模型辅助开发操作。

设计理念

  • In-App 面板:应用内浮动按钮唤起 DevTools(Shift+Alt+D 快捷键),通过 iframe 隔离 UI 与应用
  • 仅开发模式可用:生产构建自动 tree-shake 移除所有 DevTools 代码,零运行时开销
  • 双向 RPC 通信:DevTools iframe 与宿主应用通过 postMessage + 类型安全 RPC 通道通信
  • 可扩展 Tab 系统:内置核心 Tab,同时支持自定义 Tab 插件扩展
  • 钩子系统:所有 CRUD 操作前后触发 hooks,用户可监听以将数据同步到数据库/外部系统
  • AI 驱动:集成 LLM,自然语言驱动路由/接口/配置的增删改

DevTools 面板架构

┌──────────────────────────────────────────────────┐
│               ubean App (Host)                    │
│  ┌────────────────────────────────────────────┐  │
│  │  DevTools Floating Button (small Vue widget)│  │
│  └────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────┐  │
│  │  DevTools Iframe (独立 Vue 应用)            │  │
│  │  ┌────────┬────────┬────────┬────────────┐ │  │
│  │  │Overview│ Pages  │ Routes │ Config ... │ │  │
│  │  ├────────┴────────┴────────┴────────────┤ │  │
│  │  │           Tab Content                  │ │  │
│  │  │  ┌──────────────┐  ┌────────────────┐  │ │  │
│  │  │  │  CRUD UI     │  │  AI Chat Panel │  │ │  │
│  │  │  └──────────────┘  └────────────────┘  │ │  │
│  │  └────────────────────────────────────────┘ │  │
│  └────────────────────────────────────────────┘  │
│         ▲                    ▲                    │
│         │ RPC (postMessage)  │ Hook Events        │
│         ▼                    ▼                    │
│  ┌────────────────────────────────────────────┐  │
│  │  ubean DevTools Server (Vite plugin)       │  │
│  │  - File system operations (read/write)     │  │
│  │  - Route/API meta extraction               │  │
│  │  - Code generation (d.ts updates)          │  │
│  │  - Hook dispatch (before/after CRUD)       │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘

内置 Tab 功能

Tab功能操作能力
Overview项目概览:ubean 版本、Vue 版本、路由/API 数量、插件列表、构建状态、启动时间只读
Pages页面路由可视化管理:列表/树形展示所有页面路由、布局、meta、reuse 关系CRUD + 跳转预览
API RoutesAPI 接口管理:所有 GET/POST/… 端点列表、OpenAPI 文档、内置接口测试 playgroundCRUD + 在线测试
Config项目配置编辑:可视化修改 ubean.config.ts,实时预览效果编辑
Env环境变量管理:.env 文件编辑、schema 校验、服务端/客户端变量分离展示CRUD + 校验
Layouts布局组件预览:所有 layouts 的组件树、slot 结构、使用情况统计只读 + 创建
Middlewares中间件链可视化:执行顺序、meta 匹配情况、耗时监控只读 + 创建
Plugins插件列表:加载顺序、hook 注册情况、执行耗时只读
Cron Jobs定时任务管理:cron 表达式可视化编辑、手动触发、执行历史CRUD + 触发
Storage/KV存储浏览器:unstorage 数据浏览、编辑、清理CRUD
Database数据库面板:Drizzle 表结构浏览、简单 SQL 查询(可选)只读 + 查询
Hooks钩子监控:所有 hookable 事件监听、耗时、调用链追踪只读
Virtual Files虚拟文件查看:.ubean/routes.d.ts、.ubean/pages.d.ts、虚拟模块源码只读
Terminal终端面板:内嵌终端,运行 ubean page add、ubean api add 等 CLI 命令交互
AI AssistantAI 聊天面板:自然语言驱动所有 CRUD 操作对话

配置管理 (Config Tab)

// DevTools 中可视化编辑 ubean.config.ts
// 通过 AST 操作(ts-morph)安全修改配置文件,支持:
// - preset 切换
// - 路由/页面目录配置
// - 构建选项
// - 环境变量 schema
// - DevTools 自身配置

配置编辑通过真实的 DevTools RPC 函数完成。@ubean/devtools 使用 @vitejs/devtools-kit 的 defineRpcFunction 注册它们;info/env 相关的有:

RPC 函数类型签名说明
ubean:get-infoquery() => DevToolsInfo项目信息快照(版本/路由/页面/插件);客户端通常订阅 ubean:info shared state,此函数作为回退
ubean:get-envquery() => Record<string, string>环境变量(敏感值已脱敏)
ubean:crud:readquery(params: { type: 'config' | 'env' | CrudResourceType, path? }) => CrudReadResult读取配置文件、.env 条目或脚手架资源
ubean:crud:updateaction(params: { type, path?, key?, content?, value? }) => CrudResult更新配置值或 .env 条目(AST 安全编辑,默认备份)
ubean:crud:deleteaction(params: { type, path?, key?, force? }) => CrudResult删除文件或 env 条目(非 force 时创建 .bak 备份)

页面路由 CRUD (Pages Tab)

完整的页面路由增删改查能力,底层复用 CLI Shared Layer 的 page add/delete/update 逻辑(与 ubean page * 命令共用):

完整的页面路由增删改查能力。页面、API、布局、中间件、reuse 路由、定时任务与插件都收敛到同一组 CRUD RPC 函数,底层委托给与 ubean page * / ubean api * 命令相同的脚手架层:

RPC 函数类型签名说明
ubean:crud:createaction(params: { type: CrudResourceType, path, method?, schedule?, content?, force? }) => Promise<CrudResult>创建页面 / API / 布局 / 中间件 / reuse 路由 / 定时任务 / 插件(cron 用 schedule,API 用 method)
ubean:crud:readquery(params: { type, path? }) => Promise<CrudReadResult>列出或读取某类型的资源
ubean:crud:updateaction(params: { type, path?, content? }) => Promise<CrudResult>更新资源文件
ubean:crud:deleteaction(params: { type, path?, force? }) => Promise<CrudResult>删除资源(非 force 时创建 .bak 备份)
ubean:crud:restoreaction(path: string) => Promise<CrudResult>从备份恢复已删除文件

CrudResourceType = 'page' | 'api' | 'layout' | 'middleware' | 'reuse' | 'cron' | 'plugin';CrudResult 返回 { success, created?, deleted?, restored?, updated?, skipped?, errors? }。所有操作都会触发下文描述的 before/after hooks。

页面创建时自动生成模板文件:

<!-- DevTools 创建 pages/users/[id].vue 时生成 -->
<script setup lang="ts">
import { definePage } from 'ubean';

definePage({
  layout: 'default',
  meta: {
    title: 'User Detail'
  }
});
</script>

<template>
  <div>User Detail</div>
</template>

API 接口 CRUD (API Routes Tab)

API 接口复用同一组 ubean:crud:* 函数(type: 'api',可选 method 选择 GET/POST/…)——不存在独立的 api:* RPC 面。内置测试 Playground 通过 ubean:playground:invoke((params) => Promise<PlaygroundInvokeResult>)调用 handler,针对 dev server 执行并自动附带 cookie/auth 请求头——像 Postman 但零配置。

API 创建时自动生成 handler 文件:

// DevTools 创建 routes/users.ts 时生成
import { defineHandler, defineHandlerMeta, validator, describeRoute, resolver } from 'ubean/server';
import { z } from 'zod';

export const GET = defineHandler(
  describeRoute({
    tags: ['Users'],
    summary: 'List users',
    responses: {
      200: {
        description: 'OK',
        content: { 'application/json': { schema: resolver(z.array(z.object({ id: z.string(), name: z.string() }))) } }
      }
    }
  }),
  async c => {
    return c.json({ users: [] });
  }
);

const createUserSchema = z.object({ name: z.string(), email: z.string().email() });

export const POST = defineHandler(
  describeRoute({
    tags: ['Users'],
    summary: 'Create user',
    responses: {
      201: {
        description: 'Created',
        content: { 'application/json': { schema: resolver(z.object({ id: z.string() }).merge(createUserSchema)) } }
      }
    }
  }),
  validator('json', createUserSchema),
  async c => {
    const body = c.req.valid('json');
    return c.json({ id: '1', ...body }, 201);
  }
);

API 测试 Playground:在 DevTools 中直接填写参数、发送请求、查看响应(自动携带 cookie/auth header),类似 Postman 但零配置。

钩子系统 (Hooks)

DevTools 所有操作前后触发 hookable 事件,用户可以注册钩子将数据同步到数据库、触发 CI/CD、或做权限校验:

// app.ts
import { defineApp } from 'ubean';
import { useDevToolsHooks } from 'ubean/devtools';

export default defineApp({
  onAppCreated(app) {
    const hooks = useDevToolsHooks();

    // 页面路由创建前:可做权限校验、数据预存到 DB
    hooks.hook('page:beforeCreate', async (input, context) => {
      // 例如:记录到数据库
      await db.pages.create({
        data: { name: input.name, path: input.path, createdBy: context.user.id }
      });
    });

    // 页面路由创建后:可触发 Git 提交、通知等
    hooks.hook('page:afterCreate', async (result, context) => {
      console.log(`Page ${result.name} created by ${context.user?.name}`);
    });

    // API 接口创建前
    hooks.hook('api:beforeCreate', async input => {
      // 校验接口路径规范
      if (!input.path.startsWith('/api/')) {
        throw new Error('API path must start with /api/');
      }
    });

    // 环境变量更新前
    hooks.hook('env:beforeUpdate', async (key, value) => {
      // 例如:同步到外部密钥管理服务
      await secretsManager.set(key, value);
    });
  }
});

完整钩子列表:

Hook 名称参数说明
page:beforeCreate(input, ctx)页面创建前,可 throw 阻止操作
page:afterCreate(result, ctx)页面创建后
page:beforeUpdate(name, patch, ctx)页面更新前
page:afterUpdate(name, ctx)页面更新后
page:beforeDelete(name, ctx)页面删除前
page:afterDelete(name, ctx)页面删除后
api:beforeCreate(input, ctx)API 创建前
api:afterCreate(result, ctx)API 创建后
api:beforeUpdate(method, path, patch, ctx)API 更新前
api:afterUpdate(method, path, ctx)API 更新后
api:beforeDelete(method, path, ctx)API 删除前
api:afterDelete(method, path, ctx)API 删除后
config:beforeUpdate(patch, ctx)配置更新前
config:afterUpdate(config, ctx)配置更新后
env:beforeCreate(key, value, opts, ctx)环境变量创建前
env:afterCreate(key, ctx)环境变量创建后
env:beforeUpdate(key, value, ctx)环境变量更新前
env:afterUpdate(key, ctx)环境变量更新后
env:beforeDelete(key, ctx)环境变量删除前
env:afterDelete(key, ctx)环境变量删除后
cron:beforeCreate / cron:afterCreate…Cron 任务 CRUD
devtools:ready(ctx)DevTools 面板加载完成
ai:beforeToolCall(toolName, args, ctx)AI 调用工具前,可做权限/审计
ai:afterToolCall(toolName, result, ctx)AI 调用工具后

Ctx 对象包含当前用户信息(若配置了 DevTools 认证)、请求来源、时间戳等。

AI 助手 (AI Assistant Tab)

集成 LLM 大模型,通过自然语言对话驱动 DevTools 操作。AI 不直接写文件,而是调用 DevTools 的 RPC 方法(即 CRUD 接口),确保所有操作经过钩子校验:

// AI 可用的 Tool 定义(基于 RPC 方法自动生成)
const devtoolsTools = [
  {
    name: 'create_page',
    description: '创建一个新的页面路由',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: '路由路径,如 /users/[id]' },
        layout: { type: 'string', description: '布局名', enum: ['default', 'blank', 'admin'] },
        withLoader: { type: 'boolean', description: '是否创建 server loader' }
      },
      required: ['path']
    }
  },
  {
    name: 'create_api_route',
    description: '创建 API 接口',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string' },
        methods: { type: 'array', items: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] } },
        withOpenAPI: { type: 'boolean' }
      },
      required: ['path', 'methods']
    }
  },
  {
    name: 'list_pages',
    description: '列出所有页面路由'
  },
  {
    name: 'list_api_routes',
    description: '列出所有 API 接口'
  },
  {
    name: 'update_env',
    description: '更新环境变量',
    parameters: {
      type: 'object',
      properties: {
        key: { type: 'string' },
        value: { type: 'string' }
      },
      required: ['key', 'value']
    }
  }
  // ... 所有 RPC 方法映射为 AI Tools
];

AI 对话交互示例:

用户:帮我创建一个产品管理模块,需要产品列表页和产品详情页,还有对应的 CRUD 接口

AI:好的,我将为你创建以下内容:

  1. 页面:/products(列表页)和 /products/[id](详情页)
  2. API:GET/POST /api/products、GET/PATCH/DELETE /api/products/:id
  3. 都使用 default 布局

确认执行吗?

用户:确认

AI:✅ 已创建:

  • pages/products/index.vue (Products)
  • pages/products/[id].vue (ProductId)
  • routes/products.ts (GET/POST)
  • routes/products/[id].ts (GET/PATCH/DELETE)

需要为这些接口添加 OpenAPI 文档吗?

AI 集成配置:

// ubean.config.ts
export default defineConfig({
  devtools: {
    enabled: true, // dev 默认 true,prod 强制 false
    ai: {
      enabled: true,
      // 支持多种 LLM provider
      provider: 'openai', // 'openai' | 'anthropic' | 'custom'
      apiKey: process.env.OPENAI_API_KEY, // 也可在 DevTools UI 中填写
      model: 'gpt-4o'
      // 自定义 provider
      // provider: 'custom',
      // endpoint: 'https://your-llm-proxy.com/v1/chat/completions',
    }
  }
});

RPC 通信层

基于 postMessage 实现类型安全的 RPC。DevTools 仅在开发模式与 loopback host 启用;RPC 握手生成一次性 session token,并将 iframe window、允许 origin 与 token 绑定。生产构建不得包含 DevTools RPC handler 或 AI provider 配置。

// src/devtools/rpc.ts
// Host 侧(应用内)
export function createDevToolsServer({ iframeWindow, devtoolsOrigin, sessionToken }) {
  const handlers = createRpcHandlers();

  window.addEventListener('message', async event => {
    const { id, method, params, token } = event.data ?? {};
    if (
      event.origin !== devtoolsOrigin ||
      event.source !== iframeWindow ||
      token !== sessionToken ||
      !isAllowedDevToolsMethod(method)
    ) {
      return;
    }
    try {
      await hooks.callHook(`${methodToHook(method)}:before`, ...params);
      const result = await handlers[method](...params);
      await hooks.callHook(`${methodToHook(method)}:after`, result);
      iframeWindow.postMessage({ __ubean_devtools__: true, id, result }, devtoolsOrigin);
    } catch (error) {
      iframeWindow.postMessage({ __ubean_devtools__: true, id, error: toPublicError(error) }, devtoolsOrigin);
    }
  });
}

// Client 侧(DevTools iframe 内)
export function createDevToolsClient({ parentOrigin, sessionToken }) {
  let nextId = 0;
  const pending = new Map();

  window.addEventListener('message', event => {
    if (
      event.origin === parentOrigin &&
      event.source === window.parent &&
      event.data?.__ubean_devtools__ &&
      pending.has(event.data.id)
    ) {
      const { resolve, reject } = pending.get(event.data.id);
      pending.delete(event.data.id);
      if (event.data.error) reject(new Error(event.data.error));
      else resolve(event.data.result);
    }
  });

  return new Proxy(
    {},
    {
      get(_, method: string) {
        return (...params: unknown[]) =>
          new Promise((resolve, reject) => {
            const id = nextId++;
            pending.set(id, { resolve, reject });
            window.parent.postMessage(
              { __ubean_devtools__: true, id, method, params, token: sessionToken },
              parentOrigin
            );
          });
      }
    }
  ) as DevToolsRPC;
}

文件操作安全

所有 DevTools 文件写入操作遵循以下安全策略:

  1. AST 操作优先:修改 .ts/.vue 文件时使用 ts-morph 进行 AST 操作,而非字符串替换,保证代码格式正确
  2. 操作前备份:所有删除/覆写操作前自动备份到 .ubean/backup/ 目录
  3. 事务性写入:先写临时文件,成功后 rename,避免写入中断导致文件损坏
  4. 路径约束:只允许读写项目根目录内、经 allowlist 允许的文件;拒绝符号链接逃逸、绝对路径与敏感文件
  5. 最小权限:AI 工具默认只读;每次写入、删除或执行命令前展示 diff 并要求用户明确确认
  6. 操作确认:涉及删除/批量修改时 UI 层二次确认
  7. Git 检测:检测到 Git 仓库时建议用户先 commit;框架不自动创建 commit

DevTools 配置

// ubean.config.ts
export default defineConfig({
  devtools: {
    /**
     * 是否启用 DevTools
     * @default true in dev, false in prod
     */
    enabled?: boolean;

    /**
     * DevTools 面板访问路径
     * @default '/_devtools'
     */
    route?: string;

    /**
     * 自定义 Tab 插件
     */
    tabs?: DevToolsTab[];

    /**
     * AI 助手配置
     */
    ai?: {
      enabled?: boolean;
      provider?: 'openai' | 'anthropic' | 'custom';
      apiKey?: string;
      model?: string;
      endpoint?: string;
      /** AI 可使用的工具白名单,默认全部 */
      allowedTools?: string[];
    };
  },
});

UI 技术栈

DevTools 客户端(iframe 内的 Vue 应用)使用 @vean/ui + @vean/aria 构建:

  • @vean/ui:提供 Button/Input/Select/Modal/Tree/Table/Tabs/Form/CodeEditor 等组件,统一设计语言
  • @vean/aria:提供无样式的功能基元(组合式函数、状态管理)
  • CodeMirror 6:代码编辑器(API Playground 编辑、虚拟文件查看),只读模式下也用于代码片段高亮显示
  • fuse.js:路由/接口/组件模糊搜索
  • DevTools 客户端作为独立 Vue 应用预构建为单文件(内联 CSS/JS),通过 Vite 虚拟模块注入,无需额外安装依赖

可扩展自定义 Tab

用户/插件可以注册自定义 DevTools Tab:

// plugins/my-devtools-tab.ts
import { defineDevToolsTab } from '@ubean/devtools';

export default defineDevToolsTab({
  name: 'my-feature',
  title: 'My Feature',
  icon: '🔧',
  // Tab 的 Vue 组件
  component: () => import('./devtools/MyFeatureTab.vue'),
  // 自定义 RPC 方法
  rpc: {
    'my-feature:get-data': async () => { return db.query(...); },
    'my-feature:update-data': async (data) => { ... },
  },
});

4.13 CLI 命令系统

基于 citty 构建类型安全的命令行工具,所有 DevTools 可视化操作均有对应的 CLI 命令,实现 GUI 与 CLI 的功能对等。DevTools 服务端 RPC 与 CLI 共享底层 CRUD 逻辑(cli/shared/fs-ops.ts),保证两边操作结果一致。

命令总览

ubean              # 显示帮助
ubean init         # 初始化新项目脚手架
ubean dev          # 启动开发服务器
ubean build        # 构建生产版本
ubean preview      # 预览生产构建
ubean prepare      # 准备类型生成 (dev 前自动执行)
ubean analyze      # 分析客户端 bundle 体积 (支持 --check 对照基线)

# ─── 页面路由 ───
ubean page add <path>         # 添加页面 (支持 --force 覆盖 / --dry 预览)
ubean page add-reuse <path>   # 添加 reuse 路由 (.reuse.ts)
ubean page delete <path>      # 删除 (默认创建 .bak 备份, --force 彻底删除)
ubean page recovery <path>    # 从 .bak 备份恢复
ubean page list               # 列出已存在的文件

# ─── 其他脚手架资源 (同一组子命令) ───
ubean api add|delete|recovery|list          # API 接口
ubean layout add|delete|recovery|list       # 布局
ubean middleware add|delete|recovery|list   # 中间件
ubean cron add|delete|recovery|list         # 定时任务
ubean plugin add|delete|recovery|list       # 插件

# ─── 环境变量 ───
ubean env init                        # 从模板创建 .env 与 .env.example
ubean env list [--public]             # 列出变量
ubean env add <key> [value] [--public] [--force]  # 新增/更新变量 (--public 自动加 UBEAN_PUBLIC_ 前缀)
ubean env remove <key>                # 移除变量

# ─── 配置 ───
ubean config init [--preset standard] # 创建默认 ubean.config.ts (--force 覆盖)
ubean config show                     # 显示配置文件位置与内容
ubean config example                  # 打印完整配置示例
ubean config path                     # 打印解析后的配置文件路径

# ─── DevTools ───
ubean devtools info               # 显示 DevTools 信息 (访问方式/功能/配置)
ubean devtools path [--port 9527] # 打印 DevTools URL 路径

# ─── 脚手架目录 (studio / IDE 插件) ───
ubean scaffold describe   # 输出机器可读 scaffold JSON 清单

CLI 与 DevTools 共享核心逻辑

┌─────────────┐     ┌──────────────┐
│  DevTools   │     │  CLI 命令行  │
│  (Vue UI)   │     │  (citty)     │
└──────┬──────┘     └──────┬───────┘
       │  RPC call        │  direct call
       ▼                  ▼
┌─────────────────────────────────┐
│  CLI Shared Layer               │
│  (cli/shared/fs-ops.ts)         │
│  - AST 文件操作 (ts-morph)      │
│  - 模板生成                     │
│  - 备份/恢复                    │
│  - hooks 触发                   │
└─────────────────────────────────┘
  • DevTools 面板通过 RPC 调用到 Vite 插件端,Vite 插件端调用 CLI Shared Layer 执行操作
  • CLI 命令直接调用 CLI Shared Layer 执行操作
  • 两边操作都会触发同一套 hooks(page:beforeCreate 等),保证钩子一致性
  • CLI --json flag 支持机器可读输出,便于 CI/CD 集成

CLI 示例:创建页面

$ ubean page add
? 路由路径: /products/[id]
? 选择布局: default
? 是否创建 server loader? Yes
? 是否创建 server action? No
? 模板类型: Vue SFC

✅ 已创建:
  - pages/products/[id].vue       (page: ProductId)
  - pages/products/[id].server.ts (loader)

CLI 示例:创建 API 接口

$ ubean api add
? API 路径: /api/products
? HTTP 方法 (空格多选): GET, POST, DELETE
? 生成 OpenAPI 文档骨架? Yes

✅ 已创建:
  - routes/api/products.ts (GET, POST)
  - routes/api/products/[id].ts (DELETE)? 哦,路径含 [id] 了,是否拆分到子文件? Yes

✅ 已创建:
  - routes/api/products.ts     (GET, POST)
  - routes/api/products/[id].ts (GET, PATCH, DELETE)

4.14 npm scripts

用户项目 package.json 中可用的脚本(ubean init 自动生成):

{
  "scripts": {
    "dev": "ubean dev",
    "build": "ubean build",
    "preview": "ubean preview",
    "prepare": "ubean prepare",
    "typecheck": "vue-tsc --noEmit",
    "lint": "eslint .",
    "test": "vitest run",
    "test:watch": "vitest",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

4.15 错误处理

错误类型

// src/runtime/error.ts
class UbeanError extends Error {
  statusCode: number;
  statusMessage: string;
  data?: unknown;
}

// 预定义错误
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
throw createError({ statusCode: 400, statusMessage: 'Bad Request', data: { field: 'email' } });

错误页面

ubean 在 pages/ 根目录下自动检测三类特殊页面,无需手动配置即可生效:

文件角色作用
pages/404.vue未找到页面Vue Router catch-all /:pathMatch(.*)* + Hono GET * 兜底处理器;未匹配的浏览器导航返回 HTTP 404 并渲染该组件
pages/loading.vue加载占位<Suspense> fallback 组件,在 SPA 导航懒加载页面组件期间显示
pages/error.vue渲染错误兜底错误边界(ErrorBoundary)组件,当页面组件渲染/异步解析/setup 抛出错误时显示,接收 error prop;路由切换时自动重置
  • 仅根目录文件被视为特殊页面;users/404.vue 仍为常规路由 /users/404
  • loading.vue 和 error.vue 仅在客户端生效(SSR 同步解析,无需 fallback;错误在 SSR 阶段由服务端错误处理器处理)
  • 三类页面均可通过 defineApp({ loadingComponent / errorComponent }) 显式覆盖,优先级高于文件自动检测

开发模式错误展示

  • 编译错误:Vite 错误覆盖层(默认)
  • 运行时错误:DevTools 错误面板 + 控制台结构化输出
  • HMR 错误:不刷新页面,保留当前状态显示错误

4.16 Markdown/MDX 页面(内置)

参考 void 的 .md 页面支持,ubean 原生支持 Markdown 作为页面路由。

文件约定

  • pages/**/*.md — Markdown 页面文件,与 .vue 页面同级混放
  • pages/**/*.mdx — MDX 页面(支持 Vue 组件嵌入,可选)
  • Markdown 文件被扫描时自动标记为 island(需要客户端 JS 来 hydrate Vue 组件)

Frontmatter 支持

---
title: About Us
description: Company introduction
layout: default
meta:
  requiresAuth: false
  order: 2
---

# About Us

Welcome to our company...

<Counter v-client.visible />
  • YAML frontmatter 通过 front-matter 解析,提取 title/description/layout/meta
  • layout: false 可指定不使用布局
  • Markdown 正文通过 markdown-exit 渲染(markdown-it 的 TypeScript 重写版,原生支持 async),通过 @shikijs/markdown-exit 插件集成 Shiki 提供代码语法高亮,无需额外 async 补丁
  • 支持嵌入 Vue 组件,组件通过 client 指令控制 hydration 策略

MDX 真实编译(P9-20)

当 markdown.mdx: true 时,ubean 使用 @mdx-js/mdx 将 .mdx 文件编译为 Vue 组件(而非简单的 Markdown 渲染)。@mdx-js/mdx 是 optional peer dependency,需手动安装:

pnpm add @mdx-js/mdx

编译产物通过 @ubean/markdown/jsx-runtime(Vue 兼容的 JSX runtime,基于 Vue h() 函数)渲染,因此在 .mdx 文件中可以直接使用 JSX 语法和导入的 Vue 组件:

---
title: My Post
---

import Counter from '~/components/Counter.vue'

# Hello MDX

This is **MDX** with real compilation.

<Counter v-client.visible />

若未安装 @mdx-js/mdx,ubean 会自动 fallback 为 plain Markdown 渲染(将 HTML 包裹在 Vue 组件中通过 v-html 输出),功能上等价于普通 .md 文件。

可通过 markdown.remarkPlugins 和 markdown.rehypePlugins 传递额外的 remark/rehype 插件:

// ubean.config.ts
export default defineConfig({
  markdown: {
    mdx: true,
    remarkPlugins: [remarkGfm],        // e.g. GitHub Flavored Markdown
    rehypePlugins: [rehypeSlug]        // e.g. slugify headings
  }
});

配置选项

// ubean.config.ts
export default defineConfig({
  markdown: {
    enabled: true, // 默认 true,设为 false 禁用
    mdx: false, // 是否启用 MDX 真实编译(需安装 @mdx-js/mdx)
    theme: 'vitesse-dark', // Shiki 代码高亮主题(支持双主题: { light, dark })
    markdownExit: {
      // markdown-exit 配置(原生 async 渲染)
      html: true,
      linkify: true,
      breaks: false
    },
    headings: {
      // 标题锚点
      anchorLinks: true
    },
    components: {
      // 自动导入的 Vue 组件可在 md/mdx 中使用
      autoImport: true
    },
    // MDX 编译插件(仅 mdx: true 时生效)
    remarkPlugins: [],   // 传递给 @mdx-js/mdx 的 remark 插件
    rehypePlugins: []    // 传递给 @mdx-js/mdx 的 rehype 插件
  }
});

4.17 Islands 孤岛架构

参考 void 的 islands 实现(Import Attributes 方式),ubean 采用更符合 Vue 习惯的 Astro 风格 client 指令,并在 Vite 插件层实现自动孤岛检测。

Client 指令

在 Vue 模板中通过指令标记孤岛组件的 hydration 策略。推荐的 v-client.* Vue 指令语法见下一节。

v-client.* Vue 指令语法(P9-29,推荐)

v-client.* 是 Vue 自定义指令,提供完整的 TypeScript 类型定义,使 IDE 和 eslint-plugin-vue 能提供类型检查与自动补全。

<template>
  <!-- v-client.load:页面加载后立即 hydrate -->
  <Counter v-client.load />

  <!-- v-client.idle:空闲时 hydrate -->
  <HeavyChart v-client.idle />

  <!-- v-client.visible:进入视口时 hydrate -->
  <Comments v-client.visible />

  <!-- v-client.media:媒体查询匹配时 hydrate -->
  <!-- 注意:值是 Vue 表达式,字符串需加引号 -->
  <MobileNav v-client.media="'(max-width: 768px)'" />

  <!-- v-client.only:仅客户端渲染,跳过 SSR -->
  <ClientWidget v-client.only />
</template>

双层设计

v-client 指令采用双层架构:

  1. 编译时(Vite 插件):ubean:islands Vite 插件检测模板中的 v-client.* 和 client:*,将其转换为 <ubean-island> 占位元素。这是 SSR/Islands 模式的主要代码路径。

  2. 运行时(Vue 指令):当 Vite 插件未启用时(CSR-only 应用、单元测试、组件库),vClient 指令作为普通 Vue 指令注册在应用上,为元素标记 data-client-directive 等属性,使 hydrateIslands() 仍能发现并处理它们。

// 框架自动注册(createUbeanClientApp 内部):
app.directive('client', vClient);

// 手动注册(独立 Vue 应用):
import { vClient } from '@ubean/islands';
app.directive('client', vClient);

TypeScript 类型定义

v-client 指令提供完整的类型定义,包括指令参数类型、修饰符类型和钩子函数类型:

import type {
  ClientStrategy,              // 'load' | 'idle' | 'visible' | 'media' | 'only'
  ClientDirectiveModifiers,    // { load?, idle?, visible?, media?, only? }
  ClientDirectiveValue,        // string | undefined (媒体查询)
  ClientDirectiveBinding,      // DirectiveBinding<ClientDirectiveValue>
  VClientDirective             // Directive<HTMLElement, ClientDirectiveValue>
} from '@ubean/islands';

// 策略解析工具函数
import { resolveClientStrategy } from '@ubean/islands';

const strategy = resolveClientStrategy({ idle: true }); // → 'idle'

API 速查

API说明
vClientVue 自定义指令对象(Directive<HTMLElement, string | undefined>)
resolveClientStrategy(modifiers)从修饰符解析策略({ idle: true } → 'idle')
applyStrategy(el, strategy, mediaQuery?)直接对 DOM 元素应用策略
cleanupStrategy(el)清理策略资源(observer/timer)

工作原理

  1. 编译时扫描:Vite 插件扫描 .vue 和 .md 文件,检测 client:* 和 v-client.* 指令
  2. 自动 island 标记:含 client:* 指令的组件自动作为孤岛组件,服务端渲染后客户端单独 hydrate
  3. 客户端 JS 按需发送:无孤岛组件的页面不发送客户端 JS(纯静态 HTML)
  4. Layout 链继承:Layout 中的孤岛组件会传递给所有使用该 Layout 的页面
  5. Props 序列化:孤岛组件的 props 通过协议序列化传递到客户端(仅支持 JSON 可序列化值)
  6. Markdown 自动孤岛:含 Vue 组件或 <script> 的 .md 文件自动标记为需要客户端 bundle

组件自动注册(零配置)+ 自动水合

ubean v1.0 起支持 Islands 组件自动注册,无需在 app.ts 中手动维护 components map。框架还会在客户端自动水合所有 islands,无需在 onClientReady 中手动调用 hydrateIslands()。

工作流程:

  1. ubeanIslandsPlugin 在 transform 阶段扫描 .vue 文件,发现 client:* 指令时同步解析 <script setup> 的 import 语句
  2. 将组件名(模板标签名)替换为 <ubean-island v-once> 自定义元素(v-once 防止 Vue re-render 覆盖已水合内容)
  3. 将组件名与 import 路径建立映射,解析为绝对路径
  4. 生成虚拟模块 virtual:ubean-islands-registry,导出所有收集到的 island 组件
  5. ubean/client 入口的 hydrateIslands 桥接函数自动导入虚拟注册表,与用户手动传入的 components 合并(手动优先)
  6. 客户端入口在 app.mount() 后通过双重 requestAnimationFrame 自动调用 hydrateIslands(),确保 Vue 渲染循环完成后再水合
  7. SPA 导航后通过 router.afterEach 自动水合新页面中的 islands
// app.ts —— 零配置,无需任何 islands 相关代码
import { defineApp } from 'ubean/client';

export default defineApp({
  // islands 自动注册、自动水合
});

支持的 import 形式:import Foo from './Foo.vue'、import { default as Foo } from './Foo.vue'、import Foo, { bar } from './Foo.vue'

边缘场景处理:

场景处理方式
全局注册 / defineAsyncComponent / 动态 import无法静态分析 → 构建期输出警告,用户在 onClientReady 中通过 hydrateIslands({ components }) 手动注册
同名组件不同文件 import 路径不同警告,以首次发现的路径为准
node_modules 中的组件正常工作(bare specifier 原样传递给 Vite 解析)
dev 模式新增 island 用法transform 重新扫描 → 更新 registry → 失效虚拟模块 → full-reload(仅 HMR 更新时触发,初次加载不触发)

详细设计见 Islands。

对比 void 的 Import Attributes

方案优点缺点
void import X from "..." with { island: "idle" }标准语法、编译时静态分析Vue <script setup> 中写 import 加属性较突兀;需配合模板使用不便
ubean client:* 指令Vue 开发者熟悉(类似 Nuxt/Astro)、模板中直观、渐进增强自定义指令需编译转换

4.18 自动导入(内置,可配置)

自动导入分为两类,默认均启用,均可通过配置关闭。

Composables 自动导入(unimport)

自动导入项目 composables/、utils/ 目录及 ubean 内置 composables,无需手动 import:

// 无需 import,自动可用
const { t, locale } = useI18n();
// setLocale 等框架 API 同样自动可用
const user = useUser();
const data = await useAsyncData('key', async () => ({ /* ... */ }));
const router = useRouter();

自动扫描目录:

  • composables/ — 自动导入所有导出(支持嵌套目录扫描,默认只扫描一级)
  • composables/index.ts — 命名导出
  • utils/ — 工具函数(需开启配置)

Vue 组件自动导入(unplugin-vue-components)

自动导入项目 components/ 目录下的 Vue 组件,无需在 script 中 import:

<template>
  <!-- 无需 import,自动注册 -->
  <BaseButton>Click</BaseButton>
  <Icon name="home" />
</template>

自动扫描目录:

  • components/ — 扫描所有 .vue 组件(支持嵌套目录,目录名作为命名空间:Foo/Bar.vue → <FooBar />)
  • ubean 内置组件(<Link>、<Head>、<PageView>、<ClientOnly>)始终可用

配置

// ubean.config.ts
export default defineConfig({
  // 默认(true):仅自动导入 ubean 内置 API(definePage/useHead/useData/setLocale/…)
  autoImports: true,
  components: true,

  // 按库开启第三方 API 自动导入,并透传 unplugin-auto-import 其余选项
  autoImports: {
    ubean: true,        // ubean 内置 API(默认 true)
    vue: false,         // vue composables(ref/computed/watch/…)
    vueRouter: false,   // vue-router useRouter
    vueI18n: false,     // vue-i18n useI18n
    honoOpenapi: false, // hono-openapi validator/describeRoute
    dirs: ['composables', 'composables/*/index.{ts,vue}'], // 追加扫描目录
    // 其余 unplugin-auto-import 选项(imports/dts/eslintrc/…)原样透传
  },
  components: {
    ubean: true,        // 内置组件 <Link>/<Head>/<PageView>/<ClientOnly> 解析(默认 true)
    dirs: ['components'],
    directoryAsNamespace: false,
    // 其余 unplugin-vue-components 选项(resolvers/dts/deep/…)原样透传
  }
});

类型支持

自动生成 .ubean/auto-imports.d.ts 和 .ubean/components.d.ts,在 tsconfig.json 中自动引入。

4.19 i18n 国际化

ubean 用 vue-i18n 11(legacy: false)做 Vue 翻译引擎,用 @intlify/core + ALS 做 handler 翻译。语言路由是约束前缀:compileLocalePaths() 的结果同时注入 vue-router 与 Hono。配置写在 ubean.config.ts 的 i18n,中间件由 createUbeanApp 自动挂载。

详见 指南 · 国际化 与 API · i18n。

// ubean.config.ts
export default defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: [
      { code: 'en', language: 'en' },
      { code: 'zh', language: 'zh-CN' }
    ],
    strategy: 'prefix_except_default',
    detectBrowserLanguage: { redirectOn: 'root' }
  }
});
  • Vue:useI18n 从 vue-i18n 直接导入(自动导入直源 vue-i18n),setLocale 等 ubean 封装从 ubean/client 导入。切换语言必须走框架 setLocale。
  • Handler:t() 读请求 ALS;无 ALS 抛错。getRequestLocale(c) 读中间件写入的 locale。
  • <Link to="/about" locale="zh"> 经 LOCALIZE_PATH_KEY 本地化;vue-router 能匹配 /zh/about。

4.19b Color Mode 深浅色(P9-21)

ubean 内置深浅色(dark/light)模式支持,对齐 Nuxt @nuxtjs/color-mode。通过注入 no-FOUC(防闪烁)内联脚本到 <head>,在浏览器绘制前同步设置 <html> 的 class 或 data-* 属性,避免暗色模式切换时的闪烁。

基本用法

// 在任意 Vue 组件中使用(自动导入)
const colorMode = useColorMode();

colorMode.value;       // 'light' | 'dark' — 当前实际模式
colorMode.preference;  // 'system' | 'light' | 'dark' — 用户偏好
colorMode.set('dark'); // 设置偏好并持久化
colorMode.toggle();    // 在 modes 之间循环切换

工作原理

  1. SSR / 构建时:Vite 插件在 transformIndexHtml 阶段将 no-FOUC 脚本注入 <head> 最前面
  2. 浏览器加载时:脚本同步执行,从 cookie(SSR 友好)或 localStorage 读取偏好,若为 system 则检测 prefers-color-scheme
  3. Hydration 后:useColorMode() composable 从 DOM 读取当前模式,提供响应式访问

配置

// ubean.config.ts
export default defineConfig({
  colorMode: {
    preference: 'system',    // 默认偏好: 'system' | 'light' | 'dark' | 自定义
    fallback: 'light',       // 系统偏好无法检测时的回退值
    classPrefix: '',         // class 前缀
    classSuffix: '-mode',    // class 后缀 → 'light-mode', 'dark-mode'
    storageKey: 'ubean-color-mode',  // localStorage 键名
    cookieName: 'ubean-color-mode',  // cookie 名(SSR)
    dataValue: false,        // 使用 data-color-mode 属性代替 class
    modes: ['light', 'dark'] // 可用模式列表
  }
});

设为 false 可完全禁用颜色模式:

export default defineConfig({
  colorMode: false
});

自定义模式

支持超过两种模式(如 sepia):

export default defineConfig({
  colorMode: {
    modes: ['light', 'dark', 'sepia'],
    classSuffix: ''  // → class="light" / "dark" / "sepia"
  }
});

CSS 配合

/* 使用 class 模式(默认) */
html.light-mode { background: #fff; color: #333; }
html.dark-mode  { background: #1a1a1a; color: #eee; }

/* 使用 data 属性模式 */
html[data-color-mode="light"] { background: #fff; color: #333; }
html[data-color-mode="dark"]  { background: #1a1a1a; color: #eee; }

路由级强制模式

通过 forceColorMode() / unforceColorMode() 可在路由级别强制颜色模式:

// 在路由守卫中
router.beforeEach((to) => {
  if (to.meta.colorMode) {
    forceColorMode(to.meta.colorMode as string);
  } else {
    unforceColorMode();
  }
});

4.19c 第三方脚本优化 / Partytown(P9-22)

ubean 内置第三方脚本优化支持,对齐 Nuxt @nuxtjs/scripts 和 Astro Partytown 集成。通过 Partytown 将第三方脚本(Google Analytics、Facebook Pixel、GTM 等)移入 Web Worker 执行,不阻塞主线程,提升页面交互性能。

基本用法

// 在任意 Vue 组件中使用(自动导入)
const { loaded, load, remove } = useScript('https://www.googletagmanager.com/gtag/js?id=GA_ID', {
  partytown: true,       // 通过 Partytown 在 Web Worker 中执行
  trigger: 'idle',       // 浏览器空闲时加载
  attrs: { 'data-ga-id': 'GA_ID' }
});

// 手动加载(trigger: 'manual')
const script = useScript('/heavy-script.js', { trigger: 'manual' });
script.load();
script.waitForLoad().then(() => console.log('loaded'));

加载策略

策略说明适用场景
'load'页面加载后立即加载(默认)关键脚本
'idle'浏览器空闲时加载(requestIdleCallback)分析、埋点
'visible'目标元素进入视口时加载(IntersectionObserver)视频、地图
'manual'仅手动调用 load() 时加载用户交互触发
// visible 策略:元素进入视口时加载
const mapRef = ref<HTMLElement | null>(null);
useScript('https://maps.googleapis.com/maps/api/js', {
  trigger: 'visible',
  target: mapRef,
  rootMargin: '200px'  // 提前 200px 加载
});

Partytown 配置

在 ubean.config.ts 中启用 Partytown:

export default defineConfig({
  partyTown: {
    enabled: true,
    forward: ['dataLayer.push'],   // 转发主线程调用
    mainAccess: ['document.cookie'], // 主线程访问器
    debug: false,                   // 调试模式
    libPath: '~partytown'           // lib 文件路径
  }
});

// 或简写
export default defineConfig({
  partyTown: true  // 使用默认配置启用
});

设为 false(默认)可禁用:

export default defineConfig({
  partyTown: false
});

工作原理

  1. 构建时:Vite 插件在 transformIndexHtml 阶段将 Partytown 配置脚本注入 <head>(设置 window.partytown 配置 + 加载 partytown.js lib)
  2. 运行时:useScript() 创建 <script> 标签,partytown: true 时设置 type="text/partytown",Partytown 拦截并移入 Web Worker 执行
  3. 主线程转发:forward 配置的 API(如 dataLayer.push)自动从 Worker 转发到主线程

注意:使用 Partytown 需要将 Partytown lib 文件复制到 public/~partytown/ 目录。安装 @builder.io/partytown 后运行 partytown copylib public/~partytown。

API 速查

API说明
useScript(src, options)加载第三方脚本,返回 { script, loaded, error, load, remove, waitForLoad }
configurePartyTown(config)全局配置 Partytown
isPartyTownEnabled()检查是否启用
getPartyTownScript(config)生成内联配置脚本 HTML
resolvePartyTownConfig(config)合并默认配置

4.19d 流式 metadata(P9-24)

ubean 在流式 SSR 基础上支持动态 metadata 流式注入,对齐 Next.js streaming metadata。当页面组件在 setup() 内通过 useHead() / useSeoMeta() 添加 head 标签(如 og:title、canonical、动态 title)时,这些动态标签会被捕获并注入到流式响应中,确保 SEO 爬虫和社交机器人无需等待客户端水合即可看到完整 metadata。

问题背景

流式 SSR 的核心优化是先发送 <head> 再渲染 app:浏览器在 app 渲染期间可提前加载 CSS/JS,显著改善 TTFB/LCP。但这带来一个 SEO 问题——组件 setup() 内的 useHead() 调用发生在 app 渲染期间,此时 <head> 已经发送完毕,动态添加的 <meta> / <title> / <link> 标签无法进入已发送的 <head>。

解决方案

ubean 在流式渲染流程中增加动态 head 标签捕获与注入:

  1. 快照静态 head:流式开始前,调用 renderSSRHead(head) 记录静态 head 标签(来自 defineApp / definePage / locale)
  2. 流式渲染 app:Vue 组件 setup() 内的 useHead() 调用会向 head 实例追加新条目
  3. 收集动态标签:app 渲染完成后,再次调用 renderSSRHead(head) 获取完整 head,与静态快照对比,提取新增标签
  4. 注入到 tail:动态标签在 SSR state script 之后、tail 之前注入。浏览器会自动将 <meta> / <title> / <link> 标签移入 <head>
// 任意 Vue 组件——动态 metadata 会被自动捕获
import { useHead } from '@unhead/vue';

export default defineComponent({
  setup() {
    // 这些标签在流式渲染期间添加,会被捕获并注入到响应中
    useHead({
      title: 'Dynamic Page Title',
      meta: [
        { name: 'og:title', content: 'Dynamic OG Title' },
        { name: 'og:description', content: 'Description from component' }
      ],
      link: [
        { rel: 'canonical', href: 'https://example.com/article/123' }
      ]
    });

    return () => h('div', 'Page content');
  }
});

工作原理

流式响应结构:
┌─────────────────────────────────────────┐
│ <!doctype html>                         │ ← head 部分(立即发送)
│ <html><head>                            │   - 静态 title/meta/link
│   <title>静态标题</title>               │   - CSS/JS 预加载
│   <meta name="description" ...>         │
│ </head><body>                           │
│   <div id="app">                        │
│     <!-- Vue app HTML 边渲染边流式输出 --> │ ← app 部分
│     <div>页面内容</div>                  │
│   </div>                                │
│   <script id="__UBEAN_STATE__">...</script> │ ← tail 部分
│   <meta name="og:title" content="...">  │   - 动态 head 标签(P9-24)
│   <link rel="canonical" href="...">     │   - 浏览器自动移入 <head>
│ </body></html>                          │
└─────────────────────────────────────────┘

启用条件

流式 metadata 依赖流式 SSR,需在 ubean.config.ts 中启用 ssr.streaming:

export default defineConfig({
  ssr: {
    streaming: true  // 启用流式 SSR(自动启用流式 metadata)
  }
});

未启用流式 SSR 时,动态 head 标签通过 transformHtmlTemplate 在缓冲渲染中一次性注入(原有行为,不受影响)。

与静态 head 的关系

来源注入时机位置覆盖关系
defineApp({ head })流式开始前<head> 内被页面级 head 覆盖
definePage({ head }) / pageObj.head流式开始前<head> 内覆盖 app 级 head
组件内 useHead()流式渲染期间tail(浏览器移入 <head>)追加(不重复静态标签)

注意:动态 head 标签采用追加策略,不会覆盖或重复静态标签。collectDynamicHeadTags 通过逐行对比静态快照与完整 head,仅注入新增的标签行。

4.19e 全文搜索 / Pagefind(P9-26)

ubean 内置全文搜索支持,对齐 Astro Pagefind 集成。通过 Pagefind 在构建时索引生成的 HTML 文件,运行时提供客户端搜索 API,无需服务端数据库或搜索引擎。

基本用法

// ubean.config.ts — 启用 Pagefind
export default defineConfig({
  search: true
  // 或自定义配置:
  // search: {
  //   site: 'dist',           // HTML 输出目录
  //   indexPath: 'pagefind',  // 索引输出子目录
  //   verbose: true           // 详细日志
  // }
});

构建时,Vite 插件自动运行 Pagefind CLI 索引 HTML 文件:

$ ubean build
  [ubean:pagefind] Search index generated successfully.

在 Vue 组件中使用 useSearch() composable(自动导入):

<script setup lang="ts">
const { search, results, loading, error } = useSearch({ debounce: 200 });
</script>

<template>
  <input
    type="search"
    placeholder="搜索文档..."
    @input="search($event.target.value)"
  />
  <div v-if="loading">搜索中...</div>
  <div v-else-if="error">{{ error }}</div>
  <ul v-else>
    <li v-for="r in results" :key="r.id">
      <a :href="r.url">{{ r.meta.title || r.url }}</a>
      <p v-html="r.excerpt" />
    </li>
  </ul>
</template>

工作原理

构建时:
┌──────────────────────────────────────────┐
│ ubean build                              │
│   ├── Vite 构建 → 生成 HTML 文件到 dist/ │
│   └── closeBundle hook                   │
│       └── npx pagefind --site dist       │
│           → 生成 dist/pagefind/ 索引文件  │
│             (pagefind-modern.js, 索引碎片) │
└──────────────────────────────────────────┘

运行时:
┌──────────────────────────────────────────┐
│ 浏览器                                    │
│   ├── useSearch()                        │
│   │   └── 首次搜索时动态 import           │
│   │       /pagefind/pagefind-modern.js   │
│   └── pagefind.search(query)             │
│       → 返回匹配结果(url/excerpt/meta)    │
└──────────────────────────────────────────┘

配置选项

export default defineConfig({
  search: {
    enabled: true,           // 启用(默认 true 当 search 为对象时)
    site: 'dist',            // HTML 输出目录(默认从 Vite outDir 推导)
    indexPath: 'pagefind',   // 索引输出子目录(默认 'pagefind')
    glob: '**/*.html',       // HTML 文件 glob(默认所有 .html)
    excludeSelectors: ['nav', 'footer', '.sidebar'],  // 排除的 CSS 选择器
    verbose: false           // 详细索引日志
  }
});

composable 选项

const {
  query,    // Ref<string> — 当前查询
  results,  // ShallowRef<SearchResult[]> — 搜索结果
  loading,  // Ref<boolean> — 是否搜索中
  error,    // Ref<string | null> — 错误信息
  ready,    // Ref<boolean> — Pagefind 是否已加载
  search,   // (query: string, filters?) => Promise<void>
  clear,    // () => void — 清除结果
  preload   // () => Promise<void> — 预加载 Pagefind
} = useSearch({
  debounce: 150,   // debounce 延迟(ms),默认 150
  limit: 10,       // 最大结果数,默认 10
  filters: {       // 默认过滤器
    filters: { tags: 'guide' },
    sort: { date: 'desc' }
  },
  immediate: '初始查询'  // 挂载时自动搜索
});

过滤与排序

通过 HTML 中的 data-pagefind-filter 属性标记过滤字段,然后在搜索时传入 filters:

<!-- 在页面 HTML 中 -->
<article data-pagefind-filter="tags:guide">
  <h1>Getting Started</h1>
  ...
</article>
// 搜索时过滤
await search('vue', {
  filters: { tags: 'guide' }
});

// 按日期排序
await search('vue', {
  sort: { date: 'desc' }
});

API 速查

API说明
useSearch(options?)搜索 composable,返回响应式状态和搜索方法
initPagefind(options?)手动加载 Pagefind 浏览器库
executeSearch(query, options)底层搜索 API(无响应式状态)
configureSearch(config)全局配置搜索运行时
resolveSearchConfig(config)合并默认配置
isPagefindLoaded()检查 Pagefind 是否已加载

注意:使用 Pagefind 需安装 pagefind CLI:pnpm add -D pagefind。未安装时构建会跳过索引并输出警告,不影响其他功能。

4.20 跨平台队列(Queues)

ubean 提供跨平台队列抽象(@ubean/server,由 ubean/server re-export):开发/测试默认使用内存驱动,平台驱动按需显式接线。

定义队列

// queues/email.ts
import { defineQueue } from 'ubean/server';

export interface EmailJob {
  to: string;
  subject: string;
  body: string;
}

export const emailQueue = defineQueue<EmailJob>(
  {
    name: 'email',
    concurrency: 5, // 并发 worker 数(默认 5)
    retries: 3, // 每条消息重试次数(默认 3)
    retryDelay: 1000, // 重试间隔 ms(默认 1000)
    deadLetterQueue: 'email-dlq' // 重试耗尽后进入死信队列
  },
  async message => {
    const job = message.body;
    // 处理队列任务
    await sendEmail(job.to, job.subject, job.body);
  }
);

QueueOptions 为 { name, handler?, concurrency?, retries?, retryDelay?, deadLetterQueue? }——不存在 retry: { maxAttempts, backoff } 对象。

发送任务

队列定义不是可调用对象;发送通过 sendMessage / sendMessages 按队列名进行:

// routes/api/signup.ts
import { defineHandler, sendMessage, validator } from 'ubean/server';
import { z } from 'zod';

const signupSchema = z.object({ email: z.string().email() });

export const POST = defineHandler(validator('json', signupSchema), async c => {
  const { email } = c.req.valid('json');
  // ... 创建用户
  await sendMessage('email', { to: email, subject: 'Welcome', body: '...' });
  // 批量: await sendMessages('email', [job1, job2]);
  return c.json({ success: true });
});

Worker 生命周期:startQueueWorkers() / stopQueueWorkers();可观测性:getQueueStats(name) / getAllQueueStats()。

驱动与平台适配

默认驱动为内存实现。非内存驱动需显式接线——通过 setQueueDriver() 使用 @ubean/server/drivers 的平台驱动:

平台驱动
Node.js / 开发 / 测试内置内存驱动(默认)
Cloudflare WorkerscreateCloudflareQueueDriver(Cloudflare Queues binding)
VercelcreateVercelKvQueueDriver(Vercel KV)

没有内置 BullMQ / Bun / Deno 队列驱动。这些平台上内存驱动进程内运行,或自行实现自定义 QueueDriver(send / sendBatch / registerHandler? / start? / stop? / getQueueDepth? / deleteMessage?)。与数据库、存储层一致,队列在显式接平台驱动之前保持内存实现——见 examples/platform-drivers/。

4.21 Better Auth 认证插件(官方可选)

基于 Better Auth 的认证扩展包 @ubean/auth,独立包默认不进入生产 bundle。

快速启用

// vite.config.ts
import { ubeanAuthPlugin } from '@ubean/auth/vite';

export default {
  plugins: [
    ubeanAuthPlugin({
      enabled: true,
      basePath: '/api/auth',
      secret: process.env.AUTH_SECRET,
      session: {
        cookieName: 'ubean_session',
        expiresIn: 60 * 60 * 24 * 7
      },
      // 传入 betterAuth 配置即启用完整 Better Auth
      betterAuth: {
        emailAndPassword: { enabled: true },
        socialProviders: {
          github: { clientId: '...', clientSecret: '...' }
        }
      }
    })
  ]
};
<script setup lang="ts">
import { useAuth } from '@ubean/auth';

const { user, isAuthenticated, isLoading, signIn, signUp, signOut } = useAuth();
</script>

设计要点

  • 渐进降级:未安装 better-auth 包时自动 fallback 到内置 email/password 实现,保证零配置可用
  • Vite 插件:自动在 dev server 挂载 /api/auth/* 路由(Hono 中间件),无需手动配置
  • 虚拟模块:@ubean/auth/client 提供类型安全的 auth client,零网络开销导入
  • useAuth() composable:响应式 session/user/isAuthenticated/isLoading,onMounted + focus/visibilitychange 自动刷新
  • 服务端 handler:createAuthHandler() 暴露标准 Hono handler,支持任意框架集成
  • meta.public 配合:public: false 的路由自动要求登录(需结合路由中间件)

4.22 类型安全 <Link> 组件

<Link> 组件的 to 属性类型化为项目中已定义的路由名称联合类型。

<script setup lang="ts">
<!-- Link 为全局注册组件,无需导入;或 import { Link } from 'ubean/client' -->
</script>

<template>
  <!-- to 只能传已定义的 RouteName,TS 自动补全 -->
  <Link to="UserDetail" :params="{ id: '123' }">用户详情</Link>

  <!-- 字符串路径也支持,但 params 类型推导 -->
  <Link to="/users/123">用户详情</Link>

  <!-- 对象形式 -->
  <Link :to="{ name: 'UserDetail', params: { id: '123' }, query: { tab: 'profile' } }">用户详情</Link>
</template>

类型由自动生成的 .ubean/pages.d.ts 中的 RouteName 联合类型驱动,CLI/DevTools 添加/删除路由时自动更新。

4.23 Icon 图标扩展(官方可选)

参考 Nuxt Icon,ubean 通过独立的 @ubean/icon 提供统一、SSR 友好且默认不依赖公网的图标系统。它基于 Iconify 数据格式,但不将任意图标集或 Iconify API 作为核心运行时依赖。

基础 API

安装 @ubean/icon 后,Vue 应用可自动使用 <Icon> 组件:

<template>
  <Icon name="lucide:search" size="20" aria-label="搜索" />
  <Icon name="brand:logo" class="brand-logo" />
  <Icon :name="isDark ? 'lucide:moon' : 'lucide:sun'" />
</template>
  • 默认输出可继承 currentColor 的 SVG;mode: 'css' 为单色、静态图标提供 CSS mask 输出,mode: 'svg' 用于多色或需要 SVG 属性的图标。
  • 默认尺寸为 1em,完整透传原生 SVG、ARIA 与 class/style 属性;装饰性图标默认 aria-hidden="true",传入 aria-label 或 title 时自动输出可访问名称。
  • name 使用 collection:icon 形式;支持显式 alias,禁止将任意用户输入直接拼接为远程图标 URL。
  • 动态名称不会被静态扫描;必须在 clientBundle.icons 显式列出,避免生产环境或测试环境图标缺失。

配置与本地数据集(Custom Local Collections)

图标集按需安装,避免全量 @iconify/json 显著增加安装、构建和 server bundle 体积:

pnpm add -D @iconify-json/lucide @iconify-json/logos
// vite.config.ts
import { ubeanIconPlugin } from '@ubean/icon/vite';

export default {
  plugins: [
    ubeanIconPlugin({
      mode: 'svg',
      aliases: {
        search: 'lucide:search',
        github: 'logos:github-icon'
      },
      // Custom Local Collections(对标 @nuxt/icon)
      customCollections: {
        // 简写:key 为 prefix,value 为本地 SVG 目录
        'my-icons': './assets/icons',
        // 完整对象配置
        brand: {
          dir: './assets/brand-svgs',
          prefix: 'brand',
          normalizeIconName: name => name.toLowerCase().replace(/[^a-z0-9-]/g, '-')
        }
      },
      clientBundle: {
        scan: true,
        icons: ['lucide:search'],
        sizeLimitKb: 256
      },
      serverBundle: 'auto'
    })
  ]
};
  • customCollections 将本地 SVG 目录转换为 Iconify collection;嵌套子目录自动以连字符前缀命名(auth/login.svg → auth-login)
  • 构建期必须清理 SVG 中的 script、事件属性、外部引用和不安全 URL
  • 静态扫描只收集 <Icon name="..."> 与可静态求值的 name;扫描结果生成虚拟模块和 .ubean/icons.d.ts,供 client bundle、SSR 与 DevTools 共享
  • Dev server /_iconify 路由优先查找本地 custom collection(命中则直接返回 SVG),未命中再 fallback 到 Iconify API
  • 默认对超出 clientBundle.sizeLimitKb 的未压缩 bundle 失败构建,诊断应列出 collection、icon 数量和可改为按需服务的名称
  • HMR 支持:新增/修改/删除 SVG 文件自动热更新,无需重启 dev server

提供者与平台语义

图标解析的优先级固定为:client bundle -> 本地 collection/server bundle -> 显式配置的远程 provider。生产默认不回退 Iconify 公共 API;开发期远程回退必须显式开启并在终端给出提示。

场景默认策略不满足条件时的行为
Node SSR本地安装的 collection 按 collection 动态加载,/_ubean/icon/:collection 按需服务缺失 collection 返回开发诊断;生产构建失败或使用已配置远程 provider
静态 SSG / ssr: false将扫描和显式声明的图标写入 client bundle对未声明的动态 icon 进行构建诊断;不得依赖本地 server endpoint
Edge / serverlesscapability matrix 决定内联、远程 collection CDN 或仅 client bundle不支持动态 JSON import 时必须选定可用策略,禁止静默请求公共 API
Vitest / 浏览器组件测试provider: 'none' + client bundle测试不得访问网络;漏列的动态名称应使测试配置或断言失败

@ubean/icon 应暴露 Vite plugin,以便纯 Vite Vue 项目也可复用静态扫描与预打包逻辑;ubean 框架集成仅负责自动注册组件、虚拟模块、SSR endpoint 及 preset capability 诊断。

4.24 页面切换动画(View Transitions API)

基于浏览器原生 View Transitions API 实现页面导航过渡效果,不依赖第三方动画库。

基本用法

<script setup lang="ts">
// 页面内启用 view transition(默认通过 ubean config 全局配置)
// app.vue 或 layout 中无需额外代码
</script>

<style>
/* 自定义过渡动画 */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
  animation: fade-in 0.3s ease-in;
}

@keyframes fade-out {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
</style>

配置

// ubean.config.ts
export default defineConfig({
  viewTransition: {
    enabled: true // 默认 true,自动检测浏览器支持
    // 不支持 View Transitions 的浏览器自动 fallback(无动画,不阻塞导航)
  }
});

元素级过渡

支持通过 view-transition-name CSS 属性给共享元素命名,实现跨页面共享元素过渡(如图片放大转场):

<!-- 列表页 -->
<article>
  <img src="/photo.jpg" style="view-transition-name: photo-1" />
</article>

<!-- 详情页 -->
<div class="hero">
  <img src="/photo.jpg" style="view-transition-name: photo-1" />
</div>

实现要点

  • 客户端路由(<Link> 导航、router.push())使用 document.startViewTransition() 包裹 DOM 更新
  • 浏览器不支持 View Transitions API 时自动降级为普通导航,无 JS 错误
  • SSR 首屏加载不触发过渡动画(仅客户端路由切换触发)
  • 保持页面滚动位置,避免过渡期间布局跳动

4.25 PWA 渐进式Web应用(官方可选 @ubean/integrations/pwa)

提供零配置 Service Worker 注册、Web App Manifest 生成和离线缓存策略,参考 vite-plugin-pwa。

快速启用

// vite.config.ts
import { ubeanPwaPlugin } from '@ubean/integrations/pwa';

export default {
  plugins: [
    ubeanPwaPlugin({
      manifest: {
        name: 'My Ubean App',
        short_name: 'Ubean',
        theme_color: '#ffffff',
        background_color: '#ffffff',
        display: 'standalone'
      },
      registerType: 'autoUpdate',
      workbox: {
        precacheManifest: true,
        skipWaiting: true,
        clientsClaim: true
      }
    })
  ]
};

usePwa() Composable

<script setup lang="ts">
import { usePwa } from '@ubean/integrations/pwa';

const {
  isInstalled, // 是否已安装为 PWA
  isUpdateAvailable, // 是否有新版本
  isOfflineReady, // 是否已缓存可离线使用
  needRefresh, // 需要用户确认刷新
  registration, // ServiceWorkerRegistration
  register, // 手动注册 SW
  updateServiceWorker // 激活新版本
} = usePwa();
</script>

<template>
  <div v-if="needRefresh" class="update-banner">
    有新版本可用
    <button @click="updateServiceWorker()">立即刷新</button>
  </div>
  <div v-else-if="isOfflineReady" class="offline-badge">可离线使用</div>
</template>

缓存策略

内置 5 种 runtimeCaching 策略:

策略适用场景说明
cache-first静态资源(图片、字体、JS/CSS)缓存优先,后台更新
network-firstAPI/HTML网络优先,离线回退缓存
stale-while-revalidate字体、非关键API缓存立即返回+后台更新
network-only支付/认证等仅网络,失败报错
cache-only预缓存资源仅缓存,不发请求

默认 runtimeCaching 规则自动覆盖:images(/img/**, /assets/**)、fonts(Google Fonts等)、assets(静态资源)、api(/api/** 使用 stale-while-revalidate)、pages(HTML导航使用 network-first)。

设计要点

  • 构建时自动生成带 content hash 的 precache manifest,确保版本更新
  • HTML 自动注入 <link rel="manifest">、theme-color meta、Apple touch icon
  • 三种注册模式:autoUpdate(自动更新)、prompt(提示用户确认)、manual(手动调用 register())
  • Service Worker 文件在构建时输出到 .output/public/sw.js
  • DevTools 可查看 SW 注册状态和缓存列表

4.26 Pinia 状态管理(官方可选 @ubean/integrations/pinia)

ubean 通过 @ubean/integrations/pinia 子路径提供 Pinia 集成的薄封装层。它不重新导出 Pinia API,而是负责两件事:

  1. dev 预构建优化 — 将 pinia 加入 Vite 的 optimizeDeps.include,避免首次请求扫描延迟
  2. SSR 状态水合辅助 — 提供 serializePiniaState / hydratePiniaState 函数,配合 defineApp({ serializeState, hydrateState }) 钩子完成服务端状态序列化与客户端水合

Pinia 本身仍从 pinia 包导入(createPinia/defineStore/storeToRefs 等),@ubean/integrations/pinia 仅提供集成胶水。

快速启用

// ubean.config.ts
import { defineConfig } from 'ubean';

export default defineConfig({
  pinia: true
});

然后在 src/app.ts 中注册 Pinia 插件和 SSR 水合钩子:

// src/app.ts
import { createPinia } from 'pinia';
import { serializePiniaState, hydratePiniaState } from '@ubean/integrations';
import { defineApp } from 'ubean';

export default defineApp({
  plugins: [createPinia()],
  serializeState: serializePiniaState,
  hydrateState: hydratePiniaState
});

定义与使用 Store

Store 定义与常规 Pinia 完全一致:

// src/stores/counter.ts
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    double: state => state.count * 2
  },
  actions: {
    increment() {
      this.count++;
    }
  }
});

在页面或组件中使用:

<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCounterStore } from '~/stores/counter';

const store = useCounterStore();
const { count, double } = storeToRefs(store);
</script>

<template>
  <button @click="store.increment()">Count: {{ count }} (double: {{ double }})</button>
</template>

SSR 状态水合流程

ubean 的 SSR 状态协议通过 defineApp 的两个钩子实现:

  1. 服务端渲染 — renderToString(app) 完成后,ubean SSR 渲染器调用 serializeState(app)。serializePiniaState 从 app.config.globalProperties.$pinia.state.value 提取状态,返回 { pinia: ... }。

  2. HTML 注入 — 渲染器将状态对象序列化为 JSON,注入到 HTML 的 <script id="__UBEAN_STATE__" type="application/json"> 标签中。

  3. 客户端水合 — 客户端入口在 applyAppConfig(注册 createPinia() 插件)之后、app.mount() 之前调用 hydrateState(app, state)。hydratePiniaState 将 state.pinia 赋值给 pinia.state.value。

必须在 mount 前执行水合,否则 store 已用默认 state 初始化,水合无效。ubean 的客户端入口已确保此顺序。

配置选项

export interface UbeanPiniaOptions {
  /** 是否启用,默认 true。设为 false 等价于 `pinia: false` */
  enabled?: boolean;
  /**
   * 是否将 `pinia` 加入 Vite 的 `optimizeDeps.include`,默认 true。
   * dev 模式下预构建 pinia 可避免首次请求的依赖扫描延迟。
   * 若你使用了自定义的 pinia 别名或 monorepo 内的 pinia 源码,可设为 false。
   */
  optimizeDeps?: boolean;
}

显式配置示例:

// ubean.config.ts
export default defineConfig({
  pinia: { optimizeDeps: false } // 禁用 dev 预构建(如使用 monorepo 内的 pinia 源码)
});

程序化 API

import { ubeanPiniaPlugin, definePiniaConfig } from '@ubean/integrations/pinia';
import { serializePiniaState, hydratePiniaState } from '@ubean/integrations';
import type { UbeanPiniaOptions, PiniaSerializedState } from '@ubean/integrations/pinia';
  • ubeanPiniaPlugin(options?: UbeanPiniaOptions): Plugin[] — Vite 插件,通常由模块系统自动调用
  • definePiniaConfig(options: UbeanPiniaOptions): UbeanPiniaOptions — 类型安全的配置辅助函数
  • serializePiniaState(app): PiniaSerializedState — SSR 序列化,未检测到 $pinia 时返回空对象
  • hydratePiniaState(app, state): void — 客户端水合,state 为 null 或不含 pinia 字段时 no-op

设计要点

  • 零侵入:Pinia 本身仍从 pinia 包导入,@ubean/integrations/pinia 仅提供 Vite 插件和 SSR 水合辅助函数,不重新导出 Pinia API
  • 协议复用:通过 ubean 的 serializeState/hydrateState 钩子集成,不引入并行的状态模型
  • 安全降级:serializePiniaState 在未检测到 $pinia 时返回空对象;hydratePiniaState 在 state 为 null 或不含 pinia 字段时 no-op,允许在 CSR 模式或无 SSR state 时安全调用
  • 配置错误提示:若 hydrateState 被调用但 app 上未检测到 $pinia(未注册 createPinia() 插件),会在控制台输出明确警告

下一步