@ubean/build 0.5.2 → 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,2656 @@
1
+ import { a as ssrSingletonProdOptimizeExclude, m as createVirtualRegistry, o as ssrSingletonProdSsr, y as __exportAll } from "./ssr-singleton-DPTawJSx.js";
2
+ import { a as createRoutingVirtualModule, i as createPagesVirtualModule, n as createLocalesVirtualModule, r as createMetaVirtualModule, s as transformMacros, t as createAppVirtualModule } from "./virtual-modules-CvGWlsXj.js";
3
+ import { D as getDevScanCoordinator, a as createVueAppEntryVirtualModule, g as toWebRequest, h as sendWebResponse, i as createServerEntryVirtualModule, n as ubeanVite, o as createVuePagesVirtualModule, r as createClientEntryVirtualModule } from "./vue-BrbbaXTC.js";
4
+ import { createStaticSsgRenderer } from "./static-render.js";
5
+ import { prerender, resolvePrerenderStaticDir } from "./prerender.js";
6
+ import { join, relative, resolve } from "pathe";
7
+ import * as nodeFs from "node:fs";
8
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
9
+ import { loadUbeanConfig, resolveModules, resolvePrerenderConfig, tryGetConfig } from "@ubean/config";
10
+ import { isEphemeralCachePreset, registerBuiltinPresets, resolvePresetByName, resolveProductionCacheStore } from "@ubean/preset";
11
+ import { createServerRouter } from "@ubean/routes";
12
+ import { scanProject } from "@ubean/scan";
13
+ import { getLogger } from "@ubean/shared/logger";
14
+ import { getVueLocaleParam } from "@ubean/i18n";
15
+ import * as nodeFsPromises from "node:fs/promises";
16
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
17
+ import { getColorModeScript, resolveColorModeConfig } from "@ubean/client";
18
+ import { DevEnvironment, createBuilder, normalizePath, perEnvironmentPlugin } from "vite";
19
+ import { ubeanIslandsPlugin } from "@ubean/islands/vite";
20
+ import { findUserViteConfig } from "@ubean/shared/node";
21
+ import { dirname as dirname$1, extname, join as join$1, normalize as normalize$1, relative as relative$1, resolve as resolve$1, sep } from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+ //#region src/i18n-config.ts
24
+ function serializeI18nConfig(config) {
25
+ return JSON.stringify(config);
26
+ }
27
+ function localeVueParamFromI18n(config) {
28
+ if (!config.enabled) return void 0;
29
+ return getVueLocaleParam({
30
+ defaultLocale: config.defaultLocale,
31
+ locales: config.locales.map((l) => l.code),
32
+ strategy: config.strategy
33
+ }) || void 0;
34
+ }
35
+ //#endregion
36
+ //#region src/ssg-entry.ts
37
+ /**
38
+ * 生成 renderer setup 代码块(`_rendererRoutes` + `_pageRenderer`)。
39
+ *
40
+ * 依赖外层已定义:`_pages` / `_layouts` / `pageLoaders` / `layoutLoaders` /
41
+ * (notFoundCatchAll 时)`_notFoundPage`,以及 `toVueRouterLocalePath` 导入。
42
+ * ssrEnabled=false 时 `_pageRenderer = null`(与 fullstack 现状一致)。
43
+ */
44
+ function buildRendererSetup(options) {
45
+ const { ssrEnabled, mode, localeVueParam, notFoundCatchAll } = options;
46
+ if (!ssrEnabled) return `
47
+ // --- SSR disabled (mode=${mode}) ---
48
+ // pageRenderer is null; page requests fall back to a client-only HTML shell.
49
+ const _pageRenderer = null;`;
50
+ return `
51
+ // --- SSR renderer setup ---
52
+ const _defaultLayout = _layouts.find(l => l.isDefault)?.name || null;
53
+
54
+ // Same i18n locale param as the client virtual:ubean-pages route table, so
55
+ // language-prefixed URLs (e.g. /zh/playground) match the real page on SSR too.
56
+ const _localeVueParam = ${JSON.stringify(localeVueParam)};
57
+
58
+ // 页面 → Vue Router 路由记录。必须按 route 路径分组:并行路由(@slot/ 目录)在客户端是同一条
59
+ // 路由记录上的命名视图(components: { default, <slot名> });SSR 侧若把每个页面各注册成一条路由,
60
+ // 同路径的两条记录会互相覆盖 —— 实测首屏渲染的是插槽页,水合后客户端渲染默认视图(首屏与客户端
61
+ // 不一致)。分组后 SlotView 组件在 SSR 也能解析到插槽组件。
62
+ const _pageToRoute = p => {
63
+ // For reuse routes, load the target page's module (the .reuse.ts file
64
+ // only contains definePage metadata, not a Vue component).
65
+ const _targetPage = p.isReuse && p.reuseTarget ? _pages.find(tp => tp.name === p.reuseTarget) : undefined;
66
+ const _loaderKey = _targetPage?.relativePath || p.relativePath;
67
+ return {
68
+ path: _localeVueParam
69
+ ? toVueRouterLocalePath(p.route.replace(/\\*\\*:(\\w[\\w-]*)/g, ':$1(.*)*'), _localeVueParam)
70
+ : p.route.replace(/\\*\\*:(\\w[\\w-]*)/g, ':$1(.*)*'),
71
+ name: p.name,
72
+ component: async () => {
73
+ const loader = pageLoaders[_loaderKey];
74
+ if (!loader) throw new Error('Page loader not found: ' + _loaderKey);
75
+ const mod = await loader();
76
+ return mod.default || mod;
77
+ },
78
+ meta: {
79
+ layout: p.layout === false ? false : p.layout || _defaultLayout,
80
+ pageName: p.name,
81
+ cache: p.cache === true ? true : undefined
82
+ }
83
+ };
84
+ };
85
+
86
+ const _routeGroups = new Map();
87
+ for (const _p of _pages) {
88
+ const _key = _p.route;
89
+ const _group = _routeGroups.get(_key) || { slots: [] };
90
+ if (_p.slot) _group.slots.push(_p);
91
+ else if (!_group.default) _group.default = _p;
92
+ _routeGroups.set(_key, _group);
93
+ }
94
+
95
+ const _rendererRoutes = [];
96
+ for (const _group of _routeGroups.values()) {
97
+ const _primary = _group.default || _group.slots[0];
98
+ if (!_primary) continue;
99
+ const _record = _pageToRoute(_primary);
100
+ if (_group.slots.length > 0) {
101
+ // 并行路由:命名视图(component 与 components 不能同时出现)。
102
+ // 注意组件加载器在 _record 上(_primary 是扫描得到的页面条目,没有 component 字段 ——
103
+ // 写成 _primary.component 会得到 undefined,vue-router 报 Invalid route component)。
104
+ const _components = {};
105
+ if (_group.default) _components.default = _record.component;
106
+ for (const _slotPage of _group.slots) _components[_slotPage.slot] = _pageToRoute(_slotPage).component;
107
+ delete _record.component;
108
+ _record.components = _components;
109
+ }
110
+ _rendererRoutes.push(_record);
111
+ }${notFoundCatchAll ? `
112
+
113
+ // 404 渲染路由:注册为静态路径 /404(而非客户端的 /:pathMatch(.*)* catch-all)。
114
+ // vue-router 中静态段确定性优先于 repeatable 参数 —— 即使应用存在根 catch-all
115
+ // 页面(如 [...slug].vue),/404 也稳定命中本路由,保证 404.html 产物
116
+ // 渲染的是 pages/404.vue 而非被 catch-all 抢占。
117
+ if (_notFoundPage) {
118
+ const _nfLoaderKey = _notFoundPage.relativePath;
119
+ _rendererRoutes.push({
120
+ path: _localeVueParam
121
+ ? toVueRouterLocalePath('/404', _localeVueParam)
122
+ : '/404',
123
+ name: 'NotFound',
124
+ component: async () => {
125
+ const loader = pageLoaders[_nfLoaderKey];
126
+ if (!loader) throw new Error('Page loader not found: ' + _nfLoaderKey);
127
+ const mod = await loader();
128
+ return mod.default || mod;
129
+ },
130
+ meta: { pageName: 'NotFound' }
131
+ });
132
+ }` : ""}
133
+
134
+ const _layoutMap = new Map();
135
+ for (const l of _layouts) {
136
+ _layoutMap.set(l.name, l.relativePath);
137
+ }
138
+
139
+ const _pageRenderer = createVueRenderer({
140
+ routes: _rendererRoutes,
141
+ async resolveLayoutComponent(name) {
142
+ if (name === false || name == null) return null;
143
+ const relPath = _layoutMap.get(name);
144
+ if (!relPath) return null;
145
+ const loader = layoutLoaders[relPath];
146
+ if (!loader) return null;
147
+ const mod = await loader();
148
+ return mod.default || mod;
149
+ },
150
+ defaultLayout: _defaultLayout,
151
+ resolveAppConfig: () => _resolveAppConfig('server')
152
+ });
153
+ `;
154
+ }
155
+ /**
156
+ * 生成 client asset tags 代码块(读取 `.vite/manifest.json`)。
157
+ */
158
+ function buildAssetTagsSetup(favicon) {
159
+ return `
160
+ // --- Client asset tags(RM-V18:构建期注入,不再运行时读 dist/public/.vite/manifest.json)---
161
+ // 由 \`ubean:asset-manifest\` 插件提供:值在服务端构建期间从 client manifest 算出并内联,
162
+ // 因此服务端产物自包含 —— 不再依赖 client 产物的磁盘相对路径,也不再把「必须先 client
163
+ // 后 server」藏在一句运行时 try/catch 里。
164
+ import { assetTags as _injectedAssetTags } from 'virtual:ubean-asset-manifest';
165
+ let _assetTags = { ..._injectedAssetTags, favicon: ${JSON.stringify(favicon)} };
166
+ `;
167
+ }
168
+ /**
169
+ * 生成静态 SSG entry 源码(写入 virtualDir/server-entry.mjs,由 SSR build
170
+ * 产出 dist/server/entry.mjs)。
171
+ *
172
+ * 导出契约:
173
+ * - `renderStaticPage({ pageName, params, url, locale }) → { html, statusCode }`
174
+ * - `pageLoaders`(调试用)
175
+ */
176
+ function buildStaticSsgEntry(input) {
177
+ const { pagesGlob, layoutsGlob, srcPrefix, pagesJson, layoutsJson, notFoundPageJson, localeVueParam, contentBootstrap, colorModeScript, favicon } = input;
178
+ return `// Auto-generated ubean static SSG entry (mode: ssg)
179
+ import { renderPage } from '@ubean/pages';
180
+ import { createVueRenderer } from 'ubean/ssr';
181
+ import { toVueRouterLocalePath } from '@ubean/i18n';
182
+ import { i18nConfig as _i18nConfig, loadLocales as _loadLocales } from 'ubean:locales';
183
+ import { resolveAppConfig as _resolveAppConfig } from 'virtual:ubean-app';
184
+ ${contentBootstrap}
185
+ const pageModules = import.meta.glob(${pagesGlob}, { eager: false });
186
+ const layoutModules = import.meta.glob(${layoutsGlob}, { eager: false });
187
+
188
+ const _srcPrefix = ${srcPrefix};
189
+ function normalizeKey(p) {
190
+ const prefixes = ['/pages/', '/layouts/'];
191
+ for (const prefix of prefixes) {
192
+ const fullPrefix = _srcPrefix + prefix;
193
+ if (p.includes(fullPrefix)) {
194
+ const idx = p.indexOf(fullPrefix);
195
+ return p.slice(idx + fullPrefix.length);
196
+ }
197
+ }
198
+ return p;
199
+ }
200
+
201
+ export const pageLoaders = {};
202
+ for (const [key, loader] of Object.entries(pageModules)) {
203
+ pageLoaders[normalizeKey(key)] = loader;
204
+ }
205
+
206
+ const layoutLoaders = {};
207
+ for (const [key, loader] of Object.entries(layoutModules)) {
208
+ layoutLoaders[normalizeKey(key)] = loader;
209
+ }
210
+
211
+ const _pages = ${pagesJson};
212
+ const _layouts = ${layoutsJson};
213
+ const _notFoundPage = ${notFoundPageJson};
214
+ const _hasDefaultLayout = _layouts.some(l => l.isDefault);
215
+ ${buildRendererSetup({
216
+ ssrEnabled: true,
217
+ mode: "ssg",
218
+ localeVueParam,
219
+ notFoundCatchAll: true
220
+ })}
221
+ ${buildAssetTagsSetup(favicon)}
222
+ const _colorModeScript = ${JSON.stringify(colorModeScript)};
223
+
224
+ // --- i18n ---
225
+ const _i18nEnabled = _i18nConfig && _i18nConfig.enabled !== false;
226
+ const _localeCodes = _i18nEnabled
227
+ ? (_i18nConfig.locales || []).map(l => (typeof l === 'string' ? l : l.code))
228
+ : [];
229
+ const _defaultLocale = (_i18nEnabled && _i18nConfig.defaultLocale) || '';
230
+ let _localesReady = false;
231
+
232
+ async function _ensureLocales() {
233
+ if (_localesReady) return;
234
+ _localesReady = true;
235
+ if (!_i18nEnabled || _localeCodes.length === 0) return;
236
+ try {
237
+ await _loadLocales();
238
+ } catch (e) {
239
+ console.warn('[ubean-ssg] Failed to load locales:', (e && e.message) || e);
240
+ }
241
+ }
242
+
243
+ // 静态模式不执行 loader —— 检测到页面导出 loader 时警告一次(每页)
244
+ const _loaderWarned = new Set();
245
+
246
+ /**
247
+ * 渲染单个静态页面(由 createStaticSsgRenderer 调用)。
248
+ *
249
+ * @param input.pageName 目标页面名(ScannedPageRoute.name)或 'NotFound'
250
+ * @param input.params 路由参数(matchRoutePattern 提取)
251
+ * @param input.url 完整 URL path(含 locale 前缀与 query)
252
+ * @param input.locale 渲染语言(prefix 策略下已从 URL 提取)
253
+ */
254
+ export async function renderStaticPage(input) {
255
+ const pageName = input && input.pageName;
256
+ const page = pageName === 'NotFound' ? _notFoundPage : _pages.find(p => p.name === pageName);
257
+ if (!page) return { html: '', statusCode: 404 };
258
+
259
+ await _ensureLocales();
260
+
261
+ // loader 检测 warn(模块加载有缓存,此处与渲染共用同一实例)
262
+ const _targetPage =
263
+ page.isReuse && page.reuseTarget ? _pages.find(tp => tp.name === page.reuseTarget) : undefined;
264
+ const _loaderKey = (_targetPage && _targetPage.relativePath) || page.relativePath;
265
+ const _modLoader = pageLoaders[_loaderKey];
266
+ if (_modLoader && !_loaderWarned.has(page.name)) {
267
+ try {
268
+ const mod = await _modLoader();
269
+ if (mod && typeof mod.loader === 'function') {
270
+ _loaderWarned.add(page.name);
271
+ console.warn(
272
+ '[ubean-ssg] page "' + page.name + '" exports \`loader\`, which is not executed in static SSG mode'
273
+ );
274
+ }
275
+ } catch {}
276
+ }
277
+
278
+ // renderContext 构造与 routes/router.ts 的页面渲染路径保持一致
279
+ const renderContext = {};
280
+ const locale = (input && input.locale) || _defaultLocale;
281
+ if (_i18nEnabled && locale) {
282
+ renderContext.locale = locale;
283
+ renderContext.localeDir = 'ltr';
284
+ renderContext.routing = {
285
+ defaultLocale: _i18nConfig.defaultLocale || 'en',
286
+ locales: _localeCodes,
287
+ strategy: _i18nConfig.strategy || 'prefix_except_default'
288
+ };
289
+ const cookieName =
290
+ _i18nConfig.detectBrowserLanguage && _i18nConfig.detectBrowserLanguage.cookieName;
291
+ if (cookieName) renderContext.cookieName = cookieName;
292
+ if (_i18nConfig.baseUrl) renderContext.baseUrl = _i18nConfig.baseUrl;
293
+ try {
294
+ const i18nMod = await import('ubean/i18n');
295
+ renderContext.localeDir = i18nMod.getLocaleDir(locale);
296
+ renderContext.messages = i18nMod.getLocaleMessages(locale);
297
+ const fallback = i18nMod.getFallbackLocale();
298
+ renderContext.fallbackLocale = fallback;
299
+ if (fallback !== locale) {
300
+ renderContext.fallbackMessages = i18nMod.getLocaleMessages(fallback);
301
+ }
302
+ renderContext.availableLocales = i18nMod.getRegisteredLocalesMeta();
303
+ } catch {}
304
+ }
305
+
306
+ // pageObj 构造与 routes/router.ts handlePageRequest 保持一致
307
+ const pageObj = {
308
+ component: page.reuseTarget || page.name,
309
+ props: {},
310
+ params: (input && input.params) || {},
311
+ url: (input && input.url) || '/',
312
+ layout: page.layout === false
313
+ ? false
314
+ : page.layout || (page.pageMeta && page.pageMeta.layout) || (_hasDefaultLayout ? 'default' : false),
315
+ errors: null,
316
+ head: page.pageMeta && page.pageMeta.head
317
+ };
318
+
319
+ let html = await renderPage(pageObj, _assetTags, _pageRenderer, 'app', renderContext);
320
+ if (_colorModeScript) {
321
+ html = html.replace('<head>', '<head>\\n ' + _colorModeScript);
322
+ }
323
+ return { html, statusCode: 200 };
324
+ }
325
+ `;
326
+ }
327
+ //#endregion
328
+ //#region src/vite/asset-manifest.ts
329
+ /**
330
+ * 客户端资产清单的**构建期注入**(RM-V18)。
331
+ *
332
+ * 整改前:生成的 SSR entry 在**运行时**读 `join(__dirname, '..', 'public', '.vite', 'manifest.json')`
333
+ * —— 于是(a)服务端产物必须与 `dist/public` 的相对位置保持不变才能工作,(b)构建顺序被绑成
334
+ * 「必须先 client 后 server」,且这个约束只体现在一句 `try/catch` 里(清单读不到就静默降级为
335
+ * 无 asset tags 的 HTML)。
336
+ *
337
+ * 现在由核心插件提供 `virtual:ubean-asset-manifest`:值在**服务端构建期间**算出后内联进
338
+ * bundle。效果:
339
+ * - 服务端产物自包含,不再依赖 client 产物的磁盘路径(`dist/server` 可单独部署/打包);
340
+ * - 顺序依赖只剩「构建期要先拿到 manifest 对象」,不再有运行时的路径耦合;
341
+ * - 清单缺失时给出**构建期告警**,而不是运行时静默产出无样式 HTML。
342
+ *
343
+ * **唯一提供者**(2026-09-16 修复):RM-V21 把本虚拟模块从独立插件挪进核心插件时,两个提供者
344
+ * 并存过一段时间 —— 核心插件那份的 ref 只在它自己的 `buildApp` 里被填,而它在 CLI 驱动的两条
345
+ * 路径上都会**抢先解析**,于是标签被内联成空串,生产 HTML 既没有客户端入口 `<script>` 也没有
346
+ * 样式表 `<link>`(页面不水合、无样式)。文件清单比对看不见内容差异,体积门禁也不比对 HTML,
347
+ * 缺陷因此长期潜伏;由 RM-V24 的 `vite preview` 验收断言暴露。现在改为:
348
+ * 核心插件是唯一提供者,取值顺序为「调用方传入的内存 manifest(RM-V18 的传递)→ 服务端
349
+ * outDir 旁的磁盘 manifest(`<outputDir>/public/.vite/manifest.json`)→ 空 + 构建期告警」。
350
+ */
351
+ const logger$5 = getLogger("build");
352
+ /** 本插件提供的虚拟模块 id。 */
353
+ const ASSET_MANIFEST_VIRTUAL_ID = "virtual:ubean-asset-manifest";
354
+ const RESOLVED_ID = `\0${ASSET_MANIFEST_VIRTUAL_ID}`;
355
+ /**
356
+ * 从 client manifest 算出资产标签。
357
+ *
358
+ * 与旧运行时逻辑逐字等价(`<script type="module" src="/…">` + 每个 css 一个 `<link>`),
359
+ * 只是执行时机从「服务端启动时」提前到「服务端构建时」。
360
+ */
361
+ function computeAssetTags(manifest, favicon) {
362
+ const tags = {
363
+ css: "",
364
+ preloads: "",
365
+ body: "",
366
+ favicon: favicon ?? null
367
+ };
368
+ const entry = manifest ? Object.values(manifest).find((item) => item.isEntry) : void 0;
369
+ if (!entry) return tags;
370
+ tags.body = `<script type="module" src="/${entry.file}"><\/script>`;
371
+ if (Array.isArray(entry.css)) tags.css = entry.css.map((css) => `<link rel="stylesheet" href="/${css}">`).join("\n");
372
+ return tags;
373
+ }
374
+ /** 从磁盘读 client manifest(`<publicDir>/.vite/manifest.json`)。 */
375
+ function readClientManifestFromDisk(publicDir) {
376
+ const manifestPath = join$1(publicDir, ".vite", "manifest.json");
377
+ if (!existsSync(manifestPath)) return null;
378
+ try {
379
+ return JSON.parse(readFileSync(manifestPath, "utf-8"));
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+ /**
385
+ * 服务端环境构建的 outDir → 客户端产物目录(`<outputDir>/server` → `<outputDir>/public`)。
386
+ *
387
+ * 用**本次构建的 outDir** 而不是配置里的 `build.outputDir`:`--outDir` 会覆盖后者,而插件实例
388
+ * 可能来自用户 `vite.config.ts`(另一份模块实例、另一份配置对象),读配置会拿到过期的值。
389
+ */
390
+ function clientPublicDirFor(serverOutDir) {
391
+ return join$1(dirname$1(serverOutDir), "public");
392
+ }
393
+ /**
394
+ * 解析要注入的资产标签:内存 manifest 优先,其次读服务端 outDir 旁的磁盘清单。
395
+ *
396
+ * 磁盘兜底存在的原因:CLI 驱动的构建(默认路径与开关路径都算)拿不到核心插件实例的 ref ——
397
+ * 那是用户 `vite.config.ts` 里 `ubeanPlugin()` 的内部状态。此时按 outDir 推断位置读盘,仍满足
398
+ * 「服务端产物自包含」(值在构建期内联,运行时不读盘)。
399
+ */
400
+ function resolveInjectedAssetTags(inMemory, serverOutDir) {
401
+ if (inMemory) return {
402
+ tags: computeAssetTags(inMemory),
403
+ source: "memory"
404
+ };
405
+ if (serverOutDir) {
406
+ const fromDisk = readClientManifestFromDisk(clientPublicDirFor(serverOutDir));
407
+ if (fromDisk) return {
408
+ tags: computeAssetTags(fromDisk),
409
+ source: "disk"
410
+ };
411
+ }
412
+ return {
413
+ tags: computeAssetTags(null),
414
+ source: "none"
415
+ };
416
+ }
417
+ /**
418
+ * 提供 `virtual:ubean-asset-manifest` 的独立插件。
419
+ *
420
+ * **仅在没有核心插件时使用**(`!userViteConfig` 且未注册 `ubeanPlugin()` 的极端情形)。有核心
421
+ * 插件时不要注册本插件 —— 两个提供者会各自持有 ref,谁先解析谁说了算,实测就是这条导致了
422
+ * 「标签内联为空」的生产缺陷。
423
+ *
424
+ * `getManifest` 在**服务端环境加载该模块时**调用 —— 因此调用方可以先建 builder、按顺序构建
425
+ * client,再把 manifest 填进闭包变量,服务端构建时自然读到最新值(无需重建插件实例)。
426
+ */
427
+ function ubeanAssetManifestPlugin(getManifest, getServerOutDir) {
428
+ return {
429
+ name: "ubean:asset-manifest",
430
+ enforce: "pre",
431
+ resolveId(id) {
432
+ if (id === "virtual:ubean-asset-manifest") return RESOLVED_ID;
433
+ },
434
+ load(id, options) {
435
+ if (id !== RESOLVED_ID) return void 0;
436
+ const { tags, source } = resolveInjectedAssetTags(getManifest(), getServerOutDir?.());
437
+ if (source === "none") logger$5.warn(`${ASSET_MANIFEST_VIRTUAL_ID} 注入时没有可用的 client manifest(环境:${options?.ssr ? "server" : "client"})—— 产出的 HTML 将不含 asset tags。`);
438
+ return `export const assetTags = ${JSON.stringify({
439
+ ...tags,
440
+ favicon: null
441
+ })};\n`;
442
+ }
443
+ };
444
+ }
445
+ //#endregion
446
+ //#region src/vite/cloudflare-preview.ts
447
+ /**
448
+ * Cloudflare 产物的本地预览 runner(RM-V26)。
449
+ *
450
+ * 目的:`wrangler dev` 不可用时也能预览 worker 产物 —— 直接把构建出来的 `dist/server/worker.mjs`
451
+ * 交给 **miniflare** 在进程内跑,而不是让用户去装 wrangler 或上传到 Cloudflare 才知道产物对不对。
452
+ *
453
+ * `miniflare` 是**可选 peer**(同 `satori`/`@resvg/resvg-js` 的约定):本模块动态 import 它,缺失时
454
+ * 返回 `{ ok: false, reason: 'miniflare-not-installed' }` 而不是抛错 —— 调用方据此给出可执行的提示,
455
+ * 其余 preview 形态(node / static)完全不受影响。
456
+ *
457
+ * 实测过的 miniflare 面(4.20250214.0-rc.0):
458
+ * - 构造:`new Miniflare({ modules: [{ type: 'ESModule', path }], compatibilityDate })`
459
+ * - 就绪:`await mf.ready`
460
+ * - 派发:`mf.dispatchFetch(url, { method, headers, body })` —— **注意**这个版本的
461
+ * `dispatchFetch` 不接受 `Request` 实例(实测 `Failed to parse URL from [object Request]`),
462
+ * 因此这里拆成 url + init,非 GET/HEAD 时用 `arrayBuffer()` 带体(POST 体实测可达 worker)。
463
+ * - 释放:`mf.dispose()`
464
+ *
465
+ * 静态资源**不经 miniflare 的 `assets` 选项**:该选项在本版本上 `mf.ready` 直接失败
466
+ * (`The Workers runtime failed to start`)。改为与 Cloudflare 平台同构的做法 —— 静态层在当前
467
+ * 进程先服务 `dist/public`(预渲染 HTML / 资源),未命中再交给 worker。
468
+ */
469
+ const DEFAULT_COMPATIBILITY_DATE = "2024-09-01";
470
+ const MINIFLARE_INSTALL_HINT = "Install it with `pnpm add -D miniflare`, or preview with `wrangler dev` instead.";
471
+ /**
472
+ * 默认加载器:动态 import `miniflare`。
473
+ *
474
+ * 规格字符串放进变量是**故意的** —— 让打包器无法静态解析这个可选依赖(否则构建会尝试解析一个
475
+ * 未安装的包)。
476
+ */
477
+ async function defaultLoadMiniflare() {
478
+ const specifier = "miniflare";
479
+ try {
480
+ return (await import(
481
+ /* @vite-ignore */
482
+ specifier
483
+ )).Miniflare ?? null;
484
+ } catch {
485
+ return null;
486
+ }
487
+ }
488
+ /**
489
+ * 创建 Cloudflare 预览 runner。
490
+ *
491
+ * 失败不抛错:返回带 `reason` 的结果,让调用方决定是降级提示还是继续(与可选依赖的整体策略一致)。
492
+ */
493
+ async function createCloudflarePreviewRunner(options) {
494
+ const { workerPath, compatibilityDate, compatibilityFlags, loadMiniflare = defaultLoadMiniflare } = options;
495
+ const wranglerToml = resolve$1(dirname$1(workerPath), "..", "wrangler.toml");
496
+ const resolvedDate = compatibilityDate ?? readCompatibilityDate(wranglerToml) ?? DEFAULT_COMPATIBILITY_DATE;
497
+ const resolvedFlags = compatibilityFlags ?? readCompatibilityFlags(wranglerToml) ?? ["nodejs_compat"];
498
+ if (!existsSync(workerPath)) return {
499
+ ok: false,
500
+ reason: "worker-missing",
501
+ message: `Build output not found: ${workerPath}. Run \`ubean build\` first to create a production build.`
502
+ };
503
+ const Miniflare = await loadMiniflare();
504
+ if (!Miniflare) return {
505
+ ok: false,
506
+ reason: "miniflare-not-installed",
507
+ message: `The cloudflare preset needs \`miniflare\` to preview locally. ${MINIFLARE_INSTALL_HINT}`
508
+ };
509
+ const instance = new Miniflare({
510
+ modulesRoot: commonAncestor(process.cwd(), dirname$1(workerPath)),
511
+ modules: workerModuleEntries(workerPath),
512
+ compatibilityDate: resolvedDate,
513
+ compatibilityFlags: resolvedFlags
514
+ });
515
+ try {
516
+ await instance.ready;
517
+ } catch (err) {
518
+ await instance.dispose().catch(() => void 0);
519
+ return {
520
+ ok: false,
521
+ reason: "worker-failed-to-start",
522
+ message: `The Cloudflare worker bundle failed to start in workerd: ${err instanceof Error ? err.message : String(err)}`
523
+ };
524
+ }
525
+ return {
526
+ ok: true,
527
+ runner: {
528
+ async fetch(request) {
529
+ const method = request.method || "GET";
530
+ const hasBody = method !== "GET" && method !== "HEAD";
531
+ return instance.dispatchFetch(request.url, {
532
+ method,
533
+ headers: request.headers,
534
+ body: hasBody ? await request.arrayBuffer() : void 0
535
+ });
536
+ },
537
+ dispose: () => instance.dispose()
538
+ }
539
+ };
540
+ }
541
+ /**
542
+ * worker 目录下所有需要登记的模块。
543
+ *
544
+ * miniflare(同 wrangler)把 `modules` 当**权威模块表**:产物里 `worker.mjs` 会
545
+ * `import './entry.mjs'`,若只登记 worker,运行时直接报 `No such module "entry.mjs"`(实测)。
546
+ * 因此遍历同目录的 `.mjs`(含 chunk 划分的产物),路径用 **cwd 相对**形态(见调用处的路径语义说明)。
547
+ */
548
+ function workerModuleEntries(workerPath) {
549
+ const dir = dirname$1(workerPath);
550
+ const toEntry = (file) => ({
551
+ type: "ESModule",
552
+ path: relative$1(process.cwd(), file)
553
+ });
554
+ const entries = [toEntry(workerPath)];
555
+ try {
556
+ for (const file of readdirSync(dir)) {
557
+ if (!file.endsWith(".mjs")) continue;
558
+ const full = join$1(dir, file);
559
+ if (full !== workerPath) entries.push(toEntry(full));
560
+ }
561
+ } catch {}
562
+ return entries;
563
+ }
564
+ /** 两个路径的公共祖先(用于 workerd 的模块名基准)。 */
565
+ function commonAncestor(a, b) {
566
+ const partsA = resolve$1(a).split(sep);
567
+ const partsB = resolve$1(b).split(sep);
568
+ const common = [];
569
+ for (let i = 0; i < Math.min(partsA.length, partsB.length); i += 1) {
570
+ if (partsA[i] !== partsB[i]) break;
571
+ common.push(partsA[i]);
572
+ }
573
+ return common.join(sep) || sep;
574
+ }
575
+ /** 从 wrangler.toml 里取 `compatibility_flags`(预览必须与部署用同一组标志)。 */
576
+ function readCompatibilityFlags(wranglerToml) {
577
+ if (!existsSync(wranglerToml)) return void 0;
578
+ try {
579
+ const match = /compatibility_flags\s*=\s*\[([^\]]*)\]/.exec(readFileSync(wranglerToml, "utf-8"));
580
+ if (!match) return void 0;
581
+ const flags = match[1].split(",").map((part) => part.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
582
+ return flags.length > 0 ? flags : void 0;
583
+ } catch {
584
+ return;
585
+ }
586
+ }
587
+ /** 从 wrangler.toml 里取兼容性日期(preset 生成的文件就是它的产物契约)。 */
588
+ function readCompatibilityDate(wranglerToml) {
589
+ if (!existsSync(wranglerToml)) return void 0;
590
+ try {
591
+ return /compatibility_date\s*=\s*"([^"]+)"/.exec(readFileSync(wranglerToml, "utf-8"))?.[1];
592
+ } catch {
593
+ return;
594
+ }
595
+ }
596
+ /**
597
+ * workerd(Cloudflare Workers 运行时)不支持的 Node 内建 —— 即使开了 `nodejs_compat` 也不行。
598
+ * 列在这里是为了让构建期的告警能直接点名,而不是等 worker 起不来时再去猜。
599
+ */
600
+ const UNSUPPORTED_IN_WORKERD = [
601
+ "node:fs",
602
+ "node:fs/promises",
603
+ "node:net",
604
+ "node:os",
605
+ "node:child_process",
606
+ "node:worker_threads"
607
+ ];
608
+ /**
609
+ * 审计 worker 产物里的 Node 内建导入(返回命中的模块名,全部不重复)。
610
+ *
611
+ * 起因是一次实测:cloudflare preset 构建出的 `server/worker.mjs` 在图里带进了 `node:fs/promises`
612
+ * (来自 `@ubean/app` / `@ubean/server/static` 的静态服务路径),miniflare 启动时直接报
613
+ * `No such module "node:fs/promises"` —— **产物在 worker 运行时里根本起不来**。文件清单比对与体积
614
+ * 门禁都看不见这类问题(不看产物内容,更不会去跑它)。
615
+ *
616
+ * 这里只报告、不阻断:把失败从「运行时谜团」变成「构建期清单」,同时保持既有构建行为不变。
617
+ * 真正的修法是让 Node 专有路径在 worker 构建里消失(条件注册 / 动态 import),属于独立一笔。
618
+ */
619
+ function findUnsupportedNodeImports(serverDir) {
620
+ const found = /* @__PURE__ */ new Set();
621
+ const walk = (dir) => {
622
+ let entries;
623
+ try {
624
+ entries = readdirSync(dir);
625
+ } catch {
626
+ return;
627
+ }
628
+ for (const entry of entries) {
629
+ const full = join$1(dir, entry);
630
+ if (isDirectory(full)) {
631
+ walk(full);
632
+ continue;
633
+ }
634
+ if (!/\.(mjs|js)$/.test(entry)) continue;
635
+ let code;
636
+ try {
637
+ code = readFileSync(full, "utf-8");
638
+ } catch {
639
+ continue;
640
+ }
641
+ for (const specifier of UNSUPPORTED_IN_WORKERD) if (new RegExp(`(from\\s*|import\\s*\\(\\s*)["']${specifier}["']`).test(code)) found.add(specifier);
642
+ }
643
+ };
644
+ walk(serverDir);
645
+ return [...found].sort();
646
+ }
647
+ /** 目录判断(不可读时按非目录处理)。 */
648
+ function isDirectory(path) {
649
+ try {
650
+ readdirSync(path);
651
+ return true;
652
+ } catch {
653
+ return false;
654
+ }
655
+ }
656
+ //#endregion
657
+ //#region src/vite/build-steps.ts
658
+ /**
659
+ * 构建步骤的**可复用切片**(Phase 2 地基)。
660
+ *
661
+ * 收敛前的旧编排是一条约 300 行的直线流程:清理输出 → 落盘虚拟模块与 index.html →
662
+ * 两次 `viteBuild`(client / server)→ preset 包装 → 写 manifest。RM-V16 起要把它交给
663
+ * `createBuilder` 的 `buildApp` 编排(`prepare → client env → ubean env → prerender →
664
+ * preset 包装 → manifest`),但目标是**产物逐字节可比**,所以先把与「用 viteBuild 还是
665
+ * builder.build(env)」无关的几步切出来单独可测,再在 `build-app.ts` 里用环境驱动的方式
666
+ * 重新编排。
667
+ *
668
+ * 这里只搬移、不改语义:所有行为与旧编排原先逐段一致。
669
+ */
670
+ const logger$4 = getLogger("build");
671
+ /** 产物目录布局(`dist/` 根、`dist/public`、`dist/server`,虚拟模块落 `.ubean/virtual`)。 */
672
+ function getBuildOutDirs(cwd, outputDir) {
673
+ const outputRoot = resolve(cwd, outputDir);
674
+ return {
675
+ root: outputRoot,
676
+ public: join(outputRoot, "public"),
677
+ server: join(outputRoot, "server"),
678
+ assets: join(outputRoot, "public", "assets"),
679
+ virtual: join(cwd, ".ubean", "virtual")
680
+ };
681
+ }
682
+ /** 清空并重建产物目录(页面/服务端目录按 mode 决定是否创建)。 */
683
+ async function cleanBuildOutput(options) {
684
+ const { outDirs, hasPages, hasServer, mode, ssrEnabled } = options;
685
+ logger$4.info(`Cleaning output directory... (mode=${mode}${mode === "fullstack" ? `, ssr=${ssrEnabled}` : ""})`);
686
+ if (existsSync(outDirs.root)) await rm(outDirs.root, {
687
+ recursive: true,
688
+ force: true
689
+ });
690
+ if (hasPages) await mkdir(outDirs.public, { recursive: true });
691
+ if (hasServer) await mkdir(outDirs.server, { recursive: true });
692
+ }
693
+ /** 客户端构建的 HTML 入口模板(`index.html` 写进 `.ubean/virtual`,作为 client env 的 input)。 */
694
+ function renderClientIndexHtml() {
695
+ return `<!DOCTYPE html>
696
+ <html>
697
+ <head>
698
+ <meta charset="UTF-8" />
699
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
700
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
701
+ <title>Ubean App</title>
702
+ </head>
703
+ <body>
704
+ <div id="app"></div>
705
+ <script type="module" src="virtual:ubean-client-entry"><\/script>
706
+ </body>
707
+ </html>`;
708
+ }
709
+ /** 把客户端 HTML 入口写到虚拟模块目录,返回其路径(client env 的 `build.rollupOptions.input`)。 */
710
+ async function writeClientIndexHtml(virtualDir) {
711
+ const target = join(virtualDir, "index.html");
712
+ await writeFile(target, renderClientIndexHtml(), "utf-8");
713
+ return target;
714
+ }
715
+ /**
716
+ * spa 模式的静态站点入口(`public/index.html`)。
717
+ *
718
+ * fullstack / ssg 的 HTML 由 SSR 渲染(或预渲染)产出,里面的资产标签来自 client manifest;
719
+ * **spa 没有服务端**,客户端构建的 input 是虚拟 entry 而不是 HTML,因此 Vite 不会产出任何 HTML ——
720
+ * 曾经的 spa 产物只有 `assets/`,部署出去没有入口文件(文档承诺的是「static `index.html` + assets」,
721
+ * 实现与文档不符,直到 RM-V36 的矩阵按 mode 断言产物才发现)。
722
+ *
723
+ * 这里在客户端构建之后补出这个入口,资产标签复用与 SSR 相同的 `computeAssetTags()`:同一个
724
+ * manifest、同一套标签规则,避免出现第二种「入口长什么样」的定义。
725
+ */
726
+ async function writeSpaIndexHtml(options) {
727
+ const { outDirs, tags } = options;
728
+ const target = join(outDirs.public, "index.html");
729
+ const html = `<!DOCTYPE html>
730
+ <html>
731
+ <head>
732
+ <meta charset="UTF-8" />
733
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
734
+ <link rel="icon" href="${tags.favicon ?? "/favicon.svg"}" type="image/svg+xml" />
735
+ <title>Ubean App</title>
736
+ ${tags.css ? ` ${tags.css.split("\n").join("\n ")}\n` : ""}</head>
737
+ <body>
738
+ <div id="app"></div>
739
+ ${tags.body}
740
+ </body>
741
+ </html>`;
742
+ await writeFile(target, html, "utf-8");
743
+ return target;
744
+ }
745
+ /**
746
+ * 写 preset 包装文件,返回服务端入口文件名。
747
+ *
748
+ * ssg 模式**不生成**包装:产物是纯静态站点,而包装文件 import 的 `createFetchHandler`
749
+ * 在静态 entry 里不存在。
750
+ */
751
+ async function writePresetWrapper(options) {
752
+ const { mode, presetBuildConfig, outDirs, entries } = options;
753
+ if (mode === "ssg") {
754
+ logger$4.info("SSG mode: skipping preset server wrapper (static output only)");
755
+ return "entry.mjs";
756
+ }
757
+ if (presetBuildConfig.entryType === "node") {
758
+ await writeFile(join(outDirs.server, "server.mjs"), entries.node(), "utf-8");
759
+ await writeFile(join(outDirs.server, "package.json"), JSON.stringify({
760
+ type: "module",
761
+ private: true,
762
+ main: "./server.mjs",
763
+ dependencies: { hono: "^4.0.0" }
764
+ }, null, 2), "utf-8");
765
+ return "server.mjs";
766
+ }
767
+ if (presetBuildConfig.entryType === "worker") {
768
+ await writeFile(join(outDirs.server, "worker.mjs"), entries.worker(), "utf-8");
769
+ const wranglerToml = `
770
+ name = "ubean-app"
771
+ main = "./server/worker.mjs"
772
+ compatibility_date = "2024-09-23"
773
+ compatibility_flags = ["nodejs_compat"]
774
+ assets = { directory = "./public" }
775
+ `.trim();
776
+ await writeFile(join(outDirs.root, "wrangler.toml"), wranglerToml, "utf-8");
777
+ const unsupported = findUnsupportedNodeImports(outDirs.server);
778
+ if (unsupported.length > 0) logger$4.warn(`worker 产物包含 workerd 不支持的 Node 内建导入(${unsupported.join("、")})—— Worker 运行时会在启动时直接失败。来源通常是仅 Node 可用的静态服务/文件系统路径。`);
779
+ return "worker.mjs";
780
+ }
781
+ await writeFile(join(outDirs.server, "handler.mjs"), entries.standard(), "utf-8");
782
+ return "handler.mjs";
783
+ }
784
+ /** 汇总 client manifest 与 preset 信息,写 `dist/manifest.json`(构建产物契约的一部分)。 */
785
+ async function writeBuildManifest(options) {
786
+ const { cwd, outDirs, clientManifest, serverEntry, preset, hasPages, hasServer } = options;
787
+ const assets = [];
788
+ for (const [key, entry] of Object.entries(clientManifest)) assets.push({
789
+ file: entry.file,
790
+ src: key,
791
+ isEntry: entry.isEntry,
792
+ css: entry.css
793
+ });
794
+ const manifest = {
795
+ assets,
796
+ entry: serverEntry,
797
+ clientDir: hasPages ? relative(cwd, outDirs.public) : "",
798
+ serverDir: hasServer ? relative(cwd, outDirs.server) : "",
799
+ preset: preset.name || "standard"
800
+ };
801
+ await writeFile(join(outDirs.root, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
802
+ logger$4.info(`Build complete! Output: ${outDirs.root}`);
803
+ if (hasPages) logger$4.info(` Client assets: ${outDirs.public}`);
804
+ if (hasServer) {
805
+ logger$4.info(` Server bundle: ${outDirs.server}`);
806
+ logger$4.info(` Entry: ${serverEntry}`);
807
+ }
808
+ return manifest;
809
+ }
810
+ //#endregion
811
+ //#region src/vite/build-configs.ts
812
+ var build_configs_exports = /* @__PURE__ */ __exportAll({
813
+ CLIENT_VIRTUAL_EXCLUDE: () => CLIENT_VIRTUAL_EXCLUDE,
814
+ clientOptimizeDepsExclude: () => clientOptimizeDepsExclude,
815
+ clientOutputNames: () => clientOutputNames,
816
+ createBuildEnvironments: () => createBuildEnvironments,
817
+ getBuildOutDirsForConfig: () => getBuildOutDirsForConfig,
818
+ serverExternal: () => serverExternal,
819
+ serverOutputNames: () => serverOutputNames
820
+ });
821
+ /** 客户端优化依赖里必须排除的虚拟模块(`ubean` 运行时链与 vue-router 的实例一致性) */
822
+ const CLIENT_VIRTUAL_EXCLUDE = [
823
+ "virtual:ubean-pages",
824
+ "virtual:ubean-app",
825
+ "virtual:ubean-server",
826
+ "virtual:ubean-client-entry",
827
+ "#ubean-pages",
828
+ "#ubean-app",
829
+ "#ubean-server",
830
+ "#ubean-client-entry"
831
+ ];
832
+ /** 客户端 `optimizeDeps.exclude`(含 `ssrSingletonProdOptimizeExclude` 的既有规则)。 */
833
+ function clientOptimizeDepsExclude() {
834
+ return ssrSingletonProdOptimizeExclude(CLIENT_VIRTUAL_EXCLUDE);
835
+ }
836
+ /** 客户端产物命名(`assets/[name]-[hash].js` 等)。 */
837
+ function clientOutputNames() {
838
+ return {
839
+ entryFileNames: "assets/[name]-[hash].js",
840
+ chunkFileNames: "assets/chunks/[name]-[hash].js",
841
+ assetFileNames: "assets/[name]-[hash].[ext]"
842
+ };
843
+ }
844
+ /**
845
+ * 服务端 external 过滤(RM-V17 要求重审这条)。
846
+ *
847
+ * preset 的 external 里带有 `^ubean` 模式,它与 `resolve.noExternal: ['ubean']` **直接冲突**:
848
+ * 保留会把 ubean 外置,Node 从 node_modules 加载时解析不到 `virtual:ubean-*`(构建期由插件
849
+ * 提供,运行时不存在)。因此统一在派生处剔除,两条路径共用同一判断。
850
+ */
851
+ function serverExternal(presetBuildConfig) {
852
+ if (presetBuildConfig.entryType === "worker") return [];
853
+ return presetBuildConfig.external.filter((entry) => !(entry instanceof RegExp && entry.source.startsWith("^ubean")));
854
+ }
855
+ /**
856
+ * 服务端产物命名 + 打包形态。
857
+ *
858
+ * **非 node 目标必须内联动态 import**:默认路径用 `ssr.target: 'webworker'` 表达这一点
859
+ * (Vite 对 webworker 目标的 SSR 构建会内联),env 驱动路径没有 env 级的 `ssr.target`
860
+ * 等价字段,于是同一个 preset 下两条路径的 server bundle 形状分叉 —— 实测 `standard`
861
+ * (`entryType: 'fetch'`、target 为 webworker):默认路径 60 个文件(单文件 bundle),
862
+ * builder 路径 175 个(带 `chunks/`)。`node` 与 `cloudflare` 两格原本就一致(前者 target=node,
863
+ * 后者 entryType=worker),因此这里按 **target 是否为 node** 判定内联,与默认路径对齐。
864
+ */
865
+ function serverOutputNames(presetBuildConfig) {
866
+ const isNodeTarget = presetBuildConfig.target === "node18" || presetBuildConfig.entryType === "node";
867
+ return {
868
+ format: presetBuildConfig.format,
869
+ entryFileNames: "entry.mjs",
870
+ chunkFileNames: "chunks/[name]-[hash].mjs",
871
+ codeSplitting: Boolean(isNodeTarget && presetBuildConfig.entryType !== "worker")
872
+ };
873
+ }
874
+ /** 由已解析配置取产物目录(插件 `config` 钩子用)。 */
875
+ function getBuildOutDirsForConfig(config) {
876
+ return getBuildOutDirs(config.rootDir, config.build.outputDir);
877
+ }
878
+ /**
879
+ * 两个 environment 的构建配置(RM-V17 / RM-V21)。
880
+ *
881
+ * **两条路径共用同一份**:CLI 自建 builder 时把它传给 `createBuilder({ environments })`;
882
+ * 插件在 `config` 钩子里把它作为 `environments` 返回(`vite build` 路径由 Vite 建 builder)。
883
+ * 各自写一份必然漂移 —— 之前只有 `outDir` 的极简版本会让客户端环境退回默认入口 `index.html`,
884
+ * `vite build` 直接以 `Cannot resolve entry module index.html` 失败(实测)。
885
+ */
886
+ function createBuildEnvironments(input) {
887
+ const { outDirs, clientInput, minify, sourcemap, presetBuildConfig, ssrNoExternal } = input;
888
+ return {
889
+ client: {
890
+ consumer: "client",
891
+ optimizeDeps: { exclude: clientOptimizeDepsExclude() },
892
+ build: {
893
+ outDir: outDirs.public,
894
+ assetsDir: "assets",
895
+ minify: minify ? "oxc" : false,
896
+ sourcemap,
897
+ manifest: true,
898
+ ssrManifest: true,
899
+ emptyOutDir: false,
900
+ rollupOptions: {
901
+ input: { app: clientInput },
902
+ output: clientOutputNames()
903
+ }
904
+ }
905
+ },
906
+ ubean: {
907
+ consumer: "server",
908
+ ...presetBuildConfig.entryType === "worker" ? { define: {
909
+ "process.env.NODE_ENV": "\"production\"",
910
+ global: "globalThis"
911
+ } } : {},
912
+ resolve: {
913
+ noExternal: presetBuildConfig.entryType === "worker" ? [/./] : ssrNoExternal,
914
+ external: presetBuildConfig.entryType === "worker" ? [] : ["@ubean/i18n"]
915
+ },
916
+ build: {
917
+ outDir: outDirs.server,
918
+ ssr: true,
919
+ minify: presetBuildConfig.entryType === "worker" ? "oxc" : false,
920
+ sourcemap,
921
+ emptyOutDir: false,
922
+ rollupOptions: {
923
+ input: `${outDirs.virtual}/server-entry.mjs`,
924
+ external: serverExternal(presetBuildConfig),
925
+ output: serverOutputNames(presetBuildConfig)
926
+ }
927
+ }
928
+ }
929
+ };
930
+ }
931
+ //#endregion
932
+ //#region src/vite/prerender-step.ts
933
+ /**
934
+ * 预渲染步骤(RM-V21 的最后一环)。
935
+ *
936
+ * 原先这段逻辑长在 `cli/src/build.ts` 里 —— 于是 `vite build`(插件驱动的编排)永远产不出
937
+ * 静态 HTML:实测两条路径的产物清单逐项一致,唯一差异就是 9 个预渲染页面。把它搬进
938
+ * `runEnvBuilds`(两条路径共用)之后,`vite build` 与 `ubean build` 才有可比性。
939
+ *
940
+ * 包含三件事:SSG 静态渲染器 / SSR fetcher 的选择、`prerender()` 调用、内容搜索索引
941
+ * (`__search.json` + 可选 Pagefind)。
942
+ */
943
+ var prerender_step_exports = /* @__PURE__ */ __exportAll({
944
+ createSsrFetcher: () => createSsrFetcher,
945
+ loadContentForBuild: () => loadContentForBuild,
946
+ runPrerenderStep: () => runPrerenderStep
947
+ });
948
+ const logger$3 = getLogger("build");
949
+ /**
950
+ * 用构建好的 SSR entry 驱动渲染:import `entry.mjs`,取其 `createFetchHandler()`,再用合成的
951
+ * `http://localhost<path>` 请求驱动。
952
+ */
953
+ async function createSsrFetcher(cwd, manifest) {
954
+ try {
955
+ const entryPath = resolve(cwd, manifest.serverDir, "entry.mjs");
956
+ const mod = await import(
957
+ /* @vite-ignore */
958
+ pathToFileURL(entryPath).href
959
+ );
960
+ const createFetchHandler = mod.default ?? mod.createFetchHandler;
961
+ if (typeof createFetchHandler !== "function") {
962
+ logger$3.warn("SSR entry does not export createFetchHandler; falling back to placeholder prerender.");
963
+ return;
964
+ }
965
+ const fetch = await createFetchHandler();
966
+ if (typeof fetch !== "function") {
967
+ logger$3.warn("SSR entry did not return a fetch handler; falling back to placeholder prerender.");
968
+ return;
969
+ }
970
+ return {
971
+ fetch: async (url) => {
972
+ const req = new Request(`http://localhost${url || "/"}`, { headers: { "x-ubean-prerender": "1" } });
973
+ const res = await fetch(req);
974
+ return {
975
+ html: typeof res.text === "function" ? await res.text() : String(res.body ?? ""),
976
+ statusCode: res.status ?? 200
977
+ };
978
+ },
979
+ close: async () => {
980
+ try {
981
+ await mod.close?.();
982
+ } catch {}
983
+ }
984
+ };
985
+ } catch (err) {
986
+ logger$3.warn(`Failed to load SSR entry for prerender: ${err instanceof Error ? err.message : String(err)}. Falling back to placeholder prerender.`);
987
+ return;
988
+ }
989
+ }
990
+ /**
991
+ * 构建期加载内容集合(内容路由 + 快照)。
992
+ *
993
+ * 与预渲染同因:`contentSnapshot` 决定内容集合页面(如 `/blog/hello`)是否进入预渲染队列,
994
+ * 因此两条构建路径都必须加载它 —— 否则插件路径会少产出这些静态页(实测:`vite build` 比
995
+ * `ubean build` 少 `blog/hello/index.html` 与一个服务端 chunk)。
996
+ */
997
+ async function loadContentForBuild(cwd, content) {
998
+ if (!content) return { snapshot: void 0 };
999
+ try {
1000
+ const mod = await import("@ubean/content");
1001
+ const options = content === true ? {} : content;
1002
+ return { snapshot: mod.scanContentSources(cwd, options) };
1003
+ } catch (error) {
1004
+ getLogger("build").warn(`Content snapshot unavailable — content routes will be skipped: ${String(error)}`);
1005
+ return { snapshot: void 0 };
1006
+ }
1007
+ }
1008
+ /**
1009
+ * 预渲染 + 内容搜索索引。未启用预渲染(`prerender.enabled` 为假且 routeRules 无
1010
+ * `prerender: true`)时整段跳过。
1011
+ */
1012
+ async function runPrerenderStep(options) {
1013
+ const { cwd, config, scanResult, manifest, contentSnapshot } = options;
1014
+ const hasPrerenderRules = Object.values(config.routeRules || {}).some((rule) => rule?.prerender === true);
1015
+ if (!config.prerender.enabled && !hasPrerenderRules) return;
1016
+ logger$3.info("Prerendering static pages...");
1017
+ let contentRoutes = [];
1018
+ if (contentSnapshot) try {
1019
+ contentRoutes = (await import("@ubean/content")).extractContentPageRoutes(Object.values(contentSnapshot).flat());
1020
+ } catch {}
1021
+ let ssrFetcher;
1022
+ let notFoundRoute = false;
1023
+ let expandRoutes;
1024
+ if (config.mode === "ssg") {
1025
+ const staticRenderer = await createStaticSsgRenderer(cwd, manifest, {
1026
+ pages: scanResult.pages,
1027
+ notFoundPage: scanResult.notFoundPage ?? null,
1028
+ i18n: config.i18n
1029
+ });
1030
+ if (staticRenderer) {
1031
+ ssrFetcher = {
1032
+ fetch: staticRenderer.fetcher,
1033
+ close: async () => {}
1034
+ };
1035
+ expandRoutes = staticRenderer.expandRoutes;
1036
+ notFoundRoute = Boolean(scanResult.notFoundPage);
1037
+ logger$3.info("Using static SSG renderer (direct render, no HTTP pipeline)");
1038
+ }
1039
+ } else ssrFetcher = await createSsrFetcher(cwd, manifest);
1040
+ try {
1041
+ await prerender({
1042
+ cwd,
1043
+ outputDir: config.build.outputDir,
1044
+ pages: scanResult.pages,
1045
+ prerender: config.prerender,
1046
+ routeRules: config.routeRules,
1047
+ contentRoutes,
1048
+ fetcher: ssrFetcher?.fetch,
1049
+ notFoundRoute,
1050
+ expandRoutes
1051
+ });
1052
+ } finally {
1053
+ await ssrFetcher?.close();
1054
+ }
1055
+ if (!contentSnapshot) return;
1056
+ try {
1057
+ const contentMod = await import("@ubean/content");
1058
+ const searchConfig = contentMod.resolveContentSearchConfig(config.content, { ssg: config.mode === "ssg" });
1059
+ const staticDir = resolvePrerenderStaticDir(cwd, config.build.outputDir, resolvePrerenderConfig(config.prerender));
1060
+ if (searchConfig.sections) {
1061
+ const payload = contentMod.generateSearchSectionsSnapshot(contentSnapshot);
1062
+ await mkdir(staticDir, { recursive: true });
1063
+ await writeFile(join(staticDir, "__search.json"), JSON.stringify(payload), "utf-8");
1064
+ const sectionCount = Object.values(payload).reduce((sum, sections) => sum + sections.length, 0);
1065
+ logger$3.info(`Search: wrote __search.json (${sectionCount} sections)`);
1066
+ }
1067
+ if (searchConfig.pagefind) {
1068
+ const res = await contentMod.runPagefindIndex({
1069
+ siteDir: staticDir,
1070
+ createIndexOptions: searchConfig.pagefindOptions,
1071
+ logger: {
1072
+ info: (msg) => logger$3.info(msg),
1073
+ warn: (msg) => logger$3.warn(msg)
1074
+ }
1075
+ });
1076
+ if (res.indexed) logger$3.info("Search: pagefind index generated");
1077
+ else if (res.reason === "pagefind-not-installed") logger$3.info("Search: pagefind is not installed — skipping index. Run `pnpm add -D pagefind` to enable.");
1078
+ else logger$3.warn(`Search: pagefind indexing failed — ${res.error ?? res.reason}`);
1079
+ }
1080
+ } catch (err) {
1081
+ logger$3.warn(`Search index generation skipped: ${err instanceof Error ? err.message : String(err)}`);
1082
+ }
1083
+ }
1084
+ //#endregion
1085
+ //#region src/vite/build-app.ts
1086
+ /**
1087
+ * environment 驱动的生产构建(RM-V16 / RM-V21,ADR-0012 §4)。
1088
+ *
1089
+ * 收敛前的旧编排是**两次独立的 `viteBuild`**(client 一次、
1090
+ * server 一次,各自带一份内联配置。新路径用一次 `createBuilder` 建两个环境(`client` /
1091
+ * `ubean`)并由 `buildApp` 编排:
1092
+ *
1093
+ * ```text
1094
+ * prepare(清理 + 虚拟模块落盘 + HTML 模板 + 插件组装)
1095
+ * → client env 构建
1096
+ * → ubean env 构建
1097
+ * → 公共目录拷贝 → preset 包装 → manifest
1098
+ * ```
1099
+ *
1100
+ * 三个阶段刻意拆成三个出口(RM-V21),因为**谁来建 builder** 有两种情况:
1101
+ * - `buildWithEnvironments()`:调用方(CLI 的旧路径或未来的薄别名)自己建 builder,
1102
+ * env 定义在这里;
1103
+ * - 插件(`ubeanPlugin` 的 `config` 钩子)在 `experimental.viteBuilder` 打开时提供
1104
+ * `builder.buildApp`:此时 builder 由 Vite 自己创建、env 由同一钩子注册,插件只需
1105
+ * `prepareBuild` + `runEnvBuilds` —— **不能**再调 `buildWithEnvironments`(那会递归建 builder)。
1106
+ *
1107
+ * **判据是产物可比**,不是写法漂亮:与 `viteBuild` 无关的步骤已抽到 `build-steps.ts` 两侧共用,
1108
+ * env 配置逐条对应旧路径的 `viteBuild` 入参 —— 旧路径写 `ssr.noExternal`/`external` 的地方,
1109
+ * 新路径是 server env 的 `resolve.noExternal`/`external`(Vite 6+ 的等价位置);旧路径的
1110
+ * `optimizeDeps.exclude` 属于 client env;islands SSR 空壳插件只挂 ubean env。
1111
+ */
1112
+ var build_app_exports = /* @__PURE__ */ __exportAll({
1113
+ buildWithEnvironments: () => buildWithEnvironments,
1114
+ createBuildContext: () => createBuildContext,
1115
+ prepareBuild: () => prepareBuild,
1116
+ runEnvBuilds: () => runEnvBuilds
1117
+ });
1118
+ const logger$2 = getLogger("build");
1119
+ /** 读客户端 manifest(供注入与 manifest 汇总共用)。 */
1120
+ function readClientManifest(publicDir) {
1121
+ const manifestPath = join(publicDir, ".vite", "manifest.json");
1122
+ if (!existsSync(manifestPath)) return null;
1123
+ try {
1124
+ return JSON.parse(readFileSync(manifestPath, "utf-8"));
1125
+ } catch {
1126
+ return null;
1127
+ }
1128
+ }
1129
+ /** 由 options 派生上下文(不做任何 IO)。 */
1130
+ function createBuildContext(options) {
1131
+ const { cwd, config, preset, scanResult, minify = true, sourcemap = false, contentSnapshot } = options;
1132
+ const mode = config.mode;
1133
+ return {
1134
+ cwd,
1135
+ config,
1136
+ preset,
1137
+ scanResult,
1138
+ minify,
1139
+ sourcemap,
1140
+ contentSnapshot,
1141
+ mode,
1142
+ hasPages: mode !== "backend",
1143
+ hasServer: mode !== "spa",
1144
+ ssrEnabled: mode === "fullstack" && config.ssr.enabled || mode === "ssg",
1145
+ outDirs: getBuildOutDirs(cwd, config.build.outputDir)
1146
+ };
1147
+ }
1148
+ /**
1149
+ * 第一阶段:清理产物目录、虚拟模块落盘、HTML 模板、插件组装。
1150
+ *
1151
+ * 插件必须在这里(`createBuilder` 之前)备好;`manifestRef` 的**内容**稍后在 client 构建
1152
+ * 完成后填 —— 服务端构建期注入插件读的就是这个载体。
1153
+ */
1154
+ async function prepareBuild(ctx, manifestRef = { current: null }) {
1155
+ const { cwd, config, preset, scanResult, contentSnapshot, outDirs, hasPages, hasServer } = ctx;
1156
+ await cleanBuildOutput({
1157
+ outDirs,
1158
+ hasPages,
1159
+ hasServer,
1160
+ mode: ctx.mode,
1161
+ ssrEnabled: ctx.ssrEnabled
1162
+ });
1163
+ logger$2.info("Generating virtual modules...");
1164
+ const virtualRegistry = createVirtualRegistry();
1165
+ await generateVirtualModulesToDisk(cwd, config, scanResult, outDirs.virtual, preset, virtualRegistry, contentSnapshot);
1166
+ if (hasPages) await writeClientIndexHtml(outDirs.virtual);
1167
+ const userViteConfig = findUserViteConfig(cwd);
1168
+ const builtinPlugins = [];
1169
+ if (!userViteConfig) {
1170
+ builtinPlugins.push(ubeanAssetManifestPlugin(() => manifestRef.current, () => outDirs.server));
1171
+ builtinPlugins.push(ubeanPlugin({
1172
+ config,
1173
+ registry: virtualRegistry
1174
+ }));
1175
+ if (hasPages) builtinPlugins.push(...ubeanVite({
1176
+ config,
1177
+ registry: virtualRegistry
1178
+ }), ubeanIslandsPlugin());
1179
+ else if (hasServer) builtinPlugins.push(...ubeanVite({
1180
+ config,
1181
+ registry: virtualRegistry
1182
+ }));
1183
+ }
1184
+ const { plugins } = await resolveModules({
1185
+ cwd,
1186
+ config,
1187
+ builtinPlugins
1188
+ });
1189
+ if (hasServer) plugins.push(perEnvironmentPlugin("ubean", () => createIslandsSsrStubPlugin()));
1190
+ return {
1191
+ ...ctx,
1192
+ plugins,
1193
+ presetBuildConfig: getPresetBuildConfig(preset),
1194
+ manifestRef
1195
+ };
1196
+ }
1197
+ /**
1198
+ * 第二、三阶段:按顺序构建两个环境,再做公共目录拷贝 / preset 包装 / manifest。
1199
+ *
1200
+ * `builder` 由调用方提供:CLI 路径是自建的,插件路径是 Vite 建好后传进 `buildApp` 的。
1201
+ */
1202
+ async function runEnvBuilds(builder, prepared) {
1203
+ const { cwd, preset, hasPages, hasServer, outDirs, mode, ssrEnabled, presetBuildConfig, manifestRef } = prepared;
1204
+ if (hasPages) {
1205
+ logger$2.info("Building client bundle...");
1206
+ await builder.build(builder.environments.client);
1207
+ manifestRef.current = readClientManifest(outDirs.public);
1208
+ }
1209
+ if (hasServer) {
1210
+ logger$2.info(ssrEnabled ? "Building SSR bundle..." : "Building server bundle (SSR disabled)...");
1211
+ await builder.build(builder.environments.ubean);
1212
+ }
1213
+ if (hasPages) {
1214
+ const publicDir = join(cwd, "public");
1215
+ if (existsSync(publicDir)) {
1216
+ logger$2.info("Copying public assets...");
1217
+ await cp(publicDir, outDirs.public, { recursive: true });
1218
+ }
1219
+ }
1220
+ const clientManifest = hasPages ? readClientManifest(outDirs.public) ?? {} : {};
1221
+ if (mode === "spa") {
1222
+ const tags = computeAssetTags(clientManifest);
1223
+ await writeSpaIndexHtml({
1224
+ outDirs,
1225
+ tags: {
1226
+ css: tags.css,
1227
+ body: tags.body,
1228
+ favicon: prepared.config.favicon
1229
+ }
1230
+ });
1231
+ }
1232
+ const builtManifest = await writeBuildManifest({
1233
+ cwd,
1234
+ outDirs,
1235
+ clientManifest,
1236
+ serverEntry: hasServer ? await writePresetWrapper({
1237
+ mode,
1238
+ presetBuildConfig: { entryType: presetBuildConfig.entryType },
1239
+ outDirs,
1240
+ entries: {
1241
+ node: generateNodeServerEntry,
1242
+ worker: generateCloudflareWorkerEntry,
1243
+ standard: generateStandardHandlerEntry
1244
+ }
1245
+ }) : "",
1246
+ preset,
1247
+ hasPages,
1248
+ hasServer
1249
+ });
1250
+ await runPrerenderStep({
1251
+ cwd,
1252
+ config: prepared.config,
1253
+ scanResult: prepared.scanResult,
1254
+ manifest: builtManifest,
1255
+ contentSnapshot: prepared.contentSnapshot
1256
+ });
1257
+ return builtManifest;
1258
+ }
1259
+ /**
1260
+ * 一次 `createBuilder` 完成整套生产构建(`ubean build` 与 `vite build` 共用的唯一编排)。
1261
+ *
1262
+ * **驱动权声明**:本函数自己就是编排者,因此先在环境里声明「构建由调用方驱动」,让插件侧的
1263
+ * `config` 钩子**不要**再注册它自己的 `builder.buildApp` —— 否则同一个 config 的 `builder` 字段
1264
+ * 会被插件返回值合并掉,本函数的内联编排不再执行、`manifest` 保持 undefined,表现为**静默返回
1265
+ * undefined**(RM-V36 把开关默认打开后由 `build-parity.test.ts` 实测撞到)。与 CLI 用的是同一个
1266
+ * 环境变量,结束后还原,避免影响同进程内的其它调用。
1267
+ */
1268
+ async function buildWithEnvironments(options) {
1269
+ const previous = process.env.UBEAN_BUILD_DRIVEN_BY_CLI;
1270
+ process.env.UBEAN_BUILD_DRIVEN_BY_CLI = "1";
1271
+ try {
1272
+ return await runEnvOrchestration(options);
1273
+ } finally {
1274
+ if (previous === void 0) delete process.env.UBEAN_BUILD_DRIVEN_BY_CLI;
1275
+ else process.env.UBEAN_BUILD_DRIVEN_BY_CLI = previous;
1276
+ }
1277
+ }
1278
+ async function runEnvOrchestration(options) {
1279
+ const prepared = await prepareBuild(createBuildContext(options));
1280
+ const { cwd, sourcemap, minify, outDirs, presetBuildConfig } = prepared;
1281
+ const userViteConfig = findUserViteConfig(cwd);
1282
+ const clientEntryPath = join(resolve(cwd, options.config.srcDir), "entry.client.ts");
1283
+ const clientInput = existsSync(clientEntryPath) ? clientEntryPath : join(outDirs.virtual, "client-entry.mjs");
1284
+ const ssrOptions = ssrSingletonProdSsr();
1285
+ let manifest;
1286
+ await (await createBuilder({
1287
+ root: cwd,
1288
+ configFile: userViteConfig ?? false,
1289
+ mode: "production",
1290
+ plugins: prepared.plugins,
1291
+ environments: createBuildEnvironments({
1292
+ outDirs,
1293
+ clientInput,
1294
+ minify,
1295
+ sourcemap,
1296
+ presetBuildConfig,
1297
+ ssrNoExternal: ssrOptions.noExternal
1298
+ }),
1299
+ builder: { async buildApp(b) {
1300
+ manifest = await runEnvBuilds(b, prepared);
1301
+ } }
1302
+ })).buildApp();
1303
+ return manifest;
1304
+ }
1305
+ //#endregion
1306
+ //#region src/production.ts
1307
+ function toVitePath(p) {
1308
+ return p.replace(/\\/g, "/");
1309
+ }
1310
+ /**
1311
+ * 把扫描到的页面序列化进服务端入口(`const _pages = [...]`)。
1312
+ *
1313
+ * **白名单必须覆盖语义字段**:此前只序列化了 `relativePath/name/route/layout/reuseTarget/isReuse/
1314
+ * pageMeta`,于是 `matchers` / `slot` **在生产构建里丢失** ——
1315
+ * dev 用扫描得到的活对象(`enhanceDevApp` / `buildDevSsrRoutes` 直接拿 `scanResult.pages`),所以本地
1316
+ * 一切正常,只有构建产物坏掉:带着 `[id=numeric]` 的路由在 dev 返回 404、在生产返回 200(实测)。
1317
+ * 这类「dev 正常、产物丢失元数据」的缺陷对用户最不友好,因此单独抽成函数并配单测
1318
+ * (`packages/builder/test/page-metadata.test.ts`)。
1319
+ *
1320
+ * 不含 `fullPath`/`dirname`/`basename`(构建机上的文件系统细节,产物不需要)与 `frontmatter`
1321
+ * (Markdown 页正文用,服务端路由不消费)。拦截路由的两个字段(`interceptFrom` /
1322
+ * `interceptTarget`)曾在这份白名单里,随该约定一起移除([docs/adr/0010] 刻意不做)。
1323
+ */
1324
+ function serializePagesForEntry(pages) {
1325
+ return JSON.stringify(pages.map((p) => ({
1326
+ relativePath: p.relativePath,
1327
+ name: p.name,
1328
+ route: p.route,
1329
+ path: p.path,
1330
+ layout: p.layout,
1331
+ cache: p.cache,
1332
+ isReuse: p.isReuse,
1333
+ isMarkdown: p.isMarkdown,
1334
+ reuseTarget: p.reuseTarget,
1335
+ pageMeta: p.pageMeta,
1336
+ matchers: p.matchers,
1337
+ slot: p.slot
1338
+ })));
1339
+ }
1340
+ async function generateVirtualModulesToDisk(cwd, config, scanResult, virtualDir, preset, registry, contentSnapshot) {
1341
+ const mode = config.mode;
1342
+ const ssrEnabled = mode === "fullstack" && config.ssr.enabled || mode === "ssg";
1343
+ const hasPages = mode !== "backend";
1344
+ const hasServer = mode !== "spa";
1345
+ await mkdir(virtualDir, { recursive: true });
1346
+ const srcDirAbs = resolve(cwd, config.srcDir);
1347
+ const viteSrcDir = toVitePath(relative(cwd, srcDirAbs));
1348
+ const viteSrcPrefix = viteSrcDir ? `/${viteSrcDir}` : "";
1349
+ registry.register(createRoutingVirtualModule(scanResult.apiRoutes.map((r) => ({
1350
+ method: r.method?.toUpperCase() || "ALL",
1351
+ path: r.route,
1352
+ id: `${r.method}:${r.route}`,
1353
+ filePath: r.fullPath
1354
+ })), scanResult.middlewares.map((m) => ({
1355
+ path: "/**",
1356
+ filePath: m.fullPath,
1357
+ order: m.order,
1358
+ global: m.global
1359
+ })), cwd));
1360
+ if (hasPages) {
1361
+ registry.register(createPagesVirtualModule(scanResult.pages.map((p) => ({
1362
+ name: p.name,
1363
+ path: p.route,
1364
+ filePath: p.fullPath,
1365
+ layout: p.layout,
1366
+ reuseTarget: p.reuseTarget
1367
+ })), scanResult.layouts.map((l) => ({
1368
+ name: l.name,
1369
+ filePath: l.fullPath,
1370
+ isDefault: l.isDefault
1371
+ })), cwd));
1372
+ registry.register(createMetaVirtualModule());
1373
+ registry.register(createVuePagesVirtualModule(scanResult.pages, scanResult.layouts, scanResult.notFoundPage, scanResult.loadingPage, scanResult.errorPage, localeVueParamFromI18n(config.i18n)));
1374
+ registry.register(createVueAppEntryVirtualModule(scanResult.appEntry));
1375
+ registry.register(createClientEntryVirtualModule());
1376
+ }
1377
+ registry.register(createAppVirtualModule(scanResult.apiRoutes, scanResult.middlewares, hasPages ? scanResult.pages : [], viteSrcPrefix || "/"));
1378
+ registry.register(createLocalesVirtualModule(scanResult.locales, scanResult.defaultLocale, viteSrcPrefix || "/", serializeI18nConfig(config.i18n)));
1379
+ if (hasServer) registry.register(createServerEntryVirtualModule(scanResult.serverEntry));
1380
+ async function writeModule(id, filename, header = "// Auto-generated by ubean\n") {
1381
+ const mod = registry.getModules().find((m) => m.id === id);
1382
+ const content = mod ? await mod.load() : "";
1383
+ await writeFile(join(virtualDir, filename), header + content, "utf-8");
1384
+ }
1385
+ await writeModule("ubean:routes", "routes.mjs");
1386
+ await writeModule("ubean:app-config", "app-config.mjs");
1387
+ await writeModule("ubean:locales", "locales.mjs");
1388
+ if (hasPages) {
1389
+ await writeModule("ubean:pages", "pages.ts");
1390
+ await writeModule("ubean:meta", "meta.mjs");
1391
+ await writeModule("virtual:ubean-pages", "vue-pages.ts", "");
1392
+ await writeModule("virtual:ubean-app", "vue-app.ts", "");
1393
+ await writeModule("virtual:ubean-client-entry", "client-entry.mjs", "");
1394
+ }
1395
+ if (hasServer) await writeModule("virtual:ubean-server", "server-entry.ts", "");
1396
+ await writeFile(join(virtualDir, "islands-registry.ts"), "export const islands = {};\n", "utf-8");
1397
+ const pagesGlob = JSON.stringify(`${viteSrcPrefix}/pages/**/*.{vue,ts,tsx,js,jsx,md,mdx}`);
1398
+ const routesGlob = JSON.stringify(`${viteSrcPrefix}/routes/**/*.{ts,js,mjs}`);
1399
+ const middlewareGlob = JSON.stringify(`${viteSrcPrefix}/middleware/**/*.{ts,js,mjs}`);
1400
+ const layoutsGlob = JSON.stringify(`${viteSrcPrefix}/layouts/**/*.{vue,ts}`);
1401
+ const cronsDirs = (Array.isArray(config.dir.crons) ? config.dir.crons : [config.dir.crons || "crons"]).map((d) => d.replace(/\\/g, "/"));
1402
+ const cronsGlob = JSON.stringify(cronsDirs.length === 1 ? `${viteSrcPrefix}/${cronsDirs[0]}/**/*.{ts,js,mjs}` : `${viteSrcPrefix}/{${cronsDirs.join(",")}}/**/*.{ts,js,mjs}`);
1403
+ const seoGlob = JSON.stringify(`${viteSrcPrefix}/{sitemap,robots,manifest,opengraph-image,icon,apple-icon}.{ts,js,mjs,mts,cjs}`);
1404
+ const enableInProcessCron = !isEphemeralCachePreset(preset.name || "standard");
1405
+ const enableIpx = Boolean(config.image);
1406
+ const outputDir = config.build.outputDir || "dist";
1407
+ const publicDir = toVitePath(resolve(cwd, outputDir, "public"));
1408
+ const routesJson = JSON.stringify(scanResult.apiRoutes.map((r) => ({
1409
+ relativePath: r.relativePath,
1410
+ route: r.route,
1411
+ method: r.method,
1412
+ fileMeta: r.fileMeta
1413
+ })));
1414
+ const middlewareJson = JSON.stringify(scanResult.middlewares.map((m) => ({
1415
+ relativePath: m.relativePath,
1416
+ order: m.order,
1417
+ global: m.global
1418
+ })));
1419
+ const pagesJson = serializePagesForEntry(scanResult.pages);
1420
+ const layoutsJson = JSON.stringify(scanResult.layouts.map((l) => ({
1421
+ name: l.name,
1422
+ relativePath: l.relativePath,
1423
+ isDefault: l.isDefault
1424
+ })));
1425
+ const notFoundPageJson = JSON.stringify(scanResult.notFoundPage ? {
1426
+ relativePath: scanResult.notFoundPage.relativePath,
1427
+ name: scanResult.notFoundPage.name,
1428
+ route: scanResult.notFoundPage.route,
1429
+ layout: scanResult.notFoundPage.layout,
1430
+ pageMeta: scanResult.notFoundPage.pageMeta
1431
+ } : null);
1432
+ const colorModeScript = config.colorMode !== false ? getColorModeScript(resolveColorModeConfig(config.colorMode)) : "";
1433
+ const rendererImport = ssrEnabled ? `import { createVueRenderer } from 'ubean/ssr';` : "";
1434
+ const prodLocaleVueParam = localeVueParamFromI18n(config.i18n) || "";
1435
+ const rendererSetup = buildRendererSetup({
1436
+ ssrEnabled,
1437
+ mode,
1438
+ localeVueParam: prodLocaleVueParam
1439
+ });
1440
+ const assetTagsSetup = buildAssetTagsSetup(config.favicon);
1441
+ const productionCache = resolveProductionCacheStore(preset.name || "standard", config.cache);
1442
+ const contentEntries = Object.entries(contentSnapshot ?? {}).filter(([, docs]) => Array.isArray(docs));
1443
+ const contentBootstrap = contentEntries.length > 0 ? `
1444
+ import { configureContentRuntime, registerContent } from '@ubean/content';
1445
+ configureContentRuntime();
1446
+ ${contentEntries.map(([name, docs]) => `registerContent(${JSON.stringify(name)}, ${JSON.stringify(docs)});`).join("\n")}
1447
+ ` : "";
1448
+ const ipxImport = enableIpx ? `import { createIpxHonoHandler } from '@ubean/image';
1449
+ ` : "";
1450
+ const cronImport = enableInProcessCron ? `import { startCronScheduler } from '@ubean/server/cron';
1451
+ ` : "";
1452
+ if (hasServer) {
1453
+ if (mode === "ssg") {
1454
+ const staticEntry = buildStaticSsgEntry({
1455
+ pagesGlob,
1456
+ layoutsGlob,
1457
+ srcPrefix: JSON.stringify(viteSrcPrefix),
1458
+ pagesJson,
1459
+ layoutsJson,
1460
+ notFoundPageJson,
1461
+ localeVueParam: prodLocaleVueParam,
1462
+ contentBootstrap,
1463
+ colorModeScript,
1464
+ favicon: config.favicon
1465
+ });
1466
+ await writeFile(join(virtualDir, "server-entry.mjs"), staticEntry, "utf-8");
1467
+ return virtualDir;
1468
+ }
1469
+ const serverEntry = `// Auto-generated server entry
1470
+ import { createUbeanApp, applyServerConfig } from 'ubean/server';
1471
+ import { toVueRouterLocalePath } from '@ubean/i18n';
1472
+ import 'ubean:locales';
1473
+ ${rendererImport}
1474
+ import { resolveAppConfig as _resolveAppConfig } from 'virtual:ubean-app';
1475
+ import { resolveServerConfig as _resolveServerConfig } from 'virtual:ubean-server';
1476
+ import { readFileSync } from 'node:fs';
1477
+ import { join, dirname } from 'node:path';
1478
+ import { fileURLToPath } from 'node:url';
1479
+ ${contentBootstrap}${ipxImport}${cronImport}
1480
+ export { createUbeanApp };
1481
+
1482
+ const routeModules = import.meta.glob(${routesGlob}, { eager: false });
1483
+ const middlewareModules = import.meta.glob(${middlewareGlob}, { eager: false });
1484
+ const pageModules = import.meta.glob(${pagesGlob}, { eager: false });
1485
+ const layoutModules = import.meta.glob(${layoutsGlob}, { eager: false });
1486
+ const cronModules = import.meta.glob(${cronsGlob}, { eager: true });
1487
+ const seoConventionModules = import.meta.glob(${seoGlob}, { eager: true });
1488
+
1489
+ const _srcPrefix = ${JSON.stringify(viteSrcPrefix)};
1490
+ function normalizeKey(p) {
1491
+ const prefixes = ['/routes/', '/middleware/', '/pages/', '/layouts/'];
1492
+ for (const prefix of prefixes) {
1493
+ const fullPrefix = _srcPrefix + prefix;
1494
+ if (p.includes(fullPrefix)) {
1495
+ const idx = p.indexOf(fullPrefix);
1496
+ return p.slice(idx + fullPrefix.length);
1497
+ }
1498
+ }
1499
+ return p;
1500
+ }
1501
+
1502
+ export const routeLoaders = {};
1503
+ for (const [key, loader] of Object.entries(routeModules)) {
1504
+ routeLoaders[normalizeKey(key)] = loader;
1505
+ }
1506
+
1507
+ export const middlewareLoaders = {};
1508
+ for (const [key, loader] of Object.entries(middlewareModules)) {
1509
+ middlewareLoaders[normalizeKey(key)] = loader;
1510
+ }
1511
+
1512
+ export const pageLoaders = {};
1513
+ for (const [key, loader] of Object.entries(pageModules)) {
1514
+ pageLoaders[normalizeKey(key)] = loader;
1515
+ }
1516
+
1517
+ const layoutLoaders = {};
1518
+ for (const [key, loader] of Object.entries(layoutModules)) {
1519
+ layoutLoaders[normalizeKey(key)] = loader;
1520
+ }
1521
+
1522
+ const _routes = ${routesJson};
1523
+ const _middleware = ${middlewareJson};
1524
+ const _pages = ${pagesJson};
1525
+ const _layouts = ${layoutsJson};
1526
+ const _notFoundPage = ${notFoundPageJson};
1527
+ ${rendererSetup}
1528
+ ${assetTagsSetup}
1529
+
1530
+ // 进程内 cron 调度器句柄:必须声明在模块作用域 —— createApp() 赋值、导出的 close() 读取;
1531
+ // 若声明在 createApp 内部,close() 里读不到(且它整体包在 try/catch 里,会静默失败),
1532
+ // 于是 30s 的调度间隔留在进程里,预渲染后进程永不退出(实测:timer-probe 点出这一段栈)。
1533
+ let cronScheduler;
1534
+
1535
+ export async function createApp(options = {}) {
1536
+ const app = createUbeanApp({
1537
+ rootDir: ${JSON.stringify(cwd)},
1538
+ routes: _routes,
1539
+ middleware: _middleware,
1540
+ pages: _pages,
1541
+ layouts: _layouts,
1542
+ routeLoaders,
1543
+ middlewareLoaders,
1544
+ pageLoaders,
1545
+ pageRenderer: _pageRenderer,
1546
+ pageAssetTags: _assetTags,
1547
+ publicDir: ${JSON.stringify(publicDir)},
1548
+ i18nConfig: ${JSON.stringify(config.i18n)},
1549
+ ssrExclude: ${JSON.stringify(config.ssr?.exclude ?? [])},
1550
+ streaming: ${JSON.stringify(config.ssr?.streaming ?? false)},
1551
+ notFoundPage: _notFoundPage || undefined,
1552
+ colorModeScript: ${JSON.stringify(colorModeScript)},
1553
+ routeRules: ${JSON.stringify(config.routeRules || {})},
1554
+ csrf: ${JSON.stringify(config.security === false ? false : config.security?.csrf ?? true)},
1555
+ securityHeaders: ${JSON.stringify(config.security === false ? false : config.security?.headers ?? true)},
1556
+ dataCache: ${JSON.stringify(config.dataCache ?? true)},
1557
+ cache: ${JSON.stringify(productionCache)},
1558
+ seoConventions: { srcDir: ${JSON.stringify(srcDirAbs)} },
1559
+ seoConventionModules,
1560
+ ipxHandler: ${enableIpx ? `createIpxHonoHandler({ rootDir: ${JSON.stringify(publicDir)}, staticDir: '.' })` : "undefined"},
1561
+ ...options
1562
+ });
1563
+
1564
+ // Apply user's defineServer config (plugins, hooks, onAppCreate) before init
1565
+ const _serverConfig = _resolveServerConfig('prod');
1566
+ await applyServerConfig(app, _serverConfig);
1567
+
1568
+ await app.init();
1569
+
1570
+ ${enableInProcessCron ? `if (Object.keys(cronModules).length > 0) {
1571
+ cronScheduler = startCronScheduler();
1572
+ }` : ""}
1573
+
1574
+ // Call onServerReady after init completes
1575
+ if (_serverConfig.onServerReady) {
1576
+ await _serverConfig.onServerReady(app);
1577
+ }
1578
+
1579
+ return app;
1580
+ }
1581
+
1582
+ export default async function createFetchHandler() {
1583
+ const app = await createApp();
1584
+ return app.fetch;
1585
+ }
1586
+
1587
+ // 释放本 entry 打开的运行时资源(RM-V21):
1588
+ // 预渲染需要 import() 构建好的 entry 来渲染真实 HTML,而 entry 的 createApp() 会启动进程内
1589
+ // cron 调度器(以及可能被页面用到的队列 worker / 数据库连接)—— 进程因此不再退出:
1590
+ // ubean build 之后 CLI 显式退出看不出来,vite build 则会挂住(实测 13 分钟仍在)。
1591
+ // 保留调度器句柄并导出 close(),由预渲染步骤在渲染完成后调用。
1592
+ export async function close() {
1593
+ try {
1594
+ await cronScheduler?.stop?.();
1595
+ cronScheduler = null;
1596
+ } catch {}
1597
+ // 经 ubean/server(项目必然依赖 ubean)而非子包路径 @ubean/server/* —— 后者从构建产物
1598
+ // 解析不到,清理会静默失败(实测:定时器还在,进程不退)。
1599
+ try {
1600
+ const runtime = await import('ubean/server');
1601
+ runtime.disposeMemoryRateLimitStores?.();
1602
+ await runtime.stopQueueWorkers?.();
1603
+ await runtime.closeDatabases?.();
1604
+ } catch {}
1605
+ }
1606
+
1607
+ export const handler = async (req, ctx) => {
1608
+ const app = await createApp();
1609
+ return app.fetch(req, ctx);
1610
+ };
1611
+ `;
1612
+ await writeFile(join(virtualDir, "server-entry.mjs"), serverEntry, "utf-8");
1613
+ }
1614
+ return virtualDir;
1615
+ }
1616
+ function getPresetBuildConfig(preset) {
1617
+ switch (preset.name) {
1618
+ case "node":
1619
+ case "bun":
1620
+ case "deno": return {
1621
+ format: "esm",
1622
+ target: "node18",
1623
+ entryType: "node",
1624
+ external: [
1625
+ "hono",
1626
+ "vue",
1627
+ "vue/server-renderer",
1628
+ /^ubean(\/.*)?$/,
1629
+ /^node:/
1630
+ ]
1631
+ };
1632
+ case "cloudflare": return {
1633
+ format: "esm",
1634
+ target: "es2022",
1635
+ entryType: "worker",
1636
+ external: [
1637
+ "hono",
1638
+ "vue",
1639
+ "vue/server-renderer",
1640
+ /^ubean(\/.*)?$/
1641
+ ]
1642
+ };
1643
+ default: return {
1644
+ format: "esm",
1645
+ target: "es2022",
1646
+ entryType: "fetch",
1647
+ external: [
1648
+ "hono",
1649
+ "vue",
1650
+ "vue/server-renderer",
1651
+ /^ubean(\/.*)?$/
1652
+ ]
1653
+ };
1654
+ }
1655
+ }
1656
+ /**
1657
+ * SSR 期的 islands 注册表空壳插件。
1658
+ *
1659
+ * 真正的注册表由 `ubeanIslandsPlugin` 在**客户端**构建期填充;SSR 期 islands 由服务端渲染
1660
+ * (不水合),空壳即可 —— 但不能让它以裸 `virtual:ubean-islands-registry` 泄漏给 Node。
1661
+ */
1662
+ function createIslandsSsrStubPlugin() {
1663
+ return {
1664
+ name: "ubean:islands-ssr-stub",
1665
+ enforce: "pre",
1666
+ resolveId(id) {
1667
+ if (id === "virtual:ubean-islands-registry") return "\0virtual:ubean-islands-registry";
1668
+ },
1669
+ load(id) {
1670
+ if (id === "\0virtual:ubean-islands-registry") return "export const islands = {};";
1671
+ }
1672
+ };
1673
+ }
1674
+ function generateNodeServerEntry() {
1675
+ return `// Auto-generated ubean Node.js server entry
1676
+ import { createServer } from 'node:http';
1677
+ import createFetchHandler from './entry.mjs';
1678
+
1679
+ const port = Number(process.env.PORT || 9527);
1680
+ const host = process.env.HOST || '0.0.0.0';
1681
+
1682
+ async function main() {
1683
+ const fetch = await createFetchHandler();
1684
+
1685
+ const server = createServer(async (req, res) => {
1686
+ try {
1687
+ const protocol = req.socket?.encrypted ? 'https' : 'http';
1688
+ const url = \`\${protocol}://\${req.headers.host || host}\${req.url || '/'}\`;
1689
+ const headers = new Headers();
1690
+ for (const [key, value] of Object.entries(req.headers)) {
1691
+ if (value) {
1692
+ if (Array.isArray(value)) {
1693
+ for (const v of value) headers.append(key, v);
1694
+ } else {
1695
+ headers.set(key, value);
1696
+ }
1697
+ }
1698
+ }
1699
+ const method = req.method || 'GET';
1700
+ const body = method === 'GET' || method === 'HEAD' ? undefined : req;
1701
+ const webReq = new Request(url, { method, headers, body, duplex: 'half' });
1702
+ const webRes = await fetch(webReq);
1703
+
1704
+ res.statusCode = webRes.status;
1705
+ res.statusMessage = webRes.statusText;
1706
+ webRes.headers.forEach((value, key) => res.setHeader(key, value));
1707
+
1708
+ if (webRes.body) {
1709
+ const reader = webRes.body.getReader();
1710
+ try {
1711
+ while (true) {
1712
+ const { done, value: chunk } = await reader.read();
1713
+ if (done) break;
1714
+ res.write(chunk);
1715
+ }
1716
+ } finally {
1717
+ reader.releaseLock();
1718
+ }
1719
+ }
1720
+ res.end();
1721
+ } catch (err) {
1722
+ if (!res.headersSent) {
1723
+ res.statusCode = 500;
1724
+ res.setHeader('Content-Type', 'text/plain');
1725
+ }
1726
+ res.end(err instanceof Error ? err.message : String(err));
1727
+ }
1728
+ });
1729
+
1730
+ server.listen(port, host, () => {
1731
+ console.log(\`ubean server listening on http://\${host}:\${port}\`);
1732
+ });
1733
+
1734
+ const shutdown = () => {
1735
+ server.close(() => process.exit(0));
1736
+ };
1737
+ process.on('SIGINT', shutdown);
1738
+ process.on('SIGTERM', shutdown);
1739
+ }
1740
+
1741
+ main();
1742
+ `.trim();
1743
+ }
1744
+ function generateStandardHandlerEntry() {
1745
+ return `// Auto-generated ubean standard fetch handler
1746
+ import createFetchHandler from './entry.mjs';
1747
+
1748
+ const handlerPromise = createFetchHandler();
1749
+ export default async function fetch(req, ctx) {
1750
+ const handler = await handlerPromise;
1751
+ return handler(req, ctx);
1752
+ }
1753
+ `.trim();
1754
+ }
1755
+ function generateCloudflareWorkerEntry() {
1756
+ return `// Auto-generated ubean Cloudflare Worker entry
1757
+ import createFetchHandler from './entry.mjs';
1758
+
1759
+ const handlerPromise = createFetchHandler();
1760
+ export default {
1761
+ async fetch(req, env, ctx) {
1762
+ const handler = await handlerPromise;
1763
+ return handler(req, { ...ctx, env });
1764
+ }
1765
+ };
1766
+ `.trim();
1767
+ }
1768
+ //#endregion
1769
+ //#region src/vite/preview.ts
1770
+ /**
1771
+ * `vite preview` 接管(RM-V24,对齐 `nitro:src/build/vite/preview.ts`)。
1772
+ *
1773
+ * 与 dev 侧同构:请求处理属于**构建工具层**,因此落在插件里,CLI 只负责参数与 banner
1774
+ * (RM-V25)。两条形态:
1775
+ *
1776
+ * - `fullstack` / `backend`:**走生产 handler** —— 直接从 `dist/server/entry.mjs` 取
1777
+ * `createFetchHandler()` 得到的 fetch handler,在进程内 dispatch。刻意不 spawn
1778
+ * `dist/server/server.mjs`(旧 CLI 的做法):那是「预览一个 Node 服务器」,而预览要验证的
1779
+ * 是**产物**。同样刻意不经 `server.mjs`/`handler.mjs` 包装层 —— 它们要么自监听端口
1780
+ * (node/bun/deno),要么是平台适配壳(worker / standard),`entry.mjs` 才是所有 preset
1781
+ * 共同的真实产物。静态文件与预渲染 HTML 由产物内的 `serveStatic` 自己服务(与生产一致),
1782
+ * 因此这里不再叠一层静态中间件,避免「预览环境比生产环境多一层兜底」。
1783
+ * - `spa` / `ssg`:没有服务端 bundle(`hasServer = mode !== 'spa'`,ssg 构建后还会删掉
1784
+ * `dist/server`),只能静态服务 `dist/public`。解析规则(目录 `index.html`、ssg 的
1785
+ * `<path>/index.html` 与 `<path>.html`、spa 回退 `index.html`、路径穿越防护)与 CLI 的
1786
+ * `startStaticServer` 是**同一份实现** —— 两套方言必然分叉,且此处正是历史上踩过
1787
+ * `...` 文件名误判为穿越的地方。
1788
+ */
1789
+ const logger$1 = getLogger("preview");
1790
+ const MIME_TYPES = {
1791
+ ".html": "text/html; charset=utf-8",
1792
+ ".js": "text/javascript; charset=utf-8",
1793
+ ".mjs": "text/javascript; charset=utf-8",
1794
+ ".css": "text/css; charset=utf-8",
1795
+ ".json": "application/json; charset=utf-8",
1796
+ ".svg": "image/svg+xml",
1797
+ ".png": "image/png",
1798
+ ".jpg": "image/jpeg",
1799
+ ".jpeg": "image/jpeg",
1800
+ ".gif": "image/gif",
1801
+ ".webp": "image/webp",
1802
+ ".ico": "image/x-icon",
1803
+ ".woff": "font/woff",
1804
+ ".woff2": "font/woff2",
1805
+ ".ttf": "font/ttf",
1806
+ ".otf": "font/otf",
1807
+ ".wasm": "application/wasm",
1808
+ ".txt": "text/plain; charset=utf-8",
1809
+ ".map": "application/json; charset=utf-8"
1810
+ };
1811
+ /** 静态文件的 MIME 推断(CLI 的静态服务器与本插件共用)。 */
1812
+ function previewMimeType(filePath) {
1813
+ return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
1814
+ }
1815
+ /**
1816
+ * 把请求路径解析为静态文件(spa / ssg 的规则,单一实现)。
1817
+ *
1818
+ * 不能用 `pathname.includes('..')` 判穿越:文件名里合法的 `...` 会被误伤(SSG 动态路由产物
1819
+ * `_...slug_-Bqlu_Muj.js` 实测踩过)。判据只能是**规范化后的路径是否仍在 root 内**。
1820
+ */
1821
+ function resolvePreviewFile(root, pathname, mode) {
1822
+ const rootPath = resolve$1(root);
1823
+ const candidate = normalize$1(join$1(rootPath, pathname));
1824
+ if (candidate !== rootPath && !candidate.startsWith(rootPath + sep)) return { kind: "forbidden" };
1825
+ const isFile = (p) => isPath(p, "file");
1826
+ const isDir = (p) => isPath(p, "dir");
1827
+ if (!isFile(candidate) && isDir(candidate)) {
1828
+ const indexPath = join$1(candidate, "index.html");
1829
+ if (isFile(indexPath)) return {
1830
+ kind: "file",
1831
+ path: indexPath
1832
+ };
1833
+ }
1834
+ if (isFile(candidate)) return {
1835
+ kind: "file",
1836
+ path: candidate
1837
+ };
1838
+ if (mode === "ssg") {
1839
+ const indexPath = join$1(candidate, "index.html");
1840
+ if (isFile(indexPath)) return {
1841
+ kind: "file",
1842
+ path: indexPath
1843
+ };
1844
+ const htmlPath = `${candidate}.html`;
1845
+ if (isFile(htmlPath)) return {
1846
+ kind: "file",
1847
+ path: htmlPath
1848
+ };
1849
+ }
1850
+ if (mode === "spa") {
1851
+ const fallback = join$1(rootPath, "index.html");
1852
+ if (isFile(fallback)) return {
1853
+ kind: "file",
1854
+ path: fallback
1855
+ };
1856
+ }
1857
+ return { kind: "missing" };
1858
+ }
1859
+ /** 存在且类型匹配(`statSync` 抛错即视为不存在)。 */
1860
+ function isPath(p, kind) {
1861
+ try {
1862
+ const s = statSync(p);
1863
+ return kind === "file" ? s.isFile() : s.isDirectory();
1864
+ } catch {
1865
+ return false;
1866
+ }
1867
+ }
1868
+ /** 把解析结果写成响应;命中文件时流式返回内容。 */
1869
+ async function sendPreviewFile(res, path) {
1870
+ const body = await readFile(path);
1871
+ res.statusCode = 200;
1872
+ res.setHeader("Content-Type", previewMimeType(path));
1873
+ res.setHeader("Content-Length", body.length);
1874
+ res.end(body);
1875
+ }
1876
+ function sendPreviewStatus(res, status, text) {
1877
+ res.statusCode = status;
1878
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
1879
+ res.end(text);
1880
+ }
1881
+ /**
1882
+ * 预览中间件:按 mode 选择「生产 handler」或「静态文件」。
1883
+ *
1884
+ * 生产 handler 只加载一次(`entry.mjs` 的模块级单例因此保留,与生产行为一致),失败时把
1885
+ * 错误写进响应而不是让 `vite preview` 挂住 —— 预览最常见的失败就是「忘了先构建」。
1886
+ */
1887
+ function createPreviewMiddleware(options) {
1888
+ const { rootDir, outputDir, mode, presetName, host = "localhost", loadMiniflare } = options;
1889
+ const outDirAbs = resolve$1(rootDir, outputDir);
1890
+ const staticRoot = resolve$1(outDirAbs, "public");
1891
+ const serverEntry = resolve$1(outDirAbs, "server", "entry.mjs");
1892
+ const staticOnly = mode === "spa" || mode === "ssg";
1893
+ const isCloudflare = presetName === "cloudflare";
1894
+ let handlerPromise = null;
1895
+ /** cloudflare:miniflare runner(worker 产物);失败结果被缓存,避免每个请求重复尝试。 */
1896
+ let cloudflarePromise = null;
1897
+ const loadCloudflare = () => {
1898
+ cloudflarePromise ??= createCloudflarePreviewRunner({
1899
+ workerPath: resolve$1(outDirAbs, "server", "worker.mjs"),
1900
+ compatibilityDate: readCompatibilityDate(resolve$1(outDirAbs, "wrangler.toml")),
1901
+ compatibilityFlags: readCompatibilityFlags(resolve$1(outDirAbs, "wrangler.toml")),
1902
+ loadMiniflare
1903
+ });
1904
+ return cloudflarePromise;
1905
+ };
1906
+ const loadHandler = () => {
1907
+ handlerPromise ??= (async () => {
1908
+ if (!existsSync(serverEntry)) throw new Error(`Build output not found: ${serverEntry}. Run \`ubean build\` first to create a production build.`);
1909
+ const mod = await import(
1910
+ /* @vite-ignore */
1911
+ pathToFileURL(serverEntry).href
1912
+ );
1913
+ const factory = mod.default ?? mod.createFetchHandler;
1914
+ if (typeof factory !== "function") throw new Error(`${serverEntry} 未导出 createFetchHandler —— 产物与当前框架版本不匹配。`);
1915
+ return factory();
1916
+ })();
1917
+ return handlerPromise;
1918
+ };
1919
+ if (!staticOnly) return (req, res, _next) => {
1920
+ (async () => {
1921
+ try {
1922
+ const protocol = req.socket?.encrypted ? "https" : "http";
1923
+ const webReq = await toWebRequest(req, host, protocol);
1924
+ if (isCloudflare) {
1925
+ const staticFile = resolvePreviewFile(staticRoot, new URL(webReq.url).pathname, "ssg");
1926
+ if (staticFile.kind === "forbidden") return sendPreviewStatus(res, 400, "Bad Request");
1927
+ if (staticFile.kind === "file") return sendPreviewFile(res, staticFile.path);
1928
+ const runner = await loadCloudflare();
1929
+ if (!runner.ok) return sendPreviewStatus(res, 501, runner.message);
1930
+ return sendWebResponse(res, await runner.runner.fetch(webReq));
1931
+ }
1932
+ const handler = await loadHandler();
1933
+ await sendWebResponse(res, await handler(webReq));
1934
+ } catch (err) {
1935
+ logger$1.error(`Preview request failed: ${err instanceof Error ? err.message : String(err)}`);
1936
+ sendPreviewStatus(res, 500, err instanceof Error ? err.message : String(err));
1937
+ }
1938
+ })();
1939
+ };
1940
+ const staticMode = mode;
1941
+ return (req, res, _next) => {
1942
+ (async () => {
1943
+ try {
1944
+ const url = new URL(req.url || "/", `http://${req.headers.host || host}`);
1945
+ const pathname = decodeURIComponent(url.pathname);
1946
+ const resolvedFile = resolvePreviewFile(staticRoot, pathname, staticMode);
1947
+ if (resolvedFile.kind === "forbidden") return sendPreviewStatus(res, 400, "Bad Request");
1948
+ if (resolvedFile.kind === "missing") return sendPreviewStatus(res, 404, "Not Found");
1949
+ await sendPreviewFile(res, resolvedFile.path);
1950
+ } catch (err) {
1951
+ sendPreviewStatus(res, 500, err instanceof Error ? err.message : String(err));
1952
+ }
1953
+ })();
1954
+ };
1955
+ }
1956
+ /**
1957
+ * 把预览中间件挂到 server 上。
1958
+ *
1959
+ * 由**核心插件**调用(同 dev 的请求路由):「哪条路径都必然注册的那一个插件」才拿得到两端
1960
+ * 共用的配置;独立插件从别的插件的 `config` 钩子里注入,在环境化构建下到不了每个环境
1961
+ * (RM-V21 踩过同一个坑)。
1962
+ */
1963
+ function attachPreviewMiddleware(server, config, host) {
1964
+ const middleware = createPreviewMiddleware({
1965
+ rootDir: config.rootDir,
1966
+ outputDir: config.build.outputDir,
1967
+ mode: config.mode,
1968
+ presetName: resolvePresetName(config.build.preset),
1969
+ host
1970
+ });
1971
+ server.middlewares.use((req, res, next) => middleware(req, res, next));
1972
+ }
1973
+ /**
1974
+ * 解析 preset 名(未注册或未知时返回 `undefined`,此时按 node 形态处理 —— `entry.mjs` 对所有
1975
+ * 非 worker preset 都是正确的产物)。
1976
+ */
1977
+ function resolvePresetName(name) {
1978
+ if (!name) return void 0;
1979
+ try {
1980
+ registerBuiltinPresets();
1981
+ return resolvePresetByName(name).name;
1982
+ } catch {
1983
+ return name;
1984
+ }
1985
+ }
1986
+ /**
1987
+ * 独立的预览插件(`vite preview` 的显式形态)。
1988
+ *
1989
+ * 核心插件已内置同一份接线(`attachPreviewMiddleware`),这里保留独立导出便于测试与
1990
+ * 「不走 `ubeanPlugin()`」的场景。
1991
+ */
1992
+ function ubeanPreviewPlugin(options) {
1993
+ let config = options?.config ?? tryGetConfig() ?? void 0;
1994
+ return {
1995
+ name: "ubean:preview",
1996
+ apply: "serve",
1997
+ async configurePreviewServer(server) {
1998
+ if (!config) config = tryGetConfig() ?? void 0;
1999
+ if (!config) return;
2000
+ attachPreviewMiddleware(server, config, options?.host);
2001
+ logger$1.info(`Preview serving ${config.mode} build from ${join$1(config.rootDir, config.build.outputDir)}`);
2002
+ }
2003
+ };
2004
+ }
2005
+ //#endregion
2006
+ //#region src/vite/shims/index.ts
2007
+ /**
2008
+ * worker 目标下的 Node 内建桩(构建期重写为**虚拟模块**)。
2009
+ *
2010
+ * **为什么必须是构建期重写**:worker 运行时(Cloudflare 等)没有文件系统,而打包器会把静态
2011
+ * import 的 node 内建**留在产物里** —— workerd 在**模块实例化**阶段就失败
2012
+ * (`No such module "node:fs/promises"`),运行时的条件分支和动态 `import()` 都救不了
2013
+ * (后者会被 rolldown 内联,连同目标模块顶部的 node:fs)。
2014
+ *
2015
+ * **为什么用虚拟模块而不是指向一个 `.ts` 文件**:构建产物里 `import.meta.url` 指向 `dist/`,
2016
+ * 而源文件不在那里(实测报 `Could not load ../../packages/builder/dist/node-fs.ts`)。虚拟模块
2017
+ * 把桩源码内联,与包布局无关。也**不能用 `resolve.alias`**:Vite 解析器对 `node:` 前缀会短路,
2018
+ * alias 命不中(实测配了 alias 产物里照样有 `node:fs`)。
2019
+ *
2020
+ * 只覆盖运行时不支持、且实测会出现在服务端图里的两个:`node:fs` / `node:fs/promises`。
2021
+ * `node:crypto` / `node:async_hooks` / `node:path` 由 `nodejs_compat` 兼容标志支持,保持原样。
2022
+ *
2023
+ * 桩**抛错而不是静默返回空值**:静默降级会把「静态资源没被服务」「缓存没落盘」变成谜题。
2024
+ */
2025
+ /**
2026
+ * 生成一个桩模块的源码:把 Node 真实模块里的**每个函数导出**都声明一遍(各自抛错)。
2027
+ *
2028
+ * 为什么要生成而不是手写:worker 构建会把依赖全部打包(`noExternal: true`),于是第三方
2029
+ * 依赖也会 `import { readdir, realpathSync, stat } from 'fs'` —— 手写清单必然漏名,而漏名的
2030
+ * 表现是**构建期 `MISSING_EXPORT` 失败**(比运行时谜团好,但仍然是无谓的中断)。从真实的
2031
+ * `node:fs` 导出面生成,既覆盖完整又跟得上 Node 版本。
2032
+ *
2033
+ * 非函数导出(`fs.constants` / `fs.promises`)不进桩:它们不是调用点,真被使用时会由打包器
2034
+ * 报缺名 —— 那种情况更适合在构建期看见。
2035
+ */
2036
+ const RESERVED = /* @__PURE__ */ new Set([
2037
+ "default",
2038
+ "class",
2039
+ "function",
2040
+ "var",
2041
+ "let",
2042
+ "const",
2043
+ "new",
2044
+ "delete",
2045
+ "in",
2046
+ "of"
2047
+ ]);
2048
+ /** 只接受合法且非保留字的标识符(CJS 互操作会给命名空间塞一个 `default`)。 */
2049
+ function isDeclarable(name) {
2050
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED.has(name);
2051
+ }
2052
+ function buildStubSource(moduleNamespace, runtime, apiPrefix) {
2053
+ return `const MESSAGE = (api) =>
2054
+ api + ' 需要 Node 系运行时(${runtime})。当前运行时(Cloudflare Workers 等)没有文件系统:' +
2055
+ '静态资源请交给平台层(wrangler.toml 的 assets.directory),缓存请用 store: "memory" 或 KV/对象存储驱动。';
2056
+ function unsupported(api) {
2057
+ throw new Error(MESSAGE(api));
2058
+ }
2059
+ ${Object.keys(moduleNamespace).filter((name) => isDeclarable(name) && typeof moduleNamespace[name] === "function").map((name) => `export function ${name}() { return unsupported('${name}'); }`).join("\n")}
2060
+ ${Object.keys(moduleNamespace).filter((name) => isDeclarable(name) && typeof moduleNamespace[name] !== "function").map((name) => `export const ${name} = new Proxy({}, { get: (_t, prop) => unsupported('${apiPrefix}.${name}.' + String(prop)) });`).join("\n")}
2061
+ export default new Proxy({}, { get: (_t, prop) => () => unsupported('${apiPrefix}.' + String(prop)) });
2062
+ `;
2063
+ }
2064
+ const STUB_PREFIX = "\0ubean-node-stub:";
2065
+ /** 规格名 → 桩源码(`node:` 前缀与裸包名都收,两者都会被 import)。 */
2066
+ const FS_STUB_SOURCE = buildStubSource(nodeFs, "node:fs", "fs");
2067
+ const FS_PROMISES_STUB_SOURCE = buildStubSource(nodeFsPromises, "node:fs/promises", "fs/promises");
2068
+ const STUB_SOURCES = {
2069
+ "node:fs": FS_STUB_SOURCE,
2070
+ fs: FS_STUB_SOURCE,
2071
+ "node:fs/promises": FS_PROMISES_STUB_SOURCE,
2072
+ "fs/promises": FS_PROMISES_STUB_SOURCE
2073
+ };
2074
+ /** 该 id 在 worker 目标下是否要换成桩;命中时返回虚拟模块 id。 */
2075
+ function resolveWorkerNodeStub(id) {
2076
+ return id in STUB_SOURCES ? `${STUB_PREFIX}${id}` : void 0;
2077
+ }
2078
+ /** 虚拟模块 id → 源码(`load` 用);非桩 id 返回 `undefined`。 */
2079
+ function loadWorkerNodeStub(id) {
2080
+ if (!id.startsWith(STUB_PREFIX)) return void 0;
2081
+ return STUB_SOURCES[id.slice(17)];
2082
+ }
2083
+ Object.freeze(Object.keys(STUB_SOURCES));
2084
+ //#endregion
2085
+ //#region src/dev/dev-worker.ts
2086
+ /**
2087
+ * dev worker 入口(RM-V09,ADR-0012 §3)。
2088
+ *
2089
+ * dev 下服务端代码在 env-runner 的 worker 里跑:worker 内用 Vite 的 `ModuleRunner` 经 IPC
2090
+ * 向宿主取转换后的模块(对齐 `nitro:src/runtime/internal/vite/dev-worker.mjs`)。
2091
+ *
2092
+ * 这个入口由**框架生成到项目 `.ubean/` 下**,而不是随包发布:
2093
+ * - 避免打包器把 `.mjs` 资源漏掉(`vp pack` 只产出 JS 入口);
2094
+ * - 生成的入口与当前运行的框架版本天然一致,不会出现 worker 用旧版本的情况。
2095
+ */
2096
+ /** 生成到 `.ubean/` 下的文件名。 */
2097
+ const DEV_WORKER_FILE = "dev-worker.mjs";
2098
+ /**
2099
+ * worker 入口源码。刻意保持纯 ESM、无类型语法 —— worker 直接由 Node 加载,不经过转换。
2100
+ */
2101
+ function getDevWorkerEntrySource(options) {
2102
+ const envName = JSON.stringify(options.envName);
2103
+ const entryId = JSON.stringify(options.entryId);
2104
+ return `// 由 @ubean/build 生成,勿手工编辑(RM-V09)。
2105
+ import { createViteTransport } from ${JSON.stringify(options.envRunnerVite ?? "env-runner/vite")};
2106
+ import { ModuleRunner, ESModulesEvaluator } from ${JSON.stringify(options.moduleRunner ?? "vite/module-runner")};
2107
+
2108
+ const ENV_NAME = ${envName};
2109
+ const ENTRY_ID = ${entryId};
2110
+
2111
+ const listeners = [];
2112
+ let moduleRunner;
2113
+ let entryPromise;
2114
+
2115
+ async function getEntry() {
2116
+ entryPromise ??= moduleRunner.import(ENTRY_ID).then(mod => mod.default ?? mod);
2117
+ return entryPromise;
2118
+ }
2119
+
2120
+ export default {
2121
+ async fetch(request) {
2122
+ if (!moduleRunner) return new Response('ubean dev worker: module runner not ready', { status: 503 });
2123
+ try {
2124
+ const entry = await getEntry();
2125
+ if (typeof entry?.fetch === 'function') return entry.fetch(request);
2126
+ return new Response('ubean dev worker: entry has no fetch handler', { status: 500 });
2127
+ } catch (error) {
2128
+ // 把错误带回宿主:worker 直接抛会让连接重置,宿主只看到 ECONNRESET,无从诊断
2129
+ return new Response(\`ubean dev worker error: \${error?.stack || error}\`, { status: 599 });
2130
+ }
2131
+ },
2132
+ ipc: {
2133
+ onOpen({ sendMessage }) {
2134
+ moduleRunner = new ModuleRunner(
2135
+ {
2136
+ transport: createViteTransport(sendMessage, listener => listeners.push(listener), ENV_NAME)
2137
+ },
2138
+ new ESModulesEvaluator()
2139
+ );
2140
+ },
2141
+ onMessage(message) {
2142
+ // 作用域化失效:宿主已经失效了对应模块(连同其 importer 链),这里**只负责重新拿入口**。
2143
+ // 具体的重新求值由 ModuleRunner 自己完成 —— \`fetchModule\` 会因宿主侧 transformResult 被清空
2144
+ // 而返回 \`invalidate: true\`,runner 据此失效该节点,于是它连同受影响的 importer 一起重跑,
2145
+ // 而未命中的模块仍命中 \`{ cache: true }\`,实例(单例)原样保留。
2146
+ // 千万不要在这里按 url 自行失效:宿主若没失效同名模块,下一次 fetch 会因 \`cache: true\` +
2147
+ // 本地 meta 已被清空而抛 “mistakenly invalidated during fetch phase”。
2148
+ if (message?.type === 'ubean:invalidate') {
2149
+ if (Array.isArray(message.urls) && message.urls.length > 0) entryPromise = undefined;
2150
+ return;
2151
+ }
2152
+ // 其余消息按 env-runner 约定转给 transport 监听器
2153
+ for (const listener of listeners) listener(message);
2154
+ }
2155
+ }
2156
+ };
2157
+ `;
2158
+ }
2159
+ /** 把 worker 入口写到 `<dir>/dev-worker.mjs`,返回其绝对路径(供 env-runner 的 `data.entry`)。 */
2160
+ async function writeDevWorkerEntry(dir, options) {
2161
+ await mkdir(dir, { recursive: true });
2162
+ const target = join$1(dir, DEV_WORKER_FILE);
2163
+ await writeFile(target, getDevWorkerEntrySource(options), "utf8");
2164
+ return target;
2165
+ }
2166
+ function invalidateDevWorkerModules(environment, runner, files) {
2167
+ const keys = [];
2168
+ const urls = /* @__PURE__ */ new Set();
2169
+ for (const file of files) for (const key of candidateFileKeys(file)) {
2170
+ const mods = environment.moduleGraph.getModulesByFile(key);
2171
+ if (!mods) continue;
2172
+ let hit = false;
2173
+ for (const mod of mods) {
2174
+ hit = true;
2175
+ if (mod.url) urls.add(mod.url);
2176
+ environment.moduleGraph.invalidateModule(mod);
2177
+ }
2178
+ if (hit) keys.push(key);
2179
+ }
2180
+ if (urls.size > 0) runner.sendMessage({
2181
+ type: "ubean:invalidate",
2182
+ urls: [...urls]
2183
+ });
2184
+ return {
2185
+ keys,
2186
+ urls: [...urls]
2187
+ };
2188
+ }
2189
+ /** 模块图可能用原始路径或 realpath 建索引,两种都试(去重、保序)。 */
2190
+ function candidateFileKeys(file) {
2191
+ const keys = [normalizePath(file)];
2192
+ try {
2193
+ keys.push(normalizePath(realpathSync.native(file)));
2194
+ } catch {}
2195
+ return [...new Set(keys)];
2196
+ }
2197
+ //#endregion
2198
+ //#region src/dev/dev-environment.ts
2199
+ /**
2200
+ * `UbeanDevEnvironment`(RM-V08,ADR-0012 §3)。
2201
+ *
2202
+ * 形态:宿主侧保留一个继承 Vite `DevEnvironment` 的环境,`dispatchFetch(request)` 把请求转发进
2203
+ * env-runner 的 worker(对齐 `nitro/src/build/vite/dev.ts:93-164` 的形态)。
2204
+ *
2205
+ * **当前状态(2026-09-16 核对)**:本类**没有接进实际 dev 路径** —— `createEnvironment` 全仓无
2206
+ * 接线点,RM-V14 收敛后的 `dev-vite.ts` 走的是「插件自举宿主 app + 请求路由中间件」(`getDevApp`
2207
+ * 在**主进程**里跑 SSR 模块图)。因此「dev 下服务端代码在 worker 里执行」目前**不成立**,本模块
2208
+ * 与它的测试(`dev-environment.test.ts` / `dev-worker.test.ts`)验证的是能力与 IPC 契约,不是线上
2209
+ * 行为。Phase 4 的 RM-V28(跨环境单例代理)与 RM-V29(services 环境机制)都服务于这套多环境
2210
+ * 拓扑,在拓扑被采用之前不落地 —— 结论与理由见 docs/vite-plugin-migration.md。
2211
+ *
2212
+ * 分工(依据 `docs/env-runner-spike.md` §7 的实测结论):
2213
+ * - **Vite 自己**负责模块图语义与 `vite:invoke` 的分发 —— `DevEnvironment` 构造时会把传入的
2214
+ * transport 规范化,并挂到 `environment.hot`(`NormalizedHotChannel`)上,其
2215
+ * `handleInvoke()` 是公开方法,不需要我们复刻;
2216
+ * - **env-runner** 只负责「跑起 worker + 一条 IPC」。
2217
+ *
2218
+ * 因此本模块只做两件薄事:把宿主 → worker 的 payload 送出(transport),把 worker → 宿主的
2219
+ * `vite:invoke` 交给 Vite 计算后按约定回发(bridge)。
2220
+ */
2221
+ /** 与 worker 侧 `createViteTransport(sendMessage, onMessage, envName)` 必须一致。 */
2222
+ const UBEAN_DEV_ENV_NAME = "ubean";
2223
+ /**
2224
+ * 宿主 → worker 的 transport 适配器。
2225
+ *
2226
+ * 只实现 Vite `HotChannel` 里能实现的部分:`send` 把 payload 送到 worker,`on`/`off` 维护
2227
+ * 本地监听表。`skipFsCheck` 置真 —— 同进程 IPC 不是网络通道,无需 fs 访问检查。
2228
+ * Vite 会在 `DevEnvironment` 构造时把它规范化(补 `setInvokeHandler` / `handleInvoke`)。
2229
+ */
2230
+ function createEnvRunnerTransport(runner, envName = UBEAN_DEV_ENV_NAME) {
2231
+ const listeners = /* @__PURE__ */ new Map();
2232
+ return {
2233
+ skipFsCheck: true,
2234
+ send(payload) {
2235
+ runner.sendMessage(tagForEnv(payload, envName));
2236
+ },
2237
+ on(event, listener) {
2238
+ const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
2239
+ set.add(listener);
2240
+ listeners.set(event, set);
2241
+ },
2242
+ off(event, listener) {
2243
+ listeners.get(event)?.delete(listener);
2244
+ },
2245
+ api: {}
2246
+ };
2247
+ }
2248
+ /**
2249
+ * 把 worker 发来的 `vite:invoke` 交给 Vite 计算并回发。
2250
+ *
2251
+ * 回发结构照抄 vite-plus 自己的实现(`dist/vite/node/chunks/node.js:32291-32313`):
2252
+ * 应答 id 由 `send*` 改成 `response*`,`data` 为 `handleInvoke()` 的 `{ result }` / `{ error }`。
2253
+ * **必须在 `DevEnvironment` 构造之后调用** —— 那时 `environment.hot` 才存在。
2254
+ */
2255
+ function bridgeEnvRunnerInvokes(environment, runner, envName = UBEAN_DEV_ENV_NAME) {
2256
+ runner.onMessage(async (message) => {
2257
+ const payload = message;
2258
+ const inner = payload?.data;
2259
+ if (payload?.type !== "custom" || payload.event !== "vite:invoke" || !inner?.id) return;
2260
+ const outcome = await environment.hot.handleInvoke({
2261
+ type: "custom",
2262
+ event: "vite:invoke",
2263
+ data: inner
2264
+ });
2265
+ runner.sendMessage(tagForEnv({
2266
+ type: "custom",
2267
+ event: "vite:invoke",
2268
+ data: {
2269
+ name: inner.name,
2270
+ id: inner.id.replace("send", "response"),
2271
+ data: outcome
2272
+ }
2273
+ }, envName));
2274
+ });
2275
+ }
2276
+ /**
2277
+ * env-runner 按 `viteEnv` 给消息分命名空间(`env-runner/dist/vite.mjs`):worker 侧
2278
+ * `createViteTransport` 只接受带本环境名的消息。宿主发出的任何 payload 都必须打上该标记,
2279
+ * 否则会被静默丢弃 —— 表现为 worker 侧 invoke 长时间无应答。
2280
+ */
2281
+ function tagForEnv(payload, envName) {
2282
+ return {
2283
+ ...payload,
2284
+ viteEnv: envName
2285
+ };
2286
+ }
2287
+ var UbeanDevEnvironment = class extends DevEnvironment {
2288
+ #runner;
2289
+ constructor(name, config, context, runner) {
2290
+ super(name, config, {
2291
+ ...context,
2292
+ transport: context.transport ?? createEnvRunnerTransport(runner, name)
2293
+ });
2294
+ this.#runner = runner;
2295
+ bridgeEnvRunnerInvokes(this, runner, name);
2296
+ }
2297
+ /**
2298
+ * ADR-0012 §3:框架请求入口把 Web `Request` 转发进 worker 内的 Hono app。
2299
+ * 宿主进程不再执行服务端代码,因此服务端崩溃不会带走 dev server。
2300
+ */
2301
+ async dispatchFetch(request) {
2302
+ return this.#runner.fetch(request);
2303
+ }
2304
+ };
2305
+ /**
2306
+ * 生成 `environments.<name>.dev.createEnvironment` 需要的工厂(RM-V09 的能力,**尚未接线** ——
2307
+ * 全仓没有 `createEnvironment` 的注册点,原因见文件头「当前状态」)。
2308
+ */
2309
+ function createUbeanDevEnvironmentFactory(runner) {
2310
+ return (name, config, context) => new UbeanDevEnvironment(name, config, context, runner);
2311
+ }
2312
+ //#endregion
2313
+ //#region src/vite.ts
2314
+ const logger = getLogger("build");
2315
+ const VIRTUAL_MODULES = [
2316
+ "ubean:routes",
2317
+ "ubean:pages",
2318
+ "ubean:meta",
2319
+ "ubean:app-config",
2320
+ "ubean:locales"
2321
+ ];
2322
+ const VIRTUAL_PREFIX = "\0ubean:";
2323
+ const RESOLVED_ASSET_MANIFEST_ID = `\0${ASSET_MANIFEST_VIRTUAL_ID}`;
2324
+ /**
2325
+ * ubean 核心 Vite 插件(框架无关部分)。
2326
+ *
2327
+ * 提供:
2328
+ * - 虚拟模块(`ubean:routes`、`ubean:pages`、`ubean:meta`、`ubean:app-config`、`ubean:locales`)
2329
+ * - 宏转换(`definePage` / `defineMeta` 在 `.ts` / `.vue` 中被剥离)
2330
+ * - 文件监听(dev 模式下扫描 `routes` / `middleware` / `pages` / `layouts` / `plugins` / `locales`)
2331
+ * - 实体路由文件生成(当 `routing.mode` 为 `'file'` 或 `'both'` 时触发 `@ubean/vue/generator`)
2332
+ *
2333
+ * Vue 专属的虚拟模块(`virtual:ubean-pages`、`virtual:ubean-app` 等)由 `@ubean/build/vue` 的
2334
+ * `ubeanVite` 提供,二者共用 `useVirtualRegistry()` 注册表。
2335
+ *
2336
+ * @example 在 vite.config.ts 中使用(自动加载 ubean.config)
2337
+ * ```typescript
2338
+ * import { defineConfig } from 'vite';
2339
+ * import { ubeanPlugin } from '@ubean/build/vite';
2340
+ *
2341
+ * export default defineConfig({
2342
+ * plugins: [ubeanPlugin()]
2343
+ * });
2344
+ * ```
2345
+ *
2346
+ * @example 显式传入配置(ubean dev/build 内部使用)
2347
+ * ```typescript
2348
+ * ubeanPlugin({ config: resolvedConfig })
2349
+ * ```
2350
+ */
2351
+ /**
2352
+ * 开关打开时插件侧的 environment 构建配置(RM-V21)。
2353
+ *
2354
+ * 与 CLI 自建 builder 共用 `createBuildEnvironments()` —— 只写 `outDir` 的极简版本会让客户端
2355
+ * 环境退回默认入口 `index.html`(实测报 `Cannot resolve entry module index.html`)。
2356
+ */
2357
+ async function buildEnvironmentsForConfig(config) {
2358
+ const [{ createBuildEnvironments, getBuildOutDirsForConfig }, { getPresetBuildConfig }, { resolvePresetByName, registerBuiltinPresets }, { ssrSingletonProdSsr }] = await Promise.all([
2359
+ Promise.resolve().then(() => build_configs_exports),
2360
+ import("./production.js"),
2361
+ import("@ubean/preset"),
2362
+ import("./ssr-singleton-DPTawJSx.js").then((n) => n.s)
2363
+ ]);
2364
+ registerBuiltinPresets();
2365
+ const outDirs = getBuildOutDirsForConfig(config);
2366
+ const clientEntryPath = join(resolve(config.rootDir, config.srcDir), "entry.client.ts");
2367
+ return createBuildEnvironments({
2368
+ outDirs,
2369
+ clientInput: existsSync(clientEntryPath) ? clientEntryPath : join(outDirs.virtual, "client-entry.mjs"),
2370
+ minify: true,
2371
+ sourcemap: false,
2372
+ presetBuildConfig: getPresetBuildConfig(resolvePresetByName(config.build.preset)),
2373
+ ssrNoExternal: ssrSingletonProdSsr().noExternal
2374
+ });
2375
+ }
2376
+ function ubeanPlugin(options) {
2377
+ const virtualRegistry = options?.registry ?? createVirtualRegistry();
2378
+ let ubeanConfig = options?.config ?? tryGetConfig() ?? void 0;
2379
+ /** client manifest 的内存载体(RM-V18):`buildApp` 填、本插件的 load 读。 */
2380
+ const assetManifestRef = { current: null };
2381
+ let srcDirAbs = "";
2382
+ let viteSrcDir = "";
2383
+ let viteSrcPrefix = "";
2384
+ /** 当前构建是否为 worker 目标(缓存:preset 在一次构建内不变)。 */
2385
+ let workerTarget;
2386
+ function isWorkerTarget() {
2387
+ if (workerTarget !== void 0) return workerTarget;
2388
+ workerTarget = false;
2389
+ const presetName = process.env.UBEAN_BUILD_PRESET || ubeanConfig?.build.preset;
2390
+ if (presetName) try {
2391
+ registerBuiltinPresets();
2392
+ workerTarget = getPresetBuildConfig(resolvePresetByName(presetName)).entryType === "worker";
2393
+ } catch {
2394
+ workerTarget = false;
2395
+ }
2396
+ return workerTarget;
2397
+ }
2398
+ function ensureDerived() {
2399
+ if (!ubeanConfig) return;
2400
+ srcDirAbs = resolve(ubeanConfig.rootDir, ubeanConfig.srcDir);
2401
+ viteSrcDir = relative(ubeanConfig.rootDir, srcDirAbs).replace(/\\/g, "/");
2402
+ viteSrcPrefix = viteSrcDir ? `/${viteSrcDir}` : "";
2403
+ }
2404
+ if (ubeanConfig) ensureDerived();
2405
+ return {
2406
+ name: "ubean:core",
2407
+ enforce: "pre",
2408
+ /**
2409
+ * ADR-0012(RM-V36 收敛后为唯一路径):以插件身份注册 `client` / `ubean` 两个环境,生命周期
2410
+ * 交给 `vite dev|build|preview`。此前由 `experimental.viteBuilder` 开关隔离的旧编排已下线。
2411
+ *
2412
+ * 唯一例外是**构建已由调用方驱动**(`ubean build` 自建 builder 并传入同一套 env 配置)——
2413
+ * 那时插件不能再注册自己的 `buildApp`/`environments`,否则同一份构建会被编排两次
2414
+ * (插件返回值会覆盖内联的 `builder` 字段,表现为静默产出不完整)。
2415
+ */
2416
+ async config() {
2417
+ if (!ubeanConfig) {
2418
+ ubeanConfig = await loadUbeanConfig();
2419
+ ensureDerived();
2420
+ }
2421
+ if (process.env.UBEAN_BUILD_DRIVEN_BY_CLI === "1") return void 0;
2422
+ return {
2423
+ /**
2424
+ * RM-V21:开关打开时构建编排也交给插件 —— `vite build`(无 CLI)因此能产出
2425
+ * `dist/{public,server,manifest.json}`。builder 与 environments 由 Vite 按本钩子创建,
2426
+ * 插件只跑拆分好的阶段(`prepareBuild` → `runEnvBuilds`),**不**自建 builder(会递归)。
2427
+ *
2428
+ * 已知缺口:**预渲染**尚未接入本路径(`prerender()` 目前由 CLI 调用),因此
2429
+ * `vite build` 的产物缺静态 HTML,与 `ubean build` 仍不等价(RM-V23 的矩阵会据此判定)。
2430
+ */
2431
+ builder: { async buildApp(builder) {
2432
+ const [{ createBuildContext, prepareBuild, runEnvBuilds }, { resolvePresetByName, registerBuiltinPresets }, { loadContentForBuild }] = await Promise.all([
2433
+ Promise.resolve().then(() => build_app_exports),
2434
+ import("@ubean/preset"),
2435
+ Promise.resolve().then(() => prerender_step_exports)
2436
+ ]);
2437
+ registerBuiltinPresets();
2438
+ const scanResult = await scanProject({
2439
+ cwd: ubeanConfig.rootDir,
2440
+ srcDir: ubeanConfig.srcDir,
2441
+ dirs: ubeanConfig.dir,
2442
+ ignore: ubeanConfig.scanOptions?.ignore
2443
+ });
2444
+ const { snapshot: contentSnapshot } = await loadContentForBuild(ubeanConfig.rootDir, ubeanConfig.content);
2445
+ await runEnvBuilds(builder, await prepareBuild(createBuildContext({
2446
+ cwd: ubeanConfig.rootDir,
2447
+ config: ubeanConfig,
2448
+ preset: resolvePresetByName(ubeanConfig.build.preset),
2449
+ scanResult,
2450
+ contentSnapshot
2451
+ }), assetManifestRef));
2452
+ } },
2453
+ environments: await buildEnvironmentsForConfig(ubeanConfig)
2454
+ };
2455
+ },
2456
+ async buildStart() {
2457
+ if (!ubeanConfig) {
2458
+ ubeanConfig = await loadUbeanConfig();
2459
+ ensureDerived();
2460
+ }
2461
+ await scanAndRegister();
2462
+ },
2463
+ resolveId(id) {
2464
+ if (this.environment?.name === "ubean" && isWorkerTarget()) {
2465
+ const stub = resolveWorkerNodeStub(id);
2466
+ if (stub) return stub;
2467
+ }
2468
+ if (id === "virtual:ubean-asset-manifest") return RESOLVED_ASSET_MANIFEST_ID;
2469
+ if (VIRTUAL_MODULES.includes(id)) return VIRTUAL_PREFIX + id;
2470
+ },
2471
+ /**
2472
+ * worker 目标:把产物里的 `import.meta.url` 换成合法的 `file://` 值。
2473
+ *
2474
+ * workerd 的 ESM 里它**是 undefined**(实测),而打包器为 CJS 依赖生成的互操作垫片正是
2475
+ * `createRequire(import.meta.url)`,且**在模块实例化时执行** —— 于是 worker 报
2476
+ * `The argument 'path' … Received 'undefined'` 起不来。`createRequire('file:///worker.mjs')`
2477
+ * 在 workerd 上实测可用,垫片因此能构造(动态 `require()` 仍解析不到用户依赖,但那类调用只可能
2478
+ * 出现在 Node-only 分支里)。
2479
+ *
2480
+ * 用 `renderChunk` 而不是 `define`:define 只作用于源码,**够不到打包器自己生成的垫片**
2481
+ * (实测替换后第 46 行的 `__require` 仍是 `import.meta.url`)。
2482
+ */
2483
+ renderChunk(code) {
2484
+ if (this.environment?.name !== "ubean" || !isWorkerTarget() || !code.includes("import.meta.url")) return null;
2485
+ return {
2486
+ code: code.split("import.meta.url").join("\"file:///worker.mjs\""),
2487
+ map: null
2488
+ };
2489
+ },
2490
+ async load(id) {
2491
+ const stubSource = loadWorkerNodeStub(id);
2492
+ if (stubSource !== void 0) return stubSource;
2493
+ if (id === RESOLVED_ASSET_MANIFEST_ID) {
2494
+ const serverOutDir = this.environment?.config?.build?.outDir;
2495
+ const { tags, source } = resolveInjectedAssetTags(assetManifestRef.current, serverOutDir);
2496
+ if (source === "none") logger.warn(`${ASSET_MANIFEST_VIRTUAL_ID} 注入时没有可用的 client manifest(服务端 outDir=${serverOutDir ?? "unknown"})—— 产出的 HTML 将不含 asset tags。`);
2497
+ return `export const assetTags = ${JSON.stringify({
2498
+ ...tags,
2499
+ favicon: null
2500
+ })};\n`;
2501
+ }
2502
+ if (id.startsWith(VIRTUAL_PREFIX)) {
2503
+ const moduleId = id.slice(7);
2504
+ const mod = virtualRegistry.getModules().find((m) => m.id === moduleId);
2505
+ if (mod) return await mod.load();
2506
+ }
2507
+ },
2508
+ transform(code, id) {
2509
+ const result = transformMacros(code, id);
2510
+ if (result !== null && result !== code) return {
2511
+ code: result,
2512
+ map: null
2513
+ };
2514
+ return null;
2515
+ },
2516
+ configureServer(server) {
2517
+ getDevScanCoordinator(server, (source) => ({
2518
+ rootDir: ubeanConfig.rootDir,
2519
+ srcDir: ubeanConfig.srcDir,
2520
+ dirs: ubeanConfig.dir,
2521
+ ignore: ubeanConfig.scanOptions?.ignore,
2522
+ source,
2523
+ scan: () => scanProjectOnce(),
2524
+ reload: () => server.ws.send({ type: "full-reload" })
2525
+ })).subscribe(async (result, changed) => {
2526
+ await applyScan(result);
2527
+ for (const mod of VIRTUAL_MODULES) {
2528
+ const module = server.moduleGraph.getModuleById(VIRTUAL_PREFIX + mod);
2529
+ if (module) server.moduleGraph.invalidateModule(module);
2530
+ }
2531
+ if (changed.some((file) => file.includes("/locales/"))) for (const file of changed.filter((f) => f.includes("/locales/"))) server.ws.send({
2532
+ type: "custom",
2533
+ event: "ubean:locale-update",
2534
+ data: { file }
2535
+ });
2536
+ });
2537
+ },
2538
+ /**
2539
+ * RM-V24:`vite preview` 接管。
2540
+ *
2541
+ * fullstack / backend 把产物里的 fetch handler(`dist/server/entry.mjs`)在进程内接上,
2542
+ * spa / ssg 走静态服务 —— 两种形态的判据都在 `vite/preview.ts`,与 CLI 的 preview 共用
2543
+ * 同一份解析规则(RM-V25 起 CLI 退化为参数与 banner)。
2544
+ */
2545
+ configurePreviewServer(server) {
2546
+ if (!ubeanConfig) return;
2547
+ attachPreviewMiddleware(server, ubeanConfig);
2548
+ }
2549
+ };
2550
+ function scanProjectOnce() {
2551
+ return scanProject({
2552
+ cwd: ubeanConfig.rootDir,
2553
+ srcDir: ubeanConfig.srcDir,
2554
+ dirs: ubeanConfig.dir,
2555
+ ignore: ubeanConfig.scanOptions?.ignore
2556
+ });
2557
+ }
2558
+ async function scanAndRegister() {
2559
+ if (!ubeanConfig) return;
2560
+ await applyScan(await scanProjectOnce());
2561
+ }
2562
+ /**
2563
+ * 依据扫描结果重建路由/中间件注册与虚拟模块。与扫描分离(RM-V13):dev 下由
2564
+ * `dev-scan.ts` 的协调器统一扫一次,三个订阅者各取所需。
2565
+ */
2566
+ async function applyScan(result) {
2567
+ if (!ubeanConfig) return;
2568
+ const router = createServerRouter();
2569
+ for (const mw of result.middlewares) router.addMiddleware(mw);
2570
+ for (const route of result.apiRoutes) router.addApiRoute(route);
2571
+ for (const page of result.pages) router.addPage(page);
2572
+ for (const layout of result.layouts) router.addLayout(layout);
2573
+ virtualRegistry.register(createRoutingVirtualModule(result.apiRoutes.map((r) => ({
2574
+ method: r.method?.toUpperCase() || "ALL",
2575
+ path: r.route,
2576
+ id: `${r.method}:${r.route}`,
2577
+ filePath: r.fullPath
2578
+ })), result.middlewares.map((m) => ({
2579
+ path: "/**",
2580
+ filePath: m.fullPath,
2581
+ order: m.order,
2582
+ global: m.global
2583
+ })), ubeanConfig.rootDir));
2584
+ virtualRegistry.register(createPagesVirtualModule(result.pages.map((p) => ({
2585
+ name: p.name,
2586
+ path: p.route,
2587
+ filePath: p.fullPath,
2588
+ layout: p.layout,
2589
+ reuseTarget: p.reuseTarget
2590
+ })), result.layouts.map((l) => ({
2591
+ name: l.name,
2592
+ filePath: l.fullPath,
2593
+ isDefault: l.isDefault
2594
+ })), ubeanConfig.rootDir));
2595
+ virtualRegistry.register(createMetaVirtualModule());
2596
+ virtualRegistry.register(createAppVirtualModule(result.apiRoutes, result.middlewares, result.pages, viteSrcPrefix || "/"));
2597
+ virtualRegistry.register(createLocalesVirtualModule(result.locales, result.defaultLocale, viteSrcPrefix || "/", serializeI18nConfig(ubeanConfig.i18n)));
2598
+ await maybeGenerateRouteFiles(ubeanConfig, result).catch((err) => {
2599
+ console.warn("[ubean:core] Route file generation failed:", err?.message || err);
2600
+ });
2601
+ }
2602
+ }
2603
+ /**
2604
+ * 根据 `routing.mode` 决定是否触发实体文件生成。
2605
+ *
2606
+ * - `'virtual'`(默认):仅生成 `.ubean/typed-router.d.ts`(类型声明,所有模式都生成)
2607
+ * - `'file'`:额外生成 `routes.ts`/`imports.ts` 到 `outputDir`(实体文件,可编辑 `meta`)
2608
+ * - `'both'`:同 `'file'`,且虚拟模块也加载实体文件
2609
+ *
2610
+ * `typed-router.d.ts` 包含 `@ubean/scan` 和 `vue-router`/`vue-router/auto-routes`
2611
+ * 的模块增强(让 `useRoute<Name>(name)` 能推断 `route.params` 类型),所有模式
2612
+ * 都会生成到 `.ubean/typed-router.d.ts`,与 `auto-imports.d.ts`/`components.d.ts`
2613
+ * 等其他纯类型声明产物同目录,由 `.gitignore` 忽略。
2614
+ *
2615
+ * 由于 `@ubean/vue/generator` 通过动态 import 加载,前端-only 项目
2616
+ * (不依赖实体路由文件)即使没有安装 generator 相关依赖也能运行。
2617
+ *
2618
+ * 注意:`@ubean/config` 与 `@ubean/vue/generator` 的 `getRouteMeta` /
2619
+ * `onGenerated` 签名略有差异(配置层面向用户,生成器层面向内部)。本函数
2620
+ * 负责适配:把 `(filePath, frontmatter) => meta` 包装为 `(page) => meta`,
2621
+ * 把 `GeneratorResult` 转换为 `string[]` 文件路径列表。
2622
+ */
2623
+ async function maybeGenerateRouteFiles(config, scanResult) {
2624
+ const mode = config.routing?.mode;
2625
+ const generateEntityFiles = mode === "file" || mode === "both";
2626
+ const routing = config.routing;
2627
+ const outDir = resolve(config.rootDir, routing.outputDir);
2628
+ const dtsPath = resolve(config.rootDir, ".ubean", "typed-router.d.ts");
2629
+ const configGetRouteMeta = routing.getRouteMeta;
2630
+ const generatorGetRouteMeta = configGetRouteMeta ? (page) => configGetRouteMeta(page.relativePath, page.frontmatter ?? {}) : void 0;
2631
+ const { generateRouteFiles } = await import("@ubean/vue/generator");
2632
+ const result = await generateRouteFiles(scanResult, {
2633
+ cwd: config.rootDir,
2634
+ srcDir: config.srcDir,
2635
+ outDir,
2636
+ dtsPath,
2637
+ generateRoutes: generateEntityFiles,
2638
+ generateImports: generateEntityFiles,
2639
+ generateDts: true,
2640
+ routeLazy: routing.routeLazy,
2641
+ layoutLazy: routing.layoutLazy,
2642
+ getRouteMeta: generatorGetRouteMeta,
2643
+ headerComment: void 0,
2644
+ localeVueParam: localeVueParamFromI18n(config.i18n)
2645
+ });
2646
+ if (routing.onGenerated) {
2647
+ const files = [
2648
+ result.routesPath,
2649
+ result.importsPath,
2650
+ result.dtsPath
2651
+ ].filter((p) => Boolean(p));
2652
+ routing.onGenerated(files);
2653
+ }
2654
+ }
2655
+ //#endregion
2656
+ export { getPresetBuildConfig as C, createCloudflarePreviewRunner as D, MINIFLARE_INSTALL_HINT as E, defaultLoadMiniflare as O, generateVirtualModulesToDisk as S, buildWithEnvironments as T, ubeanPreviewPlugin as _, createEnvRunnerTransport as a, generateNodeServerEntry as b, getDevWorkerEntrySource as c, attachPreviewMiddleware as d, createPreviewMiddleware as f, sendPreviewStatus as g, sendPreviewFile as h, bridgeEnvRunnerInvokes as i, readCompatibilityDate as k, invalidateDevWorkerModules as l, resolvePreviewFile as m, UBEAN_DEV_ENV_NAME as n, createUbeanDevEnvironmentFactory as o, previewMimeType as p, UbeanDevEnvironment as r, DEV_WORKER_FILE as s, ubeanPlugin as t, writeDevWorkerEntry as u, createIslandsSsrStubPlugin as v, serializePagesForEntry as w, generateStandardHandlerEntry as x, generateCloudflareWorkerEntry as y };