import * as http from 'http'; import * as fs from 'fs'; import * as path from 'path'; export interface IFixtureServerService { start(port: number, fixturesPath: string): Promise; stop(): Promise; waitForReady(timeoutMs: number): Promise; getBaseUrl(): string; isRunning(): boolean; } export class FixtureServerService implements IFixtureServerService { private server: http.Server | null = null; private port: number = 3456; private resolvedFixturesPath: string = ''; async start(port: number, fixturesPath: string): Promise { if (this.server) { throw new Error('Fixture server is already running'); } this.port = port; this.resolvedFixturesPath = path.resolve(fixturesPath); if (!fs.existsSync(this.resolvedFixturesPath)) { throw new Error(`Fixtures path does not exist: ${this.resolvedFixturesPath}`); } return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); }); this.server.on('error', (error) => { this.server = null; reject(error); }); this.server.listen(this.port, () => { resolve(); }); }); } async stop(): Promise { if (!this.server) { return; } return new Promise((resolve, reject) => { this.server!.close((error) => { this.server = null; if (error) { reject(error); } else { resolve(); } }); }); } async waitForReady(timeoutMs: number = 5000): Promise { const startTime = Date.now(); const pollInterval = 100; while (Date.now() - startTime < timeoutMs) { const isReady = await this.checkHealth(); if (isReady) { return true; } await this.sleep(pollInterval); } return false; } getBaseUrl(): string { return `http://localhost:${this.port}`; } isRunning(): boolean { return this.server !== null; } private async checkHealth(): Promise { return new Promise((resolve) => { const req = http.get(`${this.getBaseUrl()}/health`, (res) => { resolve(res.statusCode === 200); }); req.on('error', () => { resolve(false); }); req.setTimeout(1000, () => { req.destroy(); resolve(false); }); }); } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { const url = req.url || '/'; res.setHeader('Access-Control-Allow-Origin', '*'); if (url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok' })); return; } const sanitizedUrl = url.startsWith('/') ? url.slice(1) : url; const filePath = path.join(this.resolvedFixturesPath, sanitizedUrl || 'index.html'); const normalizedFilePath = path.normalize(filePath); if (!normalizedFilePath.startsWith(this.resolvedFixturesPath)) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Forbidden'); return; } fs.stat(normalizedFilePath, (statErr, stats) => { if (statErr || !stats.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); return; } const ext = path.extname(normalizedFilePath).toLowerCase(); const contentType = this.getContentType(ext); fs.readFile(normalizedFilePath, (readErr, data) => { if (readErr) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error'); return; } res.writeHead(200, { 'Content-Type': contentType }); res.end(data); }); }); } private getContentType(ext: string): string { const mimeTypes: Record = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', }; return mimeTypes[ext] || 'application/octet-stream'; } }