UNPKG

@fleetbase/ember-core

Version:

Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.

1,128 lines (972 loc) 39 kB
import Service, { inject as service } from '@ember/service'; import Evented from '@ember/object/evented'; import { tracked } from '@glimmer/tracking'; import { getOwner } from '@ember/application'; import { assert, debug, warn } from '@ember/debug'; import { next } from '@ember/runloop'; import loadInstalledExtensions from '@fleetbase/ember-core/utils/load-installed-extensions'; import mapEngines from '@fleetbase/ember-core/utils/map-engines'; import config from 'ember-get-config'; import { getExtensionLoader } from '@fleetbase/console/extensions'; import { isArray } from '@ember/array'; import RSVP from 'rsvp'; import ExtensionBootState from '../../contracts/extension-boot-state'; /** * ExtensionManagerService * * Manages lazy loading of engines and extension lifecycle. * Replaces the old bootEngines mechanism with on-demand loading. * * @class ExtensionManagerService * @extends Service */ export default class ExtensionManagerService extends Service.extend(Evented) { @service universe; /** * Reference to the root Ember Application Instance. * Used for registering components/services to the application container * for cross-engine sharing. */ @tracked applicationInstance = null; /** * Creates an instance of ExtensionManagerService. * @memberof ExtensionManagerService */ constructor() { super(...arguments); // Initialize shared boot state this.bootState = this.#initializeBootState(); // Patch owner to track engine loading via router this.#patchOwnerForEngineTracking(); } /** * Set the application instance * * @method setApplicationInstance * @param {Application} application The root application instance */ setApplicationInstance(application) { this.applicationInstance = application; } /** * Initialize shared boot state singleton * Ensures all ExtensionManager instances share the same boot state * * @private * @returns {ExtensionBootState} */ #initializeBootState() { const stateKey = 'state:extension-boot'; const application = this.#getApplication(); if (!application.hasRegistration(stateKey)) { const bootState = new ExtensionBootState(); // Create the extensionsLoadedPromise bootState.extensionsLoadedPromise = new Promise((resolve) => { bootState.extensionsLoadedResolver = resolve; }); application.register(stateKey, bootState, { instantiate: false, }); } return application.resolveRegistration(stateKey); } /** * Get the application instance * Tries multiple fallback methods to find the root application * * @private * @returns {Application} */ #getApplication() { // First priority use the universe application instance if (this.universe.applicationInstance) { return this.universe.applicationInstance; } // Second priority: use applicationInstance if set if (this.applicationInstance) { return this.applicationInstance; } // Third priority: try to get application from owner const owner = getOwner(this); if (owner && owner.application) { return owner.application; } // Last resort: return owner itself (might be EngineInstance) return owner; } /** * Getters and setters for boot state properties * These delegate to the shared bootState object */ get isBooting() { return this.bootState.isBooting; } set isBooting(value) { this.bootState.isBooting = value; } get bootPromise() { return this.bootState.bootPromise; } set bootPromise(value) { this.bootState.bootPromise = value; } get extensionsLoadedPromise() { return this.bootState.extensionsLoadedPromise; } set extensionsLoadedPromise(value) { this.bootState.extensionsLoadedPromise = value; } get extensionsLoadedResolver() { return this.bootState.extensionsLoadedResolver; } set extensionsLoadedResolver(value) { this.bootState.extensionsLoadedResolver = value; } get extensionsLoaded() { return this.bootState.extensionsLoaded; } set extensionsLoaded(value) { this.bootState.extensionsLoaded = value; } get loadedEngines() { return this.bootState.loadedEngines; } set loadedEngines(value) { this.bootState.loadedEngines = value; } get registeredExtensions() { return this.bootState.registeredExtensions; } set registeredExtensions(value) { this.bootState.registeredExtensions = value; } get loadingPromises() { return this.bootState.loadingPromises; } set loadingPromises(value) { this.bootState.loadingPromises = value; } /** * Getter for engineLoadedHooks (not tracked, just a Map) * Delegates to the shared bootState object */ get #engineLoadedHooks() { return this.bootState.engineLoadedHooks; } /** * Ensure an engine is loaded * This is the key method that triggers lazy loading * * @method ensureEngineLoaded * @param {String} engineName Name of the engine to load * @returns {Promise<EngineInstance>} The loaded engine instance */ async ensureEngineLoaded(engineName) { // Return cached instance if already loaded if (this.loadedEngines.has(engineName)) { return this.loadedEngines.get(engineName); } // Return existing loading promise if currently loading if (this.loadingPromises.has(engineName)) { return this.loadingPromises.get(engineName); } // Start loading the engine const loadingPromise = this.#loadEngine(engineName); this.loadingPromises.set(engineName, loadingPromise); try { const engineInstance = await loadingPromise; this.loadedEngines.set(engineName, engineInstance); this.loadingPromises.delete(engineName); return engineInstance; } catch (error) { this.loadingPromises.delete(engineName); throw error; } } /** * Internal method to load an engine * * @private * @method #loadEngine * @param {String} name Name of the engine * @returns {Promise<EngineInstance>} The engine instance */ #loadEngine(name) { const application = this.#getApplication(); const router = application.lookup('router:main'); const instanceId = 'manual'; // Arbitrary instance id, should be unique per engine const mountPoint = this.#mountPathFromEngineName(name); if (!router._enginePromises[name]) { router._enginePromises[name] = Object.create(null); } let enginePromise = router._enginePromises[name][instanceId]; // We already have a Promise for this engine instance if (enginePromise) { return enginePromise; } if (router._engineIsLoaded(name)) { // The Engine is loaded, but has no Promise enginePromise = RSVP.resolve(); } else { // The Engine is not loaded and has no Promise enginePromise = router._assetLoader.loadBundle(name).then( () => router._registerEngine(name), (error) => { router._enginePromises[name][instanceId] = undefined; throw error; } ); } return (router._enginePromises[name][instanceId] = enginePromise.then(() => { return this.constructEngineInstance(name, instanceId, mountPoint); })); } /** * Public alias for loading an engine * * @method loadEngine * @param {String} name Name of the engine * @returns {Promise<EngineInstance>} The engine instance */ loadEngine(name) { return this.#loadEngine(name); } /** * Get mount path from engine name * Handles scoped packages and removes engine suffix * * @private * @method #mountPathFromEngineName * @param {String} engineName Engine name (e.g., '@fleetbase/fleetops-engine') * @returns {String} Mount path (e.g., 'console.fleetops') */ #mountPathFromEngineName(engineName) { let engineNameSegments = engineName.split('/'); let mountName = engineNameSegments[1]; if (typeof mountName !== 'string') { mountName = engineNameSegments[0]; } const mountPath = mountName.replace('-engine', ''); return `console.${mountPath}`; } /** * Construct an engine instance. If the instance does not exist yet, it * will be created. * * @method constructEngineInstance * @param {String} name The name of the engine * @param {String} instanceId The id of the engine instance * @param {String} mountPoint The mount point of the engine * @returns {Promise<EngineInstance>} A Promise that resolves with the constructed engine instance */ constructEngineInstance(name, instanceId, mountPoint) { const application = this.#getApplication(); const router = application.lookup('router:main'); assert(`You attempted to load the engine '${name}' with '${instanceId}', but the engine cannot be found.`, application.hasRegistration(`engine:${name}`)); let engineInstances = router._engineInstances; if (!engineInstances) { engineInstances = router._engineInstances = Object.create(null); } if (!engineInstances[name]) { engineInstances[name] = Object.create(null); } let engineInstance = engineInstances[name][instanceId]; if (!engineInstance) { const engineStartTime = performance.now(); engineInstance = application.buildChildEngineInstance(name, { routable: true, mountPoint: mountPoint, }); // store loaded instance to engineInstances for booting engineInstances[name][instanceId] = engineInstance; return engineInstance.boot().then(() => { const engineEndTime = performance.now(); const loadTime = (engineEndTime - engineStartTime).toFixed(2); debug(`[ExtensionManager] Engine '${name}' loaded in ${loadTime}ms`); // Only trigger if not already triggered (prevent double execution) if (!engineInstance._hooksTriggered) { // Fire event for universe.onEngineLoaded() API this.trigger('engine.loaded', name, engineInstance); // Run stored onEngineLoaded hooks from extension.js this.#runEngineLoadedHooks(name, engineInstance); // Clear hooks after running to prevent double execution this.#engineLoadedHooks.delete(name); // Mark as triggered engineInstance._hooksTriggered = true; } return engineInstance; }); } return RSVP.resolve(engineInstance); } /** * Setup engine parent dependencies before boot. * Fixes service and external route dependencies. * * @private * @param {Object} baseDependencies * @returns {Object} Fixed dependencies */ #setupEngineParentDependenciesBeforeBoot(baseDependencies = {}) { const dependencies = { ...baseDependencies }; // Get service from app instance const applicationInstance = this.#getApplication(); // fix services const servicesObject = {}; if (isArray(dependencies.services)) { for (let i = 0; i < dependencies.services.length; i++) { let serviceName = dependencies.services.objectAt(i); if (typeof serviceName === 'object') { Object.assign(servicesObject, serviceName); continue; } if (serviceName === 'hostRouter') { const service = applicationInstance.lookup('service:router') ?? serviceName; servicesObject[serviceName] = service; continue; } const service = applicationInstance.lookup(`service:${serviceName}`) ?? serviceName; servicesObject[serviceName] = service; } } // fix external routes const externalRoutesObject = {}; if (isArray(dependencies.externalRoutes)) { for (let i = 0; i < dependencies.externalRoutes.length; i++) { const externalRoute = dependencies.externalRoutes.objectAt(i); if (typeof externalRoute === 'object') { Object.assign(externalRoutesObject, externalRoute); continue; } externalRoutesObject[externalRoute] = externalRoute; } } dependencies.externalRoutes = externalRoutesObject; dependencies.services = servicesObject; return dependencies; } /** * Retrieves the mount point of a specified engine by its name. * * @method getEngineMountPoint * @param {String} engineName - The name of the engine for which to get the mount point. * @returns {String|null} The mount point of the engine or null if not found. */ getEngineMountPoint(engineName) { const engineInstance = this.getEngineInstance(engineName); return this.#getMountPointFromEngineInstance(engineInstance); } /** * Determines the mount point from an engine instance by reading its configuration. * * @private * @method #getMountPointFromEngineInstance * @param {Object} engineInstance - The instance of the engine. * @returns {String|null} The resolved mount point or null if the instance is undefined or the configuration is not set. */ #getMountPointFromEngineInstance(engineInstance) { if (engineInstance) { const config = engineInstance.resolveRegistration('config:environment'); if (config) { let engineName = config.modulePrefix; let mountedEngineRoutePrefix = config.mountedEngineRoutePrefix; if (!mountedEngineRoutePrefix) { mountedEngineRoutePrefix = this.#mountPathFromEngineName(engineName); } if (!mountedEngineRoutePrefix.endsWith('.')) { mountedEngineRoutePrefix = mountedEngineRoutePrefix + '.'; } return mountedEngineRoutePrefix; } } return null; } /** * Get an engine instance if it's already loaded * Does not trigger loading * * @method getEngineInstance * @param {String} engineName Name of the engine * @returns {EngineInstance|null} The engine instance or null */ getEngineInstance(engineName) { // Use loadedEngines Map which tracks all loaded engines regardless of how they were loaded if (this.loadedEngines.has(engineName)) { return this.loadedEngines.get(engineName); } return null; } /** * Register an extension * * @method registerExtension * @param {String} name Extension name * @param {Object} metadata Extension metadata */ registerExtension(name, metadata = {}) { const existing = this.registeredExtensions.find((ext) => ext.name === name); if (existing) { Object.assign(existing, metadata); } else { this.registeredExtensions.pushObject({ name, ...metadata, }); } } /** * Get all registered extensions * * @method getExtensions * @returns {Array} Array of registered extensions */ getExtensions() { return this.registeredExtensions; } /** * Get a specific extension * * @method getExtension * @param {String} name Extension name * @returns {Object|null} Extension metadata or null */ getExtension(name) { return this.registeredExtensions.find((ext) => ext.name === name) || null; } /** * Check if an extension is registered/installed * * @method isExtensionInstalled * @param {String} name Extension name * @returns {Boolean} True if extension is registered */ isExtensionInstalled(name) { return this.registeredExtensions.some((ext) => ext.name === name); } /** * Alias for isExtensionInstalled * @method isEngineInstalled */ isEngineInstalled(name) { return this.isExtensionInstalled(name); } /** * Alias for isExtensionInstalled * @method hasExtensionIndexed */ hasExtensionIndexed(name) { return this.isExtensionInstalled(name); } /** * Alias for isExtensionInstalled * @method isInstalled */ isInstalled(name) { return this.isExtensionInstalled(name); } /** * Check if an engine has been loaded (boot started or completed) * * @method isEngineLoaded * @param {String} name Engine name * @returns {Boolean} True if engine is loaded */ isEngineLoaded(name) { return this.loadedEngines.has(name); } /** * Check if an engine is currently loading * * @method isEngineLoading * @param {String} name Engine name * @returns {Boolean} True if engine is currently loading */ isEngineLoading(name) { return this.loadingPromises.has(name); } /** * Check if an extension has been set up (extension.js executed) * This checks if the extension has been registered, which happens during setup * * @method isExtensionSetup * @param {String} name Extension name * @returns {Boolean} True if extension setup has run */ isExtensionSetup(name) { return this.isExtensionInstalled(name); } /** * Alias for isExtensionSetup * @method hasExtensionSetup */ hasExtensionSetup(name) { return this.isExtensionSetup(name); } /** * Preload specific engines * Useful for critical engines that should load early * * @method preloadEngines * @param {Array<String>} engineNames Array of engine names to preload * @returns {Promise<Array>} Array of loaded engine instances */ async preloadEngines(engineNames) { const promises = engineNames.map((name) => this.ensureEngineLoaded(name)); return Promise.all(promises); } /** * Unload an engine * Useful for memory management in long-running applications * * @method unloadEngine * @param {String} engineName Name of the engine to unload */ unloadEngine(engineName) { if (this.loadedEngines.has(engineName)) { const engineInstance = this.loadedEngines.get(engineName); // Destroy the engine instance if it has a destroy method if (engineInstance && typeof engineInstance.destroy === 'function') { engineInstance.destroy(); } this.loadedEngines.delete(engineName); } } /** * Execute a callback when an engine is loaded * If the engine is already loaded, the callback runs immediately * Otherwise, it's stored and runs when the engine loads * * @method whenEngineLoaded * @param {String} engineName The name of the engine * @param {Function} callback The callback to execute, receives (engineInstance, universe, appInstance) * @example * // Simple usage * extensionManager.whenEngineLoaded('@fleetbase/fleetops-engine', (fleetopsEngine) => { * console.log('FleetOps loaded!', fleetopsEngine); * }); * * @example * // With all parameters * extensionManager.whenEngineLoaded('@fleetbase/customer-portal-engine', (portalEngine, universe, app) => { * setupIntegration(portalEngine, universe); * }); */ whenEngineLoaded(engineName, callback) { this.#storeEngineLoadedHook(engineName, callback); } /** * Mark extensions as loaded * Called by load-extensions initializer after extensions are loaded from API * * @method finishLoadingExtensions */ finishLoadingExtensions() { this.extensionsLoaded = true; // Resolve the extensions loaded promise if (this.extensionsLoadedResolver) { this.extensionsLoadedResolver(); this.extensionsLoadedResolver = null; } } /** * Wait for extensions to be loaded from API * Returns immediately if already loaded * * @method waitForExtensionsLoaded * @returns {Promise} Promise that resolves when extensions are loaded */ waitForExtensionsLoaded() { if (this.extensionsLoaded) { return Promise.resolve(); } return this.extensionsLoadedPromise; } /** * Mark the boot process as complete * Called by the Universe service after all extensions are initialized * * @method finishBoot */ finishBoot() { this.isBooting = false; // Resolve the boot promise if it exists if (this.bootPromise) { this.bootPromise.resolve(); this.bootPromise = null; } } /** * Wait for the boot process to complete * Returns immediately if already booted * * @method waitForBoot * @returns {Promise} Promise that resolves when boot is complete */ waitForBoot() { // If not booting, return resolved promise if (!this.isBooting) { return Promise.resolve(); } // If boot promise doesn't exist, create it if (!this.bootPromise) { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); this.bootPromise = { promise, resolve, reject, }; } return this.bootPromise.promise; } /** * Load extensions from API and populate application * Encapsulates the extension loading logic * * @method loadExtensions * @param {Application} application The Ember application instance * @returns {Promise<Array>} Array of loaded extension names */ async loadExtensions(application) { const startTime = performance.now(); try { // Get admin-configured extensions from config const additionalCoreExtensions = config.APP?.extensions ?? []; // Load installed extensions (includes core, admin-configured, and user-installed) const extensions = await loadInstalledExtensions(additionalCoreExtensions); application.extensions = extensions; application.engines = mapEngines(extensions); const endTime = performance.now(); debug(`[ExtensionManager] Loaded ${extensions.length} extensions in ${(endTime - startTime).toFixed(2)}ms`); // Mark extensions as loaded this.finishLoadingExtensions(); return extensions; } catch (error) { console.error('[ExtensionManager] Failed to load extensions:', error); // Set empty arrays on error application.extensions = []; application.engines = {}; // Still mark as loaded to prevent hanging this.finishLoadingExtensions(); throw error; } } /** * Setup extensions by loading and executing their extension.js files * * @method setupExtensions * @param {ApplicationInstance} appInstance The application instance * @param {Service} universe The universe service * @returns {Promise<void>} */ async setupExtensions(appInstance, universe) { const setupStartTime = performance.now(); const application = appInstance.application; // Wait for extensions to be loaded from API await this.waitForExtensionsLoaded(); // Get the list of enabled extensions const extensions = application.extensions || []; // Phase 1: Register all extensions first so isInstalled() works during setup for (const extension of extensions) { const extensionName = extension.name || extension; this.registerExtension(extensionName, extension); } // Phase 2: Load and execute extension.js from each enabled extension for (const extension of extensions) { // Extension is an object with name, version, etc. from package.json const extensionName = extension.name || extension; const extStartTime = performance.now(); // Lookup the loader function from the build-time generated map const loader = getExtensionLoader(extensionName); if (!loader) { warn(`[ExtensionManager] No loader registered for ${extensionName}. Ensure addon/extension.js exists and prebuild generated the mapping.`, false, { id: 'ember-core.extension-manager.no-loader', }); continue; } try { // Use dynamic import() via the loader function const module = await loader(); const setup = module.default ?? module; let executed = false; // Handle function export if (typeof setup === 'function') { await setup(appInstance, universe); executed = true; } // Handle object export else if (typeof setup === 'object' && setup !== null) { // Run setupExtension hook (before engine loads) if (typeof setup.setupExtension === 'function') { await setup.setupExtension(appInstance, universe); executed = true; } // Store onEngineLoaded hook (runs after engine loads) if (typeof setup.onEngineLoaded === 'function') { this.#storeEngineLoadedHook(extensionName, setup.onEngineLoaded); executed = true; } } const extEndTime = performance.now(); const totalTime = (extEndTime - extStartTime).toFixed(2); debug(`[ExtensionManager] ${extensionName} setup completed in ${totalTime}ms`); if (!executed) { warn(`[ExtensionManager] ${extensionName}/extension did not export a function or valid object with setupExtension/onEngineLoaded.`, false, { id: 'ember-core.extension-manager.invalid-export', }); } } catch (error) { console.error(`[ExtensionManager] Failed to load or run extension.js for ${extensionName}:`, error); } } // Execute boot callbacks and mark boot as complete await universe.executeBootCallbacks(); const setupEndTime = performance.now(); const totalTime = (setupEndTime - setupStartTime).toFixed(2); debug(`[ExtensionManager] All ${extensions.length} extensions setup completed in ${totalTime}ms`); } /** * Register a service into a specific engine * Allows sharing host services with engines * * @method registerServiceIntoEngine * @param {String} engineName Name of the engine * @param {String} serviceName Name of the service to register * @param {Class} serviceClass Service class to register * @param {Object} options Registration options * @returns {Boolean} True if successful, false if engine not found */ registerServiceIntoEngine(engineName, serviceName, serviceClass, options = {}) { const engineInstance = this.getEngineInstance(engineName); if (!engineInstance) { warn(`[ExtensionManager] Cannot register service '${serviceName}' - engine '${engineName}' not loaded`, false, { id: 'ember-core.extension-manager.engine-not-loaded' }); return false; } try { engineInstance.register(`service:${serviceName}`, serviceClass, options); return true; } catch (error) { console.error(`[ExtensionManager] Failed to register service '${serviceName}' into engine '${engineName}':`, error); return false; } } /** * Register a component into a specific engine * Allows sharing host components with engines * * @method registerComponentIntoEngine * @param {String} engineName Name of the engine * @param {String} componentName Name of the component to register * @param {Class} componentClass Component class to register * @param {Object} options Registration options * @returns {Boolean} True if successful, false if engine not found */ registerComponentIntoEngine(engineName, componentName, componentClass, options = {}) { const engineInstance = this.getEngineInstance(engineName); if (!engineInstance) { warn(`[ExtensionManager] Cannot register component '${componentName}' - engine '${engineName}' not loaded`, false, { id: 'ember-core.extension-manager.engine-not-loaded' }); return false; } try { engineInstance.register(`component:${componentName}`, componentClass, options); return true; } catch (error) { console.error(`[ExtensionManager] Failed to register component '${componentName}' into engine '${engineName}':`, error); return false; } } /** * Register a service into all loaded engines * Useful for sharing a host service with all engines * * @method registerServiceIntoAllEngines * @param {String} serviceName Name of the service to register * @param {Class} serviceClass Service class to register * @param {Object} options Registration options * @returns {Array<String>} Array of engine names where registration succeeded */ registerServiceIntoAllEngines(serviceName, serviceClass, options = {}) { const succeededEngines = []; for (const [engineName] of this.loadedEngines) { const success = this.registerServiceIntoEngine(engineName, serviceName, serviceClass, options); if (success) { succeededEngines.push(engineName); } } return succeededEngines; } /** * Register a component into all loaded engines * Useful for sharing a host component with all engines * * @method registerComponentIntoAllEngines * @param {String} componentName Name of the component to register * @param {Class} componentClass Component class to register * @param {Object} options Registration options * @returns {Array<String>} Array of engine names where registration succeeded */ registerComponentIntoAllEngines(componentName, componentClass, options = {}) { const succeededEngines = []; for (const [engineName] of this.loadedEngines) { const success = this.registerComponentIntoEngine(engineName, componentName, componentClass, options); if (success) { succeededEngines.push(engineName); } } return succeededEngines; } /** * Get loading statistics * * @method getStats * @returns {Object} Statistics about loaded engines */ getStats() { return { isBooting: this.isBooting, extensionsLoaded: this.extensionsLoaded, loadedCount: this.loadedEngines.size, loadingCount: this.loadingPromises.size, registeredCount: this.registeredExtensions.length, loadedEngines: Array.from(this.loadedEngines.keys()), loadingEngines: Array.from(this.loadingPromises.keys()), }; } /** * Store an onEngineLoaded hook for later execution * * @private * @method #storeEngineLoadedHook * @param {String} engineName The name of the engine * @param {Function} hookFn The hook function to store */ #storeEngineLoadedHook(engineName, hookFn) { // Check if engine is already loaded const engineInstance = this.getEngineInstance(engineName); if (engineInstance) { // Engine already loaded, run hook immediately const appInstance = this.#getApplication(); const universe = appInstance.lookup('service:universe'); try { hookFn(engineInstance, universe, appInstance); } catch (error) { console.error(`[ExtensionManager] Error in immediate onEngineLoaded hook for ${engineName}:`, error); } } else { // Engine not loaded yet, store hook for later if (!this.#engineLoadedHooks.has(engineName)) { this.#engineLoadedHooks.set(engineName, []); } this.#engineLoadedHooks.get(engineName).push(hookFn); } } /** * Patch owner's buildChildEngineInstance to track engine loading via router * This ensures hooks run even when engines are loaded through routing (LinkTo) * * @private * @method #patchOwnerForEngineTracking */ #patchOwnerForEngineTracking() { const owner = this.#getApplication(); // Check if already patched to avoid multiple wrapping if (owner._buildChildEngineInstancePatched) { return; } const originalBuildChildEngineInstance = owner.buildChildEngineInstance; const self = this; owner.buildChildEngineInstance = function (name, options) { const engineInstance = originalBuildChildEngineInstance.call(this, name, options); // correct mountPoint using engine instance const _mountPoint = self.#getMountPointFromEngineInstance(engineInstance); if (_mountPoint) { // Remove trailing dot before setting on engine instance engineInstance.mountPoint = _mountPoint.endsWith('.') ? _mountPoint.slice(0, -1) : _mountPoint; } // make sure to set dependencies from base instance if (engineInstance.base) { engineInstance.dependencies = self.#setupEngineParentDependenciesBeforeBoot(engineInstance.base.dependencies); } // Notify ExtensionManager that an engine instance was built self.#onEngineInstanceBuilt(name, engineInstance); // Patch the engine instance's boot method to trigger events/hooks after boot if (!engineInstance._bootPatched) { const originalBoot = engineInstance.boot.bind(engineInstance); engineInstance.boot = function () { return originalBoot().then(() => { // Add to loadedEngines Map for tracking if (!self.loadedEngines.has(name)) { self.loadedEngines.set(name, engineInstance); } // Only trigger if not already triggered (prevent double execution) if (!engineInstance._hooksTriggered) { // Fire event for universe.onEngineLoaded() API self.trigger('engine.loaded', name, engineInstance); // Run stored onEngineLoaded hooks from extension.js self.#runEngineLoadedHooks(name, engineInstance); // Clear hooks after running to prevent double execution self.#engineLoadedHooks.delete(name); // Mark as triggered engineInstance._hooksTriggered = true; } return engineInstance; }); }; engineInstance._bootPatched = true; } return engineInstance; }; // Mark as patched owner._buildChildEngineInstancePatched = true; } /** * Called when an engine instance is built (via router or manual loading) * Schedules hooks to run after the engine boots * * @private * @method #onEngineInstanceBuilt * @param {String} engineName The name of the engine * @param {EngineInstance} engineInstance The built engine instance */ #onEngineInstanceBuilt(engineName, engineInstance) { const hooks = this.#engineLoadedHooks.get(engineName); if (hooks && hooks.length > 0) { // Schedule hooks to run after engine boots // Use next() to ensure engine is fully initialized next(() => { // Check if hooks still exist (they might have been run by constructEngineInstance) const currentHooks = this.#engineLoadedHooks.get(engineName); if (currentHooks && currentHooks.length > 0) { this.#runEngineLoadedHooks(engineName, engineInstance); this.#engineLoadedHooks.delete(engineName); } }); } } /** * Run all stored onEngineLoaded hooks for an engine * * @private * @method #runEngineLoadedHooks * @param {String} engineName The name of the engine * @param {EngineInstance} engineInstance The loaded engine instance */ #runEngineLoadedHooks(engineName, engineInstance) { const hooks = this.#engineLoadedHooks.get(engineName) || []; if (hooks.length === 0) { return; } const appInstance = this.#getApplication(); const universe = appInstance.lookup('service:universe'); hooks.forEach((hook) => { try { hook(engineInstance, universe, appInstance); } catch (error) { console.error(`[ExtensionManager] Error in onEngineLoaded hook for ${engineName}:`, error); } }); } }