import { EventEmitter } from 'events' import fs from 'fs/promises' import { request } from './client.mjs' import HttpServer from './http.mjs' import { defaults } from './defaults.mjs' export default class Application extends EventEmitter { constructor(ctx, provider, name, opts = {}) { super() this.ctx = { db: ctx.db, util: ctx.util, log: ctx.log, core: ctx.core, app: this, } this.config = defaults({}, this.ctx.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 != null ? this.config.updateEvery : 180 this.config.startWaitUntilFail = this.config.startWaitUntilFail || (60 * 1000) this.config.heartbeatTimeout = this.config.heartbeatTimeout || (3 * 1000) this.config.heartbeatAttempts = this.config.heartbeatAttempts || 5 this.config.heartbeatAttemptsWait = this.config.heartbeatAttemptsWait || (2 * 1000) this.config.heartbeatPath = this.config.heartbeatPath || '/' Object.assign(this, { setInterval: opts.setInterval || setInterval, fs: opts.fs || fs, }) this.ctx.db.addApplication(name) } startAutoupdater() { if (this.provider.static) return if (this.config.updateEvery === 0) return let timer = this.setInterval(() => { this.update().then( () => { this.ctx.db.data.core[this.name].updater += 'Automatic update finished successfully. ' }, (err) => { this.ctx.db.data.core[this.name].updater += 'Error while running automatic update: ' + err.message + '. ' } ) }, this.config.updateEvery * 60 * 1000) timer.unref() } updateLog(message) { this.ctx.db.data.core[this.name].updater += message this.ctx.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.ctx.db.data.core[this.name].updater !== this.msgStatic) { this.ctx.db.data.core[this.name].updater = '' this.updateLog(this.msgStatic) return this.ctx.db.write().then(function() { return null }) } return Promise.resolve(null) } if (this.updating) return null this.updating = true return this._update() .then((result) => { this.updating = false return this.ctx.db.write() .then(function() { return result }) }) .then((result) => { if (result) { this.emit('updated', result) } return result }) .catch((err) => { this.updating = false return this.ctx.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.ctx.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.ctx.db.data.core[this.name].latestInstalled === latest.version) { this.updateLog('Already up to date, nothing to do. ') return null } // 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.ctx.db.get(this.ctx.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 null } // 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 null } if (latest.failtoinstall && latest.failtoinstall > 3) { this.updateLog('Version failed to install too many times, skipping this version. ') return null } // Combine the logs log = latest.log + log } else { // This is a new version, mark it with stable tag of zero. latest.stable = 0 this.ctx.db.upsertFirst(this.ctx.db.data.core[this.name].versions, latest) } // The target file for the archive and the target folder for new our version let target = this.ctx.util.getPathFromRoot(`./${this.name}/${latest.version}/file${this.ctx.util.getExtension(latest.filename)}`) folder = this.ctx.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.ctx.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.ctx.db.write() // Download was successful, extract the archived file that we downloaded await this.ctx.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.ctx.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.ctx.util.getPathFromRoot(`./${this.name}/${latest.version}/package.json`)) .catch(function() { return null }) if (packageStat) { log += this.updateLog(`running npm install --production. `) + '\n' await this.ctx.db.write() // For some weird reason, --loglevel=notice is required otherwise // we get practically zero log output. await this.ctx.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.ctx.db.data.core[this.name].latestInstalled = latest.version latest.installed = true latest.log = log return latest } registerModule(module, version = '') { 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}${version ? ' version ' + version : '' } registerModule was called with a non module missing start function`) } this.module = module } async runVersion(version) { this.ctx.db.data.core[this.name].active = version await this.ctx.db.write() if (version !== 'static') { let indexPath = this.ctx.util.getPathFromRoot(`./${this.name}/${version}/index.mjs`) await this.fs.stat(indexPath).catch((err) => { return Promise.reject(new Error(`Version was missing index.mjs: ${err.message}`)) }) this.fresh = false let module = await import(this.ctx.util.getUrlFromRoot(`./${this.name}/${version}/index.mjs`)) this.registerModule(module, version) } else { this.fresh = false } let errTimeout = new Error(`Version timed out (took over ${this.config.startWaitUntilFail}ms) while running start()`) await new Promise((res, rej) => { setTimeout(() => { rej(errTimeout) }, this.config.startWaitUntilFail) let startRes = this.module.start(this.http, this.config.port, this.ctx) if (startRes && startRes.then) { return startRes.then(res, rej) } res() }) if (!this.http.active) { return Promise.reject(new Error(`Version did not call http.createServer()`)) } let lastErr = null for (let i = 0; i < this.config.heartbeatAttempts; i++) { try { await request({ timeout: this.config.heartbeatAttemptsWait }, `http://localhost:${this.config.port}` + this.config.heartbeatPath, null, 0, true) return } catch (err) { lastErr = err } } return Promise.reject(new Error(`Version failed to start properly: ${lastErr.message}`)) } closeServer() { return this.http.closeServer() } }