mcp-web-ui
Version:
Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size
606 lines (525 loc) • 20.7 kB
JavaScript
/**
* BaseComponent - Core Foundation for Vanilla JS MCP Web UI Components
*
* This is the foundational class that all MCP UI components inherit from.
* It provides:
* - Built-in XSS protection through automatic HTML sanitization
* - Perfect CSP compliance (no eval, no Function constructor)
* - Efficient DOM updates with smart diffing
* - Secure event handling with validation
* - Session-based authentication
* - Real-time data polling with optimizations
*
* SECURITY FEATURES:
* - All user input is automatically sanitized
* - Template literals use built-in XSS protection
* - Event handlers validate event authenticity
* - Rate limiting prevents abuse
* - No eval() or Function() constructor usage
*
* DESIGN PHILOSOPHY:
* - Lightweight: ~1KB per component
* - Zero dependencies: No external libraries
* - CSP compliant: Perfect security headers
* - AI-friendly: Extensively documented for agents
* - Disposable: Easy to copy-paste and modify
*/
class BaseComponent {
/**
* Constructor for BaseComponent
* @param {HTMLElement} element - The DOM element to attach this component to
* @param {Array} data - The initial data array for this component
* @param {Object} config - Configuration object for this component
* @param {string} config.sessionToken - Authentication token for API calls
* @param {number} config.pollInterval - How often to poll for data updates (ms)
* @param {string} config.apiBase - Base URL for API endpoints
* @param {Object} config.security - Security configuration options
*/
constructor(element, data = [], config = {}) {
// Core properties
this.element = element;
this.data = data;
this.config = {
// Default configuration with security-first settings
sessionToken: '',
pollInterval: 2000,
apiBase: '/api',
maxRetries: 3,
rateLimitWindow: 5000, // 5 seconds
maxActionsPerWindow: 10,
security: {
sanitizeInput: true,
validateEvents: true,
enableRateLimit: true,
maxInputLength: 1000
},
...config
};
// Event management
this.listeners = new Map();
this.pollingInterval = null;
this.retryCount = 0;
// Rate limiting for security
this.actionTimestamps = [];
// State management
this.isDestroyed = false;
this.lastDataHash = null;
// XSS protection character map
this.escapeMap = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
'`': '`',
'=': '='
};
// Initialize component after allowing subclass constructor to complete
// This prevents the timing issue where init() is called before subclass properties are set
setTimeout(() => {
if (!this.isDestroyed) {
this.init();
}
}, 0);
}
/**
* Initialize the component
* This is called automatically by the constructor
*/
init() {
if (this.isDestroyed) return;
try {
this.render();
this.bindEvents();
this.startPolling();
this.log('INFO', `Component initialized on element: ${this.element.id || this.element.className}`);
} catch (error) {
this.log('ERROR', `Failed to initialize component: ${error.message}`);
this.handleError(error);
}
}
/**
* Secure HTML template function with built-in XSS protection
* This is the core security feature - ALL user content is automatically sanitized
*
* Usage:
* this.html`<div>${userInput}</div>` // userInput is automatically sanitized
* this.html`<span class="${className}">${content}</span>` // All values sanitized
*
* @param {Array} strings - Template literal strings
* @param {...any} values - Values to be inserted (will be sanitized)
* @returns {string} Safe HTML string with all values sanitized
*/
html(strings, ...values) {
const sanitizedValues = values.map(value => {
// Handle different value types securely
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'boolean' || typeof value === 'number') {
return String(value);
}
if (Array.isArray(value)) {
// For arrays, join safely (used for lists of HTML)
return value.join('');
}
// Check if this is trusted HTML (starts with html marker)
if (typeof value === 'string' && value.startsWith('__HTML__:')) {
// This is trusted HTML from a template - don't sanitize
return value.substring(9);
}
// Sanitize strings for XSS protection
return this.sanitize(String(value));
});
return strings.reduce((result, string, i) => {
return result + string + (sanitizedValues[i] || '');
}, '');
}
/**
* Mark content as trusted HTML that should not be sanitized
* Use this carefully and only for content you control
* @param {string} htmlContent - HTML content to mark as trusted
* @returns {string} Marked HTML content
*/
trustedHtml(htmlContent) {
return '__HTML__:' + htmlContent;
}
/**
* XSS Protection: Sanitize user input
* This function prevents all forms of XSS attacks by escaping dangerous characters
*
* @param {string} value - The string to sanitize
* @returns {string} Safe string with dangerous characters escaped
*/
sanitize(value) {
if (typeof value !== 'string') {
return value;
}
// Enhanced sanitization for comprehensive XSS protection
return value.replace(/[&<>"'`=\/]/g, (char) => {
return this.escapeMap[char] || char;
});
}
/**
* Advanced Content Security for LLM-generated content
* This provides context-aware sanitization for AI-generated content
*
* @param {string} content - Content to sanitize (potentially from LLM)
* @param {string} context - The context this content will be used in
* @returns {string} Sanitized content appropriate for the context
*/
sanitizeLLMContent(content, context = 'text') {
if (!content || typeof content !== 'string') {
return '';
}
// Layer 1: Remove dangerous script content
let clean = content
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '')
.replace(/data:text\/html/gi, '');
// Layer 2: Context-specific cleaning
switch (context) {
case 'todo-text':
// For todo text: allow basic characters, limit length
return clean.replace(/[<>{}[\]\\]/g, '').substring(0, 500);
case 'category':
// For categories: only alphanumeric and basic punctuation
return clean.replace(/[^a-zA-Z0-9\s\-_]/g, '').substring(0, 50);
case 'priority':
// For priority: only specific allowed values
const allowedPriorities = ['low', 'medium', 'high', 'urgent'];
return allowedPriorities.includes(clean.toLowerCase()) ? clean.toLowerCase() : 'medium';
default:
// Default: full HTML escaping
return this.sanitize(clean);
}
}
/**
* Smart data update with efficient DOM diffing
* Only updates the DOM when data actually changes, preserving performance
*
* @param {Array} newData - New data to update to
*/
update(newData) {
if (this.isDestroyed) return;
// Generate hash for quick comparison
const newDataHash = this.hashData(newData);
// Only update if data actually changed
if (newDataHash !== this.lastDataHash) {
const oldData = this.data;
this.data = newData;
this.lastDataHash = newDataHash;
try {
this.render();
this.log('DEBUG', 'Component updated with new data');
} catch (error) {
// Rollback on render error
this.data = oldData;
this.lastDataHash = this.hashData(oldData);
this.log('ERROR', `Failed to update component: ${error.message}`);
this.handleError(error);
}
}
}
/**
* Generate a simple hash of data for change detection
* @param {any} data - Data to hash
* @returns {string} Simple hash string
*/
hashData(data) {
return JSON.stringify(data).length + ':' + JSON.stringify(data).slice(0, 100);
}
/**
* Secure event binding with validation and rate limiting
* This prevents malicious event handling and provides built-in security
*
* @param {string} event - Event type (click, change, etc.)
* @param {string} selector - CSS selector for event delegation
* @param {Function} handler - Event handler function
*/
on(event, selector, handler) {
const secureHandler = (e) => {
// Security check: validate event authenticity
if (this.config.security.validateEvents && e.isTrusted === false) {
this.log('WARN', 'Ignored untrusted event');
return;
}
// Note: Rate limiting is now handled at the API level, not for UI events
// This allows normal user interactions while protecting against API abuse
// Element matching
if (e.target.matches(selector)) {
try {
handler(e);
} catch (error) {
this.log('ERROR', `Event handler error: ${error.message}`);
this.handleError(error);
}
}
};
this.element.addEventListener(event, secureHandler);
this.listeners.set(`${event}:${selector}`, secureHandler);
this.log('DEBUG', `Bound event: ${event} on ${selector}`);
}
/**
* Rate limiting check to prevent abuse
* @returns {boolean} True if action should be rate limited
*/
isRateLimited() {
const now = Date.now();
const window = this.config.rateLimitWindow;
const maxActions = this.config.maxActionsPerWindow;
// Clean old timestamps
this.actionTimestamps = this.actionTimestamps.filter(timestamp =>
now - timestamp < window
);
// Check if we're over the limit
if (this.actionTimestamps.length >= maxActions) {
return true;
}
// Record this action
this.actionTimestamps.push(now);
return false;
}
/**
* Secure API call with authentication and error handling
* All API calls go through this method for consistent security
*
* @param {string} endpoint - API endpoint to call
* @param {Object} options - Request options
* @returns {Promise} API response
*/
async apiCall(endpoint, options = {}) {
if (this.isDestroyed) {
throw new Error('Component destroyed');
}
// Rate limiting check for API calls (prevents API abuse)
if (this.config.security.enableRateLimit && this.isRateLimited()) {
this.log('WARN', 'API call rate limited');
throw new Error('API call rate limited - please wait before making another request');
}
const url = `${this.config.apiBase}${endpoint}?token=${this.config.sessionToken}`;
const requestOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Session-Token': this.config.sessionToken,
...options.headers
},
...options
};
try {
const response = await fetch(url, requestOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
this.retryCount = 0; // Reset retry count on success
return result;
} catch (error) {
this.log('ERROR', `API call failed: ${error.message}`);
// Retry logic for transient failures
if (this.retryCount < this.config.maxRetries) {
this.retryCount++;
this.log('INFO', `Retrying API call (${this.retryCount}/${this.config.maxRetries})`);
// Exponential backoff
await this.sleep(Math.pow(2, this.retryCount) * 1000);
return this.apiCall(endpoint, options);
}
throw error;
}
}
/**
* Start polling for data updates
* Uses smart polling that respects page visibility and user activity
*/
startPolling() {
if (this.pollingInterval || !this.config.pollInterval) {
return;
}
let isPageVisible = !document.hidden;
// Adjust polling based on page visibility
document.addEventListener('visibilitychange', () => {
isPageVisible = !document.hidden;
if (isPageVisible) {
// Immediate fetch when page becomes visible
this.fetchData();
}
});
this.pollingInterval = setInterval(() => {
// Only poll when page is visible and component is active
if (isPageVisible && !this.isDestroyed) {
this.fetchData();
}
}, this.config.pollInterval);
this.log('DEBUG', `Started polling every ${this.config.pollInterval}ms`);
}
/**
* Fetch fresh data from the server
* This is called by the polling mechanism and can be called manually
*/
async fetchData() {
try {
const result = await this.apiCall('/data');
if (result.success && result.data) {
this.update(result.data);
}
} catch (error) {
this.log('ERROR', `Failed to fetch data: ${error.message}`);
// Don't throw - polling should continue despite individual failures
}
}
/**
* Handle user actions (add, update, delete, etc.)
* @param {string} action - Action type
* @param {Object} data - Action data
*/
async handleAction(action, data) {
try {
// Validate action
if (!action || typeof action !== 'string') {
throw new Error('Invalid action');
}
// Sanitize data
const sanitizedData = this.sanitizeActionData(data);
const result = await this.apiCall('/update', {
method: 'POST',
body: JSON.stringify({ action, data: sanitizedData })
});
if (!result.success) {
throw new Error(result.error || 'Action failed');
}
this.log('INFO', `Action completed: ${action}`);
// Only refresh data if the response doesn't contain form data
// Form responses need to be handled by the component directly
// Check both top-level and nested data.showForm
const hasFormData = result.showForm || (result.data && result.data.showForm);
if (!hasFormData) {
await this.fetchData();
}
// Return the actual response data if it's nested in result.data
// This handles the case where the server wraps responses in { success, data, timestamp }
if (result.data && typeof result.data === 'object' && result.data.success !== undefined) {
return result.data;
}
return result;
} catch (error) {
this.log('ERROR', `Action failed: ${error.message}`);
this.handleError(error);
throw error;
}
}
/**
* Sanitize action data to prevent injection attacks
* @param {Object} data - Data to sanitize
* @returns {Object} Sanitized data
*/
sanitizeActionData(data) {
if (!data || typeof data !== 'object') {
return {};
}
const sanitized = {};
for (const [key, value] of Object.entries(data)) {
// Sanitize key names
const cleanKey = key.replace(/[^a-zA-Z0-9_]/g, '');
if (typeof value === 'string') {
// Apply length limits and content sanitization
const maxLength = this.config.security.maxInputLength || 1000;
sanitized[cleanKey] = this.sanitizeLLMContent(value.substring(0, maxLength), cleanKey);
} else if (typeof value === 'boolean' || typeof value === 'number') {
sanitized[cleanKey] = value;
} else if (value === null || value === undefined) {
sanitized[cleanKey] = value;
} else {
// Skip complex objects for security
this.log('WARN', `Skipped complex object for key: ${key}`);
}
}
return sanitized;
}
/**
* Utility function for delays
* @param {number} ms - Milliseconds to sleep
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Error handling with user-friendly messages
* @param {Error} error - Error to handle
*/
handleError(error) {
// Could be extended to show user notifications, send error reports, etc.
console.error('Component Error:', error);
// Example: Show user-friendly error message
if (this.element && !this.isDestroyed) {
const errorEl = this.element.querySelector('.error-message');
if (errorEl) {
errorEl.textContent = 'Something went wrong. Please try again.';
errorEl.style.display = 'block';
// Auto-hide error after 5 seconds
setTimeout(() => {
if (errorEl) errorEl.style.display = 'none';
}, 5000);
}
}
}
/**
* Logging utility for debugging and monitoring
* @param {string} level - Log level (DEBUG, INFO, WARN, ERROR)
* @param {string} message - Log message
*/
log(level, message) {
const timestamp = new Date().toISOString();
const componentName = this.constructor.name;
console.log(`[${timestamp}][${level}][${componentName}] ${message}`);
}
/**
* Clean up component and remove all event listeners
* This prevents memory leaks and should be called when removing components
*/
destroy() {
this.isDestroyed = true;
// Stop polling
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
// Remove all event listeners
this.listeners.forEach((handler, key) => {
const [event] = key.split(':');
this.element.removeEventListener(event, handler);
});
this.listeners.clear();
// Clear data references
this.data = null;
this.config = null;
this.log('INFO', 'Component destroyed and cleaned up');
}
// Abstract methods that subclasses must implement
/**
* Render the component's HTML
* This method must be implemented by all subclasses
* Use this.html`` for secure templating
*/
render() {
throw new Error('render() method must be implemented by subclasses');
}
/**
* Bind event listeners
* This method must be implemented by all subclasses
* Use this.on() for secure event binding
*/
bindEvents() {
throw new Error('bindEvents() method must be implemented by subclasses');
}
}
// Export for module systems (when used with build tools)
if (typeof module !== 'undefined' && module.exports) {
module.exports = BaseComponent;
}
// Make available globally for vanilla JS usage
if (typeof window !== 'undefined') {
window.BaseComponent = BaseComponent;
}