Files
automaker/apps/ui/src/electron/server/backend-server.ts
Shirone 0b4e9573ed refactor: Simplify tsx path lookup and remove redundant try-catch
Address review feedback:
- Simplify tsx CLI path lookup by extracting path variables and
  reducing nested try-catch blocks
- Remove redundant try-catch around electronAppExists check in
  production server path validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:54:10 +01:00

231 lines
7.3 KiB
TypeScript

/**
* Backend server management
*
* Handles starting, stopping, and monitoring the Express backend server.
* Uses centralized methods for path validation.
*/
import path from 'path';
import http from 'http';
import { spawn, execSync } from 'child_process';
import { app } from 'electron';
import {
findNodeExecutable,
buildEnhancedPath,
electronAppExists,
systemPathExists,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils/logger';
import { state } from '../state';
const logger = createLogger('BackendServer');
const serverLogger = createLogger('Server');
/**
* Start the backend server
* Uses centralized methods for path validation.
*/
export async function startServer(): Promise<void> {
const isDev = !app.isPackaged;
// Find Node.js executable (handles desktop launcher scenarios)
const nodeResult = findNodeExecutable({
skipSearch: isDev,
logger: (msg: string) => logger.info(msg),
});
const command = nodeResult.nodePath;
// Validate that the found Node executable actually exists
// systemPathExists is used because node-finder returns system paths
if (command !== 'node') {
let exists: boolean;
try {
exists = systemPathExists(command);
} catch (error) {
const originalError = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
);
}
if (!exists) {
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
}
}
let args: string[];
let serverPath: string;
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
if (isDev) {
serverPath = path.join(__dirname, '../../server/src/index.ts');
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
let tsxCliPath: string;
// Check for tsx in app bundle paths, fallback to require.resolve
const serverTsxPath = path.join(serverNodeModules, 'dist/cli.mjs');
const rootTsxPath = path.join(rootNodeModules, 'dist/cli.mjs');
try {
if (electronAppExists(serverTsxPath)) {
tsxCliPath = serverTsxPath;
} else if (electronAppExists(rootTsxPath)) {
tsxCliPath = rootTsxPath;
} else {
// Fallback to require.resolve
tsxCliPath = require.resolve('tsx/cli.mjs', {
paths: [path.join(__dirname, '../../server')],
});
}
} catch {
// electronAppExists threw or require.resolve failed
try {
tsxCliPath = require.resolve('tsx/cli.mjs', {
paths: [path.join(__dirname, '../../server')],
});
} catch {
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
}
}
args = [tsxCliPath, 'watch', serverPath];
} else {
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
args = [serverPath];
if (!electronAppExists(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
}
const serverNodeModules = app.isPackaged
? path.join(process.resourcesPath, 'server', 'node_modules')
: path.join(__dirname, '../../server/node_modules');
// Server root directory - where .env file is located
// In dev: apps/server (not apps/server/src)
// In production: resources/server
const serverRoot = app.isPackaged
? path.join(process.resourcesPath, 'server')
: path.join(__dirname, '../../server');
// IMPORTANT: Use shared data directory (not Electron's user data directory)
// This ensures Electron and web mode share the same settings/projects
// In dev: project root/data (navigate from __dirname which is apps/ui/dist-electron)
// In production: same as Electron user data (for app isolation)
const dataDir = app.isPackaged
? app.getPath('userData')
: path.join(__dirname, '../../..', 'data');
logger.info(
`[DATA_DIR] app.isPackaged=${app.isPackaged}, __dirname=${__dirname}, dataDir=${dataDir}`
);
// Build enhanced PATH that includes Node.js directory (cross-platform)
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
if (enhancedPath !== process.env.PATH) {
logger.info('Enhanced PATH with Node directory:', path.dirname(command));
}
const env = {
...process.env,
PATH: enhancedPath,
PORT: state.serverPort.toString(),
DATA_DIR: dataDir,
NODE_PATH: serverNodeModules,
// Pass API key to server for CSRF protection
AUTOMAKER_API_KEY: state.apiKey!,
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
// If not set, server will allow access to all paths
...(process.env.ALLOWED_ROOT_DIRECTORY && {
ALLOWED_ROOT_DIRECTORY: process.env.ALLOWED_ROOT_DIRECTORY,
}),
};
logger.info('Server will use port', state.serverPort);
logger.info('[DATA_DIR_SPAWN] env.DATA_DIR=', env.DATA_DIR);
logger.info('Starting backend server...');
logger.info('Server path:', serverPath);
logger.info('Server root (cwd):', serverRoot);
logger.info('NODE_PATH:', serverNodeModules);
state.serverProcess = spawn(command, args, {
cwd: serverRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
state.serverProcess.stdout?.on('data', (data) => {
serverLogger.info(data.toString().trim());
});
state.serverProcess.stderr?.on('data', (data) => {
serverLogger.error(data.toString().trim());
});
state.serverProcess.on('close', (code) => {
serverLogger.info('Process exited with code', code);
state.serverProcess = null;
});
state.serverProcess.on('error', (err) => {
serverLogger.error('Failed to start server process:', err);
state.serverProcess = null;
});
await waitForServer();
}
/**
* Wait for server to be available
*/
export async function waitForServer(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise<void>((resolve, reject) => {
const req = http.get(`http://localhost:${state.serverPort}/api/health`, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status: ${res.statusCode}`));
}
});
req.on('error', reject);
req.setTimeout(1000, () => {
req.destroy();
reject(new Error('Timeout'));
});
});
logger.info('Server is ready');
return;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
throw new Error('Server failed to start');
}
/**
* Stop the backend server if running
*/
export function stopServer(): void {
if (state.serverProcess && state.serverProcess.pid) {
logger.info('Stopping server...');
if (process.platform === 'win32') {
try {
// Windows: use taskkill with /t to kill entire process tree
// This prevents orphaned node processes when closing the app
// Using execSync to ensure process is killed before app exits
execSync(`taskkill /f /t /pid ${state.serverProcess.pid}`, { stdio: 'ignore' });
} catch (error) {
logger.error('Failed to kill server process:', (error as Error).message);
}
} else {
state.serverProcess.kill('SIGTERM');
}
state.serverProcess = null;
}
}