@ubean/build 0.5.1 → 0.6.0-beta.1

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.
@@ -0,0 +1,1729 @@
1
+ import { c as getComponentResolvers, h as defineVirtualModule, i as ssrSingletonDevPolicy, l as getCssImports, m as createVirtualRegistry } from "./ssr-singleton-DPTawJSx.js";
2
+ import { E as toArray, T as resolveComponentsConfig, b as getAutoImportPresets, w as resolveAutoImportsConfig } from "./codegen-B3oGeRtK.js";
3
+ import { createRequire } from "node:module";
4
+ import { join, resolve } from "pathe";
5
+ import { loadUbeanConfig } from "@ubean/config";
6
+ import { scanProject } from "@ubean/scan";
7
+ import { getVueLocaleParam, toVueRouterLocalePath } from "@ubean/i18n";
8
+ import { getColorModeScript, getPartyTownScript, resolveColorModeConfig, resolvePartyTownConfig } from "@ubean/client";
9
+ import { transformWithOxc } from "vite";
10
+ import vue from "@vitejs/plugin-vue";
11
+ import Components from "unplugin-vue-components/vite";
12
+ import Markdown from "unplugin-vue-markdown/vite";
13
+ import AutoImport from "unplugin-auto-import/vite";
14
+ import { ubeanMdxPlugin } from "@ubean/markdown";
15
+ import { renderFaviconLink } from "@ubean/pages";
16
+ import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite";
17
+ import { applyServerConfig, createUbeanApp } from "@ubean/app";
18
+ import { generatePagesModuleSource } from "@ubean/vue/vite";
19
+ //#region src/dev/dev-scan.ts
20
+ /** 触发扫描的目录名(相对 `srcDir`)。并集来自 core 与 vue 两个插件此前的清单。 */
21
+ const DEV_SCAN_DIRS = [
22
+ "routes",
23
+ "middleware",
24
+ "pages",
25
+ "layouts",
26
+ "plugins",
27
+ "app",
28
+ "api",
29
+ "locales"
30
+ ];
31
+ const SCAN_FILE_EXTENSIONS = /\.(ts|js|mjs|cjs|mts|cts|vue|json|md|mdx)$/;
32
+ /**
33
+ * `srcDir` **根部**的入口文件名(`app.ts` / `app.vue` / `App.vue` / `server.ts` /
34
+ * `server.dev.ts` …)。它们不在任何被监听的目录里,却同样决定服务端行为
35
+ * (appRoot、defineApp、defineServer),因此必须纳入判据。
36
+ *
37
+ * 用静态规则而不是「等第一次扫描拿到 appEntry 再登记」:后者在入口文件本身是第一个改动对象时
38
+ * 会漏掉 —— 首次扫描永远等不到,入口文件也就永远进不了监听集合(这正是此前「改 app.ts
39
+ * 完全不触发重扫」的缺陷)。
40
+ */
41
+ const ENTRY_FILE_PATTERN = /^(app|App)(\.(server|client))?\.(ts|js|mjs|mts|vue)$|^server(\.(dev|prod))?\.(ts|js|mjs|mts)$/;
42
+ function createDevScanCoordinator(options) {
43
+ const { rootDir, source, debounceMs = 150 } = options;
44
+ const srcDir = options.srcDir.startsWith("/") ? options.srcDir : `${rootDir}/${options.srcDir}`;
45
+ const srcDirNormalized = srcDir.replace(/\/+$/, "");
46
+ const subscribers = /* @__PURE__ */ new Set();
47
+ const pending = /* @__PURE__ */ new Set();
48
+ let debounceTimer = null;
49
+ let running = null;
50
+ let rerunRequested = false;
51
+ let started = false;
52
+ const warn = options.onError ?? ((error) => console.error("[ubean] scan failed:", error));
53
+ /** 扫描目录(绝对)与「额外监听的文件」(入口文件,由扫描结果给出)。 */
54
+ const scanDirPaths = DEV_SCAN_DIRS.map((dir) => `${srcDirNormalized}/${dir}`);
55
+ const extraWatchFiles = /* @__PURE__ */ new Set();
56
+ function isIgnored(file) {
57
+ if (file.startsWith(".ubean/") || file.includes("/.ubean/")) return true;
58
+ if (file.includes("/node_modules/") || file.includes("/.git/")) return true;
59
+ if (file.endsWith(".bak")) return true;
60
+ return (options.ignore ?? []).some((pattern) => file.includes(pattern.replace(/\*/g, "")));
61
+ }
62
+ /**
63
+ * 事件是否与扫描相关。
64
+ *
65
+ * 判据是**绝对路径**(Vite 的 watcher 给的就是绝对路径):早先 CLI 那套监听自己把
66
+ * `srcDir` 与事件名拼成路径,`path.join` 遇到绝对路径不重置导致拼出 `<cwd><cwd>/src/...`,
67
+ * 所有监听目标 ENOENT 且异常被吞 —— 表现为「服务端改动完全不生效」。这里不做任何路径拼接。
68
+ */
69
+ function isScanRelevant(file) {
70
+ if (!file.endsWith(".vue") && !SCAN_FILE_EXTENSIONS.test(file)) return false;
71
+ if (extraWatchFiles.has(file)) return true;
72
+ if (!file.startsWith(`${srcDirNormalized}/`)) return false;
73
+ const rest = file.slice(srcDirNormalized.length + 1);
74
+ if (DEV_SCAN_DIRS.includes(rest.split("/")[0])) return true;
75
+ return !rest.includes("/") && ENTRY_FILE_PATTERN.test(rest);
76
+ }
77
+ function registerExtraFiles(result) {
78
+ const entries = [
79
+ result.appEntry?.shared,
80
+ result.appEntry?.server,
81
+ result.appEntry?.client,
82
+ result.appEntry?.root,
83
+ result.serverEntry?.shared,
84
+ result.serverEntry?.dev,
85
+ result.serverEntry?.prod
86
+ ];
87
+ for (const entry of entries) if (entry?.exists && entry.fullPath && !extraWatchFiles.has(entry.fullPath)) {
88
+ extraWatchFiles.add(entry.fullPath);
89
+ source.add(entry.fullPath);
90
+ }
91
+ }
92
+ async function runScan(changed) {
93
+ const result = await (options.scan ?? (() => scanProject({
94
+ cwd: rootDir,
95
+ srcDir,
96
+ dirs: options.dirs,
97
+ ignore: options.ignore
98
+ })))();
99
+ registerExtraFiles(result);
100
+ for (const subscriber of subscribers) await subscriber(result, changed);
101
+ options.reload?.();
102
+ return result;
103
+ }
104
+ async function rescan(changed = []) {
105
+ if (running) {
106
+ rerunRequested = true;
107
+ return running;
108
+ }
109
+ running = runScan(changed).catch((error) => {
110
+ warn(error);
111
+ throw error;
112
+ });
113
+ try {
114
+ return await running;
115
+ } finally {
116
+ running = null;
117
+ if (rerunRequested) {
118
+ rerunRequested = false;
119
+ rescan([...pending]).catch(() => {});
120
+ }
121
+ }
122
+ }
123
+ function queue(file) {
124
+ pending.add(file);
125
+ if (debounceTimer) clearTimeout(debounceTimer);
126
+ debounceTimer = setTimeout(() => {
127
+ debounceTimer = null;
128
+ const changed = [...pending];
129
+ pending.clear();
130
+ rescan(changed).catch(() => {});
131
+ }, debounceMs);
132
+ }
133
+ const onFileEvent = (file) => {
134
+ if (isIgnored(file) || !isScanRelevant(file)) return;
135
+ queue(file);
136
+ };
137
+ return {
138
+ subscribe(fn) {
139
+ subscribers.add(fn);
140
+ return () => subscribers.delete(fn);
141
+ },
142
+ start() {
143
+ if (started) return;
144
+ started = true;
145
+ for (const dir of scanDirPaths) source.add(dir);
146
+ source.on("add", onFileEvent);
147
+ source.on("unlink", onFileEvent);
148
+ source.on("change", onFileEvent);
149
+ },
150
+ rescan,
151
+ subscriberCount: () => subscribers.size,
152
+ stop() {
153
+ if (!started) return;
154
+ started = false;
155
+ source.off?.("add", onFileEvent);
156
+ source.off?.("unlink", onFileEvent);
157
+ source.off?.("change", onFileEvent);
158
+ if (debounceTimer) {
159
+ clearTimeout(debounceTimer);
160
+ debounceTimer = null;
161
+ }
162
+ pending.clear();
163
+ }
164
+ };
165
+ }
166
+ const SCAN_STATE_KEY = "__ubeanScanState";
167
+ function scanStateOf(server) {
168
+ const holder = server;
169
+ holder[SCAN_STATE_KEY] ??= {};
170
+ return holder[SCAN_STATE_KEY];
171
+ }
172
+ /**
173
+ * 取(或建立)该 dev server 的协调器。
174
+ *
175
+ * core 与 vue 插件都调用它,因此**先到的那个**负责建实例;后到的拿同一个。
176
+ * `init` 只在首次调用时执行。
177
+ */
178
+ function getDevScanCoordinator(server, init) {
179
+ const state = scanStateOf(server);
180
+ if (state.coordinator) return state.coordinator;
181
+ const coordinator = createDevScanCoordinator(init(server.watcher));
182
+ state.coordinator = coordinator;
183
+ if (state.pending) {
184
+ for (const fn of state.pending) coordinator.subscribe(fn);
185
+ state.pending = void 0;
186
+ }
187
+ coordinator.start();
188
+ return coordinator;
189
+ }
190
+ /**
191
+ * 取该 dev server **已存在**的协调器(不存在返回 undefined)。
192
+ *
193
+ * 给「只想触发一次扫描」的调用方用(CLI 的 `rescan()`):`getDevScanCoordinator` 需要 init
194
+ * 参数、会在缺失时建实例,不适合这里。
195
+ */
196
+ function peekDevScanCoordinator(server) {
197
+ return scanStateOf(server).coordinator;
198
+ }
199
+ /**
200
+ * 订阅 dev 扫描(CLI 用:每次扫描后重建 Hono app 并让浏览器重载)。
201
+ *
202
+ * 允许在协调器建立之前订阅:`getDevScanCoordinator` 会把已登记的订阅转交给新实例。
203
+ */
204
+ function onDevScan(server, handler) {
205
+ const state = scanStateOf(server);
206
+ if (state.coordinator) return state.coordinator.subscribe(handler);
207
+ state.pending ??= /* @__PURE__ */ new Set();
208
+ state.pending.add(handler);
209
+ return () => state.pending?.delete(handler);
210
+ }
211
+ //#endregion
212
+ //#region src/dev/dev-ssr-routes.ts
213
+ /**
214
+ * dev/SSR 路由表(RM-V11 的组成部件,从 CLI dev server 抽出)。
215
+ *
216
+ * 这里解决的是一个**曾经出过缺陷**的问题类别:服务端 SSR 用的路由表与客户端
217
+ * `virtual:ubean-pages` 的路由表由两处代码各自拼装,一旦不一致就会出现
218
+ * 「SSR 与客户端解析到不同 route record」→ 水合不一致,甚至整页降级。
219
+ *
220
+ * 已实测的两次事故:
221
+ * - **R8**:dev 的服务端路由表只由扫描到的页面构成,**漏了客户端的 404 catch-all**,
222
+ * 于是渲染未知路径时 vue-router 报 `VUE_ROUTER_R0004` 无匹配,页面只渲染出布局外壳,
223
+ * 404 组件自身的 DOM 与 `useHead` 标题全部缺失。
224
+ * - 语言前缀路由:服务端若不带与客户端相同的 locale param,`/zh/xxx` 会落到 catch-all。
225
+ *
226
+ * 因此本模块把「和客户端保持一致」变成可测的约束(见 `dev-ssr-routes.test.ts` 里
227
+ * 直接对照 `generatePagesModuleSource` 产出的客户端路由表),而不是靠两处注释互相提醒。
228
+ */
229
+ /** 扫描器方言 `**:slug`(catch-all)→ vue-router 的 `:slug(.*)*`。 */
230
+ function toVueRouterPagePath(route) {
231
+ return route.replace(/\*\*:(\w[\w-]*)/g, ":$1(.*)*");
232
+ }
233
+ /**
234
+ * 构建 SSR 路由表。
235
+ *
236
+ * 顺序与形状刻意对齐客户端:页面按扫描顺序、reuse 路由复用目标页面的组件、
237
+ * 最后是 404 catch-all。catch-all 用 `/:locale?/:pathMatch(.*)*` —— SSR 渲染的是
238
+ * **请求 URL 本身**(不像 SSG 渲染静态产物、可以用 `/404`),必须与客户端同形才能
239
+ * 让两侧 `useRoute()` 解析出同一份 matched 结构。
240
+ */
241
+ function buildDevSsrRoutes(options) {
242
+ const { pages, layouts, loadComponent, localeVueParam, notFoundPage } = options;
243
+ const defaultLayout = layouts.find((l) => l.isDefault)?.name ?? null;
244
+ const withLocale = (path) => localeVueParam ? toVueRouterLocalePath(path, localeVueParam) : path;
245
+ const pageByName = /* @__PURE__ */ new Map();
246
+ for (const page of pages) pageByName.set(page.name, page);
247
+ const componentFor = (page) => {
248
+ const fullPath = (page.isReuse && page.reuseTarget ? pageByName.get(page.reuseTarget) : void 0)?.fullPath || page.fullPath;
249
+ return async () => await loadComponent(fullPath);
250
+ };
251
+ const routeFor = (page) => ({
252
+ path: withLocale(toVueRouterPagePath(page.route)),
253
+ name: page.name,
254
+ component: componentFor(page),
255
+ meta: {
256
+ layout: page.layout === false ? false : page.layout || defaultLayout,
257
+ pageName: page.name,
258
+ cache: page.cache === true ? true : void 0
259
+ }
260
+ });
261
+ const grouped = /* @__PURE__ */ new Map();
262
+ for (const page of pages) {
263
+ const group = grouped.get(page.route) ?? { slots: [] };
264
+ if (page.slot) group.slots.push(page);
265
+ else if (!group.default) group.default = page;
266
+ grouped.set(page.route, group);
267
+ }
268
+ const routes = [];
269
+ for (const group of grouped.values()) {
270
+ const primary = group.default ?? group.slots[0];
271
+ if (!primary) continue;
272
+ const route = routeFor(primary);
273
+ if (group.slots.length > 0) {
274
+ const components = {};
275
+ if (group.default) components.default = componentFor(group.default);
276
+ for (const slotPage of group.slots) components[slotPage.slot] = componentFor(slotPage);
277
+ delete route.component;
278
+ route.components = components;
279
+ }
280
+ routes.push(route);
281
+ }
282
+ if (notFoundPage) routes.push({
283
+ path: withLocale("/:pathMatch(.*)*"),
284
+ name: "NotFound",
285
+ component: async () => await loadComponent(notFoundPage.fullPath),
286
+ meta: {
287
+ layout: notFoundPage.layout === false ? false : notFoundPage.layout || defaultLayout,
288
+ pageName: "NotFound"
289
+ }
290
+ });
291
+ return routes;
292
+ }
293
+ //#endregion
294
+ //#region src/dev/dev-host-app.ts
295
+ /**
296
+ * 把 Vite SSR 图的加载器与 SSR 渲染器接到 host app 上。
297
+ *
298
+ * 必须在**每次** app 实例更新后调用(CLI 的 `updateApp`):闭包持有模块加载器,
299
+ * HMR 后需要重新指向新的扫描结果与 Vite 模块图。
300
+ */
301
+ function enhanceDevApp(options) {
302
+ const { app, layouts = [], loadModule, localeVueParam, backend, startCronScheduler, onCronError } = options;
303
+ app.options.layouts = layouts;
304
+ const routeLoaders = {};
305
+ for (const route of app.options.routes || []) {
306
+ const fullPath = route.fullPath;
307
+ routeLoaders[route.relativePath] = () => loadModule(fullPath);
308
+ }
309
+ app.options.routeLoaders = routeLoaders;
310
+ const pageByName = /* @__PURE__ */ new Map();
311
+ for (const page of app.options.pages || []) pageByName.set(page.name, page);
312
+ const pageLoaders = {};
313
+ for (const page of app.options.pages || []) {
314
+ const fullPath = (page.isReuse && page.reuseTarget ? pageByName.get(page.reuseTarget) : void 0)?.fullPath || page.fullPath;
315
+ pageLoaders[page.relativePath] = () => loadModule(fullPath);
316
+ }
317
+ app.options.pageLoaders = pageLoaders;
318
+ const middlewareLoaders = {};
319
+ for (const mw of app.options.middleware || []) {
320
+ const fullPath = mw.fullPath;
321
+ middlewareLoaders[mw.relativePath] = () => loadModule(fullPath);
322
+ }
323
+ app.options.middlewareLoaders = middlewareLoaders;
324
+ const cronFiles = app.options.crons || [];
325
+ if (cronFiles.length > 0 && startCronScheduler) Promise.all(cronFiles.map((c) => loadModule(c.fullPath))).then(() => startCronScheduler()).catch((error) => {
326
+ if (onCronError) onCronError(error);
327
+ else console.error("[ubean] Failed to load cron files:", error);
328
+ });
329
+ if (backend) {
330
+ app.resetInit();
331
+ return app;
332
+ }
333
+ const layoutMap = /* @__PURE__ */ new Map();
334
+ for (const layout of layouts) layoutMap.set(layout.name, layout.fullPath);
335
+ const defaultLayout = layouts.find((l) => l.isDefault)?.name || null;
336
+ const routes = buildDevSsrRoutes({
337
+ pages: app.options.pages || [],
338
+ layouts,
339
+ localeVueParam,
340
+ loadComponent: loadModule,
341
+ notFoundPage: app.options.notFoundPage
342
+ });
343
+ let appConfigModule = null;
344
+ const getAppConfigModule = () => {
345
+ if (!appConfigModule) appConfigModule = loadModule("virtual:ubean-app");
346
+ return appConfigModule;
347
+ };
348
+ app.options.pageRenderer = options.createRenderer({
349
+ routes,
350
+ async resolveLayoutComponent(name) {
351
+ if (name === false || name == null) return null;
352
+ const fullPath = layoutMap.get(name);
353
+ if (!fullPath) return null;
354
+ const mod = await loadModule(fullPath);
355
+ return mod.default || mod;
356
+ },
357
+ defaultLayout,
358
+ async resolveAppConfig() {
359
+ return (await getAppConfigModule()).resolveAppConfig?.("server");
360
+ }
361
+ });
362
+ return app;
363
+ }
364
+ //#endregion
365
+ //#region src/dev/dev-app.ts
366
+ /**
367
+ * 宿主 dev app 的创建与自举(RM-V11)。
368
+ *
369
+ * 与同目录 `dev-host-app.ts` 的分工:
370
+ * - `enhanceDevApp()`:把 Vite SSR 图的加载器/渲染器**接到**一个已存在的 app 上;
371
+ * - 本模块:**创建**那个 app(扫描 → `createUbeanApp`),并负责首次请求前的 ready 流程。
372
+ *
373
+ * 之所以要从 CLI 搬进来:ADR-0012 §3 的目标形态是 Vite 持有 server、框架插件自举 ——
374
+ * 插件拿不到「CLI 造好的 app」,就必须能自己造。
375
+ *
376
+ * 三个出口按依赖递增排列,调用方按自己拿得到的东西选:
377
+ * 1. `createDevApp()` —— 不需要 Vite server(CLI 的 dev 命令用它);
378
+ * 2. `createDevAppReady()` —— 需要一个 `loadModule`(`server.ssrLoadModule`);
379
+ * 3. `bootstrapDevApp()` —— 两者 + `enhanceDevApp`,即插件自举时的完整入口。
380
+ *
381
+ * 日志策略(请求日志的级别与过滤)刻意不搬:那是 CLI 的 `logging` 配置域与终端呈现,
382
+ * 由调用方通过 `configureApp` 挂 hook,builder 只保证结构。
383
+ */
384
+ const fallbackLogger = {
385
+ warn: (message) => console.warn(message),
386
+ error: (message) => console.error(message),
387
+ info: (message) => console.log(message)
388
+ };
389
+ /** 请求日志里跳过内部路径的前缀(与 CLI 的旧实现一致)。 */
390
+ const REQUEST_LOG_INTERNAL_PREFIXES = ["/_", "/@"];
391
+ /** 超过该毫秒数的请求提升为 warn(慢请求告警)。 */
392
+ const REQUEST_LOG_SLOW_THRESHOLD = 1e3;
393
+ /**
394
+ * 请求日志 hook(`logging.request` 分类):默认关闭。
395
+ *
396
+ * RM-V14 从 CLI 的 dev 命令搬来:dev 请求现在由插件服务(`vite dev` 也是),日志策略属于
397
+ * **配置域**(`ubean.config.ts` 的 `logging.request`)而不是某个命令的实现细节,放在这里
398
+ * 两条路径才能给出一致的输出。
399
+ *
400
+ * 资源/模块请求在 Vite 中间件层即被消费,不会进到 app —— 因此天然只记录应用请求。
401
+ */
402
+ function attachRequestLogger(app, enabled, logger) {
403
+ if (!enabled) return;
404
+ const requestStartTimes = /* @__PURE__ */ new WeakMap();
405
+ const isInternalPath = (path) => REQUEST_LOG_INTERNAL_PREFIXES.some((prefix) => path.startsWith(prefix));
406
+ app.hooks.hook("request:start", (c) => {
407
+ requestStartTimes.set(c, Date.now());
408
+ });
409
+ app.hooks.hook("request:end", (c, res) => {
410
+ const start = requestStartTimes.get(c);
411
+ const duration = start === void 0 ? 0 : Date.now() - start;
412
+ const line = `${c.req.method} ${c.req.path} ${res.status} ${duration}ms`;
413
+ if (res.status >= 500) {
414
+ logger.error(line);
415
+ return;
416
+ }
417
+ if (isInternalPath(c.req.path)) return;
418
+ if (res.status >= 400 || duration >= REQUEST_LOG_SLOW_THRESHOLD) {
419
+ logger.warn(line);
420
+ return;
421
+ }
422
+ logger.info(line);
423
+ });
424
+ app.hooks.hook("request:error", (c, err) => {
425
+ logger.error(`${c.req.method} ${c.req.path} ERROR ${err instanceof Error ? err.message : String(err)}`);
426
+ });
427
+ }
428
+ /** 扫描(可选)并创建 dev 用的宿主 app —— 不需要 Vite server。 */
429
+ async function createDevApp(options) {
430
+ const { rootDir, config, configureApp, logger } = options;
431
+ const scanResult = options.scanResult ?? await scanProject({
432
+ cwd: rootDir,
433
+ srcDir: config.srcDir,
434
+ dirs: config.dir,
435
+ ignore: config.scanOptions?.ignore
436
+ });
437
+ const app = createUbeanApp({
438
+ rootDir,
439
+ routes: scanResult.apiRoutes,
440
+ middleware: scanResult.middlewares,
441
+ pages: scanResult.pages,
442
+ crons: scanResult.crons,
443
+ routeRules: config.routeRules || {},
444
+ publicDir: config.dir.public,
445
+ pageAssetTags: config.favicon ? { favicon: config.favicon } : void 0,
446
+ openAPI: {
447
+ title: "UBEAN Dev API",
448
+ scalarPath: "/_scalar",
449
+ openAPIPath: "/_openapi.json"
450
+ },
451
+ i18nConfig: config.i18n,
452
+ ssrExclude: config.ssr.exclude,
453
+ streaming: config.ssr.streaming,
454
+ notFoundPage: scanResult.notFoundPage,
455
+ csrf: config.security === false ? false : config.security?.csrf ?? true,
456
+ securityHeaders: options.securityHeaders ?? resolveDevSecurityHeaders(config),
457
+ dataCache: config.dataCache,
458
+ cache: config.cache?.store === "fs" ? {
459
+ store: "fs",
460
+ dir: config.cache.dir
461
+ } : { dir: config.cache?.dir },
462
+ seoConventions: { srcDir: config.srcDir }
463
+ });
464
+ configureApp?.(app);
465
+ attachRequestLogger(app, config.logging?.request === true, logger ?? fallbackLogger);
466
+ return {
467
+ app,
468
+ scanResult,
469
+ layouts: scanResult.layouts
470
+ };
471
+ }
472
+ /**
473
+ * 首次处理请求前的初始化:加载 locales、应用用户 `defineServer` 配置、`app.init()`、
474
+ * 调一次 `onServerReady`。
475
+ *
476
+ * 每次 app 实例更换后都要重新建一个(CLI 的 `updateApp` / HMR 之后):新实例需要重新应用
477
+ * 配置并重新走 init,旧实例的 ready 缓存不能复用。
478
+ */
479
+ function createDevAppReady(options) {
480
+ const { app, loadModule } = options;
481
+ const logger = options.logger ?? fallbackLogger;
482
+ let readyPromise = null;
483
+ let cachedServerConfig = null;
484
+ let serverReadyCalled = false;
485
+ const ready = (() => {
486
+ readyPromise ??= (async () => {
487
+ try {
488
+ await loadModule("ubean:locales");
489
+ } catch (error) {
490
+ logger.warn(`[ubean] Failed to resolve locales module: ${String(error)}`);
491
+ }
492
+ try {
493
+ const serverMod = await loadModule("virtual:ubean-server");
494
+ if (serverMod?.resolveServerConfig) {
495
+ cachedServerConfig = serverMod.resolveServerConfig("dev");
496
+ await applyServerConfig(app, cachedServerConfig);
497
+ }
498
+ } catch (error) {
499
+ logger.warn(`[ubean] Failed to load server config: ${String(error)}`);
500
+ }
501
+ await app.init();
502
+ if (!serverReadyCalled) {
503
+ serverReadyCalled = true;
504
+ if (cachedServerConfig?.onServerReady) try {
505
+ await cachedServerConfig.onServerReady(app);
506
+ } catch (error) {
507
+ logger.warn(`[ubean] onServerReady error: ${String(error)}`);
508
+ }
509
+ }
510
+ })();
511
+ return readyPromise;
512
+ });
513
+ ready.serverConfig = () => cachedServerConfig;
514
+ return ready;
515
+ }
516
+ /**
517
+ * 插件自举入口:创建 app → 接上 Vite SSR 图 → 准备好 ready。
518
+ *
519
+ * `vite dev` 场景下由框架插件调用(无 CLI 参与);`ubean dev` 目前分两步调用
520
+ * (`createDevApp` 在 CLI、`createDevAppReady` 在 dev server),因为 CLI 需要在 Vite server
521
+ * 建起来之前就把 app 交给 DevTools/runner。RM-V12 摘除 CLI 的 server 层后两者会合并。
522
+ */
523
+ async function bootstrapDevApp(options) {
524
+ const created = await createDevApp(options);
525
+ const enhance = (target, targetLayouts) => enhanceDevApp({
526
+ app: target,
527
+ layouts: targetLayouts,
528
+ loadModule: options.loadModule,
529
+ createRenderer: options.createRenderer,
530
+ localeVueParam: resolveLocaleVueParam(options.config),
531
+ backend: options.config.mode === "backend",
532
+ startCronScheduler: options.startCronScheduler,
533
+ onCronError: (error) => (options.logger ?? fallbackLogger).error(`[ubean] Failed to load cron files: ${String(error)}`)
534
+ });
535
+ const bootstrap = {
536
+ app: created.app,
537
+ scanResult: created.scanResult,
538
+ layouts: created.layouts,
539
+ ready: createDevAppReady({
540
+ app: created.app,
541
+ loadModule: options.loadModule,
542
+ logger: options.logger
543
+ }),
544
+ async rebuild(scanResult) {
545
+ const next = await createDevApp({
546
+ ...options,
547
+ scanResult
548
+ });
549
+ bootstrap.app = next.app;
550
+ bootstrap.scanResult = next.scanResult;
551
+ bootstrap.layouts = next.layouts;
552
+ bootstrap.ready = createDevAppReady({
553
+ app: next.app,
554
+ loadModule: options.loadModule,
555
+ logger: options.logger
556
+ });
557
+ enhance(next.app, next.layouts);
558
+ }
559
+ };
560
+ enhance(bootstrap.app, bootstrap.layouts);
561
+ return bootstrap;
562
+ }
563
+ /**
564
+ * `config.security` → dev 安全头默认值。
565
+ *
566
+ * ssg 模式默认关闭:静态产物没有框架运行时的安全头,dev 下也没有需要保护的服务端渲染面。
567
+ */
568
+ function resolveDevSecurityHeaders(config) {
569
+ const security = config.security;
570
+ if (security === false) return false;
571
+ if (security?.headers === false) return false;
572
+ if (security?.headers !== void 0) return security.headers;
573
+ return config.mode !== "ssg";
574
+ }
575
+ /**
576
+ * locale param 必须与客户端 `virtual:ubean-pages` 的路由表一致,否则 `/zh/xxx` 在服务端
577
+ * 落不到对应路由(会掉进 404 catch-all),而客户端渲染的是真实页面 → 水合不一致。
578
+ * i18n 未启用或不需要前缀时返回空串。
579
+ */
580
+ function resolveLocaleVueParam(config) {
581
+ const i18n = config.i18n;
582
+ if (!i18n?.enabled || !i18n.locales?.length) return "";
583
+ return getVueLocaleParam({
584
+ defaultLocale: i18n.defaultLocale,
585
+ locales: i18n.locales.map((l) => l.code),
586
+ strategy: i18n.strategy
587
+ });
588
+ }
589
+ //#endregion
590
+ //#region src/dev/node-web.ts
591
+ /**
592
+ * `node:http` 请求 → Web `Request`。
593
+ *
594
+ * 非 GET/HEAD 时直接把 `IncomingMessage`(可读流)作为 body;undici 要求流式 body 显式
595
+ * 声明 `duplex: 'half'`,否则构造 `Request` 会抛错。
596
+ */
597
+ async function toWebRequest(req, host, protocol) {
598
+ const url = `${protocol}://${req.headers.host || host}${req.url || "/"}`;
599
+ const headers = new Headers();
600
+ for (const [key, value] of Object.entries(req.headers)) if (value) {
601
+ if (Array.isArray(value)) for (const v of value) headers.append(key, v);
602
+ else headers.set(key, value);
603
+ }
604
+ const method = req.method || "GET";
605
+ return new Request(url, {
606
+ method,
607
+ headers,
608
+ body: method === "GET" || method === "HEAD" ? void 0 : req,
609
+ duplex: "half"
610
+ });
611
+ }
612
+ /** Web `Response` → `node:http` 响应:写入状态与头,再流式转发 body(不整体缓冲)。 */
613
+ async function sendWebResponse(res, webRes) {
614
+ res.statusCode = webRes.status;
615
+ res.statusMessage = webRes.statusText;
616
+ webRes.headers.forEach((value, key) => {
617
+ res.setHeader(key, value);
618
+ });
619
+ if (webRes.body) {
620
+ const reader = webRes.body.getReader();
621
+ try {
622
+ while (true) {
623
+ const { done, value } = await reader.read();
624
+ if (done) break;
625
+ res.write(value);
626
+ }
627
+ } finally {
628
+ reader.releaseLock();
629
+ }
630
+ }
631
+ res.end();
632
+ }
633
+ //#endregion
634
+ //#region src/dev/dev-request-router.ts
635
+ /**
636
+ * Vite 内部的 URL 前缀。这些路径与文件系统无关,交回 Vite 才能正确解析。
637
+ *
638
+ * `/@vite/` 客户端与 HMR 运行时;`/@id/` 虚拟模块;`/@fs/` 工作区外的绝对路径;
639
+ * `/node_modules/` 依赖;`/__vite_ping` 心跳(`Accept: text/x-vite-ping`)。
640
+ */
641
+ const VITE_URL_PREFIXES = [
642
+ "/@vite/",
643
+ "/@id/",
644
+ "/@fs/",
645
+ "/node_modules/",
646
+ "/__vite_ping"
647
+ ];
648
+ /**
649
+ * ubean 保留命名空间:`_` 前缀是内置路由(`/_health`、`/_openapi.json`、`/_scalar`、
650
+ * `/_devtools`、`/_iconify`),`__` 前缀是运行时端点(`/__actions`、`/__server-component`),
651
+ * `/api/` 是约定俗成的 API 前缀。这些必须归 ubean —— 注意 `_` 规则要先于静态扩展名规则,
652
+ * 否则 `/_openapi.json` 会被当成 `.json` 静态资源。
653
+ */
654
+ const RESERVED_APP_PREFIXES = [
655
+ "/_",
656
+ "/__",
657
+ "/api/"
658
+ ];
659
+ /**
660
+ * DevTools 相关、**必须交给 Vite** 的命名空间前缀(DTK 外壳 + ubean DevTools SPA)。
661
+ *
662
+ * 它们全落在 `_` / `__` 保留命名空间里,判据默认会判给 ubean → 应用 404,因此调用方要把这份
663
+ * 前缀整体传给 `passThrough`。两个条目覆盖了 DTK 实际使用的四个名字:
664
+ * - `/__devtools` → `/__devtools/`(外壳 HTML、RPC、WS)、`/__devtools-assets/`(DTK 自带 UI
665
+ * 的静态资源,dock 图标的 `vite-plus.svg` 就在这里)、`/__devtools-client-imports.js`
666
+ * (dock 客户端模块的虚拟模块);
667
+ * - `/_devtools` → `/_devtools/index.html`(DTK 以 iframe 载入的 SPA)及其 `assets/*` 与
668
+ * `/_devtools/__connection.json`。裸 `/_devtools` 由 `devtoolsRedirect` 抢先 302,不受影响。
669
+ *
670
+ * 2026-09-17 实测:`/__devtools-assets/vite-plus.svg`(应用页控制台报 404)与
671
+ * `/_devtools/index.html`(DevTools 面板空白的真凶)此前都落进了应用 404。
672
+ */
673
+ const DEVTOOLS_PASS_THROUGH_PREFIXES = ["/__devtools", "/_devtools"];
674
+ /**
675
+ * 框架内置的 **HTML 页面**(不是应用页面):`/_scalar`(Scalar API 文档)与 `/_devtools`
676
+ * (DevTools SPA)。
677
+ *
678
+ * 它们由框架自己产出 HTML,文档里**没有**应用的挂载点,因此不能走应用的客户端入口注入 ——
679
+ * 被注入时浏览器报 `Failed to mount app: mount target selector "#app" returned null`,且 Vite 的
680
+ * HTML transform 会把页面里的内联脚本(Scalar 的 `data-configuration` 块)改写成 html-proxy
681
+ * 模块,页面直接崩(2026-09-17 实测:DevTools 的 API Docs 面板整页空白)。
682
+ *
683
+ * 判据被两处共用 —— dev 请求路由(决定要不要对响应做 `transformIndexHtml`)与 vue 插件的
684
+ * `transformIndexHtml`(决定要不要注入客户端入口);单点维护,避免两边各写一份字符串判定。
685
+ */
686
+ const FRAMEWORK_HTML_PAGES = ["/_devtools", "/_scalar"];
687
+ /** 该 URL 是否指向框架内置的 HTML 页面(见 {@link FRAMEWORK_HTML_PAGES})。 */
688
+ function isFrameworkHtmlPage(url) {
689
+ const pathname = url.split("?")[0].split("#")[0];
690
+ return FRAMEWORK_HTML_PAGES.some((page) => pathname === page || pathname.startsWith(`${page}/`));
691
+ }
692
+ /** 「取模块而不是取页面」的查询标记:Vite 的 import-analysis 会在这些请求上加它们。 */
693
+ const MODULE_QUERY_FLAGS = /* @__PURE__ */ new Set([
694
+ "import",
695
+ "raw",
696
+ "url",
697
+ "inline",
698
+ "direct",
699
+ "worker",
700
+ "vue",
701
+ "html-proxy"
702
+ ]);
703
+ /**
704
+ * `Sec-Fetch-Dest` 中表示「资源」的取值(非文档)。导航请求是 `document`,
705
+ * `fetch()`/XHR 是 `empty` —— 后者既可能是 API 调用也可能是资源请求,因此不能作为判据,
706
+ * 交给扩展名规则。
707
+ */
708
+ const RESOURCE_DESTS = /* @__PURE__ */ new Set([
709
+ "audio",
710
+ "embed",
711
+ "font",
712
+ "image",
713
+ "manifest",
714
+ "object",
715
+ "paintworklet",
716
+ "script",
717
+ "serviceworker",
718
+ "sharedworker",
719
+ "style",
720
+ "track",
721
+ "video",
722
+ "worker",
723
+ "xslt"
724
+ ]);
725
+ /**
726
+ * 会被 Vite 当资源处理的扩展名。刻意保守:只列真实静态资源与可编译模块后缀,
727
+ * `.txt` / `.xml` 这类「既可能是静态文件也可能是应用约定路由」(`robots.txt`、`sitemap.xml`)
728
+ * 也列进来 —— 没有对应静态文件时 Vite 会 `next()`,由 post 兜底回到应用,行为仍然正确。
729
+ */
730
+ const ASSET_EXTENSIONS = /* @__PURE__ */ new Set([
731
+ "avif",
732
+ "br",
733
+ "bmp",
734
+ "cjs",
735
+ "css",
736
+ "csv",
737
+ "eot",
738
+ "gif",
739
+ "gz",
740
+ "ico",
741
+ "jpeg",
742
+ "jpg",
743
+ "js",
744
+ "json",
745
+ "map",
746
+ "mjs",
747
+ "md",
748
+ "mdx",
749
+ "mp3",
750
+ "mp4",
751
+ "ogg",
752
+ "otf",
753
+ "pdf",
754
+ "png",
755
+ "scss",
756
+ "sass",
757
+ "less",
758
+ "styl",
759
+ "svg",
760
+ "ts",
761
+ "tsx",
762
+ "ttf",
763
+ "txt",
764
+ "vue",
765
+ "wasm",
766
+ "wav",
767
+ "webm",
768
+ "webmanifest",
769
+ "webp",
770
+ "woff",
771
+ "woff2",
772
+ "xml",
773
+ "zip"
774
+ ]);
775
+ /** `header` 可能是数组(Node 对重复头部的表示)。 */
776
+ function headerValue(headers, name) {
777
+ const value = headers[name];
778
+ if (!value) return "";
779
+ return (Array.isArray(value) ? value[0] : value).toLowerCase();
780
+ }
781
+ function hasModuleQuery(search) {
782
+ if (!search) return false;
783
+ for (const part of search.slice(1).split("&")) {
784
+ const key = part.split("=")[0];
785
+ if (MODULE_QUERY_FLAGS.has(key)) return true;
786
+ }
787
+ return false;
788
+ }
789
+ function hasAssetExtension(pathname) {
790
+ const lastSegment = pathname.slice(pathname.lastIndexOf("/") + 1);
791
+ const dot = lastSegment.lastIndexOf(".");
792
+ if (dot <= 0 || dot === lastSegment.length - 1) return false;
793
+ return ASSET_EXTENSIONS.has(lastSegment.slice(dot + 1).toLowerCase());
794
+ }
795
+ /**
796
+ * 这个请求是不是「Vite 的资源请求」?`true` → 交给 Vite;`false` → 归 ubean。
797
+ *
798
+ * 纯函数(只读 URL 与请求头),便于单测钉住判据 —— 这是 RM-V10 最容易出错的部分。
799
+ */
800
+ function isViteResourceRequest(url, headers = {}) {
801
+ const [rawPath, search = ""] = url.split("#")[0].split(/(?=\?)/);
802
+ const pathname = decodeURIComponent(rawPath || "/");
803
+ if (VITE_URL_PREFIXES.some((prefix) => pathname === prefix.slice(0, -1) || pathname.startsWith(prefix))) return true;
804
+ if (hasModuleQuery(search)) return true;
805
+ if (RESERVED_APP_PREFIXES.some((prefix) => pathname === prefix.slice(0, -1) || pathname.startsWith(prefix))) return false;
806
+ const dest = headerValue(headers, "sec-fetch-dest");
807
+ if (dest === "document") return false;
808
+ if (RESOURCE_DESTS.has(dest)) return true;
809
+ if (hasAssetExtension(pathname)) return true;
810
+ return false;
811
+ }
812
+ const VALID_ID_PREFIX = "/@id/";
813
+ const NULL_BYTE_PLACEHOLDER = "__x00__";
814
+ /**
815
+ * 虚拟模块 id 在模块图里带原始 `\0` 标记(如 UnoCSS 的 `\0/__uno.css`),而 NUL 是非法 HTML
816
+ * 字符(parse5 报 `unexpected-null-character`)。这里镜像 Vite 自己的 `wrapId` 编码。
817
+ */
818
+ function toDevCssHref(url) {
819
+ return url.includes("\0") ? `${VALID_ID_PREFIX}${url.replace(/\0/g, NULL_BYTE_PLACEHOLDER)}` : url;
820
+ }
821
+ /**
822
+ * 从客户端模块图收集 render-blocking CSS 链接。
823
+ *
824
+ * dev 下由 JS 引入的 CSS(虚拟 uno.css 入口、全局样式、字体 css)要等客户端模块图执行完才生效,
825
+ * SSR HTML 在这段间隙是**无样式**的(FOUC)。把同一批 CSS 以阻塞 `<link>` 注入,浏览器可与 JS
826
+ * 并行抓取,首屏即有样式。
827
+ *
828
+ * `?direct` 让 Vite 的 transform 中间件返回原始 CSS(`Content-Type: text/css`)而不是注入式 JS
829
+ * 包装;URL 自带查询串的模块(SFC `<style>` 块形如 `*.vue?vue&type=style`)无法这样服务,跳过。
830
+ */
831
+ function collectDevCssLinks(moduleGraph) {
832
+ const links = /* @__PURE__ */ new Set();
833
+ for (const mod of moduleGraph.idToModuleMap.values()) {
834
+ const { url, id } = mod;
835
+ if (!url || url.includes("?")) continue;
836
+ if (id?.endsWith(".css") || url.endsWith(".css")) links.add(`${toDevCssHref(url)}?direct`);
837
+ }
838
+ return [...links];
839
+ }
840
+ /** 在 `</head>` 前插入阻塞样式链接。 */
841
+ function injectStylesheetLinks(html, hrefs) {
842
+ if (!hrefs.length || !html.includes("</head>")) return html;
843
+ const tags = hrefs.map((href) => `<link rel="stylesheet" href="${href}">`).join("");
844
+ return html.replace("</head>", `${tags}</head>`);
845
+ }
846
+ /** 缺省 cron 启动器:动态加载,避免把服务端运行时拉进插件模块的顶层依赖。 */
847
+ async function defaultCronScheduler() {
848
+ (await import("@ubean/server/cron")).startCronScheduler();
849
+ }
850
+ /**
851
+ * 自举式 handler:首次调用时创建宿主 dev app(`bootstrapDevApp`),之后复用同一实例。
852
+ *
853
+ * 复用同一个 promise 而不是每次请求重建:`bootstrapDevApp` 会扫盘并加载 SSR 图,重建等于
854
+ * 每个请求都重扫;同时保证并发首请求只自举一次。
855
+ */
856
+ /**
857
+ * 自举出来的 app(供 DevTools 的 playground 等按需取用)。挂在 server 对象上,理由同
858
+ * dev-scan 的协调器:用户 `vite.config.ts` 由 Vite 打包加载,插件与 CLI 可能持有不同的模块实例。
859
+ */
860
+ const DEV_APP_KEY = "__ubeanDevApp";
861
+ /** 取该 dev server 上自举出的 app(还没自举时返回 undefined)。 */
862
+ function getDevApp(server) {
863
+ return server[DEV_APP_KEY];
864
+ }
865
+ function createLazyBootstrapHandler(server, options = {}) {
866
+ let bootstrapPromise = null;
867
+ const start = async () => {
868
+ const rootDir = options.rootDir ?? server.config.root;
869
+ return bootstrapDevApp({
870
+ rootDir,
871
+ config: options.loadConfig ? await options.loadConfig(rootDir) : await loadUbeanConfig(rootDir),
872
+ loadModule: (id) => server.ssrLoadModule(id),
873
+ createRenderer: (await server.ssrLoadModule("@ubean/client/ssr")).createVueRenderer,
874
+ startCronScheduler: options.startCronScheduler ?? defaultCronScheduler,
875
+ logger: server.config.logger
876
+ });
877
+ };
878
+ onDevScan(server, async (result) => {
879
+ await (await bootstrapPromise?.catch(() => null))?.rebuild(result);
880
+ });
881
+ return async (request) => {
882
+ bootstrapPromise ??= start().then((bootstrap) => {
883
+ server[DEV_APP_KEY] = bootstrap.app;
884
+ return bootstrap;
885
+ });
886
+ const bootstrap = await bootstrapPromise;
887
+ await bootstrap.ready();
888
+ server[DEV_APP_KEY] = bootstrap.app;
889
+ return bootstrap.app.fetch(request);
890
+ };
891
+ }
892
+ /** 500 错误页。dev 才看得到,直接给可读的原文与栈。 */
893
+ function renderErrorPage(error) {
894
+ return `<!DOCTYPE html>
895
+ <html>
896
+ <head>
897
+ <title>Server Error</title>
898
+ <style>
899
+ body { font-family: monospace; padding: 2rem; background: #1a1a1a; color: #ff6b6b; }
900
+ pre { background: #2d2d2d; padding: 1rem; border-radius: 4px; overflow-x: auto; }
901
+ </style>
902
+ </head>
903
+ <body>
904
+ <h1>500 · ubean dev server error</h1>
905
+ <pre>${(error instanceof Error ? `${error.message}\n\n${error.stack ?? ""}` : String(error)).replace(/[&<>]/g, (ch) => ({
906
+ "&": "&amp;",
907
+ "<": "&lt;",
908
+ ">": "&gt;"
909
+ })[ch])}</pre>
910
+ </body>
911
+ </html>`;
912
+ }
913
+ /**
914
+ * 构造 pre / post 两个 connect 中间件。
915
+ *
916
+ * 需要 Vite dev server 实例:一是拿不到它就调不了 `transformIndexHtml`(客户端入口注入),
917
+ * 二是 CSS 链接要从**客户端**环境的模块图里收集。
918
+ */
919
+ function createUbeanRequestHandlers(server, options) {
920
+ const { skipHtmlTransform, onError } = options;
921
+ const handler = options.handler ?? createLazyBootstrapHandler(server, options.bootstrap);
922
+ async function respond(req, res) {
923
+ const url = req.url || "/";
924
+ const webReq = await toWebRequest(req, "localhost", req.socket?.encrypted ? "https" : "http");
925
+ const webRes = await handler(webReq, { url });
926
+ const contentType = webRes.headers.get("content-type") || "";
927
+ const skipTransform = skipHtmlTransform?.(url) ?? false;
928
+ const isPage = !isFrameworkHtmlPage(url);
929
+ if (contentType.includes("text/html") && webRes.body && !skipTransform && isPage) {
930
+ const html = await webRes.text();
931
+ const cssLinks = collectDevCssLinks(server.environments.client.moduleGraph);
932
+ const transformed = await server.transformIndexHtml(url, injectStylesheetLinks(html, cssLinks));
933
+ res.statusCode = webRes.status;
934
+ res.statusMessage = webRes.statusText;
935
+ webRes.headers.forEach((value, key) => {
936
+ res.setHeader(key, value);
937
+ });
938
+ res.end(transformed);
939
+ return;
940
+ }
941
+ await sendWebResponse(res, webRes);
942
+ }
943
+ /** 统一错误处理:SSR 栈映射回源码,响应头已发出时只能断开。 */
944
+ async function guard(req, res, run) {
945
+ try {
946
+ await run();
947
+ } catch (error) {
948
+ server.ssrFixStacktrace(error);
949
+ if (onError) onError(error, { url: req.url || "/" });
950
+ else server.config.logger.error(String(error instanceof Error ? error.stack : error));
951
+ if (!res.headersSent) {
952
+ res.statusCode = 500;
953
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
954
+ res.end(renderErrorPage(error));
955
+ } else res.end();
956
+ }
957
+ }
958
+ const passThrough = options.passThrough ?? [];
959
+ const isPassThrough = (url) => {
960
+ const pathname = url.split("?")[0].split("#")[0];
961
+ return passThrough.some((prefix) => pathname.startsWith(prefix));
962
+ };
963
+ const pre = (req, res, next) => {
964
+ if (isPassThrough(req.url || "/") || isViteResourceRequest(req.url || "/", req.headers)) {
965
+ next();
966
+ return;
967
+ }
968
+ guard(req, res, () => respond(req, res));
969
+ };
970
+ const post = (req, res, next) => {
971
+ if (res.writableEnded) {
972
+ next();
973
+ return;
974
+ }
975
+ guard(req, res, () => respond(req, res));
976
+ };
977
+ return {
978
+ pre,
979
+ post
980
+ };
981
+ }
982
+ /**
983
+ * 把请求路由注册进 Vite 插件生命周期(RM-V10 的插件形态)。
984
+ *
985
+ * `appType: 'custom'` 是**必须**的:默认的 `spa`/`mpa` 会在静态中间件之后挂
986
+ * `htmlFallbackMiddleware` + `indexHtmlMiddleware`,把未知路径一律改写成 `index.html`
987
+ * 并用 Vite 自己的 HTML 服务 —— 那样页面请求就轮不到 ubean 了。设为 `custom` 后这两层被摘掉,
988
+ * 未匹配请求正好落到我们的 post 中间件。
989
+ */
990
+ function ubeanDevRequestPlugin(options) {
991
+ return {
992
+ name: "ubean:dev-request",
993
+ config() {
994
+ return { appType: "custom" };
995
+ },
996
+ configureServer(server) {
997
+ const { pre, post } = createUbeanRequestHandlers(server, options);
998
+ if (options.devtoolsRedirect) server.middlewares.use((req, res, next) => {
999
+ const pathname = (req.url || "/").split("?")[0].split("#")[0];
1000
+ if (pathname === "/_devtools" || pathname === "/_devtools/") {
1001
+ res.statusCode = 302;
1002
+ res.setHeader("Location", "/__devtools/");
1003
+ res.end();
1004
+ return;
1005
+ }
1006
+ next();
1007
+ });
1008
+ server.middlewares.use(pre);
1009
+ return () => {
1010
+ server.middlewares.use(post);
1011
+ };
1012
+ }
1013
+ };
1014
+ }
1015
+ //#endregion
1016
+ //#region src/vue-virtual-modules.ts
1017
+ /**
1018
+ * 页面虚拟模块(`virtual:ubean-pages`)。
1019
+ *
1020
+ * 生成逻辑已沉淀到 `@ubean/vue/vite` 的 `generatePagesModuleSource`
1021
+ * (页面路由唯一所有者,与精简内核 `virtual:ubean-vue-routes` 共用同一
1022
+ * 生成器)。此处仅保留框架层的模块 ID 注册(`defineVirtualModule`)与
1023
+ * 兼容签名;route meta 相比旧版额外携带 `matchers` / `transition` /
1024
+ * `requiresAuth` / `head` / `definePage({ meta })` 透传字段(超集,
1025
+ * 使 `createMatcherGuard()` 与 SPA head guard 在框架模式下同样可用)。
1026
+ */
1027
+ function createVuePagesVirtualModule(pages, layouts, notFoundPage, loadingPage, errorPage, localeVueParam) {
1028
+ return defineVirtualModule("virtual:ubean-pages", () => generatePagesModuleSource({
1029
+ pages,
1030
+ layouts,
1031
+ notFoundPage,
1032
+ loadingPage,
1033
+ errorPage
1034
+ }, { vueParam: localeVueParam }));
1035
+ }
1036
+ const EMPTY_APP_ENTRY = {
1037
+ shared: { exists: false },
1038
+ server: { exists: false },
1039
+ client: { exists: false }
1040
+ };
1041
+ function createVueAppEntryVirtualModule(appEntry = EMPTY_APP_ENTRY) {
1042
+ return defineVirtualModule("virtual:ubean-app", () => {
1043
+ const hasSharedApp = appEntry.shared.exists;
1044
+ const hasServerApp = appEntry.server.exists;
1045
+ const hasClientApp = appEntry.client.exists;
1046
+ const hasRootApp = appEntry.root?.exists;
1047
+ return `${`
1048
+ // Auto-generated by ubean - do not edit
1049
+ /* eslint-disable */
1050
+ ${hasSharedApp ? `import _sharedApp from ${JSON.stringify(appEntry.shared.fullPath)};` : "const _sharedApp = null;"}
1051
+ ${hasServerApp ? `import _serverApp from ${JSON.stringify(appEntry.server.fullPath)};` : "const _serverApp = null;"}
1052
+ ${hasClientApp ? `import _clientApp from ${JSON.stringify(appEntry.client.fullPath)};` : "const _clientApp = null;"}
1053
+ ${hasRootApp ? `import _rootApp from ${JSON.stringify(appEntry.root.fullPath)};` : "const _rootApp = null;"}
1054
+
1055
+ import {
1056
+ createUbeanClientApp,
1057
+ createUbeanSSRApp,
1058
+ usePage,
1059
+ useHead,
1060
+ useSeoMeta,
1061
+ Link,
1062
+ Head,
1063
+ getInitialPageData,
1064
+ getInitialState,
1065
+ defineApp,
1066
+ applyAppConfig,
1067
+ createDefaultAppConfig,
1068
+ createClientHead,
1069
+ createServerHead,
1070
+ hydrateIslands,
1071
+ hasPendingIslands,
1072
+ scheduleIslandHydration,
1073
+ configureI18nRuntime
1074
+ } from 'ubean/client';
1075
+ // useRouter 由 vue-router 直源(ubean 不再透传第三方 API)
1076
+ import { useRouter } from 'vue-router';
1077
+
1078
+ import { i18nConfig as _i18nConfig, loadLocale as _loadLocale } from 'ubean:locales';
1079
+
1080
+ if (_i18nConfig && _i18nConfig.enabled !== false) {
1081
+ configureI18nRuntime({
1082
+ config: {
1083
+ defaultLocale: _i18nConfig.defaultLocale,
1084
+ locales: (_i18nConfig.locales || []).map(l => typeof l === 'string' ? l : l.code),
1085
+ strategy: _i18nConfig.strategy || 'prefix_except_default',
1086
+ fallbackLocale: _i18nConfig.fallbackLocale || _i18nConfig.defaultLocale,
1087
+ cookieName: (_i18nConfig.detectBrowserLanguage && _i18nConfig.detectBrowserLanguage.cookieName) || 'ubean_locale',
1088
+ baseUrl: _i18nConfig.baseUrl || '',
1089
+ vueI18n: _i18nConfig.vueI18n
1090
+ },
1091
+ loadLocale: _loadLocale,
1092
+ locales: (_i18nConfig.locales || []).map(l => typeof l === 'string' ? { code: l } : l)
1093
+ });
1094
+ }
1095
+
1096
+ export {
1097
+ createUbeanClientApp,
1098
+ createUbeanSSRApp,
1099
+ usePage,
1100
+ useRouter,
1101
+ useHead,
1102
+ useSeoMeta,
1103
+ Link,
1104
+ Head,
1105
+ getInitialPageData,
1106
+ getInitialState,
1107
+ defineApp,
1108
+ applyAppConfig,
1109
+ createDefaultAppConfig
1110
+ };
1111
+
1112
+ import {
1113
+ routes,
1114
+ resolvePageComponent,
1115
+ resolveLayoutComponent,
1116
+ defaultLayout,
1117
+ pageNames,
1118
+ layoutNames,
1119
+ resolveLoadingComponent,
1120
+ resolveErrorComponent,
1121
+ hasNotFoundPage,
1122
+ hasErrorPage
1123
+ } from 'virtual:ubean-pages';
1124
+
1125
+ export {
1126
+ routes,
1127
+ resolvePageComponent,
1128
+ resolveLayoutComponent,
1129
+ defaultLayout,
1130
+ pageNames,
1131
+ layoutNames,
1132
+ resolveLoadingComponent,
1133
+ resolveErrorComponent,
1134
+ hasNotFoundPage,
1135
+ hasErrorPage
1136
+ };
1137
+
1138
+ function _mergeAppConfig(base, ...configs) {
1139
+ const result = { ...base };
1140
+ for (const cfg of configs) {
1141
+ if (!cfg) continue;
1142
+ if (cfg.plugins) result.plugins = [...(result.plugins || []), ...cfg.plugins];
1143
+ if (cfg.globalComponents) result.globalComponents = { ...(result.globalComponents || {}), ...cfg.globalComponents };
1144
+ if (cfg.provides) result.provides = { ...(result.provides || {}), ...cfg.provides };
1145
+ if (cfg.head) result.head = { ...(result.head || {}), ...cfg.head };
1146
+ if (cfg.rootId) result.rootId = cfg.rootId;
1147
+ if (cfg.rootAttrs) result.rootAttrs = { ...(result.rootAttrs || {}), ...cfg.rootAttrs };
1148
+ if (cfg.onAppCreated) result.onAppCreated = cfg.onAppCreated;
1149
+ if (cfg.onClientReady) result.onClientReady = cfg.onClientReady;
1150
+ if (cfg.errorComponent) result.errorComponent = cfg.errorComponent;
1151
+ if (cfg.loadingComponent) result.loadingComponent = cfg.loadingComponent;
1152
+ if (cfg.appRoot) result.appRoot = cfg.appRoot;
1153
+ if (cfg.viewTransitions !== undefined) result.viewTransitions = cfg.viewTransitions;
1154
+ if (cfg.serializeState) result.serializeState = cfg.serializeState;
1155
+ if (cfg.hydrateState) result.hydrateState = cfg.hydrateState;
1156
+ }
1157
+ // router.setup:累加语义 — shared 和 client/server 各自定义的 setup 都会执行
1158
+ // (顺序:shared 先,client/server 后)。这样 shared 可放通用守卫(如埋点),
1159
+ // client/server 可放环境专用守卫(如 SSR 鉴权)。
1160
+ const setups = [];
1161
+ for (const cfg of [base, ...configs]) {
1162
+ if (cfg?.router?.setup) setups.push(cfg.router.setup);
1163
+ }
1164
+ // scrollBehavior 为函数不可合并,后者覆盖(shared 之后定义的 client/server 生效)。
1165
+ let scrollBehavior;
1166
+ for (const cfg of [base, ...configs]) {
1167
+ if (cfg?.router?.scrollBehavior !== undefined) scrollBehavior = cfg.router.scrollBehavior;
1168
+ }
1169
+ const router = {};
1170
+ if (setups.length > 0) {
1171
+ router.setup = setups.length === 1 ? setups[0] : (r) => { for (const s of setups) s(r); };
1172
+ }
1173
+ if (scrollBehavior !== undefined) router.scrollBehavior = scrollBehavior;
1174
+ if (setups.length > 0 || scrollBehavior !== undefined) result.router = router;
1175
+ return result;
1176
+ }
1177
+
1178
+ export function resolveAppConfig(mode) {
1179
+ const base = createDefaultAppConfig();
1180
+ // src/app.vue / src/App.vue 自动检测(小写优先)作为默认 appRoot;
1181
+ // 显式 defineApp({ appRoot }) 在下方 merge 时按"后者覆盖"盖过它。
1182
+ // 注入 base 而非在消费点 || _rootApp,保证走 resolveAppConfig('server')
1183
+ // 的 dev/prod SSR(经 @ubean/client/ssr 的 createVueRenderer)与客户端
1184
+ // resolveAppConfig('client') 读到一致的 appRoot → 水合结构不 mismatch。
1185
+ if (_rootApp) base.appRoot = _rootApp;
1186
+ if (!_sharedApp && !_serverApp && !_clientApp) return base;
1187
+
1188
+ const sharedCfg = typeof _sharedApp === 'function' ? _sharedApp() : _sharedApp;
1189
+ const serverCfg = mode === 'server' && _serverApp ? (typeof _serverApp === 'function' ? _serverApp() : _serverApp) : null;
1190
+ const clientCfg = mode === 'client' && _clientApp ? (typeof _clientApp === 'function' ? _clientApp() : _clientApp) : null;
1191
+
1192
+ const merged = _mergeAppConfig(base, sharedCfg, mode === 'server' ? serverCfg : clientCfg);
1193
+
1194
+ // onAppCreated: shared config is the default; server/client-specific overrides it if present.
1195
+ if (sharedCfg?.onAppCreated) merged.onAppCreated = sharedCfg.onAppCreated;
1196
+ if (mode === 'client' && clientCfg?.onClientReady) merged.onClientReady = clientCfg.onClientReady;
1197
+ else if (mode === 'server' && serverCfg?.onAppCreated) merged.onAppCreated = serverCfg.onAppCreated;
1198
+
1199
+ return merged;
1200
+ }
1201
+
1202
+ export async function createApp() {
1203
+ const config = resolveAppConfig('client');
1204
+ const head = createClientHead();
1205
+
1206
+ // Push global app head (from defineApp) as defaults before any page-level head.
1207
+ if (config.head) {
1208
+ const headInput = {};
1209
+ if (config.head.title) headInput.title = config.head.title;
1210
+ if (config.head.htmlAttrs) headInput.htmlAttrs = config.head.htmlAttrs;
1211
+ if (config.head.bodyAttrs) headInput.bodyAttrs = config.head.bodyAttrs;
1212
+ if (config.head.meta) headInput.meta = config.head.meta;
1213
+ if (config.head.link) headInput.link = config.head.link;
1214
+ if (config.head.script) headInput.script = config.head.script;
1215
+ head.push(headInput);
1216
+ }
1217
+
1218
+ // Resolve the auto-detected loading/error components from pages/loading.vue
1219
+ // and pages/error.vue. These loaders return Promises (dynamic imports), so
1220
+ // they MUST be awaited — otherwise 'resolveComp' markRaw's the unresolved
1221
+ // Promise object and passes it to <Suspense>/<ErrorBoundary> as the
1222
+ // 'component' prop, causing "Hydration node mismatch: server <div> vs
1223
+ // client Symbol(v-fgt)" (rendering a Promise produces a fragment).
1224
+ // defineApp({ loadingComponent/errorComponent }) takes priority.
1225
+ const loadingComponent = config.loadingComponent || (await resolveLoadingComponent()) || undefined;
1226
+ const errorComponent = config.errorComponent || (await resolveErrorComponent()) || undefined;
1227
+
1228
+ const initialPage = getInitialPageData();
1229
+ const instance = createUbeanClientApp({
1230
+ routes,
1231
+ resolveLayoutComponent,
1232
+ defaultLayout,
1233
+ head,
1234
+ viewTransitions: config.viewTransitions,
1235
+ initialPage: initialPage || undefined,
1236
+ hydrate: !!initialPage,
1237
+ router: config.router,
1238
+ loadingComponent,
1239
+ errorComponent,
1240
+ // appRoot 已在 resolveAppConfig 中合并(src/App.vue 默认 + defineApp 显式覆盖)
1241
+ appRoot: config.appRoot
1242
+ });
1243
+
1244
+ applyAppConfig(instance.app, config, 'client');
1245
+
1246
+ if (config.onAppCreated) config.onAppCreated(instance.app);
1247
+
1248
+ // SSR 状态水合:在 applyAppConfig(注册插件)之后、mount 之前调用。
1249
+ // getInitialState() 从 DOM 的 __UBEAN_STATE__ script 读取服务端
1250
+ // serializeState 产生的状态对象(如 Pinia 的 state)。
1251
+ // 必须在 mount 前执行,否则 store 已初始化为默认值,水合无效。
1252
+ if (config.hydrateState) {
1253
+ const state = getInitialState();
1254
+ config.hydrateState(instance.app, state);
1255
+ }
1256
+
1257
+ const mountApp = () => {
1258
+ instance.app.mount('#' + (config.rootId || 'app'));
1259
+ if (config.onClientReady) {
1260
+ config.onClientReady(instance.app);
1261
+ }
1262
+ // Islands: first mount always waits two rAFs so Vue's patch finishes.
1263
+ // SPA afterEach waits one rAF, then skips the second when no pending islands.
1264
+ var appRoot = function () {
1265
+ return document.getElementById('app') || undefined;
1266
+ };
1267
+ var hydrateNow = function () {
1268
+ hydrateIslands({ appContext: instance.app, root: appRoot() });
1269
+ };
1270
+ var pendingIslands = function () {
1271
+ return hasPendingIslands(appRoot());
1272
+ };
1273
+ scheduleIslandHydration({
1274
+ requestAnimationFrame: requestAnimationFrame,
1275
+ hasPending: pendingIslands,
1276
+ hydrate: hydrateNow,
1277
+ forceDoubleFrame: true
1278
+ });
1279
+ instance.router.afterEach(function () {
1280
+ scheduleIslandHydration({
1281
+ requestAnimationFrame: requestAnimationFrame,
1282
+ hasPending: pendingIslands,
1283
+ hydrate: hydrateNow
1284
+ });
1285
+ });
1286
+ };
1287
+
1288
+ // When hydrating SSR content, must wait for router to be ready before mounting,
1289
+ // otherwise RouterView has no matched route and causes hydration mismatch.
1290
+ if (initialPage) {
1291
+ instance.router
1292
+ .isReady()
1293
+ .then(async function () {
1294
+ // SSR HTML 已包含布局链;客户端水合首帧必须同样就绪(布局是异步
1295
+ // 解析的),否则首帧渲染裸 PageView 导致 hydration mismatch。
1296
+ await instance.preloadLayouts();
1297
+ })
1298
+ .then(mountApp);
1299
+ } else {
1300
+ mountApp();
1301
+ }
1302
+
1303
+ return instance;
1304
+ }
1305
+
1306
+ export async function createSSRApp(initialPage) {
1307
+ const config = resolveAppConfig('server');
1308
+ const head = createServerHead();
1309
+
1310
+ // SSR doesn't need Suspense fallback (server resolves async synchronously),
1311
+ // but we still pass it for consistency. Await the loaders so resolved
1312
+ // Component values (not Promises) are passed to createUbeanSSRApp.
1313
+ const loadingComponent = config.loadingComponent || (await resolveLoadingComponent()) || undefined;
1314
+
1315
+ const errorComponent = config.errorComponent || (await resolveErrorComponent()) || undefined;
1316
+
1317
+ const { app, router } = createUbeanSSRApp(initialPage, {
1318
+ routes,
1319
+ resolveLayoutComponent,
1320
+ defaultLayout,
1321
+ head,
1322
+ router: config.router,
1323
+ loadingComponent,
1324
+ errorComponent,
1325
+ // appRoot 已在 resolveAppConfig 中合并(src/App.vue 默认 + defineApp 显式覆盖),
1326
+ // 客户端水合同理会从 resolveAppConfig('client') 读到一致的值。
1327
+ appRoot: config.appRoot
1328
+ });
1329
+
1330
+ applyAppConfig(app, config, 'server');
1331
+
1332
+ if (config.onAppCreated) config.onAppCreated(app);
1333
+
1334
+ return { app, router, head, config };
1335
+ }
1336
+
1337
+ export const rootId = 'app';
1338
+ `.trim()}\n`;
1339
+ });
1340
+ }
1341
+ function createClientEntryVirtualModule() {
1342
+ return defineVirtualModule("virtual:ubean-client-entry", () => {
1343
+ return `${`
1344
+ // Auto-generated by ubean client entry - do not edit
1345
+ /* eslint-disable */
1346
+ ${getCssImports().map((css) => `import ${JSON.stringify(css)};`).join("\n")}
1347
+ import { createApp } from 'virtual:ubean-app';
1348
+
1349
+ createApp();
1350
+ `.trim()}\n`;
1351
+ });
1352
+ }
1353
+ const EMPTY_SERVER_ENTRY = {
1354
+ shared: { exists: false },
1355
+ dev: { exists: false },
1356
+ prod: { exists: false }
1357
+ };
1358
+ function createServerEntryVirtualModule(serverEntry = EMPTY_SERVER_ENTRY) {
1359
+ return defineVirtualModule("virtual:ubean-server", () => {
1360
+ const hasShared = serverEntry.shared.exists;
1361
+ const hasDev = serverEntry.dev.exists;
1362
+ const hasProd = serverEntry.prod.exists;
1363
+ return `${`
1364
+ // Auto-generated by ubean server entry - do not edit
1365
+ /* eslint-disable */
1366
+ ${hasShared ? `import _sharedServer from ${JSON.stringify(serverEntry.shared.fullPath)};` : "const _sharedServer = null;"}
1367
+ ${hasDev ? `import _devServer from ${JSON.stringify(serverEntry.dev.fullPath)};` : "const _devServer = null;"}
1368
+ ${hasProd ? `import _prodServer from ${JSON.stringify(serverEntry.prod.fullPath)};` : "const _prodServer = null;"}
1369
+
1370
+ import {
1371
+ defineServer,
1372
+ createDefaultServerConfig,
1373
+ mergeServerConfigs
1374
+ } from 'ubean/server';
1375
+
1376
+ export {
1377
+ defineServer,
1378
+ createDefaultServerConfig,
1379
+ mergeServerConfigs
1380
+ };
1381
+
1382
+ /**
1383
+ * Resolve the user's server config for the given mode ('dev' | 'prod').
1384
+ * Merges shared config with mode-specific config. Returns an empty
1385
+ * config when no server entry file exists.
1386
+ */
1387
+ export function resolveServerConfig(mode) {
1388
+ const base = createDefaultServerConfig();
1389
+
1390
+ if (!_sharedServer && !_devServer && !_prodServer) return base;
1391
+
1392
+ const sharedCfg = typeof _sharedServer === 'function' ? _sharedServer() : _sharedServer;
1393
+ const modeCfg = mode === 'dev'
1394
+ ? (typeof _devServer === 'function' ? _devServer() : _devServer)
1395
+ : (typeof _prodServer === 'function' ? _prodServer() : _prodServer);
1396
+
1397
+ return mergeServerConfigs(base, sharedCfg, modeCfg);
1398
+ }
1399
+ `.trim()}\n`;
1400
+ });
1401
+ }
1402
+ //#endregion
1403
+ //#region src/vue-plugin.ts
1404
+ const VUE_PLUGIN_INCLUDE = [/\.vue$/, /\.md$/];
1405
+ const VIRTUAL_PAGES = "virtual:ubean-pages";
1406
+ const VIRTUAL_APP = "virtual:ubean-app";
1407
+ const VIRTUAL_CLIENT = "virtual:ubean-client-entry";
1408
+ const VIRTUAL_SERVER = "virtual:ubean-server";
1409
+ const CLIENT_ENTRY_URL = `/@id/${VIRTUAL_CLIENT}`;
1410
+ const TS_VIRTUAL_IDS = [
1411
+ VIRTUAL_PAGES,
1412
+ VIRTUAL_APP,
1413
+ VIRTUAL_SERVER
1414
+ ];
1415
+ const VIRTUAL_IDS = [
1416
+ VIRTUAL_PAGES,
1417
+ VIRTUAL_APP,
1418
+ VIRTUAL_CLIENT,
1419
+ VIRTUAL_SERVER
1420
+ ];
1421
+ const HASH_ID_TO_VIRTUAL = {
1422
+ "#ubean-pages": VIRTUAL_PAGES,
1423
+ "#ubean-app": VIRTUAL_APP,
1424
+ "#ubean-client-entry": VIRTUAL_CLIENT,
1425
+ "#ubean-server": VIRTUAL_SERVER
1426
+ };
1427
+ const NULL_PREFIX = "\0";
1428
+ const VIRTUAL_EXT = ".ts";
1429
+ function toResolvedVirtualId(virtualId) {
1430
+ return NULL_PREFIX + virtualId + VIRTUAL_EXT;
1431
+ }
1432
+ function parseResolvedVirtualId(resolvedId) {
1433
+ if (!resolvedId.startsWith(NULL_PREFIX)) return void 0;
1434
+ const withoutPrefix = resolvedId.slice(1);
1435
+ if (!withoutPrefix.endsWith(VIRTUAL_EXT)) return void 0;
1436
+ const virtualId = withoutPrefix.slice(0, -3);
1437
+ return VIRTUAL_IDS.includes(virtualId) ? virtualId : void 0;
1438
+ }
1439
+ function localeVueParamFromConfig(config) {
1440
+ if (config.i18n?.enabled === false) return void 0;
1441
+ const codes = (config.i18n?.locales || []).map((l) => l.code);
1442
+ if (codes.length === 0) return void 0;
1443
+ return getVueLocaleParam({
1444
+ defaultLocale: config.i18n.defaultLocale,
1445
+ locales: codes,
1446
+ strategy: config.i18n.strategy
1447
+ }) || void 0;
1448
+ }
1449
+ function ubeanVite(options) {
1450
+ const { config: ubeanConfig } = options;
1451
+ const virtualRegistry = options.registry ?? createVirtualRegistry();
1452
+ const srcDir = resolve(ubeanConfig.rootDir, ubeanConfig.srcDir);
1453
+ const dtsDir = join(ubeanConfig.rootDir, ".ubean");
1454
+ const markdownEnabled = ubeanConfig.markdown?.enabled !== false;
1455
+ const mdxEnabled = ubeanConfig.markdown?.mdx === true;
1456
+ const autoImports = resolveAutoImportsConfig(ubeanConfig.autoImports);
1457
+ const componentsAutoImport = resolveComponentsConfig(ubeanConfig.components);
1458
+ const markdownComponentsAutoImport = ubeanConfig.markdown?.components?.autoImport !== false;
1459
+ const composablesDirName = ubeanConfig.dir.composables || "composables";
1460
+ const componentsDirName = ubeanConfig.dir.components || "components";
1461
+ const composablesDirs = [join(srcDir, composablesDirName), ...autoImports.options.dirs ?? []];
1462
+ const componentsDirs = [join(srcDir, componentsDirName), ...componentsAutoImport.options.dirs ?? []];
1463
+ const mdExtensions = mdxEnabled ? ["md", "mdx"] : ["md"];
1464
+ function getVirtualModule(virtualId) {
1465
+ return virtualRegistry.getModules().find((m) => m.id === virtualId);
1466
+ }
1467
+ async function loadVirtualModule(virtualId) {
1468
+ const mod = getVirtualModule(virtualId);
1469
+ if (!mod) return void 0;
1470
+ return mod.load();
1471
+ }
1472
+ function scanProjectOnce() {
1473
+ return scanProject({
1474
+ cwd: ubeanConfig.rootDir,
1475
+ srcDir: ubeanConfig.srcDir,
1476
+ dirs: ubeanConfig.dir,
1477
+ ignore: ubeanConfig.scanOptions?.ignore
1478
+ });
1479
+ }
1480
+ async function scanAndRegister() {
1481
+ applyScan(await scanProjectOnce());
1482
+ }
1483
+ /**
1484
+ * 依据扫描结果重建本插件的虚拟模块。
1485
+ *
1486
+ * 与扫描分离(RM-V13):dev 下扫描由 `dev-scan.ts` 的协调器统一触发一次,core / vue / CLI
1487
+ * 三个订阅者各取所需;`buildStart` 与构建期则继续走 `scanAndRegister()`。
1488
+ */
1489
+ function applyScan(result) {
1490
+ virtualRegistry.register(createVuePagesVirtualModule(result.pages, result.layouts, result.notFoundPage, result.loadingPage, result.errorPage, localeVueParamFromConfig(ubeanConfig)));
1491
+ virtualRegistry.register(createVueAppEntryVirtualModule(result.appEntry));
1492
+ virtualRegistry.register(createServerEntryVirtualModule(result.serverEntry));
1493
+ virtualRegistry.register(createClientEntryVirtualModule());
1494
+ }
1495
+ const HASH_IDS = Object.keys(HASH_ID_TO_VIRTUAL);
1496
+ const plugins = [{
1497
+ name: "ubean:vue",
1498
+ enforce: "pre",
1499
+ async buildStart() {
1500
+ await scanAndRegister();
1501
+ },
1502
+ resolveId(id, importer, opts) {
1503
+ if (HASH_ID_TO_VIRTUAL[id]) return toResolvedVirtualId(HASH_ID_TO_VIRTUAL[id]);
1504
+ if (VIRTUAL_IDS.includes(id)) return toResolvedVirtualId(id);
1505
+ if (id === "@ubean/i18n" && !opts?.ssr) return this.resolve("@ubean/i18n/browser", importer, {
1506
+ skipSelf: true,
1507
+ ...opts
1508
+ });
1509
+ },
1510
+ async load(id) {
1511
+ const virtualId = parseResolvedVirtualId(id);
1512
+ if (virtualId) {
1513
+ let code = await loadVirtualModule(virtualId);
1514
+ if (code && TS_VIRTUAL_IDS.includes(virtualId)) code = (await transformWithOxc(code, `${virtualId}.ts`)).code;
1515
+ return code;
1516
+ }
1517
+ },
1518
+ config() {
1519
+ const require = createRequire(import.meta.url);
1520
+ let vueI18nEntry = "vue-i18n";
1521
+ let intlifyCoreEntry = "@intlify/core";
1522
+ let intlifyBaseEntry = "@intlify/core-base";
1523
+ try {
1524
+ vueI18nEntry = require.resolve("vue-i18n/dist/vue-i18n.esm-bundler.js");
1525
+ } catch {
1526
+ try {
1527
+ vueI18nEntry = require.resolve("vue-i18n");
1528
+ } catch {}
1529
+ }
1530
+ try {
1531
+ intlifyCoreEntry = require.resolve("@intlify/core/dist/core.node.mjs");
1532
+ } catch {}
1533
+ try {
1534
+ intlifyBaseEntry = require.resolve("@intlify/core-base/dist/core-base.mjs");
1535
+ } catch {}
1536
+ const singleton = ssrSingletonDevPolicy();
1537
+ return {
1538
+ appType: "custom",
1539
+ resolve: {
1540
+ ...singleton.resolve,
1541
+ alias: {
1542
+ "vue-i18n": vueI18nEntry,
1543
+ "@intlify/core": intlifyCoreEntry,
1544
+ "@intlify/core-base": intlifyBaseEntry
1545
+ }
1546
+ },
1547
+ optimizeDeps: {
1548
+ exclude: [
1549
+ ...singleton.optimizeDeps.exclude,
1550
+ ...VIRTUAL_IDS,
1551
+ ...HASH_IDS
1552
+ ],
1553
+ include: singleton.optimizeDeps.include
1554
+ },
1555
+ ssr: singleton.ssr
1556
+ };
1557
+ },
1558
+ transformIndexHtml(html, ctx) {
1559
+ if (ctx?.path && isFrameworkHtmlPage(ctx.path)) return html;
1560
+ let result = html;
1561
+ const colorModeConfig = ubeanConfig.colorMode;
1562
+ if (colorModeConfig !== false) {
1563
+ const script = getColorModeScript(resolveColorModeConfig(colorModeConfig));
1564
+ result = result.replace("<head>", `<head>\n ${script}`);
1565
+ }
1566
+ const partyTownConfig = ubeanConfig.partyTown;
1567
+ if (partyTownConfig !== false && partyTownConfig !== void 0) {
1568
+ const resolved = resolvePartyTownConfig(partyTownConfig === true ? { enabled: true } : partyTownConfig);
1569
+ if (resolved.enabled) {
1570
+ const script = getPartyTownScript(resolved);
1571
+ if (script) result = result.replace("</head>", ` ${script}\n</head>`);
1572
+ }
1573
+ }
1574
+ if (!/<link\b[^>]*rel=["']icon["']/i.test(result)) {
1575
+ const faviconLink = renderFaviconLink(ubeanConfig.favicon ?? void 0);
1576
+ if (faviconLink) result = result.replace("<head>", `<head>\n ${faviconLink}`);
1577
+ }
1578
+ if (result.includes(CLIENT_ENTRY_URL) || result.includes(VIRTUAL_CLIENT)) return result;
1579
+ return result.replace("</body>", ` <script type="module" src="${CLIENT_ENTRY_URL}"><\/script>\n</body>`);
1580
+ },
1581
+ configureServer(server) {
1582
+ getDevScanCoordinator(server, (source) => ({
1583
+ rootDir: ubeanConfig.rootDir,
1584
+ srcDir: ubeanConfig.srcDir,
1585
+ dirs: ubeanConfig.dir,
1586
+ ignore: ubeanConfig.scanOptions?.ignore,
1587
+ source,
1588
+ scan: scanProjectOnce,
1589
+ reload: () => server.ws.send({ type: "full-reload" })
1590
+ })).subscribe((result) => {
1591
+ applyScan(result);
1592
+ for (const vid of VIRTUAL_IDS) {
1593
+ const mod = server.moduleGraph.getModuleById(toResolvedVirtualId(vid));
1594
+ if (mod) server.moduleGraph.invalidateModule(mod);
1595
+ }
1596
+ });
1597
+ }
1598
+ }];
1599
+ if (options.vue !== false) plugins.unshift(vue({
1600
+ include: VUE_PLUGIN_INCLUDE,
1601
+ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("ubean-") } }
1602
+ }));
1603
+ if (ubeanConfig.i18n?.enabled !== false) plugins.push(VueI18nPlugin({
1604
+ include: [join(srcDir, "locales/**")],
1605
+ ssr: true,
1606
+ compositionOnly: true,
1607
+ runtimeOnly: false
1608
+ }));
1609
+ if (markdownEnabled) {
1610
+ const markdownOptions = {
1611
+ ...ubeanConfig.markdown?.markdownExit,
1612
+ html: true
1613
+ };
1614
+ plugins.push(Markdown({
1615
+ markdownOptions,
1616
+ wrapperClasses: ubeanConfig.markdown?.wrapperClass ?? "markdown-body",
1617
+ headEnabled: true,
1618
+ headField: "head"
1619
+ }));
1620
+ }
1621
+ if (mdxEnabled) plugins.push(ubeanMdxPlugin({
1622
+ remarkPlugins: ubeanConfig.markdown?.remarkPlugins || [],
1623
+ rehypePlugins: ubeanConfig.markdown?.rehypePlugins || []
1624
+ }));
1625
+ if (autoImports.enabled) {
1626
+ const userAutoImports = autoImports.options;
1627
+ plugins.push(AutoImport({
1628
+ vueTemplate: true,
1629
+ eslintrc: { enabled: false },
1630
+ ...userAutoImports,
1631
+ imports: [...getAutoImportPresets(autoImports), ...toArray(userAutoImports.imports)],
1632
+ dirs: composablesDirs,
1633
+ dts: userAutoImports.dts === void 0 ? join(dtsDir, "auto-imports.d.ts") : userAutoImports.dts
1634
+ }));
1635
+ }
1636
+ const UBEAN_BUILTIN_COMPONENTS = [
1637
+ "Link",
1638
+ "Head",
1639
+ "PageView",
1640
+ "ClientOnly"
1641
+ ];
1642
+ function ubeanComponentsResolver(componentName) {
1643
+ if (UBEAN_BUILTIN_COMPONENTS.includes(componentName)) return {
1644
+ name: componentName,
1645
+ from: "ubean/client"
1646
+ };
1647
+ }
1648
+ const dynamicResolvers = [...componentsAutoImport.ubean ? [ubeanComponentsResolver] : [], (name) => {
1649
+ for (const resolver of getComponentResolvers()) {
1650
+ const result = typeof resolver === "function" ? resolver(name) : resolver.resolve(name);
1651
+ if (result) return result;
1652
+ }
1653
+ }];
1654
+ if (componentsAutoImport.enabled) {
1655
+ const userComponents = componentsAutoImport.options;
1656
+ const extensions = ["vue"];
1657
+ const includePatterns = [/\.vue$/, /\.vue\?vue/];
1658
+ if (markdownEnabled && markdownComponentsAutoImport) {
1659
+ extensions.push(...mdExtensions);
1660
+ includePatterns.push(/\.md$/);
1661
+ if (mdxEnabled) includePatterns.push(/\.mdx$/);
1662
+ }
1663
+ plugins.push(Components({
1664
+ deep: true,
1665
+ ...userComponents,
1666
+ dirs: componentsDirs,
1667
+ extensions,
1668
+ include: includePatterns,
1669
+ dts: userComponents.dts === void 0 ? join(dtsDir, "components.d.ts") : userComponents.dts,
1670
+ resolvers: [...dynamicResolvers, ...toArray(userComponents.resolvers)]
1671
+ }));
1672
+ } else plugins.push(Components({
1673
+ dts: true,
1674
+ resolvers: dynamicResolvers
1675
+ }));
1676
+ const searchConfig = ubeanConfig.search;
1677
+ if (searchConfig !== false && searchConfig !== void 0) plugins.push({
1678
+ name: "ubean:pagefind",
1679
+ apply: "build",
1680
+ closeBundle() {
1681
+ return runPagefindIndexing(ubeanConfig, searchConfig);
1682
+ }
1683
+ });
1684
+ return plugins;
1685
+ }
1686
+ /**
1687
+ * P9-26: Run the Pagefind CLI to index built HTML files.
1688
+ *
1689
+ * Spawns `npx pagefind --site <dir>` after the build. If the `pagefind`
1690
+ * package is not installed, prints a helpful warning instead of failing.
1691
+ */
1692
+ async function runPagefindIndexing(ubeanConfig, searchConfig) {
1693
+ const { spawn } = await import("node:child_process");
1694
+ const { resolve: resolvePath } = await import("node:path");
1695
+ const isObjectConfig = typeof searchConfig === "object";
1696
+ if (!(isObjectConfig ? searchConfig.enabled !== false : true)) return;
1697
+ const outDir = isObjectConfig && searchConfig.site ? searchConfig.site : "dist";
1698
+ const indexPath = isObjectConfig && searchConfig.indexPath ? searchConfig.indexPath : "pagefind";
1699
+ const verbose = isObjectConfig && searchConfig.verbose === true;
1700
+ const args = [
1701
+ "pagefind",
1702
+ "--site",
1703
+ resolvePath(ubeanConfig.rootDir, outDir),
1704
+ "--output-subdir",
1705
+ indexPath
1706
+ ];
1707
+ if (isObjectConfig && searchConfig.glob) args.push("--glob", searchConfig.glob);
1708
+ if (isObjectConfig && searchConfig.excludeSelectors) for (const selector of searchConfig.excludeSelectors) args.push("--exclude-selectors", selector);
1709
+ if (verbose) args.push("--verbose");
1710
+ return new Promise((resolvePromise) => {
1711
+ const child = spawn("npx", args, {
1712
+ stdio: "inherit",
1713
+ cwd: ubeanConfig.rootDir,
1714
+ shell: true
1715
+ });
1716
+ child.on("error", (err) => {
1717
+ if (err.code === "ENOENT" || /not found/i.test(err.message)) console.warn("[ubean:pagefind] Pagefind CLI not found. Install it with `pnpm add -D pagefind` to enable full-text search.");
1718
+ else console.error("[ubean:pagefind] Failed to run Pagefind:", err.message);
1719
+ resolvePromise();
1720
+ });
1721
+ child.on("exit", (code) => {
1722
+ if (code === 0) console.log("[ubean:pagefind] Search index generated successfully.");
1723
+ else console.warn(`[ubean:pagefind] Pagefind exited with code ${code}. Search index may be incomplete.`);
1724
+ resolvePromise();
1725
+ });
1726
+ });
1727
+ }
1728
+ //#endregion
1729
+ export { buildDevSsrRoutes as C, getDevScanCoordinator as D, createDevScanCoordinator as E, onDevScan as O, enhanceDevApp as S, DEV_SCAN_DIRS as T, bootstrapDevApp as _, createVueAppEntryVirtualModule as a, resolveDevSecurityHeaders as b, collectDevCssLinks as c, injectStylesheetLinks as d, isFrameworkHtmlPage as f, toWebRequest as g, sendWebResponse as h, createServerEntryVirtualModule as i, peekDevScanCoordinator as k, createUbeanRequestHandlers as l, ubeanDevRequestPlugin as m, ubeanVite as n, createVuePagesVirtualModule as o, isViteResourceRequest as p, createClientEntryVirtualModule as r, DEVTOOLS_PASS_THROUGH_PREFIXES as s, VUE_PLUGIN_INCLUDE as t, getDevApp as u, createDevApp as v, toVueRouterPagePath as w, resolveLocaleVueParam as x, createDevAppReady as y };