/** * Base Module Class * Provides common functionality and enforces a consistent interface for all modules */ import { moduleRegistry } from './module-registry.js'; export class BaseModule { constructor(id, name) { this.id = id; this.name = name; this.state = 'PENDING'; this.progress = 0; this.progressCallback = null; // Add standard event target for custom events this.eventTarget = document.createElement('div'); // Add standard configuration object this.config = {}; // Track event listeners for cleanup this._eventListeners = []; // Resource loading tracking this._loadingResources = new Map(); this._totalResources = 0; this._loadedResources = 0; // Dependencies this.dependencies = []; this._loadedDependencies = new Map(); // Register after subclass constructors have assigned dependencies. // Several older modules still call moduleRegistry.register explicitly; // the registry treats those calls as idempotent. queueMicrotask(() => { moduleRegistry.register(this); }); } /** * Initialize the module interface * @param {Function} progressCallback - Function to report progress * @returns {Promise} - Resolves when initialization is complete */ async initializeInterface(progressCallback) { this.progressCallback = progressCallback; try { this.changeState('LOADING'); this.reportProgress(10, "Starting initialization"); // Skip loadDependencies() call - now handled automatically // Wait for dependencies const depStatus = await this._waitForModuleDependencies(); if (!depStatus) { // If dependencies aren't available, report waiting this.changeState('WAITING'); return Promise.resolve(false); } this.changeState('INITIALIZING'); const initResult = await this.initialize(); if (initResult) { this.changeState('FINISHED'); this.reportProgress(100, "Initialization complete"); } else { this.changeState('ERROR'); this.reportProgress(100, "Initialization failed"); } return initResult; } catch (error) { console.error(`Error in module ${this.id}:`, error); this.changeState('ERROR'); this.reportProgress(100, "Error during initialization"); return Promise.resolve(false); } } /** * Wait for module dependencies * @returns {Promise} - Resolves when dependencies are ready */ async _waitForModuleDependencies() { if (!this.dependencies || this.dependencies.length === 0) { return true; } try { this.reportProgress(15, "Waiting for dependencies"); // Get moduleRegistry - first try import then fallback to window const registry = moduleRegistry; return this._continueWaitForDependencies(registry); } catch (error) { console.error(`${this.id}: Error waiting for dependencies:`, error); return false; } } /** * Continue waiting for dependencies using the provided registry * @param {ModuleRegistry} registry - The module registry * @returns {Promise} - Resolves when dependencies are ready * @private */ async _continueWaitForDependencies(registry) { try { // Wait for all dependencies to be ready const results = await registry.waitForModules(this.dependencies); // Store references to dependencies let hasErroredDependencies = false; for (let i = 0; i < this.dependencies.length; i++) { const depId = this.dependencies[i]; const depModule = registry.getModule(depId); if (depModule) { this._loadedDependencies.set(depId, depModule); // Check if this dependency is in ERROR state if (depModule.state === 'ERROR') { hasErroredDependencies = true; console.warn(`${this.id}: Dependency ${depId} is in ERROR state but will be considered resolved`); } } } // Check if all dependencies have resolved (either success or error) // We consider a module with ERROR state as resolved const allDepsResolved = results.every(result => result === true || result === false); if (allDepsResolved) { if (hasErroredDependencies) { this.reportProgress(20, "Dependencies resolved with some errors"); } else { this.reportProgress(20, "Dependencies ready"); } return true; } else { this.reportProgress(15, "Some dependencies not ready"); return false; } } catch (error) { console.error(`${this.id}: Error in _continueWaitForDependencies:`, error); return false; } } /** * Initialize the module - Override this in child classes * @returns {Promise} - Resolves when initialization is complete */ async initialize() { return Promise.resolve(true); } /** * Change the module state and dispatch an event * @param {string} state - The new state */ changeState(state) { this.state = state; document.dispatchEvent(new ModuleEvent('stateChange', this.id, { state })); } /** * Report progress to the module loader * @param {number} percent - Progress percentage (0-100) * @param {string} message - Status message */ reportProgress(percent, message) { this.progress = Math.min(100, Math.max(0, percent)); if (this.progressCallback && typeof this.progressCallback === 'function') { this.progressCallback(percent, message); } else { document.dispatchEvent(new ModuleEvent('progress', this.id, { progress: percent })); if (message) { document.dispatchEvent(new ModuleEvent('message', this.id, { message })); } } } /** * Get the current module state * @returns {string} - Current state */ getState() { return this.state; } /** * Get the module ID * @returns {string} - Module ID */ getId() { return this.id; } /** * Get the module name * @returns {string} - Module name */ getName() { return this.name; } /** * Dispatch a module event * @param {string} name - Event name * @param {Object} detail - Event details */ dispatchEvent(name, detail = {}) { const event = new CustomEvent(name, { detail: { moduleId: this.id, ...detail }, bubbles: true }); document.dispatchEvent(event); return event; } /** * Add an event listener with automatic tracking for cleanup * @param {EventTarget} target - Event target (document, window, etc) * @param {string} type - Event type * @param {Function} listener - Event listener * @param {Object} options - Event listener options */ addEventListener(target, type, listener, options = {}) { target.addEventListener(type, listener, options); this._eventListeners.push({ target, type, listener, options }); } /** * Remove a specific event listener * @param {EventTarget} target - Event target * @param {string} type - Event type * @param {Function} listener - Event listener * @param {Object} options - Event listener options */ removeEventListener(target, type, listener, options = {}) { target.removeEventListener(type, listener, options); this._eventListeners = this._eventListeners.filter( item => !(item.target === target && item.type === type && item.listener === listener) ); } /** * Remove all event listeners registered through addEventListener */ removeAllEventListeners() { this._eventListeners.forEach(({ target, type, listener, options }) => { target.removeEventListener(type, listener, options); }); this._eventListeners = []; } /** * Get a reference to another module * @param {string} moduleId - ID of the module to get * @returns {BaseModule|null} - The module or null if not found */ getModule(moduleId) { // First check our dependency cache if (this._loadedDependencies.has(moduleId)) { return this._loadedDependencies.get(moduleId); } } /** * Auto-bind methods to preserve 'this' context * @param {Array} methodNames - Array of method names to bind */ bindMethods(methodNames) { methodNames.forEach(methodName => { if (typeof this[methodName] === 'function') { this[methodName] = this[methodName].bind(this); } else { console.warn(`Method ${methodName} not found on ${this.id} module.`); } }); } /** * Update configuration * @param {Object} newConfig - New configuration to merge */ updateConfig(newConfig = {}) { this.config = { ...this.config, ...newConfig }; } /** * Get current configuration * @returns {Object} - Current configuration */ getConfig() { return { ...this.config }; } /** * Load a JavaScript file * @param {string} url - URL of the script to load * @param {boolean} [isModule=false] - Whether to load as a module * @returns {Promise} - Promise resolving to the loaded script element */ loadScript(url, isModule = false) { return new Promise((resolve, reject) => { // Track this resource this._trackResource(url); const script = document.createElement('script'); const cacheBuster = window.MODULE_CACHE_BUSTER; if (cacheBuster && /^\/(js|css)\//.test(url)) { const separator = url.includes('?') ? '&' : '?'; script.src = `${url}${separator}v=${encodeURIComponent(cacheBuster)}`; } else { script.src = url; } if (isModule) { script.type = 'module'; } script.onload = () => { this._resourceLoaded(url); resolve(script); }; script.onerror = (error) => { this._resourceFailed(url, error); reject(new Error(`Failed to load script: ${url}`)); }; document.head.appendChild(script); }); } /** * Load a CSS stylesheet * @param {string} url - URL of the stylesheet to load * @returns {Promise} - Promise resolving to the loaded link element */ loadCSS(url) { return new Promise((resolve, reject) => { // Track this resource this._trackResource(url); const link = document.createElement('link'); link.href = url; link.rel = 'stylesheet'; link.onload = () => { this._resourceLoaded(url); resolve(link); }; link.onerror = (error) => { this._resourceFailed(url, error); reject(new Error(`Failed to load stylesheet: ${url}`)); }; document.head.appendChild(link); }); } /** * Preload an image * @param {string} url - URL of the image to load * @returns {Promise} - Promise resolving to the loaded image element */ loadImage(url) { return new Promise((resolve, reject) => { // Track this resource this._trackResource(url); const img = new Image(); img.onload = () => { this._resourceLoaded(url); resolve(img); }; img.onerror = (error) => { this._resourceFailed(url, error); reject(new Error(`Failed to load image: ${url}`)); }; img.src = url; }); } /** * Load JSON data * @param {string} url - URL of the JSON file to load * @returns {Promise} - Promise resolving to the parsed JSON data */ loadJSON(url) { return new Promise((resolve, reject) => { // Track this resource this._trackResource(url); fetch(url) .then(response => { if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } return response.json(); }) .then(data => { this._resourceLoaded(url); resolve(data); }) .catch(error => { this._resourceFailed(url, error); reject(new Error(`Failed to load JSON: ${url} - ${error.message}`)); }); }); } /** * Load a generic resource with fetch * @param {string} url - URL of the resource to load * @param {string} [responseType='text'] - Response type ('text', 'blob', 'arrayBuffer', etc.) * @returns {Promise} - Promise resolving to the loaded resource */ loadResource(url, responseType = 'text') { return new Promise((resolve, reject) => { // Track this resource this._trackResource(url); fetch(url) .then(response => { if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } switch(responseType) { case 'json': return response.json(); case 'blob': return response.blob(); case 'arrayBuffer': return response.arrayBuffer(); case 'formData': return response.formData(); case 'text': default: return response.text(); } }) .then(data => { this._resourceLoaded(url); resolve(data); }) .catch(error => { this._resourceFailed(url, error); reject(new Error(`Failed to load resource: ${url} - ${error.message}`)); }); }); } /** * Load multiple resources at once * @param {Array} resources - Array of resource descriptors * @returns {Promise} - Promise resolving to an array of loaded resources * @example * loadResources([ * { type: 'script', url: '/js/lib.js' }, * { type: 'css', url: '/css/style.css' }, * { type: 'image', url: '/img/logo.png' }, * { type: 'json', url: '/data/config.json' } * ]) */ loadResources(resources) { const promises = resources.map(resource => { switch(resource.type) { case 'script': return this.loadScript(resource.url, resource.isModule); case 'css': return this.loadCSS(resource.url); case 'image': return this.loadImage(resource.url); case 'json': return this.loadJSON(resource.url); default: return this.loadResource(resource.url, resource.responseType); } }); return Promise.all(promises); } /** * Track a resource being loaded * @param {string} url - URL of the resource * @private */ _trackResource(url) { this._loadingResources.set(url, { started: Date.now(), completed: false, failed: false }); this._totalResources++; // Report progress this._updateResourceProgress(); } /** * Mark a resource as successfully loaded * @param {string} url - URL of the resource * @private */ _resourceLoaded(url) { if (this._loadingResources.has(url)) { const resource = this._loadingResources.get(url); resource.completed = true; resource.completedAt = Date.now(); this._loadedResources++; // Report progress this._updateResourceProgress(); } } /** * Mark a resource as failed to load * @param {string} url - URL of the resource * @param {Error} error - Error that occurred * @private */ _resourceFailed(url, error) { if (this._loadingResources.has(url)) { const resource = this._loadingResources.get(url); resource.failed = true; resource.error = error; resource.completedAt = Date.now(); this._loadedResources++; // Log the error console.error(`${this.id}: Failed to load resource:`, url, error); // Report progress this._updateResourceProgress(); } } /** * Update loading progress based on resources * @private */ _updateResourceProgress() { if (this._totalResources === 0) return; // Change to FETCHING state when loading resources if (this.state === 'LOADING' && this._loadedResources === 0) { this.changeState('FETCHING'); } // Scale resource loading to 10% to 50% range of total module progress const percent = Math.round((this._loadedResources / this._totalResources) * 40) + 10; this.reportProgress(percent, `Loading resources: ${this._loadedResources}/${this._totalResources}`); } /** * Dispose resources when module is destroyed * Override in child classes to add custom cleanup */ dispose() { this.removeAllEventListeners(); } } /** * Module Events - Used for communication between modules and the loader */ export class ModuleEvent extends CustomEvent { constructor(type, moduleId, data = {}) { super(`module:${type}`, { detail: { moduleId, ...data }, bubbles: true }); } }