UNPKG

@masuidrive/ticket

Version:

Real-time ticket tracking viewer with Vite + Express

343 lines (291 loc) 10.6 kB
#!/usr/bin/env node import { createServer } from 'http'; import { parse } from 'url'; import path from 'path'; import express from 'express'; import cors from 'cors'; import { FileService } from './src/fileService'; import { FileWatcher } from './src/fileWatcher'; import { TicketContent } from './src/types'; // IMPORTANT: Port 4932 is REQUIRED for this project - DO NOT CHANGE // This port was explicitly specified by the project requirements // Changing this port will break the application functionality const DEFAULT_PORT = 4932; const dev = process.env.NODE_ENV !== 'production'; const hostname = 'localhost'; const port = parseInt(process.env.PORT || DEFAULT_PORT.toString(), 10); interface SSEClient { id: string; res: express.Response; } class UnifiedServerSSE { private expressApp: express.Application; private server: any; private fileService: FileService; private fileWatcher: FileWatcher; private sseClients: Map<string, SSEClient> = new Map(); private projectRoot: string; private clientIdCounter = 0; private hostname: string; private port: number; private distPath: string; constructor(projectRoot?: string, hostname?: string, port?: number) { this.projectRoot = projectRoot || process.cwd(); this.hostname = hostname || 'localhost'; this.port = port || parseInt(process.env.PORT || DEFAULT_PORT.toString(), 10); // Vite build directory - handle both direct tsx execution and compiled js execution if (__dirname.includes('/server/dist')) { // Compiled JS execution (npx package) this.distPath = path.join(__dirname, '..', '..', 'dist'); } else { // Direct tsx execution (development) this.distPath = path.join(__dirname, '..', 'dist'); } this.expressApp = express(); this.fileService = new FileService(this.projectRoot); this.fileWatcher = new FileWatcher(this.projectRoot); this.setupMiddleware(); this.setupExpressRoutes(); this.setupFileWatcher(); } private setupMiddleware(): void { this.expressApp.use(cors()); this.expressApp.use(express.json()); // Serve static files from Vite build directory this.expressApp.use(express.static(this.distPath)); } private setupExpressRoutes(): void { // Express API routes this.expressApp.get('/api/express/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); this.expressApp.get('/api/express/ticket', async (req, res) => { try { const content = await this.fileService.readTicketFile(); if (!content) { return res.status(404).json({ error: 'Ticket file not found', message: 'No current-ticket.md file exists in the project root' }); } res.json(content); } catch (error) { console.error('Error reading ticket file:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to read ticket file' }); } }); this.expressApp.get('/api/express/ticket/exists', async (req, res) => { try { const exists = await this.fileService.fileExists('current-ticket.md'); res.json({ exists }); } catch (error) { console.error('Error checking file existence:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to check file existence' }); } }); this.expressApp.get('/api/system-info', async (req, res) => { try { const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); // Get current directory const currentDir = this.projectRoot; // Get git branch let gitBranch = ''; try { const { stdout } = await execAsync('git branch --show-current', { cwd: currentDir }); gitBranch = stdout.trim(); } catch (gitError) { // Git command failed, branch might not exist or not a git repo gitBranch = ''; } res.json({ currentDir, gitBranch }); } catch (error) { console.error('Error getting system info:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to get system info' }); } }); // SSE endpoint this.expressApp.get('/api/ticket-stream', async (req, res) => { // Set SSE headers res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering // Generate unique client ID const clientId = `client-${++this.clientIdCounter}`; console.log(`SSE client connected: ${clientId}`); // Store client this.sseClients.set(clientId, { id: clientId, res }); // Send initial update notification if file exists try { const exists = await this.fileService.fileExists('current-ticket.md'); if (exists) { this.sendSSEMessage(res, 'update', { ticket: 'current-ticket.md' }); } } catch (error) { console.error('Error sending initial notification:', error); } // Send periodic heartbeat to keep connection alive const heartbeatInterval = setInterval(() => { this.sendSSEMessage(res, 'heartbeat', { timestamp: Date.now() }); }, 30000); // Every 30 seconds // Handle client disconnect req.on('close', () => { console.log(`SSE client disconnected: ${clientId}`); clearInterval(heartbeatInterval); this.sseClients.delete(clientId); }); }); } private setupFileWatcher(): void { this.fileWatcher.watchFile('current-ticket.md'); this.fileWatcher.on('fileChanged', async () => { console.log('Ticket file changed'); await this.broadcastUpdate(); }); this.fileWatcher.on('fileAdded', async () => { console.log('Ticket file added'); await this.broadcastUpdate(); }); this.fileWatcher.on('fileRemoved', () => { console.log('Ticket file removed'); this.broadcast('removed', { ticket: 'current-ticket.md' }); }); this.fileWatcher.on('error', (error) => { console.error('File watcher error:', error); }); } private sendSSEMessage(res: express.Response, eventType: string, data: any): void { const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; res.write(message); } private async broadcastUpdate(): Promise<void> { try { // Send only update notification, not the content this.broadcast('update', { ticket: 'current-ticket.md' }); } catch (error) { console.error('Error broadcasting update:', error); } } private broadcast(eventType: string, data: any): void { this.sseClients.forEach((client) => { try { this.sendSSEMessage(client.res, eventType, data); } catch (error) { console.error(`Error sending to client ${client.id}:`, error); this.sseClients.delete(client.id); } }); } async start(): Promise<void> { // For production, check if dist directory exists if (!dev) { const fs = await import('fs'); if (!fs.existsSync(this.distPath)) { throw new Error(`Production build directory not found at ${this.distPath}. Please run 'npm run build' first.`); } } // Serve index.html for SPA routes (fallback) this.expressApp.use((req, res, next) => { // Skip API routes if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'API endpoint not found' }); } // Skip files with extensions (likely static assets) if (path.extname(req.path)) { return res.status(404).send('File not found'); } // Serve index.html for all other routes (SPA fallback) res.sendFile(path.join(this.distPath, 'index.html'), (err) => { if (err) { console.error('Error serving index.html:', err); res.status(500).send('Error loading application'); } }); }); this.server = createServer(this.expressApp); this.server.listen(this.port, this.hostname, () => { console.log(`> Ready on http://${this.hostname}:${this.port}`); console.log(`> Express API available at: - GET /api/express/health - GET /api/express/ticket - GET /api/express/ticket/exists - GET /api/system-info - SSE /api/ticket-stream`); }); } stop(): void { this.fileWatcher.stop(); // Close all SSE connections this.sseClients.forEach((client) => { client.res.end(); }); this.sseClients.clear(); this.server.close(); } } // Parse command line arguments function parseArgs(): { projectRoot: string } { const args = process.argv.slice(2); let projectRoot = process.cwd(); for (let i = 0; i < args.length; i++) { if ((args[i] === '-d' || args[i] === '--dir') && args[i + 1]) { projectRoot = args[i + 1]; i++; } else if (args[i] === '-h' || args[i] === '--help') { showHelp(); process.exit(0); } } return { projectRoot }; } function showHelp(): void { console.log(` Unified Ticket Viewer Server with SSE - Next.js + Express Usage: tsx server/unified-server-sse.ts [options] Options: -d, --dir <path> Project directory containing current-ticket.md -h, --help Show this help message Environment Variables: PORT Server port (default: ${DEFAULT_PORT}) NODE_ENV Set to 'production' for production mode `); } async function main(): Promise<void> { const { projectRoot } = parseArgs(); console.log(`Starting Unified Server with SSE...`); console.log(`Project root: ${projectRoot}`); console.log(`Port: ${port}`); console.log(`Mode: ${dev ? 'development' : 'production'}`); const server = new UnifiedServerSSE(projectRoot); await server.start(); // Handle graceful shutdown process.on('SIGINT', () => { console.log('\nShutting down server...'); server.stop(); process.exit(0); }); process.on('SIGTERM', () => { console.log('\nShutting down server...'); server.stop(); process.exit(0); }); } // Run if this is the main module if (require.main === module) { main().catch(console.error); } export { UnifiedServerSSE };