import { EventEmitter } from 'events' import fs from 'fs/promises' import HttpServer from './http.mjs' export default class Application extends EventEmitter { constructor(util, db, provider, name, opts = {}) { super() this.util = util this.db = db this.config = db.config[name] || { } this.provider = provider this.name = name this.updating = false this.http = new HttpServer(this.config) this.module = null this.running = false // Fresh is used to indicate that when we run the application and it fails, // whether the environment we are in was fresh or not. An example would be // if we had previously run an older version. In that case, that older version // might have dirtied the runtime or left a port open or other stuff. // In which case, running the new version might fail even though it should // normally be fine. As such we have this flag here. to indicate we might // need a full restart before making another attempt. this.fresh = true // Apply defaults to config this.config.updateEvery = this.config.updateEvery || 180 this.config.waitUntilFail = this.config.waitUntilFail || (60 * 1000) Object.assign(this, { setInterval: opts.setInterval || setInterval, fs: opts.fs || fs, }) this.db.addApplication(name) } startAutoupdater() { if (this.provider.static) return let timer = this.setInterval(() => { this.update().then( () => { this.db.data.core[this.name].updater += 'Automatic update finished successfully. ' }, (err) => { this.db.data.core[this.name].updater += 'Error while running automatic update: ' + err.message + '. ' } ) }, this.config.updateEvery * 60 * 1000) timer.unref() } updateLog(message) { this.db.data.core[this.name].updater += message this.db.log.info(message) return message } msgStatic = 'Provider in question is static and so no update required, nothing to do.' update() { if (this.provider.static) { if (this.db.data.core[this.name].updater !== this.msgStatic) { this.db.data.core[this.name].updater = '' this.updateLog(this.msgStatic) return this.db.write() } return Promise.resolve() } if (this.updating) return this.updating = true return this._update() .then(() => { this.updating = false return this.db.write() }) .catch((err) => { this.updating = false return this.db.write() .then(function() { return Promise.reject(err) }) }) } logAddSeperator(log) { if (!log.endsWith('\n')) { log += '\n' } if (!log.endsWith('\n\n')) { log += '\n' } return log } async _update() { this.db.data.core[this.name].updater = '' let cleanup = true let folder = '' let log = '' let latest = null try { log += this.updateLog(`Checking for latest version at ${new Date().toISOString().replace('T', ' ').split('.')[0]}. `) + '\n' // Get the latest version from our provider latest = await this.provider.getLatestVersion() log += this.updateLog(`Found ${latest.version}. `) + '\n' // If the versino matches the latest installed, then there's nothing to do if (this.db.data.core[this.name].latestInstalled === latest.version) { this.updateLog('Already up to date, nothing to do. ') return } // Make the id for the vesion the version number. Allows for easy lookup // among other nice and simple structure. latest.id = latest.version // check to see if we already have this version in our database. var found = this.db.get(this.db.data.core[this.name].versions, latest.id) if (found) { // Check if the existing version found was already installed. if (found.installed) { this.updateLog('Version was already installed, nothing to do. ') return } // We found existing on, update the keys of the one in the databse with // the latest data we got from getLatestVersion(). This ensures that info // like link, filename and such get updated if these have changed since. Object.keys(latest).forEach(function(key) { found[key] = latest[key] }) latest = found // Check to see if the existing one has failed too many times and // if so, we should skip them and consider those versions as black // listed and avoid at all cost. if (latest.failtodownload && latest.failtodownload > 3) { this.updateLog('Version failed to download too many times, skipping this version. ') return } if (latest.failtoinstall && latest.failtoinstall > 3) { this.updateLog('Version failed to install too many times, skipping this version. ') return } // Combine the logs log = latest.log + log } else { // This is a new version, mark it with stable tag of zero. latest.stable = 0 this.db.upsertFirst(this.db.data.core[this.name].versions, latest) } // The target file for the archive and the target folder for new our version let target = this.util.getPathFromRoot(`./${this.name}/${latest.version}/file${this.util.getExtension(latest.filename)}`) folder = this.util.getPathFromRoot(`./${this.name}/${latest.version}`) // Create it in case it does not exist. await this.fs.mkdir(folder, { recursive: true }) log += this.updateLog(`Downloading ${latest.link} to ${target}. `) + '\n' await this.db.write() // Download the latest version using the provider in question. await this.provider.downloadVersion(latest, target) .catch(function(err) { latest.failtodownload = (latest.failtodownload || 0) + 1 return Promise.reject(err) }) log += '\n' + this.updateLog(`Extracting ${target}. `) + '\n' await this.db.write() // Download was successful, extract the archived file that we downloaded await this.util.extractFile(target, function(msg) { log += msg }).catch(function(err) { latest.failtodownload = (latest.failtodownload || 0) + 1 return Promise.reject(err) }) // Remove the archived file since we're done using it. await this.fs.rm(target, { force: true }).catch(function() {}) // The extracting process might not leave enough newlines for our // desired clean output for our logs so add them. log = this.logAddSeperator(log) // check if the version we downloaded had index.mjs. If this is // missing then either the extracting or download failed without erroring // or the archived is borked. await this.fs.stat(this.util.getPathFromRoot(`./${this.name}/${latest.version}/index.mjs`)) .catch((err) => { latest.failtodownload = (latest.failtodownload || 0) + 1 log += this.updateLog('Version did not include or was missing index.mjs. ') + '\n' return Promise.reject(err) }) // If we reach here, then we don't wanna cleanup or remove existing files // in case more errors occured. The download was success and preliminary // checks indicate the version is valid. As such, we are gonna skip // clearing the folder even if something occurs later on. cleanup = false // Check if we have a package.json file. If we do, we need to run // npm install. If we don't then this application either has all the // required packages or it doesn't need them to run let packageStat = await this.fs.stat(this.util.getPathFromRoot(`./${this.name}/${latest.version}/package.json`)) .catch(function() { return null }) if (packageStat) { log += this.updateLog(`running npm install --production. `) + '\n' await this.db.write() // For some weird reason, --loglevel=notice is required otherwise // we get practically zero log output. await this.util.runCommand( 'npm.cmd', ['install', '--production', '--no-optional', '--no-package-lock', '--no-audit', '--loglevel=notice'], folder, function(msg) { log += msg } ).catch(function(err) { latest.failtoinstall = (latest.failtoinstall || 0) + 1 return Promise.reject(err) }) log = this.logAddSeperator(log) } else { log += this.updateLog('Release did not contain package.json, skipping npm install. ') + '\n' } } catch (err) { log += this.updateLog(`Error: ${err.message}. `) + '\n' // Check if we have a folder and we need to do some cleanups. We do // this if the download process failed so we can have a fresh clean // tree for the next time update is run if (folder && cleanup) { await this.fs.rm(folder, { force: true, recursive: true }).catch((err) => { this.updateLog(`Error while cleaning up: ${err.message}. `) }) } if (latest) { latest.log = log } return Promise.reject(err) } // If we reached here then everything went swimmingly. Mark the version // as being installed and attach the install log to it. log += this.updateLog(`Finished updating ${this.name} to version ${latest.version}.`) + '\n' this.db.data.core[this.name].latestInstalled = latest.version latest.installed = true latest.log = log } registerModule(module) { if (module && typeof(module) === 'function') { return this.registerModule({ start: module }) } if (!module || typeof(module) !== 'object' || typeof(module.start) !== 'function') { throw new Error(`Application ${this.name} registerModule was called with a non module missing start function`) } this.module = module } async runVersion(version) { let module = this.module let errTimeout = new Error(`Application ${this.name} version ${version} timed out (took over ${this.config.waitUntilFail}ms) while running start()`) await new Promise((res, rej) => { setTimeout(() => { rej(errTimeout) }, this.config.waitUntilFail) let startRes = module.start(this.db, this.db.log, this.http, this.config.port) if (startRes && startRes.then) { return startRes.then(res, rej) } res() }) if (!this.http.active) { return Promise.reject(new Error(`Application ${this.name} version ${version} did not call http.createServer()`)) } } }