modulant-js
Version:
Stealth web extension framework for intercepting and routing web requests
729 lines (647 loc) • 24.7 kB
JavaScript
// Modulant.js - Stealth Web Extension Framework
(() => {
// Error codes
const ERROR_CODES = {
INITIALIZATION_FAILED: 'INIT_FAILED',
INVALID_ROUTE: 'INVALID_ROUTE',
PROXY_ERROR: 'PROXY_ERROR',
PARAMETER_ERROR: 'PARAM_ERROR',
SCRIPT_ERROR: 'SCRIPT_ERROR'
};
// Custom error class
class ModulantError extends Error {
constructor(message, code, context = {}) {
super(message);
this.name = 'ModulantError';
this.code = code;
this.context = context;
}
}
// Store metrics in localStorage to persist across navigation
const storage = {
memoryStore: new Map(),
getMetrics() {
try {
// Try localStorage first
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem('modulant_metrics');
if (stored) {
const parsed = JSON.parse(stored);
return new Map(parsed.map(([id, metric]) => [
Number(id),
{
...metric,
startTime: Number(metric.startTime)
}
]));
}
}
// Fallback to memory store
return new Map(this.memoryStore);
} catch (e) {
debug.error(ERROR_CODES.PARAMETER_ERROR, 'Failed to load metrics:', e);
return new Map(this.memoryStore);
}
},
setMetrics(metrics) {
try {
// Update memory store
this.memoryStore = new Map(metrics);
// Try localStorage if available
if (typeof localStorage !== 'undefined') {
localStorage.setItem('modulant_metrics',
JSON.stringify(Array.from(metrics.entries()))
);
}
} catch (e) {
debug.error(ERROR_CODES.PARAMETER_ERROR, 'Failed to save metrics:', e);
}
}
};
// Performance monitoring
const performance = {
metrics: storage.getMetrics(),
nextId: 1,
generateId: () => {
// Ensure unique IDs even for concurrent requests
const id = Date.now() * 1000 + performance.nextId;
performance.nextId = (performance.nextId + 1) % 1000;
return id;
},
startTimer: (id) => {
debug.log('Starting timer for request:', id);
performance.metrics.set(id, {
startTime: Date.now(),
status: 'in-progress'
});
storage.setMetrics(performance.metrics);
},
endTimer: (id, status = 'completed') => {
debug.log('Ending timer for request:', id);
const metric = performance.metrics.get(id);
if (metric) {
const duration = Date.now() - metric.startTime;
debug.performance('Request completed:', duration);
// Update metric status but keep in metrics Map
performance.metrics.set(id, {
startTime: metric.startTime,
duration,
status
});
storage.setMetrics(performance.metrics);
return duration;
}
return null;
},
clearMetrics: (id) => {
if (id) {
debug.log('Clearing metrics for request:', id);
performance.metrics.delete(id);
} else {
debug.log('Clearing all metrics');
performance.metrics.clear();
}
storage.setMetrics(performance.metrics);
}
};
// Debug logging utility
const debug = {
log: (...args) => {
if (typeof process !== 'undefined' && process.env.DEBUG_MODULANT === 'true') {
const log = Function.prototype.bind.call(console.log, console);
log('[ModulantJS Debug]', ...args);
}
},
error: (code, message, context) => {
if (typeof process !== 'undefined' && process.env.DEBUG_MODULANT === 'true') {
const error = Function.prototype.bind.call(console.error, console);
error(`[ModulantJS Error] ${code}:`, message, context);
}
},
performance: (operation, duration) => {
if (typeof process !== 'undefined' && process.env.DEBUG_MODULANT === 'true') {
const log = Function.prototype.bind.call(console.log, console);
log(`[ModulantJS Performance] ${operation}: ${duration}ms`);
}
}
};
class Modulant {
constructor(config = {}) {
// Core configuration
this.config = {
primaryServerURL: config.primaryServerURL || (typeof window !== 'undefined' ? window.location.origin : null),
secondaryServerURL: config.secondaryServerURL,
routes: config.routes || [],
injectScript: config.injectScript,
defaultHeaders: {
...config.defaultHeaders,
'X-Requested-With': 'XMLHttpRequest',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin'
},
// New URL parameter configuration
parameterConfig: {
...config.parameterConfig,
transformHooks: config.parameterConfig?.transformHooks || {},
parameterRules: config.parameterConfig?.parameterRules || {},
defaultValues: config.parameterConfig?.defaultValues || {},
filterPatterns: config.parameterConfig?.filterPatterns || []
}
};
// State tracking
this._state = {
isActive: false,
proxyFrame: null,
originalFunctions: new Map()
};
// Initialize only if in browser
if (typeof window !== 'undefined' && window.document) {
this._initPromise = this._initialize();
}
}
// New URL parameter handling methods
_processURLParameters(url, route) {
try {
debug.log('Processing URL parameters for:', url);
const parsedUrl = new URL(url, window.location.origin);
debug.log('Parsed URL:', parsedUrl.toString());
const params = new URLSearchParams(parsedUrl.search);
debug.log('Original parameters:', Object.fromEntries(params.entries()));
const processedParams = new URLSearchParams();
// Apply parameter rules and transformations
parameterLoop: for (const [key, value] of params.entries()) {
debug.log(`\nProcessing parameter: ${key}=${value}`);
// Check filter patterns
if (this._shouldFilterParameter(key)) {
debug.log(`Parameter ${key} filtered by pattern`);
continue parameterLoop;
}
// Apply transformation hooks
const transformedValue = this._applyTransformHooks(key, value);
debug.log(`Transformed value for ${key}:`, transformedValue);
// Get validation rules for this parameter
const rules = this.config.parameterConfig.parameterRules[key];
debug.log(`Validation rules for ${key}:`, rules);
// If parameter has rules, validate it
if (rules) {
debug.log(`Validating parameter ${key} with rules:`, rules);
const isValid = this._validateParameter(key, transformedValue);
debug.log(`Validation result for ${key}: ${isValid}`);
if (!isValid) {
debug.log(`Parameter ${key} failed validation, skipping`);
continue parameterLoop;
}
}
// Add parameter if it passed validation or has no rules
debug.log(`Adding parameter ${key}=${transformedValue} to processed params`);
processedParams.append(key, transformedValue);
}
// Add default values for missing parameters
this._addDefaultParameters(processedParams);
debug.log('Parameters after adding defaults:', Object.fromEntries(processedParams.entries()));
// Build final URL
const targetUrl = new URL(route.proxy.target + parsedUrl.pathname);
targetUrl.search = processedParams.toString();
debug.log('Final processed URL:', targetUrl.toString());
return targetUrl.toString();
} catch (error) {
debug.error(ERROR_CODES.PARAMETER_ERROR, 'Error processing URL parameters:', error);
return url;
}
}
_shouldFilterParameter(key) {
const shouldFilter = this.config.parameterConfig.filterPatterns.some(pattern =>
new RegExp(pattern).test(key)
);
debug.log(`Filter check for ${key}: ${shouldFilter}`);
return shouldFilter;
}
_applyTransformHooks(key, value) {
const hook = this.config.parameterConfig.transformHooks[key];
if (typeof hook === 'function') {
try {
const transformed = hook(value);
debug.log(`Transform hook applied for ${key}: ${value} -> ${transformed}`);
return transformed;
} catch (error) {
debug.error(ERROR_CODES.PARAMETER_ERROR, `Error in transform hook for ${key}:`, error);
return value;
}
}
return value;
}
_validateParameter(key, value) {
const rules = this.config.parameterConfig.parameterRules[key];
if (!rules) {
debug.log(`No validation rules for ${key}`);
return true;
}
try {
// Check required rule
if (rules.required && (!value || value.trim() === '')) {
debug.log(`Parameter ${key} failed required validation`);
return false;
}
// Check pattern rule
if (rules.pattern) {
const pattern = new RegExp(rules.pattern);
const matches = pattern.test(value);
debug.log(`Pattern validation for ${key}: ${rules.pattern} -> ${matches}`);
if (!matches) {
debug.log(`Parameter ${key} failed pattern validation`);
return false;
}
}
// Check custom validation
if (rules.validate && typeof rules.validate === 'function') {
const isValid = rules.validate(value);
debug.log(`Custom validation for ${key}: ${isValid}`);
if (!isValid) {
debug.log(`Parameter ${key} failed custom validation`);
return false;
}
}
debug.log(`Parameter ${key} passed all validations`);
return true;
} catch (error) {
debug.error(ERROR_CODES.PARAMETER_ERROR, `Error validating parameter ${key}:`, error);
return false;
}
}
_addDefaultParameters(params) {
for (const [key, value] of Object.entries(this.config.parameterConfig.defaultValues)) {
if (!params.has(key)) {
debug.log(`Adding default value for ${key}: ${value}`);
params.append(key, value);
}
}
}
_initialize() {
return new Promise((resolve, reject) => {
try {
// Create proxy frame
this._state.proxyFrame = this._createProxyFrame();
// Store original functions
this._state.originalFunctions.set('fetch', window.fetch);
this._state.originalFunctions.set('XMLHttpRequest', window.XMLHttpRequest);
this._state.originalFunctions.set('open', window.open);
this._state.originalFunctions.set('pushState', history.pushState);
// Initialize interceptors
this._interceptFetch();
this._interceptXHR();
this._interceptNavigation();
// Wait for frame to be ready
const readyHandler = (event) => {
if (event.data === 'modulant:ready') {
window.removeEventListener('message', readyHandler);
this._state.isActive = true;
resolve();
}
};
window.addEventListener('message', readyHandler);
// Add test event handler
window.addEventListener('message', (event) => {
debug.log('[Modulant Parent] Received message:', event.data);
});
} catch (error) {
reject(new ModulantError('Initialization failed', ERROR_CODES.INITIALIZATION_FAILED, error));
}
});
}
_createProxyFrame() {
const frame = document.createElement('iframe');
frame.style.display = 'none';
frame.sandbox = 'allow-scripts allow-same-origin';
const html = `
<!DOCTYPE html>
<html>
<head>
<script>
// Debug logging
function log(msg) {
if (typeof process !== 'undefined' && process.env.DEBUG_MODULANT === 'true') {
console.log('[Modulant Frame]', msg);
}
}
// Execute injected script
try {
log('Executing injected script');
${this.config.injectScript || ''}
log('Injected script executed');
} catch (error) {
log('Error executing injected script: ' + error);
}
// Set up message handling
window.addEventListener('message', async (event) => {
log('Received raw message: ' + JSON.stringify(event));
if (!event.data) return;
log('Processing message: ' + JSON.stringify(event.data));
if (event.data.type === 'proxy') {
try {
const response = await fetch(event.data.url, {
...event.data.init,
headers: {
...event.data.init?.headers,
...${JSON.stringify(this.config.defaultHeaders)}
}
});
const body = await response.text();
event.source.postMessage({
id: event.data.id,
body,
status: response.status,
headers: Object.fromEntries(response.headers)
}, event.origin);
} catch (error) {
event.source.postMessage({
id: event.data.id,
error: error.message
}, event.origin);
}
} else if (event.data === 'test-custom-event') {
log('Received test-custom-event');
try {
window.parent.postMessage('custom-event-fired', '*');
log('Sent custom-event-fired');
} catch (error) {
log('Error sending custom-event-fired: ' + error);
}
}
});
// Signal ready state
log('Sending ready signal');
window.parent.postMessage('modulant:ready', '*');
log('Ready signal sent');
</script>
</head>
<body></body>
</html>
`;
const blob = new Blob([html], { type: 'text/html' });
frame.src = URL.createObjectURL(blob);
document.body.appendChild(frame);
return frame;
}
sendTestEvent() {
debug.log('[Modulant Parent] Sending test event');
if (this._state.proxyFrame && this._state.proxyFrame.contentWindow) {
this._state.proxyFrame.contentWindow.postMessage('test-custom-event', '*');
debug.log('[Modulant Parent] Test event sent');
} else {
debug.log('[Modulant Parent] No proxy frame available');
}
}
_findMatchingRoute(url) {
try {
const parsedUrl = new URL(url, window.location.origin);
return this.config.routes.find(route => {
if (!route.match || !route.match.hostname || !route.match.path) return false;
const hostMatch = route.match.hostname === parsedUrl.hostname;
const pathPattern = new RegExp(route.match.path.replace(/\*/g, '.*'));
const pathMatch = pathPattern.test(parsedUrl.pathname);
return hostMatch && pathMatch;
});
} catch (error) {
debug.error(ERROR_CODES.INVALID_ROUTE, 'Error finding matching route:', error);
return null;
}
}
async _proxyRequest(url, init = {}) {
const route = this._findMatchingRoute(url);
if (!route || !route.proxy || !route.proxy.target) {
try {
return await this._state.originalFunctions.get('fetch')(url, init);
} catch (error) {
throw new ModulantError(error.message, ERROR_CODES.PROXY_ERROR, { url, error });
}
}
return new Promise((resolve, reject) => {
const id = performance.generateId();
performance.startTimer(id);
const cleanup = setTimeout(() => {
window.removeEventListener('message', handler);
// Keep as in-progress for timeout
debug.performance('Request timeout');
performance.endTimer(id, 'in-progress');
reject(new ModulantError('Request timeout', ERROR_CODES.PROXY_ERROR, { url }));
}, 30000);
const handler = async (event) => {
if (!event.data || event.data.id !== id) return;
window.removeEventListener('message', handler);
clearTimeout(cleanup);
if (event.data.error) {
// Keep as in-progress for error
debug.performance('Failed request');
performance.endTimer(id, 'in-progress');
reject(new ModulantError(event.data.error, ERROR_CODES.PROXY_ERROR, { url }));
return;
}
try {
let response = new Response(event.data.body, {
status: event.data.status,
headers: event.data.headers
});
// Handle error status codes
if (response.status >= 400) {
// Keep as in-progress for error responses
debug.performance('Error response');
performance.endTimer(id, 'in-progress');
reject(new ModulantError(
`HTTP error ${response.status}`,
ERROR_CODES.PROXY_ERROR,
{ url, status: response.status }
));
return;
}
// Apply response modifications if configured
if (route.proxy.modifyResponse) {
debug.log('Applying response modifications');
response = await route.proxy.modifyResponse(response);
}
// Process URL parameters for the proxy URL
const proxyUrl = this._processURLParameters(url, route);
Object.defineProperties(response, {
url: { value: proxyUrl },
text: { value: async () => event.data.body }
});
const duration = performance.endTimer(id, 'completed');
debug.performance('Successful request', duration);
resolve(response);
} catch (error) {
// Keep as in-progress for error
debug.performance('Response processing error');
performance.endTimer(id, 'in-progress');
reject(new ModulantError(
'Error processing response',
ERROR_CODES.PROXY_ERROR,
{ url, error: error.message }
));
}
};
window.addEventListener('message', handler);
// Use processed URL for the proxy request
const processedUrl = this._processURLParameters(url, route);
this._state.proxyFrame.contentWindow.postMessage({
type: 'proxy',
id,
url: processedUrl,
init
}, '*');
});
}
_interceptFetch() {
window.fetch = async (input, init = {}) => {
const url = typeof input === 'string' ? input : input.url;
return this._proxyRequest(url, init);
};
}
_interceptXHR() {
const XHR = window.XMLHttpRequest;
const self = this;
window.XMLHttpRequest = function() {
const xhr = new XHR();
const open = xhr.open;
xhr.open = function(method, url, ...args) {
this._modulantUrl = url;
this._modulantMethod = method;
open.call(this, method, url, ...args);
};
const send = xhr.send;
xhr.send = async function(body) {
const route = self._findMatchingRoute(this._modulantUrl);
if (!route) {
send.call(this, body);
return;
}
try {
const response = await self._proxyRequest(this._modulantUrl, {
method: this._modulantMethod,
body
});
const text = await response.text();
Object.defineProperties(this, {
responseText: { value: text },
status: { value: response.status },
readyState: { value: 4 }
});
this.onload?.();
} catch (error) {
this.onerror?.(error);
}
};
return xhr;
};
}
_interceptNavigation() {
document.addEventListener('click', (event) => {
const link = event.target.closest('a');
if (!link) return;
const route = this._findMatchingRoute(link.href);
if (!route) return;
event.preventDefault();
// Process URL parameters for navigation
const proxyUrl = this._processURLParameters(link.href, route);
window.location.href = proxyUrl;
}, true);
window.open = (url, ...args) => {
const route = this._findMatchingRoute(url);
if (!route) return this._state.originalFunctions.get('open')(url, ...args);
// Process URL parameters for window.open
const proxyUrl = this._processURLParameters(url, route);
return this._state.originalFunctions.get('open')(proxyUrl, ...args);
};
history.pushState = (state, title, url) => {
const route = this._findMatchingRoute(url);
if (!route) return this._state.originalFunctions.get('pushState').call(history, state, title, url);
const parsedUrl = new URL(url, window.location.origin);
// Process URL parameters for pushState
const proxyUrl = this._processURLParameters(url, route);
return this._state.originalFunctions.get('pushState').call(
history,
{ ...state, modulant: true, targetUrl: proxyUrl },
title,
parsedUrl.pathname
);
};
}
addRoute(route) {
try {
// Handle both object and pattern/target formats
if (typeof route === 'string') {
const pattern = route;
const target = arguments[1];
route = {
match: {
hostname: window.location.hostname,
path: pattern
},
proxy: {
target: target === 'secondary' ? this.config.secondaryServerURL : target
}
};
}
// Validate route configuration
if (!route.match?.hostname || !route.match?.path || !route.proxy?.target) {
throw new ModulantError(
'Invalid route configuration',
ERROR_CODES.INVALID_ROUTE,
{ route }
);
}
this.config.routes.push(route);
return route;
} catch (error) {
if (error instanceof ModulantError) throw error;
throw new ModulantError(
'Error adding route',
ERROR_CODES.INVALID_ROUTE,
{ route, error: error.message }
);
}
}
isActive() {
return this._state.isActive;
}
static async init(config) {
try {
const instance = new Modulant(config);
await instance._initPromise;
return instance;
} catch (error) {
throw new ModulantError(
'Initialization failed',
ERROR_CODES.INITIALIZATION_FAILED,
{ error: error.message }
);
}
}
// Performance monitoring methods
getRequestMetrics() {
// Load latest metrics from storage
performance.metrics = storage.getMetrics();
const metrics = Array.from(performance.metrics.entries()).map(([id, metric]) => {
const duration = metric.duration || (
metric.status === 'in-progress' ? Date.now() - metric.startTime : undefined
);
return {
id,
duration,
status: metric.status
};
});
debug.log('Current metrics:', metrics);
return metrics;
}
}
// Expose Modulant and its error types
if (typeof module !== 'undefined' && module.exports) {
module.exports = Modulant;
module.exports.ModulantError = ModulantError;
module.exports.ERROR_CODES = ERROR_CODES;
}
if (typeof window !== 'undefined') {
window.Modulant = Modulant;
window.Modulant.ModulantError = ModulantError;
window.Modulant.ERROR_CODES = ERROR_CODES;
}
})();