@ubean/cli 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.
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
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,310 +443,99 @@ const buildCommand = {
487
443
  }
488
444
  };
489
445
  //#endregion
490
- //#region src/dev-server/vite-server.ts
446
+ //#region src/dev-server/dev-vite.ts
447
+ /**
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 数据上报)。
456
+ *
457
+ * 保留两处 CLI 专有接线(无法下沉):`@ubean/devtools` / `@vitejs/devtools` 的按需加载
458
+ * (可选依赖 + `config.devtools.enabled` 闸门),以及 DevTools 的 scaffold CRUD
459
+ * (页面增删改是 CLI 的能力)。
460
+ */
491
461
  const logger$8 = getLogger("dev-server");
492
462
  /**
493
- * 收编 Vite 原生日志(hmr update、依赖预构建提示等)到 ubean 分类闸门:
494
- * - info 级别(如 `[vite] hmr update /src/x.vue`)默认静默;`logging.lifecycle` 开启时以 `[vite]` 前缀透传
495
- * - warn/error 始终透传(不可静默)
463
+ * 收编 Vite 原生日志到 ubean 分类闸门:`lifecycle` 关闭时(默认)info 降为 debug,
464
+ * warn/error 原样保留(这两级永远可见)。
496
465
  */
497
466
  function createGatedViteLogger(logging) {
498
467
  return {
499
- info(msg) {
500
- if (logging.lifecycle) logger$8.info(`[vite] ${msg}`);
501
- },
502
- warn(msg) {
503
- logger$8.warn(msg);
504
- },
505
- warnOnce(msg) {
506
- logger$8.warn(msg);
507
- },
508
- error(msg) {
509
- logger$8.error(msg);
510
- },
511
- clearScreen() {},
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: () => {},
512
473
  hasWarned: false,
513
- hasErrorLogged() {
514
- return false;
515
- }
474
+ ...logging.lifecycle ? { info: (msg) => logger$8.info(msg) } : {}
516
475
  };
517
476
  }
518
- /**
519
- * Compute the vue-router locale param (e.g. `:locale(zh)?`) from the resolved
520
- * i18n routing config, mirroring `localeVueParamFromI18n` (`@ubean/build`).
521
- * Returns `''` when i18n is disabled or no prefix is needed.
522
- */
523
- function resolveLocaleVueParam(config) {
524
- if (!config?.enabled || !config.locales?.length) return "";
525
- return getVueLocaleParam({
526
- defaultLocale: config.defaultLocale,
527
- locales: config.locales.map((l) => l.code),
528
- strategy: config.strategy
529
- });
530
- }
531
- async function toWebRequest(req, host, protocol) {
532
- const url = `${protocol}://${req.headers.host || host}${req.url || "/"}`;
533
- const headers = new Headers();
534
- for (const [key, value] of Object.entries(req.headers)) if (value) {
535
- if (Array.isArray(value)) for (const v of value) headers.append(key, v);
536
- else headers.set(key, value);
537
- }
538
- const method = req.method || "GET";
539
- return new Request(url, {
540
- method,
541
- headers,
542
- body: method === "GET" || method === "HEAD" ? void 0 : req,
543
- duplex: "half"
544
- });
545
- }
546
- async function sendWebResponse(res, webRes) {
547
- res.statusCode = webRes.status;
548
- res.statusMessage = webRes.statusText;
549
- webRes.headers.forEach((value, key) => {
550
- res.setHeader(key, value);
551
- });
552
- if (webRes.body) {
553
- const reader = webRes.body.getReader();
554
- try {
555
- while (true) {
556
- const { done, value } = await reader.read();
557
- if (done) break;
558
- res.write(value);
559
- }
560
- } finally {
561
- reader.releaseLock();
562
- }
563
- }
564
- res.end();
565
- }
566
- function loadScaffoldOps() {
567
- return {
568
- createFsOps,
569
- scaffold,
570
- deleteScaffold,
571
- recoverScaffold
572
- };
573
- }
574
- /** Vite's browser-facing prefix for virtual module ids (see `wrapId`). */
575
- const VALID_ID_PREFIX = "/@id/";
576
- /** Vite's URL-safe encoding of the `\0` virtual-module marker. */
577
- const NULL_BYTE_PLACEHOLDER = "__x00__";
578
- /**
579
- * Encode a module-graph URL into a browser-servable path.
580
- *
581
- * Virtual module ids carry a raw `\0` marker in the module graph (e.g.
582
- * UnoCSS's `\0/__uno.css`). A literal NUL is invalid inside HTML — parse5
583
- * rejects it with `unexpected-null-character`, which surfaces as a warning
584
- * in `transformIndexHtml`. Mirror Vite's own import-analysis rewriting
585
- * (`wrapId`: `/@id/` prefix + `\0` → `__x00__`) so the href stays valid
586
- * and resolvable by the dev server.
587
- */
588
- function toDevCssHref(url) {
589
- return url.includes("\0") ? `${VALID_ID_PREFIX}${url.replace(/\0/g, NULL_BYTE_PLACEHOLDER)}` : url;
590
- }
591
- /**
592
- * Collect render-blocking CSS URLs from the Vite dev module graph.
593
- *
594
- * In dev, CSS imported from JS (virtual uno.css entry, global.css, font css)
595
- * is only applied once the client entry module graph has fully loaded and
596
- * executed — SSR HTML paints unstyled for that gap (FOUC). Injecting the same
597
- * CSS modules as blocking `<link>` tags lets the browser fetch them in
598
- * parallel with the JS graph, so the first paint is styled.
599
- *
600
- * `?direct` tells Vite's transform middleware to return raw CSS
601
- * (`Content-Type: text/css`) instead of the JS style-injection wrapper.
602
- * Modules whose URL already carries a query (SFC `<style>` blocks such as
603
- * `*.vue?vue&type=style&...`) cannot be served this way and are skipped.
604
- */
605
- function collectDevCssLinks(moduleGraph) {
606
- const links = /* @__PURE__ */ new Set();
607
- for (const mod of moduleGraph.idToModuleMap.values()) {
608
- const { url, id } = mod;
609
- if (!url || url.includes("?")) continue;
610
- if (id?.endsWith(".css") || url.endsWith(".css")) links.add(`${toDevCssHref(url)}?direct`);
611
- }
612
- return [...links];
613
- }
614
- /** Inject blocking stylesheet links right before `</head>`. */
615
- function injectStylesheetLinks(html, hrefs) {
616
- if (!hrefs.length || !html.includes("</head>")) return html;
617
- const tags = hrefs.map((href) => `<link rel="stylesheet" href="${href}">`).join("");
618
- return html.replace("</head>", `${tags}</head>`);
619
- }
620
- async function createViteDevServer(options) {
621
- const { cwd, config, app: initialApp, layouts: initialLayouts = [] } = options;
622
- const host = options.host || "localhost";
623
- const strictPort = options.strictPort ?? false;
624
- let currentApp = initialApp;
625
- let currentLayouts = initialLayouts;
626
- let httpServer = null;
627
- let viteServer = null;
628
- let serverConfigApplied = false;
629
- let serverReadyCalled = false;
630
- let cachedServerConfig = null;
631
- let refreshDevtools = null;
632
- const requestedPort = options.port;
633
- let actualPort;
634
- try {
635
- actualPort = await findAvailablePort(requestedPort, {
636
- host,
637
- strictPort
638
- });
639
- } catch (err) {
640
- 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 });
641
- throw err;
642
- }
643
- if (actualPort !== requestedPort) logger$8.warn(`Port ${requestedPort} is in use, trying ${actualPort} instead.`);
644
- httpServer = createServer(async (req, res) => {
645
- try {
646
- const pathname = (req.url || "/").split("?")[0].split("#")[0];
647
- if (devtoolsEnabled && (pathname === "/_devtools" || pathname === "/_devtools/")) {
648
- res.statusCode = 302;
649
- res.setHeader("Location", "/__devtools/");
650
- res.end();
651
- return;
652
- }
653
- if (!viteServer) {
654
- res.statusCode = 503;
655
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
656
- res.end("ubean dev server is shutting down");
657
- return;
658
- }
659
- await new Promise((resolve, reject) => {
660
- viteServer.middlewares(req, res, (err) => {
661
- if (err) reject(err);
662
- else resolve();
663
- });
664
- });
665
- if (res.writableEnded) return;
666
- try {
667
- await viteServer.ssrLoadModule("ubean:locales");
668
- } catch (err) {
669
- logger$8.warn("[ubean] Failed to resolve locales module:", err);
670
- }
671
- if (!serverConfigApplied) {
672
- serverConfigApplied = true;
673
- try {
674
- const serverMod = await viteServer.ssrLoadModule("virtual:ubean-server");
675
- if (serverMod?.resolveServerConfig) {
676
- cachedServerConfig = serverMod.resolveServerConfig("dev");
677
- await applyServerConfig(currentApp, cachedServerConfig);
678
- }
679
- } catch (err) {
680
- logger$8.warn("[ubean] Failed to load server config:", err);
681
- }
682
- }
683
- await currentApp.init();
684
- if (!serverReadyCalled) {
685
- serverReadyCalled = true;
686
- if (cachedServerConfig?.onServerReady) try {
687
- await cachedServerConfig.onServerReady(currentApp);
688
- } catch (err) {
689
- logger$8.warn("[ubean] onServerReady error:", err);
690
- }
691
- }
692
- const protocol = req.socket?.encrypted ? "https" : "http";
693
- const webReq = await toWebRequest(req, host, protocol);
694
- const webRes = await currentApp.fetch(webReq);
695
- const contentType = webRes.headers.get("content-type") || "";
696
- const skipTransform = (req.url || "").startsWith("/_devtools");
697
- if (contentType.includes("text/html") && webRes.body && !skipTransform) {
698
- const htmlWithCss = injectStylesheetLinks(await webRes.text(), collectDevCssLinks(viteServer.moduleGraph));
699
- const transformedHtml = await viteServer.transformIndexHtml(req.url || "/", htmlWithCss);
700
- res.statusCode = webRes.status;
701
- res.statusMessage = webRes.statusText;
702
- webRes.headers.forEach((value, key) => {
703
- res.setHeader(key, value);
704
- });
705
- res.end(transformedHtml);
706
- } else await sendWebResponse(res, webRes);
707
- } catch (err) {
708
- if (viteServer) viteServer.ssrFixStacktrace(err);
709
- if (!res.headersSent) {
710
- res.statusCode = 500;
711
- const errorHtml = `<!DOCTYPE html>
712
- <html>
713
- <head>
714
- <title>Server Error</title>
715
- <style>
716
- body { font-family: monospace; padding: 2rem; background: #1a1a1a; color: #ff6b6b; }
717
- pre { background: #2d2d2d; padding: 1rem; border-radius: 4px; overflow-x: auto; }
718
- </style>
719
- </head>
720
- <body>
721
- <h1>Internal Server Error</h1>
722
- <pre>${err.stack || err.message}</pre>
723
- </body>
724
- </html>`;
725
- res.setHeader("Content-Type", "text/html");
726
- res.end(errorHtml);
727
- } else if (!res.writableEnded) res.end(err instanceof Error ? err.message : "Internal Server Error");
728
- }
729
- });
730
- const devtoolsEnabled = options.config.devtools.enabled;
731
- let devtoolsPlugin = null;
732
- if (devtoolsEnabled) try {
733
- const { ubeanDevtoolsPlugin } = await import("@ubean/devtools");
734
- const devtoolsOpts = options.devtools;
735
- const scaffoldOps = await loadScaffoldOps();
736
- devtoolsPlugin = ubeanDevtoolsPlugin({
737
- getCwd: () => cwd,
738
- getApp: () => currentApp,
739
- ...scaffoldOps ? { scaffoldOps } : {},
740
- ...devtoolsOpts?.getScanResult ? { getScanResult: devtoolsOpts.getScanResult } : {},
741
- ...devtoolsOpts?.getConfigMeta ? { getConfigMeta: devtoolsOpts.getConfigMeta } : {},
742
- ...devtoolsOpts?.getCustomTabs ? { getCustomTabs: devtoolsOpts.getCustomTabs } : {},
743
- ...devtoolsOpts?.ai ? { ai: devtoolsOpts.ai } : {},
744
- ...devtoolsOpts?.getApp ? { getApp: devtoolsOpts.getApp } : {},
745
- ...devtoolsOpts?.triggerRescan ? { triggerRescan: devtoolsOpts.triggerRescan } : {},
746
- registerRefresh: (fn) => {
747
- refreshDevtools = fn;
748
- }
749
- });
750
- } catch {}
751
- let viteDevtoolsPlugins = [];
752
- if (devtoolsEnabled) try {
753
- const { DevTools } = await import("@vitejs/devtools");
754
- // @vitejs/devtools 也解析到含 @vitejs+devtools peer 上下文的 vite-plus-core
755
- viteDevtoolsPlugins = await DevTools({ builtinDevTools: false });
756
- } catch {}
477
+ async function createDevViteServer(options) {
478
+ const { cwd, config, host, strictPort = false } = options;
479
+ const devtoolsEnabled = config.devtools.enabled;
757
480
  const userViteConfig = findUserViteConfig(cwd);
758
481
  const hasUserViteConfig = !!userViteConfig;
759
482
  const isBackendMode = config.mode === "backend";
760
- const builtinPlugins = [
761
- {
762
- name: "ubean:http-server-binder",
763
- enforce: "pre",
764
- configureServer(server) {
765
- if (!server.httpServer && httpServer) server.httpServer = httpServer;
766
- }
767
- },
768
- ...isBackendMode ? [] : [vue({
769
- include: VUE_PLUGIN_INCLUDE,
770
- template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("ubean-") } }
771
- })],
772
- ...hasUserViteConfig ? [] : [
773
- ubeanPlugin({ config }),
774
- ...isBackendMode ? [] : ubeanVite({ config }),
775
- ...isBackendMode ? [] : [ubeanIslandsPlugin()]
776
- ],
777
- ...viteDevtoolsPlugins,
778
- ...devtoolsPlugin ? [devtoolsPlugin] : []
779
- ];
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
+ }
780
525
  const { plugins } = await resolveModules({
781
526
  cwd,
782
527
  config,
783
528
  builtinPlugins
784
529
  });
785
- if (devtoolsEnabled) process.env.VITE_DEVTOOLS_DISABLE_CLIENT_AUTH = "true";
786
530
  viteServer = await createServer$1({
787
531
  root: cwd,
788
532
  configFile: userViteConfig ?? false,
789
533
  customLogger: createGatedViteLogger({ lifecycle: config.logging.lifecycle }),
790
534
  resolve: { dedupe: ["vue", "vue-router"] },
791
535
  server: {
792
- middlewareMode: { server: httpServer },
793
- hmr: { port: actualPort + 1e3 }
536
+ host,
537
+ port: options.port,
538
+ strictPort
794
539
  },
795
540
  appType: "custom",
796
541
  plugins,
@@ -805,98 +550,29 @@ async function createViteDevServer(options) {
805
550
  "virtual:ubean-app",
806
551
  "virtual:ubean-server",
807
552
  "virtual:ubean-client-entry",
808
- "#ubean-pages",
809
- "#ubean-app",
810
- "#ubean-server",
811
- "#ubean-client-entry"
553
+ "ubean:locales",
554
+ "ubean:pages",
555
+ "ubean:routes",
556
+ "ubean:meta",
557
+ "ubean:app-config"
812
558
  ] },
813
559
  ssr: {
814
560
  noExternal: ["ubean", /^@ubean\//],
815
561
  external: ["@ubean/i18n"]
816
562
  }
817
563
  });
818
- const { createVueRenderer } = await viteServer.ssrLoadModule("@ubean/client/ssr");
819
- function enhanceAppWithVite(app, layouts = []) {
820
- app.options.layouts = layouts;
821
- const routeLoaders = {};
822
- for (const route of app.options.routes || []) {
823
- const fullPath = route.fullPath;
824
- routeLoaders[route.relativePath] = () => viteServer.ssrLoadModule(fullPath);
825
- }
826
- app.options.routeLoaders = routeLoaders;
827
- const pageLoaders = {};
828
- const pageByName = /* @__PURE__ */ new Map();
829
- for (const page of app.options.pages || []) pageByName.set(page.name, page);
830
- for (const page of app.options.pages || []) {
831
- const fullPath = (page.isReuse && page.reuseTarget ? pageByName.get(page.reuseTarget) : void 0)?.fullPath || page.fullPath;
832
- pageLoaders[page.relativePath] = () => viteServer.ssrLoadModule(fullPath);
833
- }
834
- app.options.pageLoaders = pageLoaders;
835
- const middlewareLoaders = {};
836
- for (const mw of app.options.middleware || []) {
837
- const fullPath = mw.fullPath;
838
- middlewareLoaders[mw.relativePath] = () => viteServer.ssrLoadModule(fullPath);
839
- }
840
- app.options.middlewareLoaders = middlewareLoaders;
841
- const cronFiles = app.options.crons || [];
842
- if (cronFiles.length > 0) Promise.all(cronFiles.map((c) => viteServer.ssrLoadModule(c.fullPath))).then(async () => {
843
- (await import("@ubean/server/cron")).startCronScheduler();
844
- }).catch((err) => {
845
- console.error("[ubean] Failed to load cron files:", err);
846
- });
847
- const layoutMap = /* @__PURE__ */ new Map();
848
- for (const layout of layouts) layoutMap.set(layout.name, layout.fullPath);
849
- const defaultLayout = layouts.find((l) => l.isDefault)?.name || null;
850
- if (config.mode === "backend") {
851
- app.resetInit();
852
- return;
853
- }
854
- const scannedPages = app.options.pages || [];
855
- const ssrLocaleParam = resolveLocaleVueParam(config.i18n);
856
- const scannedPageByName = /* @__PURE__ */ new Map();
857
- for (const p of scannedPages) scannedPageByName.set(p.name, p);
858
- const routes = scannedPages.map((p) => {
859
- const componentFullPath = (p.isReuse && p.reuseTarget ? scannedPageByName.get(p.reuseTarget) : void 0)?.fullPath || p.fullPath;
860
- const routePath = p.route.replace(/\*\*:(\w[\w-]*)/g, ":$1(.*)*");
861
- return {
862
- path: ssrLocaleParam ? toVueRouterLocalePath(routePath, ssrLocaleParam) : routePath,
863
- name: p.name,
864
- component: async () => {
865
- const mod = await viteServer.ssrLoadModule(componentFullPath);
866
- return mod.default || mod;
867
- },
868
- meta: {
869
- layout: p.layout === false ? false : p.layout || defaultLayout,
870
- pageName: p.name,
871
- cache: p.cache === true ? true : void 0
872
- }
873
- };
874
- });
875
- let appConfigModule = null;
876
- const getAppConfigModule = () => {
877
- if (!appConfigModule) appConfigModule = viteServer.ssrLoadModule("virtual:ubean-app");
878
- return appConfigModule;
879
- };
880
- app.options.pageRenderer = createVueRenderer({
881
- routes,
882
- async resolveLayoutComponent(name) {
883
- if (name === false || name == null) return null;
884
- const fullPath = layoutMap.get(name);
885
- if (!fullPath) return null;
886
- const mod = await viteServer.ssrLoadModule(fullPath);
887
- return mod.default || mod;
888
- },
889
- defaultLayout,
890
- async resolveAppConfig() {
891
- return (await getAppConfigModule()).resolveAppConfig("server");
892
- }
893
- });
894
- app.resetInit();
895
- }
896
- enhanceAppWithVite(currentApp, currentLayouts);
897
- const displayHost = isWildcardHost(host) ? "localhost" : host;
898
- const getNetworkUrls = () => isLoopbackHost(host) ? [] : getLanAddresses().map((address) => `http://${address}:${actualPort}`);
899
- 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
+ });
900
576
  return {
901
577
  get port() {
902
578
  return actualPort;
@@ -905,213 +581,19 @@ async function createViteDevServer(options) {
905
581
  return host;
906
582
  },
907
583
  get url() {
908
- return getUrl();
584
+ return url;
909
585
  },
910
586
  get viteServer() {
911
587
  return viteServer;
912
588
  },
913
- async start() {
914
- await new Promise((resolve, reject) => {
915
- const tryListen = (port) => {
916
- httpServer.removeAllListeners("error");
917
- httpServer.once("error", (err) => {
918
- if (err.code === "EADDRINUSE" && !strictPort) {
919
- logger$8.warn(`Port ${port} is in use, trying ${port + 1} instead.`);
920
- tryListen(port + 1);
921
- } else reject(err);
922
- });
923
- httpServer.listen(port, host, () => {
924
- const addr = httpServer.address();
925
- actualPort = typeof addr === "object" && addr ? addr.port : port;
926
- resolve();
927
- });
928
- };
929
- tryListen(actualPort);
930
- });
931
- options.onListen?.({
932
- port: actualPort,
933
- host,
934
- url: getUrl(),
935
- networkUrls: getNetworkUrls()
936
- });
589
+ async rescan() {
590
+ await rescanDev();
937
591
  },
938
592
  async stop() {
939
- if (httpServer) {
940
- const server = httpServer;
941
- httpServer = null;
942
- await new Promise((resolve) => {
943
- server.close(() => resolve());
944
- server.closeAllConnections();
945
- });
946
- }
947
- if (viteServer) {
948
- await viteServer.close();
949
- viteServer = null;
950
- }
951
- },
952
- updateApp(app, layouts) {
953
- currentApp = app;
954
- if (layouts) currentLayouts = layouts;
955
- serverConfigApplied = false;
956
- serverReadyCalled = false;
957
- cachedServerConfig = null;
958
- enhanceAppWithVite(app, currentLayouts);
959
- refreshDevtools?.();
960
- },
961
- sendFullReload() {
962
- if (viteServer) viteServer.ws.send({ type: "full-reload" });
963
- }
964
- };
965
- }
966
- //#endregion
967
- //#region src/dev-server/runner.ts
968
- var ViteNodeDevRunner = class {
969
- viteDevServer = null;
970
- currentApp;
971
- currentLayouts;
972
- options;
973
- _port;
974
- constructor(options) {
975
- this.options = options;
976
- this.currentApp = options.app;
977
- this.currentLayouts = options.layouts || [];
978
- this._port = options.port;
979
- }
980
- get port() {
981
- return this._port;
982
- }
983
- get host() {
984
- return this.options.host;
985
- }
986
- get url() {
987
- return `http://${this.options.host}:${this._port}`;
988
- }
989
- get preset() {
990
- return this.options.preset;
991
- }
992
- async start() {
993
- this.viteDevServer = await createViteDevServer({
994
- cwd: this.options.cwd,
995
- port: this._port,
996
- host: this.options.host,
997
- strictPort: this.options.strictPort,
998
- config: this.options.config,
999
- app: this.currentApp,
1000
- layouts: this.currentLayouts,
1001
- devtools: this.options.devtools,
1002
- onListen: ({ port, host, url, networkUrls }) => {
1003
- this._port = port;
1004
- this.options.onListen?.({
1005
- port,
1006
- host,
1007
- url,
1008
- networkUrls
1009
- });
1010
- }
1011
- });
1012
- await this.viteDevServer.start();
1013
- }
1014
- async stop() {
1015
- if (this.viteDevServer) {
1016
- await this.viteDevServer.stop();
1017
- this.viteDevServer = null;
1018
- }
1019
- }
1020
- async reload() {
1021
- await this.options.onBeforeReload?.();
1022
- if (this.viteDevServer) this.viteDevServer.updateApp(this.currentApp, this.currentLayouts);
1023
- await this.options.onAfterReload?.();
1024
- }
1025
- updateApp(app, layouts) {
1026
- this.currentApp = app;
1027
- if (layouts) this.currentLayouts = layouts;
1028
- if (this.viteDevServer) this.viteDevServer.updateApp(app, this.currentLayouts);
1029
- }
1030
- sendFullReload() {
1031
- this.viteDevServer?.sendFullReload();
1032
- }
1033
- };
1034
- const runners = [{
1035
- name: "vite-node",
1036
- isAvailable() {
1037
- return typeof process !== "undefined" && !!process.versions?.node;
1038
- },
1039
- async createRunner(options) {
1040
- return new ViteNodeDevRunner(options);
1041
- }
1042
- }];
1043
- async function selectRunner(_preset) {
1044
- for (const runner of runners) if (await runner.isAvailable()) return runner;
1045
- return null;
1046
- }
1047
- async function createDevRunner(options) {
1048
- const runner = await selectRunner(options.preset);
1049
- 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).`);
1050
- return runner.createRunner(options);
1051
- }
1052
- //#endregion
1053
- //#region src/dev-server/watcher.ts
1054
- function createDevWatcher(options) {
1055
- const { cwd, dirs, ignore = [], debounceMs = 100 } = options;
1056
- const watchers = [];
1057
- const watchedDirs = /* @__PURE__ */ new Set();
1058
- let pendingEvents = [];
1059
- let debounceTimer = null;
1060
- function isIgnored(relPath) {
1061
- return ignore.some((pattern) => {
1062
- if (pattern.includes("*")) return new RegExp(`^${pattern.replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(relPath);
1063
- return relPath.startsWith(pattern) || relPath === pattern;
1064
- });
1065
- }
1066
- function queueEvent(type, fullPath) {
1067
- const relPath = relative(cwd, fullPath).replace(/\\/g, "/");
1068
- if (isIgnored(relPath)) return;
1069
- if (relPath.startsWith("node_modules/") || relPath.startsWith(".git/")) return;
1070
- if (relPath.endsWith(".bak") || relPath.startsWith(".ubean/")) return;
1071
- pendingEvents.push({
1072
- type,
1073
- path: fullPath,
1074
- relativePath: relPath
1075
- });
1076
- if (debounceTimer) clearTimeout(debounceTimer);
1077
- debounceTimer = setTimeout(() => {
1078
- const events = pendingEvents;
1079
- pendingEvents = [];
1080
- debounceTimer = null;
1081
- options.onChange?.(events);
1082
- }, debounceMs);
1083
- }
1084
- function watchDir(dir) {
1085
- if (watchedDirs.has(dir)) return;
1086
- watchedDirs.add(dir);
1087
- try {
1088
- const w = watch(dir, { recursive: true }, (eventType, filename) => {
1089
- if (!filename) return;
1090
- const fullPath = join(dir, filename);
1091
- queueEvent(eventType === "rename" ? "unlink" : "change", fullPath);
1092
- });
1093
- watchers.push(w);
1094
- } catch {}
1095
- }
1096
- function start() {
1097
- for (const dir of dirs) watchDir(join(cwd, dir));
1098
- }
1099
- function stop() {
1100
- for (const w of watchers) w.close();
1101
- watchers.length = 0;
1102
- watchedDirs.clear();
1103
- if (debounceTimer) {
1104
- clearTimeout(debounceTimer);
1105
- debounceTimer = null;
593
+ const server = viteServer;
594
+ viteServer = null;
595
+ await server?.close();
1106
596
  }
1107
- }
1108
- function addDir(dir) {
1109
- watchDir(join(cwd, dir));
1110
- }
1111
- return {
1112
- start,
1113
- stop,
1114
- addDir
1115
597
  };
1116
598
  }
1117
599
  //#endregion
@@ -1164,20 +646,6 @@ import("@ubean/devtools").then((mod) => {
1164
646
  function getCustomTabs() {
1165
647
  return _customTabsGetter();
1166
648
  }
1167
- /**
1168
- * dev 环境 SecurityHeaders 解析(ADR-0011)。
1169
- *
1170
- * ssg 模式默认关闭:静态产物不携带框架注入的安全头(响应头由托管平台
1171
- * 控制),dev 默认关闭以对齐生产行为,消除「dev 有 CSP / 线上没有」的
1172
- * 偏差。显式配置(`headers: true` 或对象)仍会被尊重,作为 CSP 调试
1173
- * 入口。其余模式默认开启不变。
1174
- */
1175
- function resolveDevSecurityHeaders(config) {
1176
- if (config.security === false) return false;
1177
- if (config.security?.headers === false) return false;
1178
- if (config.security?.headers !== void 0) return config.security.headers;
1179
- return config.mode !== "ssg";
1180
- }
1181
649
  const devCommand = {
1182
650
  meta: {
1183
651
  name: "dev",
@@ -1246,138 +714,102 @@ const devCommand = {
1246
714
  logger$6.info(`Source directory: ${config.srcDir}`);
1247
715
  logger$6.info(`Preset: ${preset.name}`);
1248
716
  }
1249
- const capabilities = createCapabilitySet(preset.capabilities || {});
1250
717
  if (logging.scan) logger$6.info("Running capability diagnostics...");
1251
718
  const diagnostics = diagnoseCapabilities(preset.name, preset.capabilities || {}, NODE_REQUIREMENTS);
1252
719
  logDiagnostics(diagnostics, { verbose: logging.diagnostics });
1253
720
  if (!diagnostics.valid) logger$6.warn("Some capability requirements are not met. Dev server may not function correctly.");
1254
- let { app: currentApp, layouts: currentLayouts, scanResult: currentScanResult } = await buildApp(cwd, config, logging);
1255
- let rescanInProgress = false;
1256
- async function rescan() {
1257
- if (rescanInProgress) return;
1258
- 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;
1259
735
  try {
1260
- const { app: newApp, layouts: newLayouts, scanResult } = await buildApp(cwd, config, logging);
1261
- currentApp = newApp;
1262
- currentLayouts = newLayouts;
1263
- currentScanResult = scanResult;
1264
- runner.updateApp(currentApp, currentLayouts);
1265
- await runner.reload();
1266
- 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
+ });
1267
744
  } catch (err) {
1268
745
  logger$6.error(`Rescan failed: ${err instanceof Error ? err.message : String(err)}`);
1269
- } finally {
1270
- rescanInProgress = false;
1271
746
  }
1272
747
  }
1273
- let reloadStartedAt = Date.now();
1274
748
  let lastChangedFile = null;
1275
- const runner = await createDevRunner({
1276
- cwd,
1277
- srcDir: config.srcDir,
1278
- port,
1279
- host,
1280
- strictPort: args.strictPort,
1281
- preset,
1282
- config,
1283
- capabilities,
1284
- app: currentApp,
1285
- layouts: currentLayouts,
1286
- devtools: {
1287
- getScanResult: () => currentScanResult,
1288
- getConfigMeta: () => ({
1289
- preset: config.build.preset,
1290
- rootDir: config.rootDir,
1291
- srcDir: config.srcDir,
1292
- openAPI: {
1293
- enabled: true,
1294
- scalarPath: "/_scalar",
1295
- openAPIPath: "/_openapi.json"
1296
- },
1297
- dir: config.dir
1298
- }),
1299
- getCustomTabs: () => getCustomTabs(),
1300
- triggerRescan: () => rescan()
1301
- },
1302
- onListen({ url, networkUrls }) {
1303
- const label = (text) => dim(text);
1304
- const modeSuffix = dim(` (${config.mode} · ${preset.name})`);
1305
- const lines = [
1306
- `${green(bold(`🚀 ubean dev server ready in ${Date.now() - startedAt}ms`))}${modeSuffix}\n`,
1307
- ` → ${label("Local:")} ${cyan(url)}`,
1308
- ...(networkUrls || []).map((n) => ` → ${label("Network:")} ${cyan(n)}`)
1309
- ];
1310
- const hasBackend = config.mode !== "ssg" && config.mode !== "spa";
1311
- if (hasBackend) lines.push(` → ${label("Scalar UI:")} ${cyan(`${url}/_scalar`)}`, ` → ${label("OpenAPI:")} ${cyan(`${url}/_openapi.json`)}`);
1312
- if (config.devtools.enabled) lines.push(` → ${label("DevTools:")} ${cyan(`${url}${config.devtools.route}`)}`);
1313
- lines.push(` → ${dim("Press Ctrl+C to stop")}`);
1314
- logger$6.info(lines.join("\n"));
1315
- if (hasBackend) generateOpenApiTypesFromServer(url, { outDir: resolve(cwd, ".ubean") }).then((filePath) => {
1316
- if (filePath && logging.scan) logger$6.info(`OpenAPI types generated: ${filePath}`);
1317
- }).catch((err) => {
1318
- logger$6.warn(`Failed to generate OpenAPI types: ${err instanceof Error ? err.message : String(err)}`);
1319
- });
1320
- },
1321
- onBeforeReload() {
1322
- reloadStartedAt = Date.now();
1323
- if (logging.lifecycle) logger$6.info("Reloading...");
1324
- },
1325
- onAfterReload() {
1326
- if (logging.lifecycle) logger$6.info("Reloaded");
1327
- else {
1328
- const file = lastChangedFile ? dim(` · ${lastChangedFile}`) : "";
1329
- logger$6.info(dim(`↻ Reloaded in ${Date.now() - reloadStartedAt}ms${file}`));
1330
- }
1331
- lastChangedFile = null;
1332
- }
1333
- });
749
+ let devServer = null;
1334
750
  try {
1335
- 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
+ });
1336
793
  } catch (err) {
1337
794
  logger$6.error(err?.stack || err?.message || String(err));
1338
795
  process.exit(1);
1339
796
  }
1340
- const watcher = createDevWatcher({
1341
- cwd,
1342
- dirs: [
1343
- "api",
1344
- "pages",
1345
- "middleware",
1346
- "layouts",
1347
- "plugins",
1348
- "app",
1349
- "routes"
1350
- ].map((d) => `${config.srcDir}/${d}`),
1351
- ignore: [
1352
- "**/node_modules/**",
1353
- "**/.git/**",
1354
- "**/.ubean/**"
1355
- ],
1356
- debounceMs: 150,
1357
- async onChange(events) {
1358
- const relevantEvents = events.filter((e) => /\.(ts|js|vue|mjs|cjs|json)$/.test(e.relativePath) && !e.relativePath.includes(".bak"));
1359
- if (relevantEvents.length === 0) return;
1360
- lastChangedFile = relevantEvents[0].relativePath;
1361
- if (logging.lifecycle) logger$6.info(`File change detected: ${lastChangedFile}`);
1362
- await rescan();
1363
- }
1364
- });
1365
- watcher.start();
1366
797
  const cleanup = async () => {
1367
798
  if (logging.lifecycle) logger$6.info("\nShutting down...");
1368
- watcher.stop();
1369
- await runner.stop();
799
+ await devServer?.stop();
1370
800
  process.exit(0);
1371
801
  };
1372
802
  process.on("SIGINT", cleanup);
1373
803
  process.on("SIGTERM", cleanup);
1374
804
  }
1375
805
  };
1376
- /** 默认跳过请求日志的内部路径前缀(`/_health`、`/_devtools`、`/_openapi.json`、Vite 内部 `/@id/...` 等)。 */
1377
- const REQUEST_LOG_INTERNAL_PREFIXES = ["/_", "/@"];
1378
- /** 请求日志中,超过该毫秒数的请求提升为 warn(慢请求告警)。 */
1379
- const REQUEST_LOG_SLOW_THRESHOLD = 1e3;
1380
- async function buildApp(cwd, config, logging) {
806
+ /**
807
+ * 初始扫描 + 类型生成。
808
+ *
809
+ * RM-V14 起 CLI 不在这里建 app(由 `ubeanDevRequestPlugin` 自举),只负责扫描与 codegen;
810
+ * 扫描结果交给 DevTools 作启动数据,并在每次文件变更后由 `onScan` 刷新。
811
+ */
812
+ async function initialScan(cwd, config, logging) {
1381
813
  if (logging.scan) logger$6.info("Scanning project files...");
1382
814
  const result = await scanProject({
1383
815
  cwd,
@@ -1385,8 +817,10 @@ async function buildApp(cwd, config, logging) {
1385
817
  dirs: config.dir,
1386
818
  ignore: config.scanOptions?.ignore
1387
819
  });
1388
- if (logging.scan) 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`);
1389
- if (logging.scan) 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
+ }
1390
824
  await generateTypes(result, {
1391
825
  cwd,
1392
826
  srcDir: config.srcDir,
@@ -1395,59 +829,12 @@ async function buildApp(cwd, config, logging) {
1395
829
  autoImports: config.autoImports,
1396
830
  components: config.components
1397
831
  });
1398
- const app = createUbeanApp({
1399
- rootDir: cwd,
1400
- routes: result.apiRoutes,
1401
- middleware: result.middlewares,
1402
- pages: result.pages,
1403
- crons: result.crons,
1404
- routeRules: config.routeRules || {},
1405
- publicDir: config.dir.public,
1406
- pageAssetTags: config.favicon ? { favicon: config.favicon } : void 0,
1407
- openAPI: {
1408
- title: "UBEAN Dev API",
1409
- scalarPath: "/_scalar",
1410
- openAPIPath: "/_openapi.json"
1411
- },
1412
- i18nConfig: config.i18n,
1413
- ssrExclude: config.ssr.exclude,
1414
- streaming: config.ssr.streaming,
1415
- notFoundPage: result.notFoundPage,
1416
- csrf: config.security === false ? false : config.security?.csrf ?? true,
1417
- securityHeaders: resolveDevSecurityHeaders(config),
1418
- dataCache: config.dataCache,
1419
- cache: config.cache,
1420
- seoConventions: { srcDir: resolve(cwd, config.srcDir) }
1421
- });
1422
- const requestStartTimes = /* @__PURE__ */ new WeakMap();
1423
- const isInternalPath = (path) => REQUEST_LOG_INTERNAL_PREFIXES.some((p) => path.startsWith(p));
1424
- app.hooks.hook("request:start", (c) => {
1425
- requestStartTimes.set(c, Date.now());
1426
- logger$6.debug(`${c.req.method} ${c.req.path}`);
1427
- });
1428
- app.hooks.hook("request:end", (c, res) => {
1429
- if (!logging.request) return;
1430
- const start = requestStartTimes.get(c);
1431
- const duration = start === void 0 ? 0 : Date.now() - start;
1432
- const line = `${c.req.method} ${c.req.path} ${res.status} ${duration}ms`;
1433
- if (res.status >= 500) {
1434
- logger$6.error(line);
1435
- return;
1436
- }
1437
- if (isInternalPath(c.req.path)) return;
1438
- if (res.status >= 400 || duration >= REQUEST_LOG_SLOW_THRESHOLD) logger$6.warn(line);
1439
- else logger$6.info(line);
1440
- });
1441
- app.hooks.hook("request:error", (c, err) => {
1442
- if (!logging.request) return;
1443
- logger$6.error(`${c.req.method} ${c.req.path} ERROR ${err instanceof Error ? err.message : String(err)}`);
1444
- });
1445
- return {
1446
- app,
1447
- layouts: result.layouts,
1448
- scanResult: result
1449
- };
832
+ return result;
1450
833
  }
834
+ /**
835
+ * RM-V14:请求日志(`logging.request`)不再由 CLI 实现 —— 见 `@ubean/build` 的
836
+ * `createDevApp({ logger })`。配置域行为放 builder,两条 dev 路径输出一致。
837
+ */
1451
838
  //#endregion
1452
839
  //#region src/devtools.ts
1453
840
  const logger$5 = getLogger("cli");
@@ -2152,89 +1539,38 @@ const prepareCommand = {
2152
1539
  };
2153
1540
  //#endregion
2154
1541
  //#region src/preview.ts
2155
- const MIME_TYPES = {
2156
- ".html": "text/html; charset=utf-8",
2157
- ".js": "text/javascript; charset=utf-8",
2158
- ".mjs": "text/javascript; charset=utf-8",
2159
- ".css": "text/css; charset=utf-8",
2160
- ".json": "application/json; charset=utf-8",
2161
- ".svg": "image/svg+xml",
2162
- ".png": "image/png",
2163
- ".jpg": "image/jpeg",
2164
- ".jpeg": "image/jpeg",
2165
- ".gif": "image/gif",
2166
- ".webp": "image/webp",
2167
- ".ico": "image/x-icon",
2168
- ".woff": "font/woff",
2169
- ".woff2": "font/woff2",
2170
- ".ttf": "font/ttf",
2171
- ".otf": "font/otf",
2172
- ".wasm": "application/wasm",
2173
- ".txt": "text/plain; charset=utf-8",
2174
- ".map": "application/json; charset=utf-8"
2175
- };
2176
1542
  /**
2177
- * 启动静态文件服务器,用于 spa / ssg 模式预览。
1543
+ * 静态文件服务器(spa / ssg 的降级路径)。
2178
1544
  *
2179
- * - ssg 模式:优先查找 `<path>/index.html` 或 `<path>.html`,找不到时返回 404
2180
- * - spa 模式:所有非文件路由回退到 `index.html`(客户端路由接管)
1545
+ * RM-V25:判定规则不再自己写一份,改用 `@ubean/build/vite` 的 `resolvePreviewFile` —— 插件接管
1546
+ * `vite preview` 时用的是同一个函数,两套方言必然分叉(文件名含合法 `...` 的产物曾被
1547
+ * `includes('..')` 判成路径穿越)。
2181
1548
  *
2182
- * 使用 Node 内置 `http` + `fs`,避免引入 `sirv` 等依赖。
1549
+ * 主路径已改为 `vite preview` + 插件中间件(RM-V24);本函数保留给「Vite 预览起不来」的
1550
+ * spa / ssg 场景(静态产物不需要服务端能力),以及既有回归测试。
2183
1551
  */
2184
1552
  function startStaticServer(opts) {
2185
1553
  const { root, port, host, mode } = opts;
2186
- const spaFallback = mode === "spa";
2187
1554
  return createServer(async (req, res) => {
2188
1555
  try {
2189
1556
  const url = new URL(req.url || "/", `http://${host}`);
2190
1557
  const pathname = decodeURIComponent(url.pathname);
2191
- let filePath = normalize(join(root, pathname));
2192
- const rootPath = resolve(root);
2193
- if (filePath !== rootPath && !filePath.startsWith(rootPath + sep)) {
1558
+ const resolvedFile = resolvePreviewFile(root, pathname, mode);
1559
+ if (resolvedFile.kind === "forbidden") {
2194
1560
  res.statusCode = 400;
1561
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
2195
1562
  res.end("Bad Request");
2196
1563
  return;
2197
1564
  }
2198
- let fileExists = existsSync(filePath) && (await stat(filePath)).isFile();
2199
- if (!fileExists && existsSync(filePath) && (await stat(filePath)).isDirectory()) {
2200
- const indexPath = join(filePath, "index.html");
2201
- if (existsSync(indexPath)) {
2202
- filePath = indexPath;
2203
- fileExists = true;
2204
- }
2205
- }
2206
- if (!fileExists && mode === "ssg") {
2207
- const indexPath = join(filePath, "index.html");
2208
- if (existsSync(indexPath) && (await stat(indexPath)).isFile()) {
2209
- filePath = indexPath;
2210
- fileExists = true;
2211
- }
2212
- }
2213
- if (!fileExists && mode === "ssg") {
2214
- const htmlPath = `${filePath}.html`;
2215
- if (existsSync(htmlPath) && (await stat(htmlPath)).isFile()) {
2216
- filePath = htmlPath;
2217
- fileExists = true;
2218
- }
2219
- }
2220
- if (!fileExists && spaFallback) {
2221
- const fallbackPath = join(root, "index.html");
2222
- if (existsSync(fallbackPath)) {
2223
- filePath = fallbackPath;
2224
- fileExists = true;
2225
- }
2226
- }
2227
- if (!fileExists) {
1565
+ if (resolvedFile.kind === "missing") {
2228
1566
  res.statusCode = 404;
2229
1567
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
2230
1568
  res.end("Not Found");
2231
1569
  return;
2232
1570
  }
2233
- const ext = extname(filePath).toLowerCase();
2234
- const contentType = MIME_TYPES[ext] || "application/octet-stream";
2235
- const body = await readFile(filePath);
1571
+ const body = await readFile(resolvedFile.path);
2236
1572
  res.statusCode = 200;
2237
- res.setHeader("Content-Type", contentType);
1573
+ res.setHeader("Content-Type", previewMimeType(resolvedFile.path));
2238
1574
  res.setHeader("Content-Length", body.length);
2239
1575
  res.end(body);
2240
1576
  } catch (err) {
@@ -2268,6 +1604,10 @@ const previewCommand = {
2268
1604
  type: "string",
2269
1605
  description: "Project root directory",
2270
1606
  default: "."
1607
+ },
1608
+ outDir: {
1609
+ type: "string",
1610
+ description: "Output directory to preview (overrides ubean.config.ts build.outputDir)"
2271
1611
  }
2272
1612
  },
2273
1613
  async run({ args }) {
@@ -2277,6 +1617,7 @@ const previewCommand = {
2277
1617
  const config = await loadUbeanConfig(cwd);
2278
1618
  const preset = resolvePresetByName(config.build.preset);
2279
1619
  logger$1.info(`Preset: ${preset.name}`);
1620
+ if (args.outDir) config.build.outputDir = String(args.outDir);
2280
1621
  const outputDir = config.build.outputDir || "dist";
2281
1622
  const mode = config.mode;
2282
1623
  const host = args.host || config.preview.host;
@@ -2299,110 +1640,71 @@ const previewCommand = {
2299
1640
  const bannerUrl = `http://${host}:${port}`;
2300
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")}`);
2301
1642
  };
2302
- if (mode === "spa" || mode === "ssg") {
2303
- const staticRoot = join(cwd, outputDir, "public");
2304
- if (!existsSync(staticRoot)) {
2305
- logger$1.error(`Build output not found: ${staticRoot}`);
2306
- 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)}`);
2307
1681
  process.exit(1);
2308
1682
  }
2309
- 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({
2310
1685
  root: staticRoot,
2311
1686
  port: actualPort,
2312
1687
  host,
2313
1688
  mode
2314
1689
  });
2315
1690
  printBanner(actualPort, `static (${mode})`);
2316
- const cleanup = () => {
2317
- server.close(() => process.exit(0));
1691
+ const cleanupFallback = () => {
1692
+ fallback.close(() => process.exit(0));
2318
1693
  setTimeout(() => process.exit(0), 1e3).unref();
2319
1694
  };
2320
- process.on("SIGINT", cleanup);
2321
- process.on("SIGTERM", cleanup);
2322
- server.on("error", (err) => {
2323
- logger$1.error(`Preview server error: ${err.message || err}`);
2324
- process.exit(1);
2325
- });
1695
+ process.on("SIGINT", cleanupFallback);
1696
+ process.on("SIGTERM", cleanupFallback);
2326
1697
  return;
2327
1698
  }
2328
- const presetName = preset.name;
2329
- let serverFile;
2330
- if (presetName === "node" || presetName === "bun" || presetName === "deno") serverFile = "server.mjs";
2331
- else if (presetName === "cloudflare") {
2332
- logger$1.error("Cloudflare preset preview is not supported yet. Use `wrangler dev` instead.");
2333
- process.exit(1);
2334
- } else {
2335
- serverFile = "handler.mjs";
2336
- logger$1.warn(`Preset "${presetName}" uses a fetch handler entry. Preview may not work as a standalone server.`);
2337
- }
2338
- const serverPath = join(cwd, outputDir, "server", serverFile);
2339
- if (!existsSync(serverPath)) {
2340
- logger$1.error(`Build output not found: ${serverPath}`);
2341
- logger$1.info("Run `ubean build` first to create a production build.");
2342
- process.exit(1);
2343
- }
2344
- const { child, exited } = spawnPreviewServer({
2345
- serverPath,
2346
- cwd,
2347
- port: actualPort,
2348
- host,
2349
- strictPort
2350
- });
2351
- try {
2352
- await waitForPort(actualPort, {
2353
- host,
2354
- retries: 40,
2355
- delay: 250
2356
- });
2357
- } catch {
2358
- if (exited.value) logger$1.error(`Preview server exited before becoming ready on port ${actualPort}.`);
2359
- else logger$1.warn(`Preview server on port ${actualPort} is not responding yet, the banner may be premature.`);
2360
- }
2361
- printBanner(actualPort, "production");
1699
+ printBanner(actualPort, staticMode ? `static (${mode})` : "production");
2362
1700
  const cleanup = () => {
2363
- child.kill("SIGTERM");
2364
- process.exit(0);
1701
+ server?.close().catch(() => void 0).finally(() => process.exit(0));
1702
+ setTimeout(() => process.exit(0), 1e3).unref();
2365
1703
  };
2366
1704
  process.on("SIGINT", cleanup);
2367
1705
  process.on("SIGTERM", cleanup);
2368
- child.on("exit", (code) => {
2369
- if (code !== 0 && code !== null) logger$1.error(`Preview server exited with code ${code}`);
2370
- process.exit(code ?? 0);
2371
- });
2372
1706
  }
2373
1707
  };
2374
- function spawnPreviewServer(opts) {
2375
- const exited = { value: false };
2376
- const child = spawn("node", [opts.serverPath], {
2377
- cwd: opts.cwd,
2378
- env: {
2379
- ...process.env,
2380
- PORT: String(opts.port),
2381
- HOST: opts.host
2382
- },
2383
- stdio: [
2384
- "inherit",
2385
- "inherit",
2386
- "pipe"
2387
- ]
2388
- });
2389
- let addrInUseReported = false;
2390
- child.stderr?.on("data", (chunk) => {
2391
- const text = chunk.toString();
2392
- process.stderr.write(text);
2393
- if (!addrInUseReported && /EADDRINUSE/.test(text)) {
2394
- addrInUseReported = true;
2395
- 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" : ""}.`);
2396
- }
2397
- });
2398
- child.on("exit", () => {
2399
- exited.value = true;
2400
- });
2401
- return {
2402
- child,
2403
- exited
2404
- };
2405
- }
2406
1708
  //#endregion
2407
1709
  //#region src/scaffold-commands.ts
2408
1710
  const logger = getLogger("cli");