previewService.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import { spawn, ChildProcess } from "child_process";
  2. import { execSync } from "child_process";
  3. import path from "path";
  4. import fs from "fs";
  5. import http from "http";
  6. import { createAssetsSymlink, generateAdConfig } from "./configGenerator";
  7. const TEMPLATE_DIR = path.resolve(__dirname, "../../../../templates/coloring");
  8. const PREVIEW_PORT = 5199;
  9. let viteProcess: ChildProcess | null = null;
  10. let currentCreativeId: string | null = null;
  11. /**
  12. * 等待 HTTP 服务就绪
  13. */
  14. function waitForReady(url: string, maxRetries = 15): Promise<void> {
  15. return new Promise((resolve, reject) => {
  16. let tries = 0;
  17. function check() {
  18. http.get(url, (res) => {
  19. if (res.statusCode === 200) resolve();
  20. else retry();
  21. }).on("error", retry);
  22. }
  23. function retry() {
  24. if (++tries >= maxRetries) {
  25. reject(new Error(`Preview server did not start within ${maxRetries}s`));
  26. return;
  27. }
  28. setTimeout(check, 1000);
  29. }
  30. check();
  31. });
  32. }
  33. /**
  34. * 启动实时预览。等待 Vite dev server 就绪后才返回。
  35. */
  36. export async function startPreview(
  37. creativeId: string,
  38. theme: Record<string, string>,
  39. storageDir: string
  40. ): Promise<{ url: string }> {
  41. // 1. 停止旧的预览(如果有)
  42. stopPreview();
  43. // 2. 创建 symlink
  44. createAssetsSymlink(creativeId, storageDir);
  45. // 3. 生成配置
  46. const configContent = generateAdConfig({ creativeId, theme, storageDir });
  47. const configPath = path.join(TEMPLATE_DIR, "src", "filler", "_ad_config_.ts");
  48. fs.writeFileSync(configPath, configContent, "utf-8");
  49. // 4. 用 vite.config.preview.js(硬编码 base 路径)覆盖默认 config
  50. const previewBase = process.env.PREVIEW_BASE_PATH || "/";
  51. const originalConfig = fs.readFileSync(path.join(TEMPLATE_DIR, "vite.config.js"), "utf-8");
  52. // 在 return 语句前注入 base
  53. const patchedConfig = originalConfig.replace(
  54. "return {",
  55. `return {\n base: "${previewBase}",`
  56. );
  57. const previewConfigPath = path.join(TEMPLATE_DIR, "vite.config.preview.js");
  58. fs.writeFileSync(previewConfigPath, patchedConfig, "utf-8");
  59. console.log(`[preview] Starting Vite dev server on port ${PREVIEW_PORT} (base: ${previewBase})...`);
  60. viteProcess = spawn(path.join(TEMPLATE_DIR, "node_modules", ".bin", "vite"), [
  61. "--port", String(PREVIEW_PORT),
  62. "--strictPort",
  63. "--config", previewConfigPath,
  64. ], {
  65. cwd: TEMPLATE_DIR,
  66. env: { ...process.env, AD_CONFIG_PATH: "src/filler/_ad_config_.ts" },
  67. stdio: ["ignore", "pipe", "pipe"],
  68. });
  69. viteProcess.stdout?.on("data", (data: Buffer) => {
  70. console.log(`[preview:vite] ${data.toString().trim()}`);
  71. });
  72. viteProcess.stderr?.on("data", (data: Buffer) => {
  73. console.log(`[preview:vite] ${data.toString().trim()}`);
  74. });
  75. viteProcess.on("exit", (code) => {
  76. console.log(`[preview] Vite dev server exited (code ${code})`);
  77. viteProcess = null;
  78. currentCreativeId = null;
  79. });
  80. currentCreativeId = creativeId;
  81. // 5. 等待 Vite 就绪
  82. const localUrl = `http://localhost:${PREVIEW_PORT}`;
  83. console.log("[preview] Waiting for Vite to be ready...");
  84. await waitForReady(localUrl);
  85. console.log("[preview] Vite is ready.");
  86. // 生产环境通过 nginx 代理暴露公网 URL,本地开发直接用 localhost
  87. const publicUrl = process.env.PREVIEW_PUBLIC_URL || localUrl;
  88. return { url: publicUrl };
  89. }
  90. /**
  91. * 更新预览配置(主题变更时调用)。Vite HMR 会自动检测并刷新页面。
  92. */
  93. export function updatePreviewConfig(
  94. creativeId: string,
  95. theme: Record<string, string>,
  96. storageDir: string
  97. ): void {
  98. if (currentCreativeId !== creativeId) return;
  99. const configContent = generateAdConfig({ creativeId, theme, storageDir });
  100. const configPath = path.join(TEMPLATE_DIR, "src", "filler", "_ad_config_.ts");
  101. fs.writeFileSync(configPath, configContent, "utf-8");
  102. console.log(`[preview] Config updated for creative ${creativeId}`);
  103. }
  104. /**
  105. * 停止预览
  106. */
  107. export function stopPreview(): void {
  108. if (viteProcess) {
  109. console.log("[preview] Stopping Vite dev server...");
  110. try {
  111. viteProcess.kill("SIGTERM");
  112. } catch {
  113. // ignore
  114. }
  115. viteProcess = null;
  116. }
  117. currentCreativeId = null;
  118. // 清理临时 vite config
  119. try {
  120. const previewConfigPath = path.join(TEMPLATE_DIR, "vite.config.preview.js");
  121. if (fs.existsSync(previewConfigPath)) fs.unlinkSync(previewConfigPath);
  122. } catch {}
  123. // 确保端口释放
  124. try {
  125. execSync(`lsof -ti :${PREVIEW_PORT} | xargs kill -9 2>/dev/null`, { stdio: "ignore" });
  126. } catch {}
  127. }
  128. export function getPreviewStatus(): { active: boolean; creativeId: string | null; url: string | null } {
  129. return {
  130. active: viteProcess !== null && currentCreativeId !== null,
  131. creativeId: currentCreativeId,
  132. url: currentCreativeId ? `http://localhost:${PREVIEW_PORT}` : null,
  133. };
  134. }