UNPKG

freshroute-server

Version:

Local development server for FreshRoute extension with API mocking and extended functionality

719 lines (624 loc) 21.3 kB
import express from 'express'; import cors from 'cors'; import axios from 'axios'; import bodyParser from 'body-parser'; import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import fs from 'fs/promises'; import fsSync from 'fs'; import path from 'path'; import os from 'os'; import multer from 'multer'; import { fileURLToPath } from 'url'; // Get current directory for ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = 3001; const WS_PORT = 3002; // Middleware app.use(cors()); app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' })); // In-memory storage for mock rules let mockRules = []; let mockResponses = new Map(); // WebSocket server for real-time communication with extension const server = createServer(app); const wss = new WebSocketServer({ port: WS_PORT }); // WebSocket connections from FreshRoute extension const extensionConnections = new Set(); wss.on('connection', (ws) => { console.log('🔌 FreshRoute extension connected'); extensionConnections.add(ws); // Send current mock rules to newly connected extension ws.send(JSON.stringify({ type: 'MOCK_RULES_UPDATE', data: mockRules })); ws.on('message', (message) => { try { const data = JSON.parse(message); handleExtensionMessage(data, ws); } catch (error) { console.error('❌ Error parsing WebSocket message:', error); } }); ws.on('close', () => { console.log('🔌 FreshRoute extension disconnected'); extensionConnections.delete(ws); }); }); // Handle messages from FreshRoute extension function handleExtensionMessage(data, ws) { switch (data.type) { case 'ADD_MOCK_RULE': addMockRule(data.rule); broadcastToExtensions({ type: 'MOCK_RULES_UPDATE', data: mockRules }); break; case 'UPDATE_MOCK_RULE': updateMockRule(data.ruleId, data.rule); broadcastToExtensions({ type: 'MOCK_RULES_UPDATE', data: mockRules }); break; case 'DELETE_MOCK_RULE': deleteMockRule(data.ruleId); broadcastToExtensions({ type: 'MOCK_RULES_UPDATE', data: mockRules }); break; case 'GET_MOCK_RULES': ws.send(JSON.stringify({ type: 'MOCK_RULES_UPDATE', data: mockRules })); break; } } // Broadcast message to all connected extensions function broadcastToExtensions(message) { extensionConnections.forEach(ws => { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify(message)); } }); } // Mock rule management function addMockRule(rule) { const newRule = { id: Date.now().toString(), enabled: true, createdAt: new Date().toISOString(), ...rule }; mockRules.push(newRule); saveMockRules(); console.log('✅ Added mock rule:', newRule.name); } function updateMockRule(ruleId, updatedRule) { const index = mockRules.findIndex(rule => rule.id === ruleId); if (index !== -1) { mockRules[index] = { ...mockRules[index], ...updatedRule }; saveMockRules(); console.log('✅ Updated mock rule:', mockRules[index].name); } } function deleteMockRule(ruleId) { const index = mockRules.findIndex(rule => rule.id === ruleId); if (index !== -1) { const deletedRule = mockRules.splice(index, 1)[0]; saveMockRules(); console.log('🗑️ Deleted mock rule:', deletedRule.name); } } // Main proxy endpoint - handles all intercepted requests app.all('/proxy/*', async (req, res) => { try { const originalUrl = req.url.replace('/proxy/', ''); const fullUrl = decodeURIComponent(originalUrl); console.log(`🔄 Proxying ${req.method} ${fullUrl}`); // Check if this request matches any mock rules const matchingRule = findMatchingMockRule(req.method, fullUrl, req.headers, req.body); if (matchingRule && matchingRule.enabled) { console.log(`🎭 Using mock rule: ${matchingRule.name}`); return handleMockResponse(matchingRule, req, res); } // No mock rule found, proxy to original server await proxyToOriginalServer(fullUrl, req, res); } catch (error) { console.error('❌ Proxy error:', error); res.status(500).json({ error: 'FreshRoute server error', message: error.message, timestamp: new Date().toISOString() }); } }); // Find matching mock rule function findMatchingMockRule(method, url, headers, body) { return mockRules.find(rule => { if (!rule.enabled) return false; // Method matching if (rule.method && rule.method !== 'ANY' && rule.method !== method) { return false; } // URL matching (supports regex) if (rule.urlPattern) { try { const regex = new RegExp(rule.urlPattern); if (!regex.test(url)) return false; } catch (error) { // Fallback to simple string matching if (!url.includes(rule.urlPattern)) return false; } } // Header matching if (rule.headerConditions) { for (const [key, value] of Object.entries(rule.headerConditions)) { if (headers[key.toLowerCase()] !== value) return false; } } // Body matching (for POST/PUT requests) if (rule.bodyConditions && body) { try { const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); for (const [key, value] of Object.entries(rule.bodyConditions)) { if (!bodyStr.includes(value)) return false; } } catch (error) { console.warn('⚠️ Body matching error:', error); } } return true; }); } // Handle mock response function handleMockResponse(rule, req, res) { try { // Add delay if specified const delay = rule.delay || 0; setTimeout(async () => { // Set custom headers if (rule.responseHeaders) { Object.entries(rule.responseHeaders).forEach(([key, value]) => { res.setHeader(key, value); }); } // Set status code const statusCode = rule.statusCode || 200; res.status(statusCode); // Handle different response types if (rule.responseType === 'json') { res.json(rule.responseBody); } else if (rule.responseType === 'text') { res.send(rule.responseBody); } else if (rule.responseType === 'file') { // Serve file from mock-server/responses directory const filePath = path.resolve(`responses/${rule.responseFile}`); try { // Check if file exists await fs.access(filePath); // Set appropriate content type based on file extension const ext = path.extname(rule.responseFile).toLowerCase(); if (ext === '.json') { res.setHeader('Content-Type', 'application/json'); } else if (ext === '.xml') { res.setHeader('Content-Type', 'application/xml'); } else if (ext === '.html') { res.setHeader('Content-Type', 'text/html'); } else if (ext === '.txt') { res.setHeader('Content-Type', 'text/plain'); } res.sendFile(filePath); } catch (fileError) { console.error(`❌ File not found: ${rule.responseFile}`); res.status(404).json({ error: 'Mock response file not found', file: rule.responseFile }); } } else { res.json(rule.responseBody); } console.log(`✅ Mock response sent: ${statusCode} for ${rule.name}`); }, delay); } catch (error) { console.error('❌ Mock response error:', error); res.status(500).json({ error: 'Mock response generation failed' }); } } // Proxy to original server async function proxyToOriginalServer(url, req, res) { try { const axiosConfig = { method: req.method, url: url, headers: { ...req.headers }, timeout: 30000, validateStatus: () => true // Don't throw on HTTP error status codes }; // Remove host header to avoid conflicts delete axiosConfig.headers.host; delete axiosConfig.headers['content-length']; // Add body for POST/PUT/PATCH requests if (['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase()) && req.body) { axiosConfig.data = req.body; } const response = await axios(axiosConfig); // Copy response headers Object.entries(response.headers).forEach(([key, value]) => { res.setHeader(key, value); }); res.status(response.status).send(response.data); console.log(`✅ Proxied to original: ${response.status} ${url}`); } catch (error) { console.error('❌ Proxy to original server failed:', error.message); res.status(502).json({ error: 'Failed to reach original server', message: error.message, url: url }); } } // API endpoints for managing mock rules app.get('/api/rules', (req, res) => { res.json(mockRules); }); // Get available response files app.get('/api/files', async (req, res) => { try { const files = await fs.readdir('responses'); const fileList = await Promise.all( files.map(async (file) => { const filePath = path.join('responses', file); const stats = await fs.stat(filePath); return { name: file, size: stats.size, modified: stats.mtime, extension: path.extname(file) }; }) ); res.json(fileList); } catch (error) { res.status(500).json({ error: 'Failed to read response files' }); } }); // Configure multer for file uploads const upload = multer({ storage: multer.diskStorage({ destination: 'responses/', filename: (req, file, cb) => { // Keep original filename cb(null, file.originalname); } }), limits: { fileSize: 5 * 1024 * 1024 // 5MB limit }, fileFilter: (req, file, cb) => { // Accept common file types const allowedTypes = ['.json', '.xml', '.txt', '.html', '.css', '.js']; const ext = path.extname(file.originalname).toLowerCase(); if (allowedTypes.includes(ext)) { cb(null, true); } else { cb(new Error(`File type not allowed. Supported types: ${allowedTypes.join(', ')}`)); } } }); // Upload new response file app.post('/api/files/upload', upload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No file provided' }); } console.log(`📁 File uploaded: ${req.file.originalname} (${req.file.size} bytes)`); res.json({ success: true, file: { name: req.file.originalname, size: req.file.size, path: req.file.path } }); } catch (error) { console.error('❌ Upload endpoint error:', error); res.status(500).json({ error: 'Upload failed', message: error.message }); } }); // Get file content app.get('/api/files/:filename/content', async (req, res) => { try { const filename = req.params.filename; const filePath = path.join('responses', filename); // Check if file exists try { await fs.access(filePath); } catch { return res.status(404).json({ error: 'File not found' }); } const content = await fs.readFile(filePath, 'utf8'); console.log(`📖 File content requested: ${filename}`); // Set appropriate content type const ext = path.extname(filename).toLowerCase(); if (ext === '.json') { res.setHeader('Content-Type', 'application/json'); } else if (ext === '.xml') { res.setHeader('Content-Type', 'application/xml'); } else if (ext === '.html') { res.setHeader('Content-Type', 'text/html'); } else { res.setHeader('Content-Type', 'text/plain'); } res.send(content); } catch (error) { console.error('❌ Error reading file content:', error); res.status(500).json({ error: 'Failed to read file content', message: error.message }); } }); // Delete response file app.delete('/api/files/:filename', async (req, res) => { try { const filename = req.params.filename; const filePath = path.join('responses', filename); // Check if file exists try { await fs.access(filePath); } catch { return res.status(404).json({ error: 'File not found' }); } await fs.unlink(filePath); console.log(`🗑️ File deleted: ${filename}`); res.json({ success: true, message: `File ${filename} deleted successfully` }); } catch (error) { console.error('❌ Error deleting file:', error); res.status(500).json({ error: 'Failed to delete file', message: error.message }); } }); app.post('/api/rules', (req, res) => { const rule = req.body; addMockRule(rule); res.json({ success: true, rule: mockRules[mockRules.length - 1] }); }); app.put('/api/rules/:id', (req, res) => { const ruleId = req.params.id; const updatedRule = req.body; updateMockRule(ruleId, updatedRule); res.json({ success: true }); }); app.delete('/api/rules/:id', (req, res) => { const ruleId = req.params.id; deleteMockRule(ruleId); res.json({ success: true }); }); // Store mock rules from extension for quick lookup const extensionMockRules = new Map(); // Endpoint to receive mock rules from extension app.post('/api/extension-rules', (req, res) => { try { const rules = req.body.rules || []; console.log(`📋 Received ${rules.length} mock rules from extension`); // Clear existing rules and add new ones extensionMockRules.clear(); rules.forEach(rule => { extensionMockRules.set(rule.id, rule); console.log(`✅ Stored mock rule: ${rule.name} (ID: ${rule.id})`); }); res.json({ success: true, rulesStored: rules.length }); } catch (error) { console.error('❌ Error storing extension rules:', error); res.status(500).json({ error: 'Failed to store rules' }); } }); // New endpoint for Chrome extension mock response redirects (with rule ID) app.all('/api/mock-response/:ruleId', async (req, res) => { try { const ruleId = req.params.ruleId; const originalUrl = req.query.originalUrl; console.log(`🎭 Mock response request for rule ID: ${ruleId}`); console.log(`🌐 Original URL: ${originalUrl || 'not provided'}`); // Get the mock rule from stored rules const mockRule = extensionMockRules.get(ruleId); if (!mockRule) { console.error(`❌ Mock rule not found for ID: ${ruleId}`); return res.status(404).json({ error: 'Mock rule not found', ruleId: ruleId, availableRules: Array.from(extensionMockRules.keys()) }); } console.log(`✅ Processing mock rule: ${mockRule.name} (${mockRule.statusCode})`); // Handle the mock response using existing logic await handleMockResponse(mockRule, req, res); } catch (error) { console.error('❌ Mock response endpoint error:', error); res.status(500).json({ error: 'Mock response generation failed', message: error.message, timestamp: new Date().toISOString() }); } }); // Legacy endpoint for Chrome extension mock response redirects (query params) app.all('/api/mock-response', async (req, res) => { try { console.log(`🎭 Mock response request: ${req.method} ${req.originalUrl}`); console.log(`🔍 Query parameters:`, req.query); // Extract rule data from query parameters const { ruleId, ruleName, method, urlPattern, matchingType, statusCode, responseType, responseBody, responseHeaders, delay, originalUrl } = req.query; // Parse response headers if provided let parsedHeaders = {}; try { if (responseHeaders) { parsedHeaders = JSON.parse(responseHeaders); } } catch (e) { console.warn('⚠️ Failed to parse response headers:', e); } // Parse response body safely let parsedResponseBody = {}; try { if (responseBody) { // Handle case where responseBody might be "[object Object]" string if (responseBody === '[object Object]') { console.warn('⚠️ Received "[object Object]" string, using empty object'); parsedResponseBody = {}; } else { parsedResponseBody = JSON.parse(responseBody); } } } catch (e) { console.warn(`⚠️ Failed to parse response body "${responseBody}", using as string:`, e); parsedResponseBody = responseBody || ''; } // Create a mock rule object from the query parameters const mockRule = { id: ruleId || 'extension-rule', name: ruleName || 'Extension Mock Rule', method: method || 'ANY', urlPattern: urlPattern || '', matchingType: matchingType || 'regex', statusCode: parseInt(statusCode) || 200, responseType: responseType || 'json', responseBody: parsedResponseBody, responseHeaders: parsedHeaders, delay: parseInt(delay) || 0 }; console.log(`✅ Processing mock rule: ${mockRule.name} (${mockRule.statusCode})`); console.log(`🌐 Original URL: ${originalUrl || 'not provided'}`); // Handle the mock response using existing logic await handleMockResponse(mockRule, req, res); } catch (error) { console.error('❌ Mock response endpoint error:', error); res.status(500).json({ error: 'Mock response generation failed', message: error.message, timestamp: new Date().toISOString() }); } }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), mockRules: mockRules.length, connections: extensionConnections.size }); }); // Save/load mock rules to/from file async function saveMockRules() { try { await fs.writeFile('mock-rules.json', JSON.stringify(mockRules, null, 2)); } catch (error) { console.error('❌ Failed to save mock rules:', error); } } async function loadMockRules() { try { const data = await fs.readFile('mock-rules.json', 'utf8'); mockRules = JSON.parse(data); console.log(`📋 Loaded ${mockRules.length} mock rules from mock-rules.json`); } catch (error) { console.log('📋 No existing mock rules found, loading defaults...'); try { const defaultData = await fs.readFile('default-rules.json', 'utf8'); mockRules = JSON.parse(defaultData); console.log(`📋 Loaded ${mockRules.length} default mock rules`); // Save default rules as current rules await saveMockRules(); } catch (defaultError) { console.log('📋 No default rules found either, starting fresh'); mockRules = []; } } } // Create responses directory if it doesn't exist async function ensureDirectories() { try { await fs.mkdir('responses', { recursive: true }); } catch (error) { console.error('❌ Failed to create directories:', error); } } // Start server async function startServer() { await ensureDirectories(); await loadMockRules(); app.listen(PORT, () => { console.log('🚀 FreshRoute Server Started!'); console.log(`📡 HTTP Server: http://localhost:${PORT}`); console.log(`🔌 WebSocket Server: ws://localhost:${WS_PORT}`); console.log(`📋 Mock Rules: ${mockRules.length} loaded`); console.log(''); console.log('💡 Usage:'); console.log(' - Configure FreshRoute extension to route requests to this server'); console.log(' - Manage mock rules and other development features through the extension UI'); console.log(' - All intercepted requests and development tools are handled here'); console.log(''); }); } // Serve static files from extension directory app.use('/extension', express.static(path.join(__dirname, 'extension'))); // Serve setup page app.get('/setup', (req, res) => { res.sendFile(path.join(__dirname, 'setup.html')); }); // API endpoint to check Chrome management status app.get('/api/chrome-status', (req, res) => { let organizationManaged = false; try { // Check for organization management files const policyPaths = [ '/Library/Managed Preferences/com.google.Chrome.plist', path.join(os.homedir(), 'Library/Managed Preferences/com.google.Chrome.plist'), '/Library/Application Support/Google/Chrome/policies', path.join(os.homedir(), 'Library/Application Support/Google/Chrome/policies') ]; for (const policyPath of policyPaths) { if (fsSync.existsSync(policyPath)) { organizationManaged = true; break; } } } catch (error) { // Ignore errors, assume not managed } res.json({ organizationManaged, platform: process.platform, timestamp: new Date().toISOString() }); }); // API endpoint to get extension installation path app.get('/api/extension-path', (req, res) => { const extensionPath = path.join(os.homedir(), '.freshroute', 'extension'); res.json({ path: extensionPath, exists: fsSync.existsSync(extensionPath), platform: process.platform }); }); startServer().catch(console.error);