local-services.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. const { spawn } = require('child_process');
  2. const path = require('path');
  3. const fs = require('fs');
  4. const http = require('http');
  5. const { app } = require('electron');
  6. interface ServiceProcess {
  7. process: ReturnType<typeof spawn> | null;
  8. name: string;
  9. port: number;
  10. ready: boolean;
  11. restartCount: number;
  12. lastRestartTime: number;
  13. }
  14. const NODE_PORT = 14890;
  15. const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
  16. const MAX_RESTARTS = 4;
  17. const RESTART_WINDOW_MS = 2 * 60 * 1000;
  18. const services: { node: ServiceProcess } = {
  19. node: {
  20. process: null,
  21. name: 'Node Server',
  22. port: NODE_PORT,
  23. ready: false,
  24. restartCount: 0,
  25. lastRestartTime: 0,
  26. },
  27. };
  28. let logFilePath = '';
  29. function getLogPath(): string {
  30. if (!logFilePath) {
  31. try {
  32. logFilePath = path.join(app.getPath('userData'), 'local-services.log');
  33. } catch {
  34. logFilePath = path.join(process.cwd(), 'local-services.log');
  35. }
  36. }
  37. return logFilePath;
  38. }
  39. function log(level: string, ...args: unknown[]): void {
  40. const line = `[${new Date().toISOString()}] [${level}] ${args.map((item) => typeof item === 'string' ? item : JSON.stringify(item)).join(' ')}`;
  41. if (level === 'ERROR') {
  42. console.error(line);
  43. } else {
  44. console.log(line);
  45. }
  46. try {
  47. fs.appendFileSync(getLogPath(), line + '\n', 'utf8');
  48. } catch {
  49. // ignore logging failures
  50. }
  51. }
  52. function isPackaged(): boolean {
  53. return app.isPackaged;
  54. }
  55. function getServerDir(): string {
  56. if (isPackaged()) {
  57. return path.join((process as any).resourcesPath, 'server');
  58. }
  59. return path.resolve(__dirname, '..', '..', 'server');
  60. }
  61. function getPlaywrightBrowsersPath(): string | null {
  62. if (!isPackaged()) return null;
  63. const bundled = path.join((process as any).resourcesPath, 'playwright');
  64. return fs.existsSync(bundled) ? bundled : null;
  65. }
  66. function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } {
  67. const serverDir = getServerDir();
  68. const distEntry = path.join(serverDir, 'dist', 'app.js');
  69. const srcEntry = path.join(serverDir, 'src', 'app.ts');
  70. const isWin = process.platform === 'win32';
  71. if (isPackaged()) {
  72. return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true };
  73. }
  74. if (fs.existsSync(distEntry)) {
  75. return { cmd: 'node', args: [distEntry] };
  76. }
  77. const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx');
  78. if (fs.existsSync(tsxBin)) {
  79. return { cmd: tsxBin, args: [srcEntry] };
  80. }
  81. return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] };
  82. }
  83. function checkHealth(port: number): Promise<boolean> {
  84. return new Promise((resolve) => {
  85. const req = http.get(`http://127.0.0.1:${port}/api/health`, { timeout: 2500 }, (res: any) => {
  86. res.resume();
  87. resolve(res.statusCode >= 200 && res.statusCode < 500);
  88. });
  89. req.on('error', () => resolve(false));
  90. req.on('timeout', () => {
  91. req.destroy();
  92. resolve(false);
  93. });
  94. });
  95. }
  96. async function waitForService(port: number, timeoutMs = 45000): Promise<boolean> {
  97. const startedAt = Date.now();
  98. while (Date.now() - startedAt < timeoutMs) {
  99. if (await checkHealth(port)) {
  100. return true;
  101. }
  102. await new Promise((resolve) => setTimeout(resolve, 1000));
  103. }
  104. return false;
  105. }
  106. function shouldRestart(service: ServiceProcess): boolean {
  107. const now = Date.now();
  108. if (now - service.lastRestartTime > RESTART_WINDOW_MS) {
  109. service.restartCount = 0;
  110. }
  111. service.lastRestartTime = now;
  112. service.restartCount += 1;
  113. return service.restartCount <= MAX_RESTARTS;
  114. }
  115. function startNodeServer(): void {
  116. const service = services.node;
  117. const serverDir = getServerDir();
  118. const { cmd, args, useElectronAsNode } = findNodeRunner();
  119. const env: Record<string, string> = {
  120. ...(process.env as Record<string, string>),
  121. PORT: String(NODE_PORT),
  122. HOST: '127.0.0.1',
  123. USE_REDIS_QUEUE: 'false',
  124. LOCAL_NODE_ONLY_AUTOMATION: 'true',
  125. NODE_OPTIONS: '--max-old-space-size=512',
  126. };
  127. if (useElectronAsNode) {
  128. env.ELECTRON_RUN_AS_NODE = '1';
  129. }
  130. if (isPackaged()) {
  131. const uploadsDir = path.join(app.getPath('userData'), 'uploads');
  132. fs.mkdirSync(uploadsDir, { recursive: true });
  133. env.UPLOAD_PATH = uploadsDir;
  134. }
  135. const playwrightPath = getPlaywrightBrowsersPath();
  136. if (playwrightPath) {
  137. env.PLAYWRIGHT_BROWSERS_PATH = playwrightPath;
  138. }
  139. log('INFO', 'Starting node service', cmd, args.join(' '));
  140. const child = spawn(cmd, args, {
  141. cwd: serverDir,
  142. env,
  143. stdio: ['ignore', 'pipe', 'pipe'],
  144. shell: false,
  145. windowsHide: true,
  146. });
  147. service.process = child;
  148. service.ready = false;
  149. child.stdout?.on('data', (chunk: Buffer) => {
  150. const message = chunk.toString().trim();
  151. if (!message) return;
  152. log('NODE', message);
  153. if (message.includes('Server running') || message.includes('listening')) {
  154. service.ready = true;
  155. }
  156. });
  157. child.stderr?.on('data', (chunk: Buffer) => {
  158. const message = chunk.toString().trim();
  159. if (message) log('NODE-ERR', message);
  160. });
  161. child.on('error', (error: Error) => {
  162. log('ERROR', 'Node spawn error', error.message);
  163. });
  164. child.on('exit', (code: number | null, signal: string | null) => {
  165. log('WARN', `Node service exited code=${code} signal=${signal}`);
  166. service.process = null;
  167. service.ready = false;
  168. if (signal === 'SIGTERM' || signal === 'SIGINT') {
  169. return;
  170. }
  171. if (!shouldRestart(service)) {
  172. log('ERROR', 'Node service restart budget exhausted');
  173. return;
  174. }
  175. const backoffMs = Math.min(service.restartCount * 4000, 15000);
  176. log('INFO', `Restarting node service in ${backoffMs}ms`);
  177. setTimeout(() => startNodeServer(), backoffMs);
  178. });
  179. }
  180. async function startLocalServices(): Promise<{ nodeOk: boolean }> {
  181. if (!services.node.process) {
  182. startNodeServer();
  183. }
  184. const nodeOk = await waitForService(NODE_PORT);
  185. services.node.ready = nodeOk;
  186. return { nodeOk };
  187. }
  188. function stopLocalServices(): void {
  189. const service = services.node;
  190. service.ready = false;
  191. if (!service.process) return;
  192. try {
  193. service.process.kill('SIGTERM');
  194. } catch (error) {
  195. log('ERROR', 'Failed to stop node service', error);
  196. }
  197. service.process = null;
  198. }
  199. function getServiceStatus() {
  200. return {
  201. node: {
  202. ready: services.node.ready,
  203. port: services.node.port,
  204. restartCount: services.node.restartCount,
  205. },
  206. };
  207. }
  208. export {
  209. NODE_PORT,
  210. LOCAL_NODE_URL,
  211. startLocalServices,
  212. stopLocalServices,
  213. getServiceStatus,
  214. getLogPath,
  215. };