UNPKG

@masuidrive/ticket

Version:

Real-time ticket tracking viewer with Vite + Express

621 lines (618 loc) 25.4 kB
#!/usr/bin/env node "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.UnifiedServerSSE = void 0; const http_1 = require("http"); const path_1 = __importDefault(require("path")); const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); const fileService_1 = require("./src/fileService"); const fileWatcher_1 = require("./src/fileWatcher"); // 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); class UnifiedServerSSE { constructor(projectRoot, hostname, port) { this.sseClients = new Map(); this.clientIdCounter = 0; 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_1.default.join(__dirname, '..', '..', 'dist'); } else { // Direct tsx execution (development) this.distPath = path_1.default.join(__dirname, '..', 'dist'); } this.expressApp = (0, express_1.default)(); this.fileService = new fileService_1.FileService(this.projectRoot); this.fileWatcher = new fileWatcher_1.FileWatcher(this.projectRoot); this.setupMiddleware(); this.setupExpressRoutes(); this.setupFileWatcher(); } setupMiddleware() { this.expressApp.use((0, cors_1.default)()); this.expressApp.use(express_1.default.json()); // Serve static files from Vite build directory this.expressApp.use(express_1.default.static(this.distPath)); } setupExpressRoutes() { // 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' }); } }); // API endpoint for ticket list this.expressApp.get('/api/tickets', async (req, res) => { try { const tickets = await this.getTicketsList(); res.json(tickets); } catch (error) { console.error('Error fetching tickets list:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to fetch tickets list' }); } }); // API endpoint for specific ticket this.expressApp.get('/api/ticket/:id', async (req, res) => { try { const ticketId = req.params.id; // Validate ticket ID format if (!ticketId || !/^[\w-]+$/.test(ticketId)) { return res.status(400).json({ error: 'Invalid ticket ID', message: 'Ticket ID must contain only alphanumeric characters, hyphens, and underscores' }); } const ticketContent = await this.getTicketById(ticketId); if (!ticketContent) { return res.status(404).json({ error: 'Ticket not found', message: `No ticket with ID ${ticketId} found` }); } res.json(ticketContent); } catch (error) { console.error('Error reading specific ticket:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to read ticket file' }); } }); 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); }); }); } setupFileWatcher() { 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); }); } sendSSEMessage(res, eventType, data) { const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; res.write(message); } async broadcastUpdate() { try { // Send only update notification, not the content this.broadcast('update', { ticket: 'current-ticket.md' }); } catch (error) { console.error('Error broadcasting update:', error); } } broadcast(eventType, data) { 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() { // For production, check if dist directory exists if (!dev) { const fs = await Promise.resolve().then(() => __importStar(require('fs'))); if (!fs.existsSync(this.distPath)) { throw new Error(`Production build directory not found at ${this.distPath}. Please run 'npm run build' first.`); } } // Catch-all handler for API routes not found this.expressApp.use((req, res, next) => { if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'API endpoint not found' }); } next(); }); // Serve index.html for SPA routes (fallback) this.expressApp.use((req, res) => { // Skip files with extensions (likely static assets) if (path_1.default.extname(req.path)) { return res.status(404).send('File not found'); } // Serve index.html for all other routes (SPA fallback) res.sendFile(path_1.default.join(this.distPath, 'index.html'), (err) => { if (err) { console.error('Error serving index.html:', err); res.status(500).send('Error loading application'); } }); }); this.server = (0, http_1.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`); }); } async getTicketById(ticketId) { const fs = require('fs').promises; const path = require('path'); // Security: Prevent path traversal if (ticketId.includes('..') || ticketId.includes('/') || ticketId.includes('\\')) { return null; } // Helper function to read and parse ticket content const readTicketFile = async (filePath) => { try { const content = await fs.readFile(filePath, 'utf-8'); // Parse frontmatter and content const lines = content.split('\n'); let inFrontmatter = false; let frontmatter = {}; let markdownContent = []; let foundFrontmatterEnd = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (i === 0 && line === '---') { inFrontmatter = true; continue; } if (inFrontmatter && line === '---') { inFrontmatter = false; foundFrontmatterEnd = true; continue; } if (inFrontmatter) { const match = line.match(/^(\w+):\s*(.*)$/); if (match) { const [, key, value] = match; if (value.startsWith('[') && value.endsWith(']')) { frontmatter[key] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/["']/g, '')); } else if (value === 'null') { frontmatter[key] = null; } else if (!isNaN(Number(value))) { frontmatter[key] = Number(value); } else { frontmatter[key] = value.replace(/["']/g, '').trim(); } } } else if (foundFrontmatterEnd || i > 0) { markdownContent.push(line); } } return { content: markdownContent.join('\n'), metadata: frontmatter, ticketId: ticketId }; } catch (error) { console.error(`Error reading ticket file ${filePath}:`, error); return null; } }; // Check if this is current-ticket if (ticketId === 'current') { try { const currentTicketPath = path.join(this.projectRoot, 'current-ticket.md'); const stats = await fs.lstat(currentTicketPath); if (stats.isSymbolicLink()) { const target = await fs.readlink(currentTicketPath); const resolvedPath = path.resolve(this.projectRoot, target); return await readTicketFile(resolvedPath); } else if (stats.isFile()) { return await readTicketFile(currentTicketPath); } } catch (error) { // No current ticket return null; } } // Try to find ticket in various locations const possiblePaths = [ path.join(this.projectRoot, 'tickets', `${ticketId}.md`), path.join(this.projectRoot, 'tickets', 'done', `${ticketId}.md`), path.join(this.projectRoot, `${ticketId}.md`) // For backward compatibility ]; for (const ticketPath of possiblePaths) { try { const stats = await fs.stat(ticketPath); if (stats.isFile()) { return await readTicketFile(ticketPath); } } catch (error) { // File doesn't exist, try next path continue; } } return null; } async getTicketsList() { const fs = require('fs').promises; const path = require('path'); const result = { current: null, pending: [], done: [] }; // Helper function to validate ticket ID const isValidTicketId = (filename) => { // Pattern: YYMMDD-HHMMSS-slug.md const pattern = /^\d{6}-\d{6}-[a-z0-9-]+\.md$/i; return pattern.test(filename); }; // Helper function to extract ticket title from content const extractTicketInfo = async (filePath) => { try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); // Parse YAML frontmatter let inFrontmatter = false; let frontmatter = {}; let titleLine = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (i === 0 && line === '---') { inFrontmatter = true; continue; } if (inFrontmatter) { if (line === '---') { inFrontmatter = false; continue; } const match = line.match(/^(\w+):\s*(.*)$/); if (match) { const [, key, value] = match; // Parse value based on content if (value.startsWith('[') && value.endsWith(']')) { // Parse array frontmatter[key] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/["']/g, '')); } else if (value === 'null') { frontmatter[key] = null; } else if (!isNaN(Number(value))) { frontmatter[key] = Number(value); } else { frontmatter[key] = value.replace(/["']/g, '').trim(); } } } else if (line.startsWith('# ')) { titleLine = line.substring(2).trim(); break; } } const filename = path.basename(filePath, '.md'); return { id: filename, title: titleLine || frontmatter.description || filename, priority: frontmatter.priority || 999, tags: frontmatter.tags || [], created_at: frontmatter.created_at || null, started_at: frontmatter.started_at || null, closed_at: frontmatter.closed_at || null }; } catch (error) { console.error(`Error reading ticket ${filePath}:`, error); return null; } }; // Check for current ticket (resolve symlink) try { const currentTicketPath = path.join(this.projectRoot, 'current-ticket.md'); const stats = await fs.lstat(currentTicketPath); if (stats.isSymbolicLink()) { const target = await fs.readlink(currentTicketPath); const resolvedPath = path.resolve(this.projectRoot, target); const ticketInfo = await extractTicketInfo(resolvedPath); if (ticketInfo) { result.current = ticketInfo; } } else if (stats.isFile()) { const ticketInfo = await extractTicketInfo(currentTicketPath); if (ticketInfo) { result.current = ticketInfo; } } } catch (error) { // No current ticket } // Read pending tickets try { const ticketsDir = path.join(this.projectRoot, 'tickets'); const files = await fs.readdir(ticketsDir); for (const file of files) { if (isValidTicketId(file)) { const filePath = path.join(ticketsDir, file); const stats = await fs.stat(filePath); if (stats.isFile()) { const ticketInfo = await extractTicketInfo(filePath); if (ticketInfo) { result.pending.push(ticketInfo); } } } } // Sort pending tickets by priority, then by date result.pending.sort((a, b) => { if (a.priority !== b.priority) { return a.priority - b.priority; } return b.id.localeCompare(a.id); // Newer first }); } catch (error) { console.error('Error reading pending tickets:', error); } // Read done tickets try { const doneDir = path.join(this.projectRoot, 'tickets', 'done'); const files = await fs.readdir(doneDir); for (const file of files) { if (isValidTicketId(file)) { const filePath = path.join(doneDir, file); const stats = await fs.stat(filePath); if (stats.isFile()) { const ticketInfo = await extractTicketInfo(filePath); if (ticketInfo) { result.done.push(ticketInfo); } } } } // Sort done tickets by closed date (newer first) result.done.sort((a, b) => b.id.localeCompare(a.id)); } catch (error) { console.error('Error reading done tickets:', error); } return result; } stop() { this.fileWatcher.stop(); // Close all SSE connections this.sseClients.forEach((client) => { client.res.end(); }); this.sseClients.clear(); this.server.close(); } } exports.UnifiedServerSSE = UnifiedServerSSE; // Parse command line arguments function parseArgs() { 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() { 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() { 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); } //# sourceMappingURL=unified-server.js.map