adapt-authoring-adaptframework/lib/AdaptFrameworkModule.js

import { AbstractModule, Hook, readJson } from 'adapt-authoring-core'
import AdaptFrameworkBuild from './AdaptFrameworkBuild.js'
import AdaptFrameworkImport from './AdaptFrameworkImport.js'
import fs from 'node:fs/promises'
import { getHandler, postHandler, importHandler, postUpdateHandler, getUpdateHandler } from './handlers.js'
import { loadRouteConfig, registerRoutes } from 'adapt-authoring-server'
import { runCliCommand, readFrameworkPluginVersions, migrateExistingCourses, computePluginHash, prebuildCache } from './utils.js'
import BuildCache from './BuildCache.js'
import path from 'node:path'
import semver from 'semver'

/**
 * Module to handle the interface with the Adapt framework
 * @memberof adaptframework
 * @extends {AbstractModule}
 */
class AdaptFrameworkModule extends AbstractModule {
  /** @override */
  async init () {
    /**
     * Location of the local Adapt framework files
     * @type {String}
     */
    this.path = this.getConfig('frameworkDir')
    /**
     * Invoked after a framework install
     * @type {Hook}
     */
    this.postInstallHook = new Hook()
    /**
     * Invoked after a framework update
     * @type {Hook}
     */
    this.postUpdateHook = new Hook()

    /**
     * Invoked prior to a course being imported. The AdaptFrameworkImport instance is passed to any observers.
     * @type {Hook}
     */
    this.preImportHook = new Hook({ mutable: true })
    /**
     * Invoked after a course has been imported. The AdaptFrameworkImport instance is passed to any observers.
     * @type {Hook}
     */
    this.postImportHook = new Hook()
    /**
     * Invoked prior to a course being built. The AdaptFrameworkBuild instance is passed to any observers.
     * @type {Hook}
     */
    this.preBuildHook = new Hook({ mutable: true })
    /**
     * Invoked after a course has been built. The AdaptFrameworkBuild instance is passed to any observers.
     * @type {Hook}
     */
    this.postBuildHook = new Hook({ mutable: true })
    /**
     * Middleware hook wrapping the full import lifecycle (pre → import → post).
     * Observers receive (next, importer) and must call next() to proceed.
     * @type {Hook}
     */
    this.importHook = new Hook({ type: Hook.Types.Middleware })
    /**
     * Middleware hook wrapping the full build lifecycle (pre → build → post).
     * Observers receive (next, builder) and must call next() to proceed.
     * @type {Hook}
     */
    this.buildHook = new Hook({ type: Hook.Types.Middleware })
    /**
     * Content migration functions to be run on import
     * @type {Array}
     */
    this.contentMigrations = []

    const meta = await readJson(path.resolve(this.rootDir, 'adapt-authoring.json'))
    /**
     * The major version of the Adapt framework this module is designed to work with
     * @type {Number}
     */
    this._targetFrameworkVersion = meta.framework?.targetVersion

    this.app.waitForModule('content').then(content => {
      content.accessCheckHook.tap(this.checkContentAccess.bind(this))
    })

    await this.installFramework()

    this.app.waitForModule('contentplugin').then(contentplugin => {
      contentplugin.postInsertHook.tap(() => this.invalidatePrebuiltCache())
      contentplugin.postUpdateHook.tap(() => this.invalidatePrebuiltCache())
      contentplugin.postDeleteHook.tap(() => this.invalidatePrebuiltCache())
    })
    this.postUpdateHook.tap(() => this.invalidatePrebuiltCache())

    if (this.getConfig('prebuildCache')) {
      this.prebuildCache()
    }

    process.env.BROWSERSLIST_IGNORE_OLD_DATA = '1'

    if (this.app.args['update-framework'] === true) {
      await this.updateFramework()
    }
    this._version = await this.runCliCommand('getCurrentFrameworkVersion')

    await Promise.all([this.loadSchemas(), this.initRoutes()])

    this.logStatus()
  }

  /**
   * Reference to runCliCommand utility
   */
  get runCliCommand () {
    return runCliCommand
  }

  /**
   * Semver formatted version number of the local framework copy
   * @type {String}
   */
  get version () {
    return this._version
  }

  /**
   * The major version of the Adapt framework this module is designed to work with
   * @type {Number|undefined}
   */
  get targetFrameworkVersion () {
    return this._targetFrameworkVersion
  }

  /**
   * Returns a semver range string constrained to the target major version, or undefined if no target is set
   * @type {String|undefined}
   */
  get targetVersionRange () {
    if (this._targetFrameworkVersion === undefined) return undefined
    return `>=${this._targetFrameworkVersion}.0.0 <${this._targetFrameworkVersion + 1}.0.0`
  }

  /**
   * Checks whether the given version is compatible with the configured target major version
   * @param {string} version Semver version string to check
   * @throws If the version's major does not match the target major version
   */
  checkVersionCompatibility (version) {
    if (this._targetFrameworkVersion === undefined) return
    const major = semver.major(version)
    if (major !== this._targetFrameworkVersion) {
      throw this.app.errors.FW_VERSION_NOT_ALLOWED.setData({ version, targetMajorVersion: this._targetFrameworkVersion, allowedRange: this.targetVersionRange })
    }
  }

  /**
   * Installs a local copy of the Adapt framework
   * @return {Promise}
   */
  async installFramework (version, force = false) {
    this.log('verbose', 'INSTALL')
    try {
      try {
        await fs.readFile(path.resolve(this.path, 'package.json'))
        if (force) {
          this.log('verbose', 'INSTALL forcing new framework install')
        } else {
          return this.log('verbose', 'INSTALL no action, force !== true')
        }
        await fs.rm(this.path, { recursive: true })
      } catch (e) {
        // package is missing, an install is required
      }
      if (version) {
        this.checkVersionCompatibility(version)
      }
      if (!version && this.targetVersionRange) {
        version = await this.getLatestVersion()
      }
      await this.runCliCommand('installFramework', { version })
    } catch (e) {
      this.log('error', `failed to install framework, ${e.message}`)
      throw e.statusCode ? e : this.app.errors.FW_INSTALL_FAILED.setData({ reason: e.message })
    }
    this.log('verbose', 'INSTALL hook invoke')
    await this.postInstallHook.invoke()
  }

  /**
   * Updates the local copy of the Adapt framework
   * @return {Promise}
   */
  async getLatestVersion () {
    try {
      return this.runCliCommand('getLatestFrameworkVersion', { versionLimit: this.targetVersionRange })
    } catch (e) {
      this.log('error', `failed to retrieve framework update data, ${e.message}`)
      throw this.app.errors.FW_LATEST_VERSION_FAILED.setData({ reason: e.message })
    }
  }

  /**
   * Retrieves the plugins listed in the framework manifest, but not necessarily installed
   * @return {Promise}
   */
  async getManifestPlugins () {
    const manifest = await readJson(path.resolve(this.path, 'adapt.json'))
    return Object.entries(manifest.dependencies)
  }

  /**
   * Retrieves the locally installed plugins
   * @return {Promise}
   */
  async getInstalledPlugins () {
    return this.runCliCommand('getInstalledPlugins')
  }

  /**
   * Updates the local copy of the Adapt framework
   * @param {string} version The version to update to
   * @return {Promise}
   */
  async updateFramework (version) {
    let migrationResult
    try {
      if (version) {
        this.checkVersionCompatibility(version)
      }
      if (!version && this.targetVersionRange) {
        version = await this.getLatestVersion()
      }
      const fromPlugins = await readFrameworkPluginVersions(this.path)
      await this.runCliCommand('updateFramework', { version })
      this._version = await this.runCliCommand('getCurrentFrameworkVersion')
      const toPlugins = await readFrameworkPluginVersions(this.path)
      migrationResult = await migrateExistingCourses({ fromPlugins, toPlugins, frameworkDir: this.path })
    } catch (e) {
      this.log('error', `failed to update framework, ${e.message}`)
      throw e.statusCode ? e : this.app.errors.FW_UPDATE_FAILED.setData({ reason: e.message })
    }
    await this.postUpdateHook.invoke()
    return migrationResult
  }

  /**
   * Returns a cached plugin hash, computing it on first call
   * @return {Promise<String>}
   */
  async getPluginHash () {
    if (!this._pluginHash) {
      this._pluginHash = await computePluginHash(this.path)
    }
    return this._pluginHash
  }

  /**
   * Invalidates the prebuilt compilation cache and optionally
   * triggers an eager rebuild of the shared cache in the background
   */
  async invalidatePrebuiltCache () {
    this._pluginHash = null

    try {
      await new BuildCache(path.join(this.getConfig('buildDir'), 'prebuilt-cache')).invalidate()
    } catch (e) {
      this.log('warn', `failed to invalidate prebuilt cache: ${e.message}`)
    }

    if (this.getConfig('prebuildCache')) {
      this.prebuildCache()
    }
  }

  /**
   * Eagerly rebuilds the prebuilt cache in the background, iterating every
   * (theme, menu) combination of installed plugins. Safe to call multiple
   * times — if a build is already in progress, it will be reused.
   * Idempotent — already-cached combos are skipped.
   * @return {Promise<void>}
   */
  prebuildCache () {
    if (this._eagerBuildPromise) {
      return this._eagerBuildPromise
    }
    this._eagerBuildPromise = prebuildCache({
      buildDir: this.getConfig('buildDir'),
      frameworkDir: this.path
    }).catch(e => {
      this.log('warn', `eager prebuild failed: ${e.message}`)
      if (e.cmd) this.log('warn', `cmd: ${e.cmd}`)
      if (e.raw) this.log('warn', `output: ${e.raw}`)
      if (e.stderr) this.log('warn', `stderr: ${e.stderr}`)
    }).finally(() => {
      this._eagerBuildPromise = null
    })
    return this._eagerBuildPromise
  }

  /**
   * Logs relevant framework status messages
   */
  async logStatus () {
    const current = this.version
    const latest = await this.getLatestVersion()

    this.log('info', `local adapt_framework v${current} installed`)
    if (semver.lt(current, latest)) {
      this.log('info', `a newer version of the adapt_framework is available (${latest}), pass the --update-framework flag to update`)
    }
  }

  /**
   * Loads schemas from the local copy of the Adapt framework and registers them with the app
   * @return {Promise}
   */
  async loadSchemas () {
    const jsonschema = await this.app.waitForModule('jsonschema')
    const schemas = (await this.runCliCommand('getSchemaPaths')).filter(s => s.includes('/core/'))
    schemas.forEach(s => jsonschema.registerSchema(s))
  }

  /**
   * Checks whether the request user should be given access to the content they're requesting
   * @param {external:ExpressRequest} req
   * @param {Object} data
   * @return {Promise} Resolves with boolean
   */
  async checkContentAccess (req, data) {
    const content = await this.app.waitForModule('content')
    let course
    if (data._type === 'course') {
      course = data
    } else {
      course = await content.findOne({ _id: data._courseId || (await content.findOne(data, { strict: false }))?._courseId })
    }
    if (!course) {
      return
    }
    const shareWithUsers = course._shareWithUsers?.map(id => id.toString()) ?? []
    const userId = req.auth.user._id.toString()
    return course.createdBy.toString() === userId || course._isShared || shareWithUsers.includes(userId)
  }

  /**
   * Initialises the module routing
   * @return {Promise}
   */
  async initRoutes () {
    const [auth, server] = await this.app.waitForModule('auth', 'server')
    /**
     * Router for handling all non-API calls
     * @type {Router}
     */
    this.rootRouter = server.root.createChildRouter('adapt')
    this.rootRouter.addRoute({
      route: '/preview/:id/{*splat}',
      handlers: {
        get: (req, res, next) => { // fail silently
          getHandler(req, res, e => e ? res.status(e.statusCode || 500).end() : next())
        }
      }
    })
    /**
     * Router for handling all API calls
     * @type {Router}
     */
    const config = await loadRouteConfig(this.rootDir, this, {
      handlerAliases: { getHandler, postHandler, importHandler, postUpdateHandler, getUpdateHandler }
    })
    this.apiRouter = server.api.createChildRouter(config.root)
    registerRoutes(this.apiRouter, config.routes, auth)
  }

  registerImportContentMigration (migration) {
    if (typeof migration !== 'function') {
      return this.log('warn', `Cannot register content migration, unexpected type (${typeof migration})`)
    }
    this.contentMigrations.push(migration)
  }

  /**
   * Migrates content for specific courses. Called by contentplugin on plugin update.
   * @param {Object} options
   * @param {Array<{name: String, version: String}>} options.fromPlugins Plugin versions before the update
   * @param {Array<{name: String, version: String}>} options.toPlugins Plugin versions after the update
   * @param {String[]} options.courseIds Course IDs to migrate
   * @returns {Promise<{migrated: Number, failed: Number, errors: Array}>}
   */
  async migrateCourses ({ fromPlugins, toPlugins, courseIds }) {
    return migrateExistingCourses({ fromPlugins, toPlugins, frameworkDir: this.path, courseIds })
  }

  /**
   * Builds a single Adapt framework course
   * @param {AdaptFrameworkBuildOptions} options
   * @return {AdaptFrameworkBuild}
   */
  async buildCourse (options) {
    const builder = new AdaptFrameworkBuild(options)
    builder.preBuildHook.tap(() => this.preBuildHook.invoke(builder))
    builder.postBuildHook.tap(() => this.postBuildHook.invoke(builder))
    if (this.buildHook.hasObservers) {
      return this.buildHook.invoke(async () => builder.build(), builder)
    }
    return builder.build()
  }

  /**
   * Imports a single Adapt framework course
   * @param {AdaptFrameworkImportOptions} options
   * @return {AdaptFrameworkImportSummary}
   */
  async importCourse (options) {
    const importer = new AdaptFrameworkImport(options)
    importer.preImportHook.tap(() => this.preImportHook.invoke(importer))
    importer.postImportHook.tap(() => this.postImportHook.invoke(importer))
    if (this.importHook.hasObservers) {
      return this.importHook.invoke(async () => importer.import(), importer)
    }
    return importer.import()
  }
}

export default AdaptFrameworkModule