previewService.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  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 dev server
  50. // base 路径通过 PREVIEW_BASE_PATH 环境变量传入 vite.config.js
  51. // 注意:nginx proxy_pass 不能有尾部斜杠,以保留 /ads-preview/ 前缀
  52. const previewBase = process.env.PREVIEW_BASE_PATH || "/";
  53. console.log(`[preview] Starting Vite dev server on port ${PREVIEW_PORT} (base: ${previewBase})...`);
  54. viteProcess = spawn(path.join(TEMPLATE_DIR, "node_modules", ".bin", "vite"), [
  55. "--port", String(PREVIEW_PORT),
  56. "--strictPort",
  57. ], {
  58. cwd: TEMPLATE_DIR,
  59. env: {
  60. ...process.env,
  61. AD_CONFIG_PATH: "src/filler/_ad_config_.ts",
  62. },
  63. stdio: ["ignore", "pipe", "pipe"],
  64. });
  65. viteProcess.stdout?.on("data", (data: Buffer) => {
  66. console.log(`[preview:vite] ${data.toString().trim()}`);
  67. });
  68. viteProcess.stderr?.on("data", (data: Buffer) => {
  69. console.log(`[preview:vite] ${data.toString().trim()}`);
  70. });
  71. viteProcess.on("exit", (code) => {
  72. console.log(`[preview] Vite dev server exited (code ${code})`);
  73. viteProcess = null;
  74. currentCreativeId = null;
  75. });
  76. currentCreativeId = creativeId;
  77. // 5. 等待 Vite 就绪
  78. // Vite 有 base 路径时,根路径可能 404,需要用带 prefix 的 URL 检查
  79. const checkUrl = previewBase !== "/"
  80. ? `http://localhost:${PREVIEW_PORT}${previewBase}`
  81. : `http://localhost:${PREVIEW_PORT}`;
  82. console.log(`[preview] Waiting for Vite to be ready (checking ${checkUrl})...`);
  83. await waitForReady(checkUrl);
  84. console.log("[preview] Vite is ready.");
  85. // 生产环境通过 nginx 代理暴露公网 URL,本地开发直接用 localhost
  86. const localUrl = `http://localhost:${PREVIEW_PORT}`;
  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. // 确保端口释放
  119. try {
  120. execSync(`lsof -ti :${PREVIEW_PORT} | xargs kill -9 2>/dev/null`, { stdio: "ignore" });
  121. } catch {}
  122. }
  123. export function getPreviewStatus(): { active: boolean; creativeId: string | null; url: string | null } {
  124. return {
  125. active: viteProcess !== null && currentCreativeId !== null,
  126. creativeId: currentCreativeId,
  127. url: currentCreativeId ? `http://localhost:${PREVIEW_PORT}` : null,
  128. };
  129. }