@ubean/cli 0.5.1 → 0.6.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,29 +1,22 @@
1
- import { A as summarizeBundle, D as compareBundleBaseline, E as createFsOps, O as findClientManifest, S as renderTemplate, a as scaffold, c as scaffoldStarterTemplate, i as recoverScaffold, j as writeBundleBaseline, k as readViteManifest, l as scaffoldUnifyTemplate, n as listScaffoldableFiles, o as scaffoldBlogTemplate, r as pageCommand, s as scaffoldMinimalTemplate, t as deleteScaffold } from "./page-Um0fHKa9.js";
2
- import { existsSync, mkdirSync, readFileSync, watch } from "node:fs";
1
+ import { A as readViteManifest, D as compareBundleBaseline, E as createFsOps, M as writeBundleBaseline, O as findClientManifest, S as renderTemplate, a as scaffold, c as scaffoldStarterTemplate, i as recoverScaffold, j as summarizeBundle, k as formatKilobytes, l as scaffoldUnifyTemplate, n as listScaffoldableFiles, o as scaffoldBlogTemplate, r as pageCommand, s as scaffoldMinimalTemplate, t as deleteScaffold } from "./page-C2Bde9EG.js";
2
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { basename, join, relative, resolve } from "pathe";
4
- import { extname, normalize, sep } from "node:path";
5
- import { getLogger, setDebugLogging } from "@ubean/shared/logger";
4
+ import { getLogger, setDebugLogging, setMinLevel } from "@ubean/shared/logger";
6
5
  import { defineCommand, runMain } from "citty";
7
6
  import { BUILTIN_MODULES, isBuiltinDisabled, loadUbeanConfig, resolveModules, resolvePrerenderConfig, resolveSsrConfig } from "@ubean/config";
8
- import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
9
- import { pathToFileURL } from "node:url";
7
+ import { readFile, rm } from "node:fs/promises";
10
8
  import { generateOpenApiTypesFromServer, generateTypes } from "@ubean/build/codegen";
11
- import { prerender } from "@ubean/build/prerender";
12
- import { buildProduction } from "@ubean/build/production";
13
- import { createStaticSsgRenderer } from "@ubean/build/static-render";
14
- import { NODE_REQUIREMENTS, createCapabilitySet, diagnoseCapabilities, registerBuiltinPresets, resolvePresetByName } from "@ubean/preset";
9
+ import { buildWithEnvironments } from "@ubean/build/production";
10
+ import { NODE_REQUIREMENTS, diagnoseCapabilities, registerBuiltinPresets, resolvePresetByName } from "@ubean/preset";
15
11
  import { scanProject } from "@ubean/scan";
16
- import { applyServerConfig, createUbeanApp } from "@ubean/app";
17
12
  import { bold, cyan, dim, green } from "kolorist";
18
13
  import { createServer } from "node:http";
19
- import { createServer as createServer$1 } from "vite";
20
- import vue from "@vitejs/plugin-vue";
21
- import { ubeanPlugin } from "@ubean/build/vite";
22
- import { VUE_PLUGIN_INCLUDE, ubeanVite } from "@ubean/build/vue";
23
- import { getVueLocaleParam, toVueRouterLocalePath } from "@ubean/i18n";
14
+ import { DEVTOOLS_PASS_THROUGH_PREFIXES, createCloudflarePreviewRunner, createVirtualRegistry, getDevApp, onDevScan, peekDevScanCoordinator, previewMimeType, resolvePreviewFile, ubeanDevRequestPlugin, ubeanPlugin, ubeanPreviewPlugin } from "@ubean/build/vite";
15
+ import { createServer as createServer$1, preview } from "vite";
16
+ import { ubeanVite } from "@ubean/build/vue";
24
17
  import { ubeanIslandsPlugin } from "@ubean/islands/vite";
25
- import { findAvailablePort, findUserViteConfig, getLanAddresses, isLoopbackHost, isWildcardHost, waitForPort } from "@ubean/shared/node";
26
- import { execSync, spawn } from "node:child_process";
18
+ import { findAvailablePort, findUserViteConfig, getLanAddresses, isLoopbackHost, isWildcardHost } from "@ubean/shared/node";
19
+ import { execSync } from "node:child_process";
27
20
  //#region src/config.ts
28
21
  const logger$11 = getLogger("cli");
29
22
  const CONFIG_TEMPLATE = `import { defineConfig } from 'ubean';
@@ -174,6 +167,14 @@ const logger$10 = getLogger("cli");
174
167
  function readStringArg(value) {
175
168
  return typeof value === "string" && value.length > 0 ? value : void 0;
176
169
  }
170
+ /** Absolute ceilings are given in kB on the CLI; the budget API works in bytes. */
171
+ function readKilobyteArg(value, flag) {
172
+ const raw = readStringArg(value);
173
+ if (raw === void 0) return void 0;
174
+ const kb = Number(raw);
175
+ if (!Number.isFinite(kb) || kb < 0) throw new Error(`[ubean] analyze: ${flag} must be a non-negative number of kB`);
176
+ return Math.round(kb * 1024);
177
+ }
177
178
  const analyzeCommand = {
178
179
  meta: {
179
180
  name: "analyze",
@@ -200,6 +201,18 @@ const analyzeCommand = {
200
201
  maxIncrease: {
201
202
  type: "string",
202
203
  description: "Allowed relative gzip growth when using --check (default 0.05)"
204
+ },
205
+ maxTotalKb: {
206
+ type: "string",
207
+ description: "Absolute ceiling for total client JS gzip in kB (checked against this build alone)"
208
+ },
209
+ maxEntryKb: {
210
+ type: "string",
211
+ description: "Absolute ceiling for entry chunk gzip in kB"
212
+ },
213
+ maxChunkKb: {
214
+ type: "string",
215
+ description: "Absolute ceiling for any single chunk gzip in kB"
203
216
  }
204
217
  },
205
218
  async run({ args }) {
@@ -214,7 +227,7 @@ const analyzeCommand = {
214
227
  const manifest = readViteManifest(found.manifestPath);
215
228
  const baseline = summarizeBundle(found.outDir, manifest);
216
229
  baseline.outDir = relative(cwd, found.outDir).replace(/\\/g, "/") || baseline.outDir;
217
- logger$10.info(`client JS: ${(baseline.totalGzip / 1024).toFixed(1)} kB gzip (${baseline.entries.length} js chunks, entry ${(baseline.entryGzip / 1024).toFixed(1)} kB)`);
230
+ logger$10.info(`client JS: ${formatKilobytes(baseline.totalGzip)} gzip / ${formatKilobytes(baseline.totalBrotli ?? 0)} brotli (${baseline.entries.length} js chunks, entry ${formatKilobytes(baseline.entryGzip)} gzip)`);
218
231
  for (const entry of baseline.entries.slice(0, 12)) logger$10.info(` ${entry.isEntry ? "[entry] " : ""}${entry.file} ${(entry.gzip / 1024).toFixed(1)} kB gzip`);
219
232
  if (args.write !== false) {
220
233
  const outArg = readStringArg(args.out);
@@ -223,23 +236,40 @@ const analyzeCommand = {
223
236
  logger$10.info(`wrote ${out}`);
224
237
  }
225
238
  const checkArg = readStringArg(args.check);
226
- if (checkArg) {
227
- const checkPath = resolve(cwd, checkArg);
228
- if (!existsSync(checkPath)) {
229
- logger$10.error(`baseline not found: ${checkPath}`);
230
- throw new Error("missing budget baseline");
231
- }
232
- const committed = JSON.parse(readFileSync(checkPath, "utf8"));
239
+ const absoluteBudget = {
240
+ maxTotalGzip: readKilobyteArg(args.maxTotalKb, "--max-total-kb"),
241
+ maxEntryGzip: readKilobyteArg(args.maxEntryKb, "--max-entry-kb"),
242
+ maxChunkGzip: readKilobyteArg(args.maxChunkKb, "--max-chunk-kb")
243
+ };
244
+ const hasAbsoluteBudget = Object.values(absoluteBudget).some((value) => value !== void 0);
245
+ if (checkArg || hasAbsoluteBudget) {
246
+ let committed;
247
+ if (checkArg) {
248
+ const checkPath = resolve(cwd, checkArg);
249
+ if (!existsSync(checkPath)) {
250
+ logger$10.error(`baseline not found: ${checkPath}`);
251
+ throw new Error("missing budget baseline");
252
+ }
253
+ const parsed = JSON.parse(readFileSync(checkPath, "utf8"));
254
+ committed = {
255
+ totalGzip: parsed.totalGzip ?? 0,
256
+ entryGzip: parsed.entryGzip ?? 0
257
+ };
258
+ } else committed = {
259
+ totalGzip: baseline.totalGzip,
260
+ entryGzip: baseline.entryGzip
261
+ };
233
262
  const maxIncrease = Number(args.maxIncrease ?? .05);
234
- const result = compareBundleBaseline(baseline, {
235
- totalGzip: committed.totalGzip ?? 0,
236
- entryGzip: committed.entryGzip ?? 0
237
- }, { maxIncrease: Number.isFinite(maxIncrease) ? maxIncrease : .05 });
263
+ const result = compareBundleBaseline(baseline, committed, {
264
+ maxIncrease: Number.isFinite(maxIncrease) ? maxIncrease : .05,
265
+ ...absoluteBudget
266
+ });
238
267
  if (!result.ok) {
239
268
  for (const message of result.messages) logger$10.error(message);
240
- throw new Error("client JS budget exceeded");
269
+ throw new Error(result.violations.some((v) => v.kind === "missing") ? "client JS budget check failed" : "client JS budget exceeded");
241
270
  }
242
- logger$10.info(`budget ok (total ${(result.totalRatio * 100).toFixed(1)}%, entry ${(result.entryRatio * 100).toFixed(1)}% vs baseline, max ${(result.maxIncrease * 100).toFixed(0)}%)`);
271
+ const relativeSummary = checkArg ? `total ${(result.totalRatio * 100).toFixed(1)}%, entry ${(result.entryRatio * 100).toFixed(1)}% vs baseline, max ${(result.maxIncrease * 100).toFixed(0)}%` : "no baseline comparison";
272
+ logger$10.info(`budget ok (${relativeSummary})`);
243
273
  }
244
274
  }
245
275
  };
@@ -275,33 +305,9 @@ async function loadContentForBuild(cwd, content) {
275
305
  * If the SSR entry cannot be loaded (e.g. missing dependencies), returns
276
306
  * `undefined` so the caller falls back to the existing placeholder behavior.
277
307
  */
278
- async function createSsrFetcher(cwd, manifest) {
279
- try {
280
- const entryPath = resolve(cwd, manifest.serverDir, "entry.mjs");
281
- const mod = await import(pathToFileURL(entryPath).href);
282
- const createFetchHandler = mod.default ?? mod.createFetchHandler;
283
- if (typeof createFetchHandler !== "function") {
284
- logger$9.warn("SSR entry does not export createFetchHandler; falling back to placeholder prerender.");
285
- return;
286
- }
287
- const fetch = await createFetchHandler();
288
- if (typeof fetch !== "function") {
289
- logger$9.warn("SSR entry did not return a fetch handler; falling back to placeholder prerender.");
290
- return;
291
- }
292
- return async (url) => {
293
- const req = new Request(`http://localhost${url || "/"}`, { headers: { "x-ubean-prerender": "1" } });
294
- const res = await fetch(req);
295
- return {
296
- html: typeof res.text === "function" ? await res.text() : String(res.body ?? ""),
297
- statusCode: res.status ?? 200
298
- };
299
- };
300
- } catch (err) {
301
- logger$9.warn(`Failed to load SSR entry for prerender: ${err instanceof Error ? err.message : String(err)}. Falling back to placeholder prerender.`);
302
- return;
303
- }
304
- }
308
+ /**
309
+ * RM-V21:`createSsrFetcher` 已随预渲染逻辑下沉到 `@ubean/build` 的 `prerender-step.ts`。
310
+ */
305
311
  const buildCommand = {
306
312
  meta: {
307
313
  name: "build",
@@ -325,6 +331,10 @@ const buildCommand = {
325
331
  description: "Shortcut for --mode ssg",
326
332
  default: false
327
333
  },
334
+ outDir: {
335
+ type: "string",
336
+ description: "Output directory (overrides ubean.config.ts build.outputDir)"
337
+ },
328
338
  minify: {
329
339
  type: "boolean",
330
340
  description: "Minify output",
@@ -390,8 +400,12 @@ const buildCommand = {
390
400
  config
391
401
  });
392
402
  logger$9.info("Building with Vite...");
393
- const { snapshot: contentSnapshot, routes: contentRoutes } = await loadContentForBuild(cwd, config.content);
394
- const manifest = await buildProduction({
403
+ const nodeEnv = process.env.NODE_ENV;
404
+ if (nodeEnv && nodeEnv !== "production") logger$9.warn(`NODE_ENV=${nodeEnv}:客户端产物会包含开发态代码(如 Vue dev runtime 与开发警告)。生产构建请设置 NODE_ENV=production,或不要设置该变量。`);
405
+ if (args.outDir) config.build.outputDir = String(args.outDir);
406
+ process.env.UBEAN_BUILD_PRESET = resolvedPreset.name;
407
+ const { snapshot: contentSnapshot } = await loadContentForBuild(cwd, config.content);
408
+ const manifest = await buildWithEnvironments({
395
409
  cwd,
396
410
  config,
397
411
  preset: resolvedPreset,
@@ -400,64 +414,6 @@ const buildCommand = {
400
414
  sourcemap: args.sourcemap,
401
415
  contentSnapshot
402
416
  });
403
- const hasPrerenderRules = Object.values(config.routeRules || {}).some((r) => r?.prerender === true);
404
- if (config.prerender.enabled || hasPrerenderRules) {
405
- logger$9.info("Prerendering static pages...");
406
- let fetcher;
407
- let notFoundRoute = false;
408
- let expandRoutes;
409
- if (config.mode === "ssg") {
410
- const staticRenderer = await createStaticSsgRenderer(cwd, manifest, {
411
- pages: result.pages,
412
- notFoundPage: result.notFoundPage ?? null,
413
- i18n: config.i18n
414
- });
415
- if (staticRenderer) {
416
- fetcher = staticRenderer.fetcher;
417
- expandRoutes = staticRenderer.expandRoutes;
418
- notFoundRoute = Boolean(result.notFoundPage);
419
- logger$9.info("Using static SSG renderer (direct render, no HTTP pipeline)");
420
- }
421
- } else fetcher = await createSsrFetcher(cwd, manifest);
422
- await prerender({
423
- cwd,
424
- outputDir: config.build.outputDir,
425
- pages: result.pages,
426
- prerender: config.prerender,
427
- routeRules: config.routeRules,
428
- contentRoutes,
429
- fetcher,
430
- notFoundRoute,
431
- expandRoutes
432
- });
433
- if (contentSnapshot) try {
434
- const contentMod = await import("@ubean/content");
435
- const searchConfig = contentMod.resolveContentSearchConfig(config.content, { ssg: config.mode === "ssg" });
436
- const staticDir = resolve(cwd, resolvePrerenderConfig(config.prerender).staticDir);
437
- if (searchConfig.sections) {
438
- const payload = contentMod.generateSearchSectionsSnapshot(contentSnapshot);
439
- await mkdir(staticDir, { recursive: true });
440
- await writeFile(join(staticDir, "__search.json"), JSON.stringify(payload), "utf-8");
441
- const sectionCount = Object.values(payload).reduce((sum, sections) => sum + sections.length, 0);
442
- logger$9.info(`Search: wrote __search.json (${sectionCount} sections)`);
443
- }
444
- if (searchConfig.pagefind) {
445
- const res = await contentMod.runPagefindIndex({
446
- siteDir: staticDir,
447
- createIndexOptions: searchConfig.pagefindOptions,
448
- logger: {
449
- info: (msg) => logger$9.info(msg),
450
- warn: (msg) => logger$9.warn(msg)
451
- }
452
- });
453
- if (res.indexed) logger$9.info("Search: pagefind index generated");
454
- else if (res.reason === "pagefind-not-installed") logger$9.info("Search: pagefind is not installed — skipping index. Run `pnpm add -D pagefind` to enable.");
455
- else logger$9.warn(`Search: pagefind indexing failed — ${res.error ?? res.reason}`);
456
- }
457
- } catch (err) {
458
- logger$9.warn(`Search index generation skipped: ${err instanceof Error ? err.message : String(err)}`);
459
- }
460
- }
461
417
  if (config.mode === "ssg" && !process.env.UBEAN_KEEP_SSR) {
462
418
  const serverDir = join(cwd, config.build.outputDir, "server");
463
419
  if (existsSync(serverDir)) {
@@ -487,283 +443,99 @@ const buildCommand = {
487
443
  }
488
444
  };
489
445
  //#endregion
490
- //#region src/dev-server/vite-server.ts
491
- const logger$8 = getLogger("dev-server");
492
- /**
493
- * Compute the vue-router locale param (e.g. `:locale(zh)?`) from the resolved
494
- * i18n routing config, mirroring `localeVueParamFromI18n` (`@ubean/build`).
495
- * Returns `''` when i18n is disabled or no prefix is needed.
496
- */
497
- function resolveLocaleVueParam(config) {
498
- if (!config?.enabled || !config.locales?.length) return "";
499
- return getVueLocaleParam({
500
- defaultLocale: config.defaultLocale,
501
- locales: config.locales.map((l) => l.code),
502
- strategy: config.strategy
503
- });
504
- }
505
- async function toWebRequest(req, host, protocol) {
506
- const url = `${protocol}://${req.headers.host || host}${req.url || "/"}`;
507
- const headers = new Headers();
508
- for (const [key, value] of Object.entries(req.headers)) if (value) {
509
- if (Array.isArray(value)) for (const v of value) headers.append(key, v);
510
- else headers.set(key, value);
511
- }
512
- const method = req.method || "GET";
513
- return new Request(url, {
514
- method,
515
- headers,
516
- body: method === "GET" || method === "HEAD" ? void 0 : req,
517
- duplex: "half"
518
- });
519
- }
520
- async function sendWebResponse(res, webRes) {
521
- res.statusCode = webRes.status;
522
- res.statusMessage = webRes.statusText;
523
- webRes.headers.forEach((value, key) => {
524
- res.setHeader(key, value);
525
- });
526
- if (webRes.body) {
527
- const reader = webRes.body.getReader();
528
- try {
529
- while (true) {
530
- const { done, value } = await reader.read();
531
- if (done) break;
532
- res.write(value);
533
- }
534
- } finally {
535
- reader.releaseLock();
536
- }
537
- }
538
- res.end();
539
- }
540
- function loadScaffoldOps() {
541
- return {
542
- createFsOps,
543
- scaffold,
544
- deleteScaffold,
545
- recoverScaffold
546
- };
547
- }
548
- /** Vite's browser-facing prefix for virtual module ids (see `wrapId`). */
549
- const VALID_ID_PREFIX = "/@id/";
550
- /** Vite's URL-safe encoding of the `\0` virtual-module marker. */
551
- const NULL_BYTE_PLACEHOLDER = "__x00__";
446
+ //#region src/dev-server/dev-vite.ts
552
447
  /**
553
- * Encode a module-graph URL into a browser-servable path.
448
+ * dev 的 Vite 装配(RM-V14:取代 `vite-server.ts` + `runner.ts`)。
449
+ *
450
+ * CLI 不再自建 app、不再持有 HTTP server、也不再管热重载:
451
+ * - **请求路由**与**宿主 app** 由 `@ubean/build` 的 `ubeanDevRequestPlugin` 负责(自举 +
452
+ * 随扫描重建);
453
+ * - **文件监听与重载顺序**由 `dev-scan` 协调器负责;
454
+ * - CLI 只装配插件、起 Vite server、打印 banner,并订阅扫描做命令级事务(类型生成、
455
+ * DevTools 数据上报)。
554
456
  *
555
- * Virtual module ids carry a raw `\0` marker in the module graph (e.g.
556
- * UnoCSS's `\0/__uno.css`). A literal NUL is invalid inside HTML — parse5
557
- * rejects it with `unexpected-null-character`, which surfaces as a warning
558
- * in `transformIndexHtml`. Mirror Vite's own import-analysis rewriting
559
- * (`wrapId`: `/@id/` prefix + `\0` → `__x00__`) so the href stays valid
560
- * and resolvable by the dev server.
457
+ * 保留两处 CLI 专有接线(无法下沉):`@ubean/devtools` / `@vitejs/devtools` 的按需加载
458
+ * (可选依赖 + `config.devtools.enabled` 闸门),以及 DevTools 的 scaffold CRUD
459
+ * (页面增删改是 CLI 的能力)。
561
460
  */
562
- function toDevCssHref(url) {
563
- return url.includes("\0") ? `${VALID_ID_PREFIX}${url.replace(/\0/g, NULL_BYTE_PLACEHOLDER)}` : url;
564
- }
461
+ const logger$8 = getLogger("dev-server");
565
462
  /**
566
- * Collect render-blocking CSS URLs from the Vite dev module graph.
567
- *
568
- * In dev, CSS imported from JS (virtual uno.css entry, global.css, font css)
569
- * is only applied once the client entry module graph has fully loaded and
570
- * executed — SSR HTML paints unstyled for that gap (FOUC). Injecting the same
571
- * CSS modules as blocking `<link>` tags lets the browser fetch them in
572
- * parallel with the JS graph, so the first paint is styled.
573
- *
574
- * `?direct` tells Vite's transform middleware to return raw CSS
575
- * (`Content-Type: text/css`) instead of the JS style-injection wrapper.
576
- * Modules whose URL already carries a query (SFC `<style>` blocks such as
577
- * `*.vue?vue&type=style&...`) cannot be served this way and are skipped.
463
+ * 收编 Vite 原生日志到 ubean 分类闸门:`lifecycle` 关闭时(默认)info 降为 debug,
464
+ * warn/error 原样保留(这两级永远可见)。
578
465
  */
579
- function collectDevCssLinks(moduleGraph) {
580
- const links = /* @__PURE__ */ new Set();
581
- for (const mod of moduleGraph.idToModuleMap.values()) {
582
- const { url, id } = mod;
583
- if (!url || url.includes("?")) continue;
584
- if (id?.endsWith(".css") || url.endsWith(".css")) links.add(`${toDevCssHref(url)}?direct`);
585
- }
586
- return [...links];
587
- }
588
- /** Inject blocking stylesheet links right before `</head>`. */
589
- function injectStylesheetLinks(html, hrefs) {
590
- if (!hrefs.length || !html.includes("</head>")) return html;
591
- const tags = hrefs.map((href) => `<link rel="stylesheet" href="${href}">`).join("");
592
- return html.replace("</head>", `${tags}</head>`);
466
+ function createGatedViteLogger(logging) {
467
+ return {
468
+ info: (msg) => logger$8.debug(msg),
469
+ warn: (msg) => logger$8.warn(msg),
470
+ warnOnce: (msg) => logger$8.warn(msg),
471
+ error: (msg, options) => logger$8.error(options?.error ? `${msg} ${options.error}` : msg),
472
+ clearScreen: () => {},
473
+ hasWarned: false,
474
+ ...logging.lifecycle ? { info: (msg) => logger$8.info(msg) } : {}
475
+ };
593
476
  }
594
- async function createViteDevServer(options) {
595
- const { cwd, config, app: initialApp, layouts: initialLayouts = [] } = options;
596
- const host = options.host || "localhost";
597
- const strictPort = options.strictPort ?? false;
598
- let currentApp = initialApp;
599
- let currentLayouts = initialLayouts;
600
- let httpServer = null;
601
- let viteServer = null;
602
- let serverConfigApplied = false;
603
- let serverReadyCalled = false;
604
- let cachedServerConfig = null;
605
- let refreshDevtools = null;
606
- const requestedPort = options.port;
607
- let actualPort;
608
- try {
609
- actualPort = await findAvailablePort(requestedPort, {
610
- host,
611
- strictPort
612
- });
613
- } catch (err) {
614
- if (err?.code === "EADDRINUSE") throw new Error(`Port ${requestedPort} is already in use${host ? ` on ${host}` : ""}. Try a different port or remove the --strictPort flag.`, { cause: err });
615
- throw err;
616
- }
617
- if (actualPort !== requestedPort) logger$8.warn(`Port ${requestedPort} is in use, trying ${actualPort} instead.`);
618
- httpServer = createServer(async (req, res) => {
619
- try {
620
- const pathname = (req.url || "/").split("?")[0].split("#")[0];
621
- if (devtoolsEnabled && (pathname === "/_devtools" || pathname === "/_devtools/")) {
622
- res.statusCode = 302;
623
- res.setHeader("Location", "/__devtools/");
624
- res.end();
625
- return;
626
- }
627
- if (!viteServer) {
628
- res.statusCode = 503;
629
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
630
- res.end("ubean dev server is shutting down");
631
- return;
632
- }
633
- await new Promise((resolve, reject) => {
634
- viteServer.middlewares(req, res, (err) => {
635
- if (err) reject(err);
636
- else resolve();
637
- });
638
- });
639
- if (res.writableEnded) return;
640
- try {
641
- await viteServer.ssrLoadModule("ubean:locales");
642
- } catch (err) {
643
- logger$8.warn("[ubean] Failed to resolve locales module:", err);
644
- }
645
- if (!serverConfigApplied) {
646
- serverConfigApplied = true;
647
- try {
648
- const serverMod = await viteServer.ssrLoadModule("virtual:ubean-server");
649
- if (serverMod?.resolveServerConfig) {
650
- cachedServerConfig = serverMod.resolveServerConfig("dev");
651
- await applyServerConfig(currentApp, cachedServerConfig);
652
- }
653
- } catch (err) {
654
- logger$8.warn("[ubean] Failed to load server config:", err);
655
- }
656
- }
657
- await currentApp.init();
658
- if (!serverReadyCalled) {
659
- serverReadyCalled = true;
660
- if (cachedServerConfig?.onServerReady) try {
661
- await cachedServerConfig.onServerReady(currentApp);
662
- } catch (err) {
663
- logger$8.warn("[ubean] onServerReady error:", err);
664
- }
665
- }
666
- const protocol = req.socket?.encrypted ? "https" : "http";
667
- const webReq = await toWebRequest(req, host, protocol);
668
- const webRes = await currentApp.fetch(webReq);
669
- const contentType = webRes.headers.get("content-type") || "";
670
- const skipTransform = (req.url || "").startsWith("/_devtools");
671
- if (contentType.includes("text/html") && webRes.body && !skipTransform) {
672
- const htmlWithCss = injectStylesheetLinks(await webRes.text(), collectDevCssLinks(viteServer.moduleGraph));
673
- const transformedHtml = await viteServer.transformIndexHtml(req.url || "/", htmlWithCss);
674
- res.statusCode = webRes.status;
675
- res.statusMessage = webRes.statusText;
676
- webRes.headers.forEach((value, key) => {
677
- res.setHeader(key, value);
678
- });
679
- res.end(transformedHtml);
680
- } else await sendWebResponse(res, webRes);
681
- } catch (err) {
682
- if (viteServer) viteServer.ssrFixStacktrace(err);
683
- if (!res.headersSent) {
684
- res.statusCode = 500;
685
- const errorHtml = `<!DOCTYPE html>
686
- <html>
687
- <head>
688
- <title>Server Error</title>
689
- <style>
690
- body { font-family: monospace; padding: 2rem; background: #1a1a1a; color: #ff6b6b; }
691
- pre { background: #2d2d2d; padding: 1rem; border-radius: 4px; overflow-x: auto; }
692
- </style>
693
- </head>
694
- <body>
695
- <h1>Internal Server Error</h1>
696
- <pre>${err.stack || err.message}</pre>
697
- </body>
698
- </html>`;
699
- res.setHeader("Content-Type", "text/html");
700
- res.end(errorHtml);
701
- } else if (!res.writableEnded) res.end(err instanceof Error ? err.message : "Internal Server Error");
702
- }
703
- });
704
- const devtoolsEnabled = options.config.devtools.enabled;
705
- let devtoolsPlugin = null;
706
- if (devtoolsEnabled) try {
707
- const { ubeanDevtoolsPlugin } = await import("@ubean/devtools");
708
- const devtoolsOpts = options.devtools;
709
- const scaffoldOps = await loadScaffoldOps();
710
- devtoolsPlugin = ubeanDevtoolsPlugin({
711
- getCwd: () => cwd,
712
- getApp: () => currentApp,
713
- ...scaffoldOps ? { scaffoldOps } : {},
714
- ...devtoolsOpts?.getScanResult ? { getScanResult: devtoolsOpts.getScanResult } : {},
715
- ...devtoolsOpts?.getConfigMeta ? { getConfigMeta: devtoolsOpts.getConfigMeta } : {},
716
- ...devtoolsOpts?.getCustomTabs ? { getCustomTabs: devtoolsOpts.getCustomTabs } : {},
717
- ...devtoolsOpts?.ai ? { ai: devtoolsOpts.ai } : {},
718
- ...devtoolsOpts?.getApp ? { getApp: devtoolsOpts.getApp } : {},
719
- ...devtoolsOpts?.triggerRescan ? { triggerRescan: devtoolsOpts.triggerRescan } : {},
720
- registerRefresh: (fn) => {
721
- refreshDevtools = fn;
722
- }
723
- });
724
- } catch {}
725
- let viteDevtoolsPlugins = [];
726
- if (devtoolsEnabled) try {
727
- const { DevTools } = await import("@vitejs/devtools");
728
- // @vitejs/devtools 也解析到含 @vitejs+devtools peer 上下文的 vite-plus-core
729
- viteDevtoolsPlugins = await DevTools({ builtinDevTools: false });
730
- } catch {}
477
+ async function createDevViteServer(options) {
478
+ const { cwd, config, host, strictPort = false } = options;
479
+ const devtoolsEnabled = config.devtools.enabled;
731
480
  const userViteConfig = findUserViteConfig(cwd);
732
481
  const hasUserViteConfig = !!userViteConfig;
733
482
  const isBackendMode = config.mode === "backend";
734
- const builtinPlugins = [
735
- {
736
- name: "ubean:http-server-binder",
737
- enforce: "pre",
738
- configureServer(server) {
739
- if (!server.httpServer && httpServer) server.httpServer = httpServer;
740
- }
741
- },
742
- ...isBackendMode ? [] : [vue({
743
- include: VUE_PLUGIN_INCLUDE,
744
- template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("ubean-") } }
745
- })],
746
- ...hasUserViteConfig ? [] : [
747
- ubeanPlugin({ config }),
748
- ...isBackendMode ? [] : ubeanVite({ config }),
749
- ...isBackendMode ? [] : [ubeanIslandsPlugin()]
750
- ],
751
- ...viteDevtoolsPlugins,
752
- ...devtoolsPlugin ? [devtoolsPlugin] : []
753
- ];
483
+ let viteServer = null;
484
+ const rescanDev = () => peekDevScanCoordinator(viteServer)?.rescan();
485
+ const builtinPlugins = [...hasUserViteConfig ? [] : [ubeanDevRequestPlugin({
486
+ passThrough: devtoolsEnabled ? [...DEVTOOLS_PASS_THROUGH_PREFIXES] : [],
487
+ devtoolsRedirect: devtoolsEnabled
488
+ })], ...hasUserViteConfig ? [] : [
489
+ ubeanPlugin({
490
+ config,
491
+ registry: createVirtualRegistry()
492
+ }),
493
+ ...isBackendMode ? [] : ubeanVite({
494
+ config,
495
+ registry: createVirtualRegistry()
496
+ }),
497
+ ...isBackendMode ? [] : [ubeanIslandsPlugin()]
498
+ ]];
499
+ if (devtoolsEnabled) {
500
+ process.env.VITE_DEVTOOLS_DISABLE_CLIENT_AUTH = "true";
501
+ try {
502
+ const { ubeanDevtoolsPlugin } = await import("@ubean/devtools");
503
+ builtinPlugins.push(ubeanDevtoolsPlugin({
504
+ getScanResult: options.devtools?.getScanResult,
505
+ getConfigMeta: options.devtools?.getConfigMeta,
506
+ getCustomTabs: options.devtools?.getCustomTabs,
507
+ getCwd: () => cwd,
508
+ scaffoldOps: {
509
+ createFsOps,
510
+ scaffold,
511
+ deleteScaffold,
512
+ recoverScaffold
513
+ },
514
+ getApp: () => getDevApp(viteServer) ?? void 0,
515
+ triggerRescan: async () => {
516
+ await rescanDev();
517
+ }
518
+ }));
519
+ } catch {}
520
+ try {
521
+ const { DevTools } = await import("@vitejs/devtools");
522
+ builtinPlugins.push(...await DevTools({ builtinDevTools: false }));
523
+ } catch {}
524
+ }
754
525
  const { plugins } = await resolveModules({
755
526
  cwd,
756
527
  config,
757
528
  builtinPlugins
758
529
  });
759
- if (devtoolsEnabled) process.env.VITE_DEVTOOLS_DISABLE_CLIENT_AUTH = "true";
760
530
  viteServer = await createServer$1({
761
531
  root: cwd,
762
532
  configFile: userViteConfig ?? false,
533
+ customLogger: createGatedViteLogger({ lifecycle: config.logging.lifecycle }),
763
534
  resolve: { dedupe: ["vue", "vue-router"] },
764
535
  server: {
765
- middlewareMode: { server: httpServer },
766
- hmr: { port: actualPort + 1e3 }
536
+ host,
537
+ port: options.port,
538
+ strictPort
767
539
  },
768
540
  appType: "custom",
769
541
  plugins,
@@ -778,98 +550,29 @@ async function createViteDevServer(options) {
778
550
  "virtual:ubean-app",
779
551
  "virtual:ubean-server",
780
552
  "virtual:ubean-client-entry",
781
- "#ubean-pages",
782
- "#ubean-app",
783
- "#ubean-server",
784
- "#ubean-client-entry"
553
+ "ubean:locales",
554
+ "ubean:pages",
555
+ "ubean:routes",
556
+ "ubean:meta",
557
+ "ubean:app-config"
785
558
  ] },
786
559
  ssr: {
787
560
  noExternal: ["ubean", /^@ubean\//],
788
561
  external: ["@ubean/i18n"]
789
562
  }
790
563
  });
791
- const { createVueRenderer } = await viteServer.ssrLoadModule("@ubean/client/ssr");
792
- function enhanceAppWithVite(app, layouts = []) {
793
- app.options.layouts = layouts;
794
- const routeLoaders = {};
795
- for (const route of app.options.routes || []) {
796
- const fullPath = route.fullPath;
797
- routeLoaders[route.relativePath] = () => viteServer.ssrLoadModule(fullPath);
798
- }
799
- app.options.routeLoaders = routeLoaders;
800
- const pageLoaders = {};
801
- const pageByName = /* @__PURE__ */ new Map();
802
- for (const page of app.options.pages || []) pageByName.set(page.name, page);
803
- for (const page of app.options.pages || []) {
804
- const fullPath = (page.isReuse && page.reuseTarget ? pageByName.get(page.reuseTarget) : void 0)?.fullPath || page.fullPath;
805
- pageLoaders[page.relativePath] = () => viteServer.ssrLoadModule(fullPath);
806
- }
807
- app.options.pageLoaders = pageLoaders;
808
- const middlewareLoaders = {};
809
- for (const mw of app.options.middleware || []) {
810
- const fullPath = mw.fullPath;
811
- middlewareLoaders[mw.relativePath] = () => viteServer.ssrLoadModule(fullPath);
812
- }
813
- app.options.middlewareLoaders = middlewareLoaders;
814
- const cronFiles = app.options.crons || [];
815
- if (cronFiles.length > 0) Promise.all(cronFiles.map((c) => viteServer.ssrLoadModule(c.fullPath))).then(async () => {
816
- (await import("@ubean/server/cron")).startCronScheduler();
817
- }).catch((err) => {
818
- console.error("[ubean] Failed to load cron files:", err);
819
- });
820
- const layoutMap = /* @__PURE__ */ new Map();
821
- for (const layout of layouts) layoutMap.set(layout.name, layout.fullPath);
822
- const defaultLayout = layouts.find((l) => l.isDefault)?.name || null;
823
- if (config.mode === "backend") {
824
- app.resetInit();
825
- return;
826
- }
827
- const scannedPages = app.options.pages || [];
828
- const ssrLocaleParam = resolveLocaleVueParam(config.i18n);
829
- const scannedPageByName = /* @__PURE__ */ new Map();
830
- for (const p of scannedPages) scannedPageByName.set(p.name, p);
831
- const routes = scannedPages.map((p) => {
832
- const componentFullPath = (p.isReuse && p.reuseTarget ? scannedPageByName.get(p.reuseTarget) : void 0)?.fullPath || p.fullPath;
833
- const routePath = p.route.replace(/\*\*:(\w[\w-]*)/g, ":$1(.*)*");
834
- return {
835
- path: ssrLocaleParam ? toVueRouterLocalePath(routePath, ssrLocaleParam) : routePath,
836
- name: p.name,
837
- component: async () => {
838
- const mod = await viteServer.ssrLoadModule(componentFullPath);
839
- return mod.default || mod;
840
- },
841
- meta: {
842
- layout: p.layout === false ? false : p.layout || defaultLayout,
843
- pageName: p.name,
844
- cache: p.cache === true ? true : void 0
845
- }
846
- };
847
- });
848
- let appConfigModule = null;
849
- const getAppConfigModule = () => {
850
- if (!appConfigModule) appConfigModule = viteServer.ssrLoadModule("virtual:ubean-app");
851
- return appConfigModule;
852
- };
853
- app.options.pageRenderer = createVueRenderer({
854
- routes,
855
- async resolveLayoutComponent(name) {
856
- if (name === false || name == null) return null;
857
- const fullPath = layoutMap.get(name);
858
- if (!fullPath) return null;
859
- const mod = await viteServer.ssrLoadModule(fullPath);
860
- return mod.default || mod;
861
- },
862
- defaultLayout,
863
- async resolveAppConfig() {
864
- return (await getAppConfigModule()).resolveAppConfig("server");
865
- }
866
- });
867
- app.resetInit();
868
- }
869
- enhanceAppWithVite(currentApp, currentLayouts);
870
- const displayHost = isWildcardHost(host) ? "localhost" : host;
871
- const getNetworkUrls = () => isLoopbackHost(host) ? [] : getLanAddresses().map((address) => `http://${address}:${actualPort}`);
872
- const getUrl = () => `http://${displayHost}:${actualPort}`;
564
+ if (options.onScan) onDevScan(viteServer, options.onScan);
565
+ await viteServer.listen();
566
+ const address = viteServer.httpServer?.address();
567
+ const actualPort = address && typeof address === "object" ? address.port : options.port;
568
+ const url = `http://${isWildcardHost(host) ? "localhost" : host}:${actualPort}`;
569
+ const networkUrls = isLoopbackHost(host) ? [] : getLanAddresses().map((lanAddress) => `http://${lanAddress}:${actualPort}`);
570
+ options.onListen?.({
571
+ port: actualPort,
572
+ host,
573
+ url,
574
+ networkUrls
575
+ });
873
576
  return {
874
577
  get port() {
875
578
  return actualPort;
@@ -878,213 +581,19 @@ async function createViteDevServer(options) {
878
581
  return host;
879
582
  },
880
583
  get url() {
881
- return getUrl();
584
+ return url;
882
585
  },
883
586
  get viteServer() {
884
587
  return viteServer;
885
588
  },
886
- async start() {
887
- await new Promise((resolve, reject) => {
888
- const tryListen = (port) => {
889
- httpServer.removeAllListeners("error");
890
- httpServer.once("error", (err) => {
891
- if (err.code === "EADDRINUSE" && !strictPort) {
892
- logger$8.warn(`Port ${port} is in use, trying ${port + 1} instead.`);
893
- tryListen(port + 1);
894
- } else reject(err);
895
- });
896
- httpServer.listen(port, host, () => {
897
- const addr = httpServer.address();
898
- actualPort = typeof addr === "object" && addr ? addr.port : port;
899
- resolve();
900
- });
901
- };
902
- tryListen(actualPort);
903
- });
904
- options.onListen?.({
905
- port: actualPort,
906
- host,
907
- url: getUrl(),
908
- networkUrls: getNetworkUrls()
909
- });
589
+ async rescan() {
590
+ await rescanDev();
910
591
  },
911
592
  async stop() {
912
- if (httpServer) {
913
- const server = httpServer;
914
- httpServer = null;
915
- await new Promise((resolve) => {
916
- server.close(() => resolve());
917
- server.closeAllConnections();
918
- });
919
- }
920
- if (viteServer) {
921
- await viteServer.close();
922
- viteServer = null;
923
- }
924
- },
925
- updateApp(app, layouts) {
926
- currentApp = app;
927
- if (layouts) currentLayouts = layouts;
928
- serverConfigApplied = false;
929
- serverReadyCalled = false;
930
- cachedServerConfig = null;
931
- enhanceAppWithVite(app, currentLayouts);
932
- refreshDevtools?.();
933
- },
934
- sendFullReload() {
935
- if (viteServer) viteServer.ws.send({ type: "full-reload" });
936
- }
937
- };
938
- }
939
- //#endregion
940
- //#region src/dev-server/runner.ts
941
- var ViteNodeDevRunner = class {
942
- viteDevServer = null;
943
- currentApp;
944
- currentLayouts;
945
- options;
946
- _port;
947
- constructor(options) {
948
- this.options = options;
949
- this.currentApp = options.app;
950
- this.currentLayouts = options.layouts || [];
951
- this._port = options.port;
952
- }
953
- get port() {
954
- return this._port;
955
- }
956
- get host() {
957
- return this.options.host;
958
- }
959
- get url() {
960
- return `http://${this.options.host}:${this._port}`;
961
- }
962
- get preset() {
963
- return this.options.preset;
964
- }
965
- async start() {
966
- this.viteDevServer = await createViteDevServer({
967
- cwd: this.options.cwd,
968
- port: this._port,
969
- host: this.options.host,
970
- strictPort: this.options.strictPort,
971
- config: this.options.config,
972
- app: this.currentApp,
973
- layouts: this.currentLayouts,
974
- devtools: this.options.devtools,
975
- onListen: ({ port, host, url, networkUrls }) => {
976
- this._port = port;
977
- this.options.onListen?.({
978
- port,
979
- host,
980
- url,
981
- networkUrls
982
- });
983
- }
984
- });
985
- await this.viteDevServer.start();
986
- }
987
- async stop() {
988
- if (this.viteDevServer) {
989
- await this.viteDevServer.stop();
990
- this.viteDevServer = null;
593
+ const server = viteServer;
594
+ viteServer = null;
595
+ await server?.close();
991
596
  }
992
- }
993
- async reload() {
994
- await this.options.onBeforeReload?.();
995
- if (this.viteDevServer) this.viteDevServer.updateApp(this.currentApp, this.currentLayouts);
996
- await this.options.onAfterReload?.();
997
- }
998
- updateApp(app, layouts) {
999
- this.currentApp = app;
1000
- if (layouts) this.currentLayouts = layouts;
1001
- if (this.viteDevServer) this.viteDevServer.updateApp(app, this.currentLayouts);
1002
- }
1003
- sendFullReload() {
1004
- this.viteDevServer?.sendFullReload();
1005
- }
1006
- };
1007
- const runners = [{
1008
- name: "vite-node",
1009
- isAvailable() {
1010
- return typeof process !== "undefined" && !!process.versions?.node;
1011
- },
1012
- async createRunner(options) {
1013
- return new ViteNodeDevRunner(options);
1014
- }
1015
- }];
1016
- async function selectRunner(_preset) {
1017
- for (const runner of runners) if (await runner.isAvailable()) return runner;
1018
- return null;
1019
- }
1020
- async function createDevRunner(options) {
1021
- const runner = await selectRunner(options.preset);
1022
- if (!runner) throw new Error(`No compatible dev runner found for preset "${options.preset.name}". Ensure you are running in a supported environment (Node.js).`);
1023
- return runner.createRunner(options);
1024
- }
1025
- //#endregion
1026
- //#region src/dev-server/watcher.ts
1027
- function createDevWatcher(options) {
1028
- const { cwd, dirs, ignore = [], debounceMs = 100 } = options;
1029
- const watchers = [];
1030
- const watchedDirs = /* @__PURE__ */ new Set();
1031
- let pendingEvents = [];
1032
- let debounceTimer = null;
1033
- function isIgnored(relPath) {
1034
- return ignore.some((pattern) => {
1035
- if (pattern.includes("*")) return new RegExp(`^${pattern.replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(relPath);
1036
- return relPath.startsWith(pattern) || relPath === pattern;
1037
- });
1038
- }
1039
- function queueEvent(type, fullPath) {
1040
- const relPath = relative(cwd, fullPath).replace(/\\/g, "/");
1041
- if (isIgnored(relPath)) return;
1042
- if (relPath.startsWith("node_modules/") || relPath.startsWith(".git/")) return;
1043
- if (relPath.endsWith(".bak") || relPath.startsWith(".ubean/")) return;
1044
- pendingEvents.push({
1045
- type,
1046
- path: fullPath,
1047
- relativePath: relPath
1048
- });
1049
- if (debounceTimer) clearTimeout(debounceTimer);
1050
- debounceTimer = setTimeout(() => {
1051
- const events = pendingEvents;
1052
- pendingEvents = [];
1053
- debounceTimer = null;
1054
- options.onChange?.(events);
1055
- }, debounceMs);
1056
- }
1057
- function watchDir(dir) {
1058
- if (watchedDirs.has(dir)) return;
1059
- watchedDirs.add(dir);
1060
- try {
1061
- const w = watch(dir, { recursive: true }, (eventType, filename) => {
1062
- if (!filename) return;
1063
- const fullPath = join(dir, filename);
1064
- queueEvent(eventType === "rename" ? "unlink" : "change", fullPath);
1065
- });
1066
- watchers.push(w);
1067
- } catch {}
1068
- }
1069
- function start() {
1070
- for (const dir of dirs) watchDir(join(cwd, dir));
1071
- }
1072
- function stop() {
1073
- for (const w of watchers) w.close();
1074
- watchers.length = 0;
1075
- watchedDirs.clear();
1076
- if (debounceTimer) {
1077
- clearTimeout(debounceTimer);
1078
- debounceTimer = null;
1079
- }
1080
- }
1081
- function addDir(dir) {
1082
- watchDir(join(cwd, dir));
1083
- }
1084
- return {
1085
- start,
1086
- stop,
1087
- addDir
1088
597
  };
1089
598
  }
1090
599
  //#endregion
@@ -1100,7 +609,13 @@ function formatDiagnostics(diagnostics) {
1100
609
  warnings
1101
610
  };
1102
611
  }
1103
- function logDiagnostics(diagnostics) {
612
+ /**
613
+ * 输出能力诊断结果。
614
+ *
615
+ * `'auto'` 语义(默认):健康时静默 —— "All capability checks passed" 仅在
616
+ * `verbose` 时输出;告警/错误属于 warn/error 级别,始终输出。
617
+ */
618
+ function logDiagnostics(diagnostics, options = {}) {
1104
619
  const { errors, warnings } = formatDiagnostics(diagnostics);
1105
620
  if (errors.length > 0) {
1106
621
  logger$7.error(`Capability errors (${errors.length}):`);
@@ -1110,7 +625,7 @@ function logDiagnostics(diagnostics) {
1110
625
  logger$7.warn(`Capability warnings (${warnings.length}):`);
1111
626
  for (const w of warnings) logger$7.warn(w);
1112
627
  }
1113
- if (errors.length === 0 && warnings.length === 0) logger$7.info("All capability checks passed");
628
+ if (errors.length === 0 && warnings.length === 0 && options.verbose) logger$7.info("All capability checks passed");
1114
629
  }
1115
630
  //#endregion
1116
631
  //#region src/dev.ts
@@ -1131,20 +646,6 @@ import("@ubean/devtools").then((mod) => {
1131
646
  function getCustomTabs() {
1132
647
  return _customTabsGetter();
1133
648
  }
1134
- /**
1135
- * dev 环境 SecurityHeaders 解析(ADR-0011)。
1136
- *
1137
- * ssg 模式默认关闭:静态产物不携带框架注入的安全头(响应头由托管平台
1138
- * 控制),dev 默认关闭以对齐生产行为,消除「dev 有 CSP / 线上没有」的
1139
- * 偏差。显式配置(`headers: true` 或对象)仍会被尊重,作为 CSP 调试
1140
- * 入口。其余模式默认开启不变。
1141
- */
1142
- function resolveDevSecurityHeaders(config) {
1143
- if (config.security === false) return false;
1144
- if (config.security?.headers === false) return false;
1145
- if (config.security?.headers !== void 0) return config.security.headers;
1146
- return config.mode !== "ssg";
1147
- }
1148
649
  const devCommand = {
1149
650
  meta: {
1150
651
  name: "dev",
@@ -1164,6 +665,14 @@ const devCommand = {
1164
665
  description: "Exit if the port is already in use, instead of auto-incrementing",
1165
666
  default: false
1166
667
  },
668
+ verbose: {
669
+ type: "boolean",
670
+ description: "Show detailed startup logs (directories, scan, codegen, diagnostics, reload events)"
671
+ },
672
+ logRequests: {
673
+ type: "boolean",
674
+ description: "Log each request (GET /path 200 12ms). Ignored in ssg/spa modes."
675
+ },
1167
676
  open: {
1168
677
  type: "boolean",
1169
678
  description: "Open browser on startup"
@@ -1176,142 +685,142 @@ const devCommand = {
1176
685
  },
1177
686
  async run({ args }) {
1178
687
  const cwd = resolve(args.cwd || process.cwd());
1179
- logger$6.info("Starting ubean dev server...");
1180
688
  registerBuiltinPresets();
1181
689
  const config = await loadUbeanConfig(cwd);
690
+ const logging = config.logging;
691
+ if (args.verbose) {
692
+ logging.scan = true;
693
+ logging.lifecycle = true;
694
+ logging.diagnostics = true;
695
+ }
696
+ if (args.logRequests) {
697
+ logging.request = true;
698
+ logging.requestSuppressed = false;
699
+ }
700
+ const backendCapable = config.mode === "fullstack" || config.mode === "backend";
701
+ if (logging.request && !backendCapable) {
702
+ logging.request = false;
703
+ logging.requestSuppressed = true;
704
+ }
705
+ if (logging.requestSuppressed) logger$6.warn(`Request logging is not applicable in "${config.mode}" mode — skipping.`);
706
+ setMinLevel(logging.level);
707
+ const startedAt = Date.now();
708
+ if (logging.lifecycle) logger$6.info("Starting ubean dev server...");
1182
709
  const port = Number(args.port) || config.dev.port;
1183
710
  const host = args.host || config.dev.host;
1184
- logger$6.info(`Root directory: ${config.rootDir}`);
1185
- logger$6.info(`Source directory: ${config.srcDir}`);
1186
711
  const preset = resolvePresetByName(config.build.preset);
1187
- logger$6.info(`Preset: ${preset.name}`);
1188
- const capabilities = createCapabilitySet(preset.capabilities || {});
1189
- logger$6.info("Running capability diagnostics...");
712
+ if (logging.scan) {
713
+ logger$6.info(`Root directory: ${config.rootDir}`);
714
+ logger$6.info(`Source directory: ${config.srcDir}`);
715
+ logger$6.info(`Preset: ${preset.name}`);
716
+ }
717
+ if (logging.scan) logger$6.info("Running capability diagnostics...");
1190
718
  const diagnostics = diagnoseCapabilities(preset.name, preset.capabilities || {}, NODE_REQUIREMENTS);
1191
- logDiagnostics(diagnostics);
719
+ logDiagnostics(diagnostics, { verbose: logging.diagnostics });
1192
720
  if (!diagnostics.valid) logger$6.warn("Some capability requirements are not met. Dev server may not function correctly.");
1193
- let { app: currentApp, layouts: currentLayouts, scanResult: currentScanResult } = await buildApp(cwd, config);
1194
- let rescanInProgress = false;
1195
- async function rescan() {
1196
- if (rescanInProgress) return;
1197
- rescanInProgress = true;
721
+ let currentScanResult = await initialScan(cwd, config, logging);
722
+ /**
723
+ * 扫描后的命令级事务(RM-V13:扫描由 dev-scan 协调器驱动)。
724
+ *
725
+ * app 的重建不在这里 —— 它归 `ubeanDevRequestPlugin`(RM-V14)。CLI 负责把类型定义重新
726
+ * 生成,并把新扫描结果交给 DevTools。**重载不由 CLI 发**:协调器会在所有订阅者完成后统一发
727
+ * `full-reload`,因此浏览器不会带着旧的 `definePage` 元数据刷新。
728
+ */
729
+ async function onScan(result, changed) {
730
+ if (changed.length > 0) {
731
+ lastChangedFile = relative(cwd, changed[0]).replace(/\\/g, "/");
732
+ if (logging.lifecycle) logger$6.info(`File change detected: ${lastChangedFile}`);
733
+ }
734
+ currentScanResult = result;
1198
735
  try {
1199
- const { app: newApp, layouts: newLayouts, scanResult } = await buildApp(cwd, config);
1200
- currentApp = newApp;
1201
- currentLayouts = newLayouts;
1202
- currentScanResult = scanResult;
1203
- runner.updateApp(currentApp, currentLayouts);
1204
- await runner.reload();
1205
- runner.sendFullReload();
736
+ await generateTypes(result, {
737
+ cwd,
738
+ srcDir: config.srcDir,
739
+ buildDir: ".ubean",
740
+ dirs: config.dir,
741
+ autoImports: config.autoImports,
742
+ components: config.components
743
+ });
1206
744
  } catch (err) {
1207
745
  logger$6.error(`Rescan failed: ${err instanceof Error ? err.message : String(err)}`);
1208
- } finally {
1209
- rescanInProgress = false;
1210
746
  }
1211
747
  }
1212
- const runner = await createDevRunner({
1213
- cwd,
1214
- srcDir: config.srcDir,
1215
- port,
1216
- host,
1217
- strictPort: args.strictPort,
1218
- preset,
1219
- config,
1220
- capabilities,
1221
- app: currentApp,
1222
- layouts: currentLayouts,
1223
- devtools: {
1224
- getScanResult: () => currentScanResult,
1225
- getConfigMeta: () => ({
1226
- preset: config.build.preset,
1227
- rootDir: config.rootDir,
1228
- srcDir: config.srcDir,
1229
- openAPI: {
1230
- enabled: true,
1231
- scalarPath: "/_scalar",
1232
- openAPIPath: "/_openapi.json"
1233
- },
1234
- dir: config.dir
1235
- }),
1236
- getCustomTabs: () => getCustomTabs(),
1237
- triggerRescan: () => rescan()
1238
- },
1239
- onListen({ url, networkUrls }) {
1240
- const label = (text) => dim(text);
1241
- const lines = [
1242
- `${green(bold("🚀 ubean dev server ready"))}\n`,
1243
- ` → ${label("Local:")} ${cyan(url)}`,
1244
- ...(networkUrls || []).map((n) => ` → ${label("Network:")} ${cyan(n)}`)
1245
- ];
1246
- const hasBackend = config.mode !== "ssg" && config.mode !== "spa";
1247
- if (hasBackend) lines.push(` → ${label("Scalar UI:")} ${cyan(`${url}/_scalar`)}`, ` → ${label("OpenAPI:")} ${cyan(`${url}/_openapi.json`)}`);
1248
- if (config.devtools.enabled) lines.push(` → ${label("DevTools:")} ${cyan(`${url}${config.devtools.route}`)}`);
1249
- lines.push(` → ${dim("Press Ctrl+C to stop")}`);
1250
- logger$6.info(lines.join("\n"));
1251
- if (hasBackend) generateOpenApiTypesFromServer(url, { outDir: resolve(cwd, ".ubean") }).then((filePath) => {
1252
- if (filePath) logger$6.info(`OpenAPI types generated: ${filePath}`);
1253
- }).catch((err) => {
1254
- logger$6.warn(`Failed to generate OpenAPI types: ${err instanceof Error ? err.message : String(err)}`);
1255
- });
1256
- },
1257
- onBeforeReload() {
1258
- logger$6.info("Reloading...");
1259
- },
1260
- onAfterReload() {
1261
- logger$6.info("Reloaded");
1262
- }
1263
- });
748
+ let lastChangedFile = null;
749
+ let devServer = null;
1264
750
  try {
1265
- await runner.start();
751
+ devServer = await createDevViteServer({
752
+ cwd,
753
+ port,
754
+ host,
755
+ strictPort: args.strictPort,
756
+ config,
757
+ onScan,
758
+ devtools: {
759
+ getScanResult: () => currentScanResult,
760
+ getConfigMeta: () => ({
761
+ preset: config.build.preset,
762
+ rootDir: config.rootDir,
763
+ srcDir: config.srcDir,
764
+ openAPI: {
765
+ enabled: true,
766
+ scalarPath: "/_scalar",
767
+ openAPIPath: "/_openapi.json"
768
+ },
769
+ dir: config.dir
770
+ }),
771
+ getCustomTabs: () => getCustomTabs()
772
+ },
773
+ onListen({ url, networkUrls }) {
774
+ const label = (text) => dim(text);
775
+ const modeSuffix = dim(` (${config.mode} · ${preset.name})`);
776
+ const lines = [
777
+ `${green(bold(`🚀 ubean dev server ready in ${Date.now() - startedAt}ms`))}${modeSuffix}\n`,
778
+ ` → ${label("Local:")} ${cyan(url)}`,
779
+ ...(networkUrls || []).map((n) => ` → ${label("Network:")} ${cyan(n)}`)
780
+ ];
781
+ const hasBackend = config.mode !== "ssg" && config.mode !== "spa";
782
+ if (hasBackend) lines.push(` → ${label("Scalar UI:")} ${cyan(`${url}/_scalar`)}`, ` → ${label("OpenAPI:")} ${cyan(`${url}/_openapi.json`)}`);
783
+ if (config.devtools.enabled) lines.push(` → ${label("DevTools:")} ${cyan(`${url}${config.devtools.route}`)}`);
784
+ lines.push(` → ${dim("Press Ctrl+C to stop")}`);
785
+ logger$6.info(lines.join("\n"));
786
+ if (hasBackend) generateOpenApiTypesFromServer(url, { outDir: resolve(cwd, ".ubean") }).then((filePath) => {
787
+ if (filePath && logging.scan) logger$6.info(`OpenAPI types generated: ${filePath}`);
788
+ }).catch((err) => {
789
+ logger$6.warn(`Failed to generate OpenAPI types: ${err instanceof Error ? err.message : String(err)}`);
790
+ });
791
+ }
792
+ });
1266
793
  } catch (err) {
1267
794
  logger$6.error(err?.stack || err?.message || String(err));
1268
795
  process.exit(1);
1269
796
  }
1270
- const watcher = createDevWatcher({
1271
- cwd,
1272
- dirs: [
1273
- "api",
1274
- "pages",
1275
- "middleware",
1276
- "layouts",
1277
- "plugins",
1278
- "app",
1279
- "routes"
1280
- ].map((d) => `${config.srcDir}/${d}`),
1281
- ignore: [
1282
- "**/node_modules/**",
1283
- "**/.git/**",
1284
- "**/.ubean/**"
1285
- ],
1286
- debounceMs: 150,
1287
- async onChange(events) {
1288
- const relevantEvents = events.filter((e) => /\.(ts|js|vue|mjs|cjs|json)$/.test(e.relativePath) && !e.relativePath.includes(".bak"));
1289
- if (relevantEvents.length === 0) return;
1290
- logger$6.info(`File change detected: ${relevantEvents[0].relativePath}`);
1291
- await rescan();
1292
- }
1293
- });
1294
- watcher.start();
1295
797
  const cleanup = async () => {
1296
- logger$6.info("\nShutting down...");
1297
- watcher.stop();
1298
- await runner.stop();
798
+ if (logging.lifecycle) logger$6.info("\nShutting down...");
799
+ await devServer?.stop();
1299
800
  process.exit(0);
1300
801
  };
1301
802
  process.on("SIGINT", cleanup);
1302
803
  process.on("SIGTERM", cleanup);
1303
804
  }
1304
805
  };
1305
- async function buildApp(cwd, config) {
1306
- logger$6.info("Scanning project files...");
806
+ /**
807
+ * 初始扫描 + 类型生成。
808
+ *
809
+ * RM-V14 起 CLI 不在这里建 app(由 `ubeanDevRequestPlugin` 自举),只负责扫描与 codegen;
810
+ * 扫描结果交给 DevTools 作启动数据,并在每次文件变更后由 `onScan` 刷新。
811
+ */
812
+ async function initialScan(cwd, config, logging) {
813
+ if (logging.scan) logger$6.info("Scanning project files...");
1307
814
  const result = await scanProject({
1308
815
  cwd,
1309
816
  srcDir: config.srcDir,
1310
817
  dirs: config.dir,
1311
818
  ignore: config.scanOptions?.ignore
1312
819
  });
1313
- logger$6.info(`Found ${result.apiRoutes.length} API routes, ${result.pages.length} pages, ${result.layouts.length} layouts, ${result.middlewares.length} middlewares, ${result.plugins.length} plugins`);
1314
- logger$6.info("Generating type definitions...");
820
+ if (logging.scan) {
821
+ logger$6.info(`Found ${result.apiRoutes.length} API routes, ${result.pages.length} pages, ${result.layouts.length} layouts, ${result.middlewares.length} middlewares, ${result.plugins.length} plugins`);
822
+ logger$6.info("Generating type definitions...");
823
+ }
1315
824
  await generateTypes(result, {
1316
825
  cwd,
1317
826
  srcDir: config.srcDir,
@@ -1320,39 +829,12 @@ async function buildApp(cwd, config) {
1320
829
  autoImports: config.autoImports,
1321
830
  components: config.components
1322
831
  });
1323
- const app = createUbeanApp({
1324
- rootDir: cwd,
1325
- routes: result.apiRoutes,
1326
- middleware: result.middlewares,
1327
- pages: result.pages,
1328
- crons: result.crons,
1329
- routeRules: config.routeRules || {},
1330
- publicDir: config.dir.public,
1331
- pageAssetTags: config.favicon ? { favicon: config.favicon } : void 0,
1332
- openAPI: {
1333
- title: "UBEAN Dev API",
1334
- scalarPath: "/_scalar",
1335
- openAPIPath: "/_openapi.json"
1336
- },
1337
- i18nConfig: config.i18n,
1338
- ssrExclude: config.ssr.exclude,
1339
- streaming: config.ssr.streaming,
1340
- notFoundPage: result.notFoundPage,
1341
- csrf: config.security === false ? false : config.security?.csrf ?? true,
1342
- securityHeaders: resolveDevSecurityHeaders(config),
1343
- dataCache: config.dataCache,
1344
- cache: config.cache,
1345
- seoConventions: { srcDir: resolve(cwd, config.srcDir) }
1346
- });
1347
- app.hooks.hook("request:start", (c) => {
1348
- logger$6.debug(`${c.req.method} ${c.req.path}`);
1349
- });
1350
- return {
1351
- app,
1352
- layouts: result.layouts,
1353
- scanResult: result
1354
- };
832
+ return result;
1355
833
  }
834
+ /**
835
+ * RM-V14:请求日志(`logging.request`)不再由 CLI 实现 —— 见 `@ubean/build` 的
836
+ * `createDevApp({ logger })`。配置域行为放 builder,两条 dev 路径输出一致。
837
+ */
1356
838
  //#endregion
1357
839
  //#region src/devtools.ts
1358
840
  const logger$5 = getLogger("cli");
@@ -2057,89 +1539,38 @@ const prepareCommand = {
2057
1539
  };
2058
1540
  //#endregion
2059
1541
  //#region src/preview.ts
2060
- const MIME_TYPES = {
2061
- ".html": "text/html; charset=utf-8",
2062
- ".js": "text/javascript; charset=utf-8",
2063
- ".mjs": "text/javascript; charset=utf-8",
2064
- ".css": "text/css; charset=utf-8",
2065
- ".json": "application/json; charset=utf-8",
2066
- ".svg": "image/svg+xml",
2067
- ".png": "image/png",
2068
- ".jpg": "image/jpeg",
2069
- ".jpeg": "image/jpeg",
2070
- ".gif": "image/gif",
2071
- ".webp": "image/webp",
2072
- ".ico": "image/x-icon",
2073
- ".woff": "font/woff",
2074
- ".woff2": "font/woff2",
2075
- ".ttf": "font/ttf",
2076
- ".otf": "font/otf",
2077
- ".wasm": "application/wasm",
2078
- ".txt": "text/plain; charset=utf-8",
2079
- ".map": "application/json; charset=utf-8"
2080
- };
2081
1542
  /**
2082
- * 启动静态文件服务器,用于 spa / ssg 模式预览。
1543
+ * 静态文件服务器(spa / ssg 的降级路径)。
2083
1544
  *
2084
- * - ssg 模式:优先查找 `<path>/index.html` 或 `<path>.html`,找不到时返回 404
2085
- * - spa 模式:所有非文件路由回退到 `index.html`(客户端路由接管)
1545
+ * RM-V25:判定规则不再自己写一份,改用 `@ubean/build/vite` 的 `resolvePreviewFile` —— 插件接管
1546
+ * `vite preview` 时用的是同一个函数,两套方言必然分叉(文件名含合法 `...` 的产物曾被
1547
+ * `includes('..')` 判成路径穿越)。
2086
1548
  *
2087
- * 使用 Node 内置 `http` + `fs`,避免引入 `sirv` 等依赖。
1549
+ * 主路径已改为 `vite preview` + 插件中间件(RM-V24);本函数保留给「Vite 预览起不来」的
1550
+ * spa / ssg 场景(静态产物不需要服务端能力),以及既有回归测试。
2088
1551
  */
2089
1552
  function startStaticServer(opts) {
2090
1553
  const { root, port, host, mode } = opts;
2091
- const spaFallback = mode === "spa";
2092
1554
  return createServer(async (req, res) => {
2093
1555
  try {
2094
1556
  const url = new URL(req.url || "/", `http://${host}`);
2095
1557
  const pathname = decodeURIComponent(url.pathname);
2096
- let filePath = normalize(join(root, pathname));
2097
- const rootPath = resolve(root);
2098
- if (filePath !== rootPath && !filePath.startsWith(rootPath + sep)) {
1558
+ const resolvedFile = resolvePreviewFile(root, pathname, mode);
1559
+ if (resolvedFile.kind === "forbidden") {
2099
1560
  res.statusCode = 400;
1561
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
2100
1562
  res.end("Bad Request");
2101
1563
  return;
2102
1564
  }
2103
- let fileExists = existsSync(filePath) && (await stat(filePath)).isFile();
2104
- if (!fileExists && existsSync(filePath) && (await stat(filePath)).isDirectory()) {
2105
- const indexPath = join(filePath, "index.html");
2106
- if (existsSync(indexPath)) {
2107
- filePath = indexPath;
2108
- fileExists = true;
2109
- }
2110
- }
2111
- if (!fileExists && mode === "ssg") {
2112
- const indexPath = join(filePath, "index.html");
2113
- if (existsSync(indexPath) && (await stat(indexPath)).isFile()) {
2114
- filePath = indexPath;
2115
- fileExists = true;
2116
- }
2117
- }
2118
- if (!fileExists && mode === "ssg") {
2119
- const htmlPath = `${filePath}.html`;
2120
- if (existsSync(htmlPath) && (await stat(htmlPath)).isFile()) {
2121
- filePath = htmlPath;
2122
- fileExists = true;
2123
- }
2124
- }
2125
- if (!fileExists && spaFallback) {
2126
- const fallbackPath = join(root, "index.html");
2127
- if (existsSync(fallbackPath)) {
2128
- filePath = fallbackPath;
2129
- fileExists = true;
2130
- }
2131
- }
2132
- if (!fileExists) {
1565
+ if (resolvedFile.kind === "missing") {
2133
1566
  res.statusCode = 404;
2134
1567
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
2135
1568
  res.end("Not Found");
2136
1569
  return;
2137
1570
  }
2138
- const ext = extname(filePath).toLowerCase();
2139
- const contentType = MIME_TYPES[ext] || "application/octet-stream";
2140
- const body = await readFile(filePath);
1571
+ const body = await readFile(resolvedFile.path);
2141
1572
  res.statusCode = 200;
2142
- res.setHeader("Content-Type", contentType);
1573
+ res.setHeader("Content-Type", previewMimeType(resolvedFile.path));
2143
1574
  res.setHeader("Content-Length", body.length);
2144
1575
  res.end(body);
2145
1576
  } catch (err) {
@@ -2173,6 +1604,10 @@ const previewCommand = {
2173
1604
  type: "string",
2174
1605
  description: "Project root directory",
2175
1606
  default: "."
1607
+ },
1608
+ outDir: {
1609
+ type: "string",
1610
+ description: "Output directory to preview (overrides ubean.config.ts build.outputDir)"
2176
1611
  }
2177
1612
  },
2178
1613
  async run({ args }) {
@@ -2182,6 +1617,7 @@ const previewCommand = {
2182
1617
  const config = await loadUbeanConfig(cwd);
2183
1618
  const preset = resolvePresetByName(config.build.preset);
2184
1619
  logger$1.info(`Preset: ${preset.name}`);
1620
+ if (args.outDir) config.build.outputDir = String(args.outDir);
2185
1621
  const outputDir = config.build.outputDir || "dist";
2186
1622
  const mode = config.mode;
2187
1623
  const host = args.host || config.preview.host;
@@ -2204,110 +1640,71 @@ const previewCommand = {
2204
1640
  const bannerUrl = `http://${host}:${port}`;
2205
1641
  logger$1.info(`${green(bold("🚀 ubean preview server ready"))}\n\n → ${label("Local:")} ${cyan(bannerUrl)}\n → ${label("Mode:")} ${dim(modeLabel)}\n → ${label("Preset:")} ${cyan(preset.name)}\n → ${dim("Press Ctrl+C to stop")}`);
2206
1642
  };
2207
- if (mode === "spa" || mode === "ssg") {
2208
- const staticRoot = join(cwd, outputDir, "public");
2209
- if (!existsSync(staticRoot)) {
2210
- logger$1.error(`Build output not found: ${staticRoot}`);
2211
- logger$1.info("Run `ubean build` first to create a production build.");
1643
+ const staticMode = mode === "spa" || mode === "ssg";
1644
+ const isCloudflare = preset.name === "cloudflare";
1645
+ const staticRoot = join(cwd, outputDir, "public");
1646
+ const requiredArtifact = staticMode ? join(staticRoot, "index.html") : isCloudflare ? join(cwd, outputDir, "server", "worker.mjs") : join(cwd, outputDir, "server", "entry.mjs");
1647
+ if (!existsSync(requiredArtifact)) {
1648
+ logger$1.error(`Build output not found: ${requiredArtifact}`);
1649
+ logger$1.info("Run `ubean build` first to create a production build.");
1650
+ process.exit(1);
1651
+ }
1652
+ if (isCloudflare) {
1653
+ const probe = await createCloudflarePreviewRunner({ workerPath: requiredArtifact });
1654
+ if (!probe.ok) {
1655
+ logger$1.error(probe.message);
1656
+ process.exit(1);
1657
+ }
1658
+ await probe.runner.dispose();
1659
+ }
1660
+ const userViteConfig = findUserViteConfig(cwd);
1661
+ let server;
1662
+ try {
1663
+ server = await preview({
1664
+ root: cwd,
1665
+ configFile: userViteConfig ?? false,
1666
+ mode: "production",
1667
+ logLevel: "warn",
1668
+ preview: {
1669
+ port: actualPort,
1670
+ host,
1671
+ strictPort
1672
+ },
1673
+ ...userViteConfig ? {} : { plugins: [ubeanPreviewPlugin({
1674
+ config,
1675
+ host
1676
+ })] }
1677
+ });
1678
+ } catch (err) {
1679
+ if (!staticMode) {
1680
+ logger$1.error(`Failed to start the preview server: ${err instanceof Error ? err.message : String(err)}`);
2212
1681
  process.exit(1);
2213
1682
  }
2214
- const server = startStaticServer({
1683
+ logger$1.warn(`Vite preview unavailable (${err instanceof Error ? err.message : String(err)}); falling back to the built-in static server.`);
1684
+ const fallback = startStaticServer({
2215
1685
  root: staticRoot,
2216
1686
  port: actualPort,
2217
1687
  host,
2218
1688
  mode
2219
1689
  });
2220
1690
  printBanner(actualPort, `static (${mode})`);
2221
- const cleanup = () => {
2222
- server.close(() => process.exit(0));
1691
+ const cleanupFallback = () => {
1692
+ fallback.close(() => process.exit(0));
2223
1693
  setTimeout(() => process.exit(0), 1e3).unref();
2224
1694
  };
2225
- process.on("SIGINT", cleanup);
2226
- process.on("SIGTERM", cleanup);
2227
- server.on("error", (err) => {
2228
- logger$1.error(`Preview server error: ${err.message || err}`);
2229
- process.exit(1);
2230
- });
1695
+ process.on("SIGINT", cleanupFallback);
1696
+ process.on("SIGTERM", cleanupFallback);
2231
1697
  return;
2232
1698
  }
2233
- const presetName = preset.name;
2234
- let serverFile;
2235
- if (presetName === "node" || presetName === "bun" || presetName === "deno") serverFile = "server.mjs";
2236
- else if (presetName === "cloudflare") {
2237
- logger$1.error("Cloudflare preset preview is not supported yet. Use `wrangler dev` instead.");
2238
- process.exit(1);
2239
- } else {
2240
- serverFile = "handler.mjs";
2241
- logger$1.warn(`Preset "${presetName}" uses a fetch handler entry. Preview may not work as a standalone server.`);
2242
- }
2243
- const serverPath = join(cwd, outputDir, "server", serverFile);
2244
- if (!existsSync(serverPath)) {
2245
- logger$1.error(`Build output not found: ${serverPath}`);
2246
- logger$1.info("Run `ubean build` first to create a production build.");
2247
- process.exit(1);
2248
- }
2249
- const { child, exited } = spawnPreviewServer({
2250
- serverPath,
2251
- cwd,
2252
- port: actualPort,
2253
- host,
2254
- strictPort
2255
- });
2256
- try {
2257
- await waitForPort(actualPort, {
2258
- host,
2259
- retries: 40,
2260
- delay: 250
2261
- });
2262
- } catch {
2263
- if (exited.value) logger$1.error(`Preview server exited before becoming ready on port ${actualPort}.`);
2264
- else logger$1.warn(`Preview server on port ${actualPort} is not responding yet, the banner may be premature.`);
2265
- }
2266
- printBanner(actualPort, "production");
1699
+ printBanner(actualPort, staticMode ? `static (${mode})` : "production");
2267
1700
  const cleanup = () => {
2268
- child.kill("SIGTERM");
2269
- process.exit(0);
1701
+ server?.close().catch(() => void 0).finally(() => process.exit(0));
1702
+ setTimeout(() => process.exit(0), 1e3).unref();
2270
1703
  };
2271
1704
  process.on("SIGINT", cleanup);
2272
1705
  process.on("SIGTERM", cleanup);
2273
- child.on("exit", (code) => {
2274
- if (code !== 0 && code !== null) logger$1.error(`Preview server exited with code ${code}`);
2275
- process.exit(code ?? 0);
2276
- });
2277
1706
  }
2278
1707
  };
2279
- function spawnPreviewServer(opts) {
2280
- const exited = { value: false };
2281
- const child = spawn("node", [opts.serverPath], {
2282
- cwd: opts.cwd,
2283
- env: {
2284
- ...process.env,
2285
- PORT: String(opts.port),
2286
- HOST: opts.host
2287
- },
2288
- stdio: [
2289
- "inherit",
2290
- "inherit",
2291
- "pipe"
2292
- ]
2293
- });
2294
- let addrInUseReported = false;
2295
- child.stderr?.on("data", (chunk) => {
2296
- const text = chunk.toString();
2297
- process.stderr.write(text);
2298
- if (!addrInUseReported && /EADDRINUSE/.test(text)) {
2299
- addrInUseReported = true;
2300
- logger$1.error(`Port ${opts.port} is already in use${opts.host ? ` on ${opts.host}` : ""}. Try a different port${opts.strictPort ? " or remove the --strictPort flag" : ""}.`);
2301
- }
2302
- });
2303
- child.on("exit", () => {
2304
- exited.value = true;
2305
- });
2306
- return {
2307
- child,
2308
- exited
2309
- };
2310
- }
2311
1708
  //#endregion
2312
1709
  //#region src/scaffold-commands.ts
2313
1710
  const logger = getLogger("cli");