@masuidrive/ticket
Version:
Real-time ticket tracking viewer with Vite + Express
621 lines (618 loc) • 25.4 kB
JavaScript
;
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