import * as fs from "fs/promises"; import * as pathUtil from "path"; import * as semver from "semver"; import { EventEmitter } from "events"; import { pathToFileURL } from "url"; import { Logger } from "../../logger.js"; import { PROXY_VERSION } from "../../meta.js"; import { Util } from "../Util.js"; import { Enums } from "../Enums.js"; import { Chat } from "../Chat.js"; import { Constants } from "../Constants.js"; import { Motd } from "../Motd.js"; import { Player } from "../Player.js"; import { MineProtocol } from "../Protocol.js"; import { EaglerSkins } from "../skins/EaglerSkins.js"; import { BungeeUtil } from "../BungeeUtil.js"; export class PluginManager extends EventEmitter { plugins; proxy; Logger = Logger; Enums = Enums; Chat = Chat; Constants = Constants; Motd = Motd; Player = Player; MineProtocol = MineProtocol; EaglerSkins = EaglerSkins; Util = Util; BungeeUtil = BungeeUtil; _loadDir; _logger; constructor(loadDir) { super(); this.setMaxListeners(0); this._loadDir = loadDir; this.plugins = new Map(); this.Logger = Logger; this._logger = new this.Logger("PluginManager"); } async loadPlugins() { this._logger.info("Loading plugin metadata files..."); const pluginMeta = await this._findPlugins(this._loadDir); await this._validatePluginList(pluginMeta); let pluginsString = ""; for (const [id, plugin] of pluginMeta) { pluginsString += `${id}@${plugin.version}`; } pluginsString = pluginsString.substring(0, pluginsString.length - 1); this._logger.info(`Found ${pluginMeta.size} plugin(s): ${pluginsString}`); if (pluginMeta.size !== 0) { this._logger.info(`Loading ${pluginMeta.size} plugin(s)...`); const successLoadCount = await this._loadPlugins(pluginMeta, this._getLoadOrder(pluginMeta)); this._logger.info(`Successfully loaded ${successLoadCount} plugin(s).`); } this.emit("pluginsFinishLoading", this); } async _findPlugins(dir) { const ret = new Map(); const lsRes = (await Promise.all((await fs.readdir(dir)).filter((ent) => !ent.endsWith(".disabled")).map(async (res) => [pathUtil.join(dir, res), await fs.stat(pathUtil.join(dir, res))]))); for (const [path, details] of lsRes) { if (details.isFile()) { if (path.endsWith(".jar")) { this._logger.warn(`Non-EaglerProxy plugin found! (${path})`); this._logger.warn(`BungeeCord plugins are NOT supported! Only custom EaglerProxy plugins are allowed.`); } else if (path.endsWith(".zip")) { this._logger.warn(`.zip file found in plugin directory! (${path})`); this._logger.warn(`A .zip file was found in the plugins directory! Perhaps you forgot to unzip it?`); } else this._logger.debug(`Skipping file found in plugin folder: ${path}`); } else { const metadataPath = pathUtil.resolve(pathUtil.join(path, "metadata.json")); let metadata; try { const file = await fs.readFile(metadataPath); metadata = JSON.parse(file.toString()); // do some type checking if (typeof metadata.name != "string") throw new TypeError(".name is either null or not of a string type!"); if (typeof metadata.id != "string") throw new TypeError(".id is either null or not of a string type!"); if (/ /gm.test(metadata.id)) throw new Error(`.id contains whitespace!`); if (!semver.valid(metadata.version)) throw new Error(".version is either null, not a string, or is not a valid SemVer!"); if (typeof metadata.entry_point != "string") throw new TypeError(".entry_point is either null or not a string!"); if (!metadata.entry_point.endsWith(".js")) throw new Error(`.entry_point (${metadata.entry_point}) references a non-JavaScript file!`); if (!(await Util.fsExists(pathUtil.resolve(path, metadata.entry_point)))) throw new Error(`.entry_point (${metadata.entry_point}) references a non-existent file!`); if (metadata.requirements instanceof Array == false) throw new TypeError(".requirements is either null or not an array!"); for (const requirement of metadata.requirements) { if (typeof requirement != "object" || requirement == null) throw new TypeError(`.requirements[${metadata.requirements.indexOf(requirement)}] is either null or not an object!`); if (typeof requirement.id != "string") throw new TypeError(`.requirements[${metadata.requirements.indexOf(requirement)}].id is either null or not a string!`); if (/ /gm.test(requirement.id)) throw new TypeError(`.requirements[${metadata.requirements.indexOf(requirement)}].id contains whitespace!`); if (semver.validRange(requirement.version) == null && requirement.version != "any") throw new TypeError(`.requirements[${metadata.requirements.indexOf(requirement)}].version is either null or not a valid SemVer!`); } if (metadata.load_after instanceof Array == false) throw new TypeError(".load_after is either null or not an array!"); for (const loadReq of metadata.load_after) { if (typeof loadReq != "string") throw new TypeError(`.load_after[${metadata.load_after.indexOf(loadReq)}] is either null, or not a valid ID!`); if (/ /gm.test(loadReq)) throw new TypeError(`.load_after[${metadata.load_after.indexOf(loadReq)}] contains whitespace!`); } if (metadata.incompatibilities instanceof Array == false) throw new TypeError(".incompatibilities is either null or not an array!"); for (const incompatibility of metadata.incompatibilities) { if (typeof incompatibility != "object" || incompatibility == null) throw new TypeError(`.incompatibilities[${metadata.load_after.indexOf(incompatibility)}] is either null or not an object!`); if (typeof incompatibility.id != "string") throw new TypeError(`.incompatibilities[${metadata.load_after.indexOf(incompatibility)}].id is either null or not a string!`); if (/ /gm.test(incompatibility.id)) throw new TypeError(`.incompatibilities[${metadata.load_after.indexOf(incompatibility)}].id contains whitespace!`); if (semver.validRange(incompatibility.version) == null) throw new TypeError(`.incompatibilities[${metadata.load_after.indexOf(incompatibility)}].version is either null or not a valid SemVer!`); } if (ret.has(metadata.id)) throw new Error(`Duplicate plugin ID detected: ${metadata.id}. Are there duplicate plugins in the plugin folder?`); ret.set(metadata.id, { path: pathUtil.resolve(path), ...metadata, }); } catch (err) { this._logger.warn(`Failed to load plugin metadata file at ${metadataPath}: ${err.stack ?? err}`); this._logger.warn("This plugin will skip loading due to an error."); } } } return ret; } async _validatePluginList(plugins) { for (const [id, plugin] of plugins) { for (const req of plugin.requirements) { if (!plugins.has(req.id) && req.id != "eaglerproxy" && !req.id.startsWith("module:")) { this._logger.fatal(`Error whilst loading plugins: Plugin ${plugin.name}@${plugin.version} requires plugin ${req.id}@${req.version}, but it is not found!`); this._logger.fatal("Loading has halted due to missing dependencies."); process.exit(1); } if (req.id == "eaglerproxy") { if (!semver.satisfies(PROXY_VERSION, req.version) && req.version != "any") { this._logger.fatal(`Error whilst loading plugins: Plugin ${plugin.name}@${plugin.version} requires a proxy version that satisfies the SemVer requirement ${req.version}, but the proxy version is ${PROXY_VERSION} and does not satisfy the SemVer requirement!`); this._logger.fatal("Loading has halted due to dependency issues."); process.exit(1); } } else if (req.id.startsWith("module:")) { const moduleName = req.id.replace("module:", ""); try { await import(moduleName); } catch (err) { if (err.code == "ERR_MODULE_NOT_FOUND") { this._logger.fatal(`Plugin ${plugin.name}@${plugin.version} requires NPM module ${moduleName}${req.version == "any" ? "" : `@${req.version}`} to be installed, but it is not found!`); this._logger.fatal(`Please install this missing package by running "npm install ${moduleName}${req.version == "any" ? "" : `@${req.version}`}". If you're using yarn, run "yarn add ${moduleName}${req.version == "any" ? "" : `@${req.version}`}" instead.`); this._logger.fatal("Loading has halted due to dependency issues."); process.exit(1); } } } else { let dep = plugins.get(req.id); if (!semver.satisfies(dep.version, req.version) && req.version != "any") { this._logger.fatal(`Error whilst loading plugins: Plugin ${plugin.name}@${plugin.version} requires a version of plugin ${dep.name} that satisfies the SemVer requirement ${req.version}, but the plugin ${dep.name}'s version is ${dep.version} and does not satisfy the SemVer requirement!`); this._logger.fatal("Loading has halted due to dependency issues."); process.exit(1); } } } plugin.incompatibilities.forEach((incomp) => { const plugin_incomp = plugins.get(incomp.id); if (plugin_incomp) { if (semver.satisfies(plugin_incomp.version, incomp.version)) { this._logger.fatal(`Error whilst loading plugins: Plugin incompatibility found! Plugin ${plugin.name}@${plugin.version} is incompatible with ${plugin_incomp.name}@${plugin_incomp.version} as it satisfies the SemVer requirement of ${incomp.version}!`); this._logger.fatal("Loading has halted due to plugin incompatibility issues."); process.exit(1); } } else if (incomp.id == "eaglerproxy") { if (semver.satisfies(PROXY_VERSION, incomp.version)) { this._logger.fatal(`Error whilst loading plugins: Plugin ${plugin.name}@${plugin.version} is incompatible with proxy version ${PROXY_VERSION} as it satisfies the SemVer requirement of ${incomp.version}!`); this._logger.fatal("Loading has halted due to plugin incompatibility issues."); process.exit(1); } } }); } } _getLoadOrder(plugins) { let order = [], lastPlugin; plugins.forEach((v) => order.push(v.id)); for (const [id, plugin] of plugins) { const load = plugin.load_after.filter((dep) => plugins.has(dep)); if (load.length < 0) { order.push(plugin.id); } else { let mostLastIndexFittingDeps = -1; for (const loadEnt of load) { if (loadEnt != lastPlugin) { if (order.indexOf(loadEnt) + 1 > mostLastIndexFittingDeps) { mostLastIndexFittingDeps = order.indexOf(loadEnt) + 1; } } } if (mostLastIndexFittingDeps != -1) { order.splice(order.indexOf(plugin.id), 1); order.splice(mostLastIndexFittingDeps - 1, 0, plugin.id); lastPlugin = plugin; } } } return order; } async _loadPlugins(plugins, order) { let successCount = 0; for (const id of order) { let pluginMeta = plugins.get(id); try { const imp = await import(process.platform == "win32" ? pathToFileURL(pathUtil.join(pluginMeta.path, pluginMeta.entry_point)).toString() : pathUtil.join(pluginMeta.path, pluginMeta.entry_point)); this.plugins.set(pluginMeta.id, { exports: imp, metadata: pluginMeta, }); successCount++; this.emit("pluginLoad", pluginMeta.id, imp); } catch (err) { this._logger.warn(`Failed to load plugin entry point for plugin (${pluginMeta.name}) at ${pluginMeta.path}: ${err.stack ?? err}`); this._logger.warn("This plugin will skip loading due to an error."); } return successCount; } } }