adapt-authoring-core/lib/DependencyLoader.js

import { glob } from 'glob'
import path from 'path'
import Hook from './Hook.js'
import { metadataFileName, packageFileName, stripScope, readJson } from './Utils.js'

/**
 * Handles the loading of Adapt authoring tool module dependencies.
 * @memberof core
 */
class DependencyLoader {
  /**
   * Creates a new DependencyLoader instance
   * @param {App} app The main app instance
   */
  constructor (app) {
    /**
     * Name of the class (convenience function to stay consistent with other classes)
     * @type {string}
     */
    this.name = this.constructor.name.toLowerCase()
    /**
     * Reference to the main app
     * @type {App}
     */
    this.app = app
    /**
     * Key/value store of all the Adapt dependencies' configs. Note this includes dependencies which are not loaded as Adapt modules (i.e. `module: false`).
     * @type {Object<string, Object>}
     */
    this.configs = {}
    /**
     * Map of module names to their loaded instances
     * @type {Object<string, Object>}
     */
    this.instances = {}
    /**
     * Map of module names to arrays of modules that depend on them as peer dependencies
     * @type {Object<string, Array<string>>}
     */
    this.peerDependencies = {}
    /**
     * List of module names which have failed to load
     * @type {Array<string>}
     */
    this.failedModules = []
    /**
     * Hook called once all module configs are loaded
     * @type {Hook}
     */
    this.configsLoadedHook = new Hook()
    /**
     * Hook for individual module load
     * @type {Hook}
     */
    this.moduleLoadedHook = new Hook()

    this.moduleLoadedHook.tap(this.logProgress, this)
  }

  /**
   * Loads configuration files for all Adapt dependencies found in node_modules.
   * @return {Promise<void>}
   */
  async loadConfigs () {
    /** @ignore */ this._configsLoaded = false
    const corePathSegment = `/${this.app.name}/`
    const files = await glob(`${this.app.rootDir}/node_modules/**/${metadataFileName}`)
    const deps = files
      .map(d => d.replace(`${metadataFileName}`, ''))
      .sort((a, b) => {
        if (a.endsWith(corePathSegment)) return -1
        if (b.endsWith(corePathSegment)) return 1
        return a.length - b.length
      })
    for (const d of deps) {
      try {
        const c = await this.loadModuleConfig(d)
        if (!this.configs[c.name]) {
          this.configs[c.name] = c
          if (c.peerDependencies) {
            Object.keys(c.peerDependencies).forEach(p => {
              this.peerDependencies[p] = [...(this.peerDependencies[p] || []), c.name]
            })
          }
        }
      } catch (e) {
        this.log('error', `Failed to load config for '${d}', module will not be loaded`)
        this.log('error', e)
      }
    }
    this._configsLoaded = true
    await this.configsLoadedHook.invoke()
  }

  /**
   * Loads the relevant configuration files for an Adapt module by reading and merging package.json and adapt.json
   * @param {string} modDir Absolute path to the module directory
   * @return {Promise<Object>} Resolves with configuration object
   */
  async loadModuleConfig (modDir) {
    const pkg = await readJson(path.join(modDir, packageFileName))
    return {
      ...pkg,
      ...await readJson(path.join(modDir, metadataFileName)),
      name: stripScope(pkg.name),
      packageName: pkg.name,
      rootDir: modDir
    }
  }

  /**
   * Loads a single Adapt module by dynamically importing it, instantiating it, and waiting for its onReady promise. Should not need to be called directly.
   * @param {string} modName Name of the module to load (e.g., 'adapt-authoring-core')
   * @return {Promise<Object>} Resolves with module instance when module.onReady completes
   * @throws {Error} When module already exists, is in an unknown format or cannot be initialised (or initialisation exceeds 60 second timeout)
   */
  async loadModule (modName) {
    if (this.instances[modName]) {
      throw this.app.errors.DEP_ALREADY_LOADED.setData({ module: modName })
    }
    const config = this.configs[modName]

    if (config.module === false) {
      return
    }
    const { default: ModClass } = await import(config.packageName)

    if (typeof ModClass !== 'function') {
      throw this.app.errors.DEP_INVALID_EXPORT.setData({ module: modName })
    }
    const instance = new ModClass(this.app, config)

    if (typeof instance.onReady !== 'function') {
      throw this.app.errors.DEP_NO_ONREADY.setData({ module: modName })
    }
    try {
      const timeout = this.app.getConfig('moduleLoadTimeout') ?? 10000
      await Promise.race([
        instance.onReady(),
        new Promise((resolve, reject) => setTimeout(() => reject(this.app.errors.DEP_TIMEOUT.setData({ module: modName, timeout })), timeout))
      ])
      this.instances[modName] = instance
      await this.moduleLoadedHook.invoke(null, instance)
      return instance
    } catch (e) {
      await this.moduleLoadedHook.invoke(e, { name: modName })
      throw e
    }
  }

  /**
   * Loads Adapt modules. If no list is provided, loads all configured dependencies.
   * @param {Array<string>} [modules] Module names to load (defaults to all dependencies)
   * @return {Promise<void>} Resolves when all modules have loaded or failed
   * @throws {Error} When any module throws a fatal error (error.isFatal or error.cause.isFatal)
   */
  async loadModules (modules = Object.values(this.configs).map(c => c.name)) {
    await Promise.all(modules.map(async m => {
      try {
        await this.loadModule(m)
      } catch (e) {
        if (e.isFatal || e.cause?.isFatal) {
          throw e
        }
        this.log('error', `Failed to load '${m}',`, e)
        const deps = this.peerDependencies[m]
        if (deps?.length) {
          this.log('error', 'The following modules are peer dependencies, and may not work:')
          deps.forEach(d => this.log('error', `- ${d}`))
        }
        this.failedModules.push(m)
      }
    }))
  }

  /**
   * Waits for a single module to load. Returns the instance (if loaded), or hooks into moduleLoadedHook to wait for it.
   * @param {string} modName Name of module to wait for (accepts short names without 'adapt-authoring-' prefix)
   * @return {Promise<Object>} Resolves with module instance when module.onReady completes
   * @throws {Error} When module is missing from configs or has failed to load
   */
  async waitForModule (modName) {
    if (!this._configsLoaded) {
      await this.configsLoadedHook.onInvoke()
    }
    if (!modName.startsWith('adapt-authoring-')) modName = `adapt-authoring-${modName}`
    if (!this.configs[modName]) {
      throw this.app.errors.DEP_MISSING.setData({ module: modName })
    }
    if (this.failedModules.includes(modName)) {
      throw this.app.errors.DEP_FAILED.setData({ module: modName })
    }
    const instance = this.instances[modName]
    if (instance) {
      return instance.onReady()
    }
    return new Promise((resolve, reject) => {
      this.moduleLoadedHook.tap((error, instance) => {
        if (instance?.name !== modName) return
        if (error) return reject(this.app.errors.DEP_FAILED.setData({ module: modName }))
        resolve(instance)
      })
    })
  }

  /**
   * Logs load progress
   * @param {AbstractModule} instance The last loaded instance
   */
  logProgress (error, instance) {
    if (error) return

    const toShort = names => names.map(n => n.replace('adapt-authoring-', '')).join(', ')
    const loaded = []
    const notLoaded = []
    let totalCount = 0
    Object.keys(this.configs).forEach(key => {
      if (this.configs[key].module === false) return
      this.instances[key]?._isReady || key === instance.name ? loaded.push(key) : notLoaded.push(key)
      totalCount++
    })
    const progress = Math.round((loaded.length / totalCount) * 100)
    this.log('verbose', 'LOAD', [
      toShort([instance.name]),
      `${loaded.length}/${totalCount} (${progress}%)`,
      notLoaded.length && `awaiting: ${toShort(notLoaded)}`,
      this.failedModules.length && `failed: ${toShort(this.failedModules)}`
    ].filter(Boolean).join(', '))

    if (progress === 100) {
      const initTimes = Object.fromEntries(
        Object.entries(this.instances)
          .sort(([, a], [, b]) => a.initTime - b.initTime)
          .map(([name, inst]) => [name, inst.initTime])
      )
      this.log('verbose', initTimes)
    }
  }

  /**
   * Logs a message using the app logger
   * @param {...*} args Arguments to be logged
   */
  log (level, ...args) {
    this.app.logger?.log(level, this.name, ...args)
  }
}

export default DependencyLoader