agent-ecosystem/src/main/services/infrastructure/HttpServer.ts
matt da1a8998fc chore: clean up project configuration and remove unused dependencies
- Updated knip.json to exclude unused Remotion paths and dependencies.
- Cleaned up pnpm-lock.yaml by removing obsolete Remotion packages.
- Refactored TypeScript function signatures in main files for improved clarity.
- Enhanced various components for better code readability and maintainability.
2026-02-16 23:27:43 +09:00

178 lines
5.9 KiB
TypeScript

/**
* 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;
}
}