562 lines
18 KiB
JavaScript
562 lines
18 KiB
JavaScript
/**
|
|
* 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();
|
|
|
|
// Auto-register with module registry
|
|
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<boolean>} - 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<boolean>} - 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<boolean>} - 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;
|
|
}
|
|
|
|
/**
|
|
* 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<string>} 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<HTMLScriptElement>} - 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');
|
|
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<HTMLLinkElement>} - 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<HTMLImageElement>} - 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<Object>} - 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<any>} - 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<Object>} resources - Array of resource descriptors
|
|
* @returns {Promise<Array>} - 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
|
|
});
|
|
}
|
|
}
|