freshroute-server
Version:
Local development server for FreshRoute extension with API mocking and extended functionality
719 lines (624 loc) • 21.3 kB
JavaScript
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);