/** * HttpServer - Fastify-based HTTP server for serving the renderer UI and API routes. * * Binds to 127.0.0.1 only for localhost security. * Dynamically allocates a port starting from 3456. * In production, serves static files from the renderer output directory. * In development, Vite dev server handles static files. */ import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import { type HttpServices, registerHttpRoutes } from '@main/http'; import { broadcastEvent } from '@main/http/events'; import { createLogger } from '@shared/utils/logger'; import Fastify, { type FastifyInstance } from 'fastify'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; const logger = createLogger('Service:HttpServer'); /** * Resolves the renderer output directory from multiple candidate paths. * Returns the first path that exists on disk. */ function resolveRendererPath(): string | null { const candidates = [ // Electron production (asarUnpack): app.asar.unpacked/out/renderer (real filesystem) join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked'), // Electron production (asar fallback): app.asar/out/renderer join(__dirname, '../../out/renderer'), // Standalone: dist-standalone/index.cjs → ../out/renderer join(__dirname, '../out/renderer'), // Fallback: relative to cwd (dev mode, standalone) join(process.cwd(), 'out/renderer'), ]; // Allow explicit override via env if (process.env.RENDERER_PATH) { candidates.unshift(process.env.RENDERER_PATH); } return candidates.find((candidate) => existsSync(candidate)) ?? null; } export class HttpServer { private app: FastifyInstance | null = null; private port: number = 3456; private running: boolean = false; /** * Start the HTTP server. * @param services - Service instances to pass to route handlers * @param sshModeSwitchCallback - Callback for SSH mode switching * @param preferredPort - Port to try first (default 3456) * @param host - Host to bind to (default '127.0.0.1') */ async start( services: HttpServices, sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise, preferredPort: number = 3456, host: string = '127.0.0.1' ): Promise { this.app = Fastify({ logger: false }); // Register CORS const corsOrigin = process.env.CORS_ORIGIN; if (corsOrigin === '*') { // Standalone/Docker mode: allow all origins (Docker network isolation replaces CORS) await this.app.register(cors, { origin: true, credentials: true }); } else if (corsOrigin) { // Custom origin(s) from env const origins = corsOrigin.split(',').map((o) => o.trim()); await this.app.register(cors, { origin: origins, credentials: true }); } else { // Default: allow all localhost origins // eslint-disable-next-line security/detect-unsafe-regex -- anchored, no backtracking risk const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/; await this.app.register(cors, { origin: (origin, cb) => { if (!origin) { cb(null, true); return; } if (localhostPattern.test(origin)) { cb(null, true); return; } cb(new Error('Not allowed by CORS'), false); }, credentials: true, }); } // Register static file serving and SPA fallback when renderer output exists. // In dev mode this requires a prior `pnpm build`; in production/standalone it's always present. const rendererPath = resolveRendererPath(); if (rendererPath) { logger.info(`Serving static files from: ${rendererPath}`); // Cache index.html for SPA fallback const indexHtml = readFileSync(join(rendererPath, 'index.html'), 'utf-8'); await this.app.register(fastifyStatic, { root: rendererPath, prefix: '/', wildcard: false, }); // Register all API routes BEFORE the not-found handler registerHttpRoutes(this.app, services, sshModeSwitchCallback); // SPA fallback: serve index.html for all non-API routes this.app.setNotFoundHandler(async (request, reply) => { if (request.url.startsWith('/api/')) { return reply.status(404).send({ error: 'Not found' }); } return reply.type('text/html').send(indexHtml); }); } else { logger.warn('Renderer output directory not found (run `pnpm build` first), serving API only'); registerHttpRoutes(this.app, services, sshModeSwitchCallback); } // Try ports starting from preferredPort for (let attempt = 0; attempt <= 10; attempt++) { const tryPort = preferredPort + attempt; try { await this.app.listen({ host, port: tryPort }); this.port = tryPort; this.running = true; logger.info(`HTTP server started on http://${host}:${tryPort}`); return tryPort; } catch (err: unknown) { const error = err as NodeJS.ErrnoException; if (error.code === 'EADDRINUSE') { logger.info(`Port ${tryPort} in use, trying next...`); continue; } throw err; } } throw new Error(`Could not find available port (tried ${preferredPort}-${preferredPort + 10})`); } /** * Stop the HTTP server gracefully. */ async stop(): Promise { if (this.app && this.running) { await this.app.close(); this.running = false; this.app = null; logger.info('HTTP server stopped'); } } /** * Broadcast an event to all connected SSE clients. */ broadcast(channel: string, data: unknown): void { broadcastEvent(channel, data); } /** * Get the current port the server is running on. */ getPort(): number { return this.port; } /** * Check if the server is currently running. */ isRunning(): boolean { return this.running; } }