UNPKG

reviewit

Version:

A lightweight command-line tool that spins up a local web server to display Git commit diffs in a GitHub-like Files changed view

231 lines (230 loc) 9.27 kB
import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import express from 'express'; import open from 'open'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); import { getFileExtension } from '../utils/fileUtils.js'; import { GitDiffParser } from './git-diff.js'; export async function startServer(options) { const app = express(); const parser = new GitDiffParser(); let diffData = null; let currentIgnoreWhitespace = options.ignoreWhitespace || false; const diffMode = options.mode || 'side-by-side'; app.use(express.json()); app.use(express.text()); // For sendBeacon text/plain requests app.use((_req, res, next) => { res.header('Access-Control-Allow-Origin', 'http://localhost:*'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); next(); }); const isValidCommit = await parser.validateCommit(options.targetCommitish); if (!isValidCommit) { throw new Error(`Invalid or non-existent commit: ${options.targetCommitish}`); } diffData = await parser.parseDiff(options.targetCommitish, options.baseCommitish, currentIgnoreWhitespace); app.get('/api/diff', async (req, res) => { const ignoreWhitespace = req.query.ignoreWhitespace === 'true'; if (ignoreWhitespace !== currentIgnoreWhitespace) { currentIgnoreWhitespace = ignoreWhitespace; diffData = await parser.parseDiff(options.targetCommitish, options.baseCommitish, ignoreWhitespace); } res.json({ ...diffData, ignoreWhitespace, mode: diffMode, baseCommitish: options.baseCommitish, targetCommitish: options.targetCommitish, }); }); app.get(/^\/api\/blob\/(.*)$/, async (req, res) => { try { const filepath = req.params[0]; const ref = req.query.ref || 'HEAD'; const blob = await parser.getBlobContent(filepath, ref); // Determine content type based on file extension const ext = getFileExtension(filepath); const contentTypes = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', bmp: 'image/bmp', svg: 'image/svg+xml', webp: 'image/webp', ico: 'image/x-icon', tiff: 'image/tiff', tif: 'image/tiff', avif: 'image/avif', heic: 'image/heic', heif: 'image/heif', }; const contentType = contentTypes[ext || ''] || 'application/octet-stream'; res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.send(blob); } catch (error) { console.error('Error fetching blob:', error); res.status(404).json({ error: 'File not found' }); } }); // Store comments for final output let finalComments = []; app.post('/api/comments', (req, res) => { try { // Handle both JSON and text/plain content types (sendBeacon sends as text/plain) let comments = []; if (req.headers['content-type']?.includes('application/json')) { comments = req.body.comments || []; } else if (typeof req.body === 'string') { const parsed = JSON.parse(req.body); comments = parsed.comments || []; } else { comments = req.body.comments || []; } finalComments = comments; res.json({ success: true }); } catch (error) { console.error('Error parsing comments:', error); res.status(400).json({ error: 'Invalid comment data' }); } }); app.get('/api/comments-output', (_req, res) => { if (finalComments.length > 0) { const output = formatCommentsOutput(finalComments); res.send(output); } else { res.send(''); } }); // Function to format comments for output function formatCommentsOutput(comments) { const prompts = comments.map((comment) => { return `${comment.file}:${comment.line}\n${comment.body}`; }); return [ '\n📝 Comments from review session:', '='.repeat(50), prompts.join('\n=====\n'), '='.repeat(50), `Total comments: ${comments.length}\n`, ].join('\n'); } // Function to output comments when server shuts down function outputFinalComments() { if (finalComments.length > 0) { console.log(formatCommentsOutput(finalComments)); } } // SSE endpoint to detect when tab is closed app.get('/api/heartbeat', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*', }); // Send initial heartbeat res.write('data: connected\n\n'); // Send heartbeat every 5 seconds const heartbeatInterval = setInterval(() => { res.write('data: heartbeat\n\n'); }, 5000); // When client disconnects (tab closed, navigation, etc.) req.on('close', () => { clearInterval(heartbeatInterval); console.log('Client disconnected, shutting down server...'); outputFinalComments(); process.exit(0); }); }); // Always runs in production mode when distributed as a CLI tool const isProduction = process.env.NODE_ENV === 'production' || process.env.NODE_ENV !== 'development'; if (isProduction) { // Find client files relative to the CLI executable location const distPath = join(__dirname, '..', 'client'); app.use(express.static(distPath)); app.get('/{*splat}', (_req, res) => { res.sendFile(join(distPath, 'index.html')); }); } else { app.get('/', (_req, res) => { res.send(` <!DOCTYPE html> <html> <head> <title>ReviewIt - Dev Mode</title> </head> <body> <div id="root"></div> <script> console.log('ReviewIt development mode'); console.log('Diff data available at /api/diff'); </script> </body> </html> `); }); } const { port, url, server } = await startServerWithFallback(app, options.preferredPort || 3000, options.host || '127.0.0.1'); // Security warning for non-localhost binding if (options.host && options.host !== '127.0.0.1' && options.host !== 'localhost') { console.warn('\n⚠️ WARNING: Server is accessible from external network!'); console.warn(` Binding to: ${options.host}:${port}`); console.warn(' Make sure this is intended and your network is secure.\n'); } // Check if diff is empty and skip browser opening if (diffData.isEmpty) { // Don't open browser if no differences found } else if (options.openBrowser) { try { await open(url); } catch { console.warn('Failed to open browser automatically'); } } return { port, url, isEmpty: diffData.isEmpty, server }; } async function startServerWithFallback(app, preferredPort, host) { return new Promise((resolve, reject) => { // express's listen() method uses listen() method in node:net Server instance internally // https://expressjs.com/en/5x/api.html#app.listen // so, an error will be an instance of NodeJS.ErrnoException const server = app.listen(preferredPort, host, (err) => { const displayHost = host === '0.0.0.0' ? 'localhost' : host; const url = `http://${displayHost}:${preferredPort}`; if (!err) { resolve({ port: preferredPort, url, server }); return; } // Handling errors when failed to launch a server switch (err.code) { // Try another port until it succeeds case 'EADDRINUSE': { console.log(`Port ${preferredPort} is busy, trying ${preferredPort + 1}...`); return startServerWithFallback(app, preferredPort + 1, host) .then(({ port, url, server }) => { resolve({ port, url, server }); }) .catch(reject); } // Unexpected error default: { reject(new Error(`Failed to launch a server: ${err.message}`)); } } }); }); }