/** * 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<void>, preferredPort: number = 3456, host: string = '127.0.0.1' ): Promise<number> { 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<void> { 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; }}
/**
* 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<void>,
preferredPort: number = 3456,
host: string = '127.0.0.1'
): Promise<number> {
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<void> {
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;
}
}