Server/project/src/loaders/PreAkiModLoader.ts

741 lines
26 KiB
TypeScript
Raw Normal View History

import { execSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
2023-03-03 15:23:46 +00:00
import semver from "semver";
import { DependencyContainer, inject, injectable } from "tsyringe";
import { BundleLoader } from "@spt-aki/loaders/BundleLoader";
import { ModLoadOrder } from "@spt-aki/loaders/ModLoadOrder";
import { ModTypeCheck } from "@spt-aki/loaders/ModTypeCheck";
import { ModDetails } from "@spt-aki/models/eft/profile/IAkiProfile";
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
import { IPreAkiLoadMod } from "@spt-aki/models/external/IPreAkiLoadMod";
import { IPreAkiLoadModAsync } from "@spt-aki/models/external/IPreAkiLoadModAsync";
import { ICoreConfig } from "@spt-aki/models/spt/config/ICoreConfig";
import { IModLoader } from "@spt-aki/models/spt/mod/IModLoader";
import { IPackageJsonData } from "@spt-aki/models/spt/mod/IPackageJsonData";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
import { LocalisationService } from "@spt-aki/services/LocalisationService";
import { ModCompilerService } from "@spt-aki/services/ModCompilerService";
import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { VFS } from "@spt-aki/utils/VFS";
2023-03-03 15:23:46 +00:00
@injectable()
export class PreAkiModLoader implements IModLoader
{
protected static container: DependencyContainer;
protected readonly basepath = "user/mods/";
protected readonly modOrderPath = "user/mods/order.json";
protected order: Record<string, number> = {};
protected imported: Record<string, IPackageJsonData> = {};
protected akiConfig: ICoreConfig;
protected serverDependencies: Record<string, string>;
protected skippedMods: Set<string>;
2023-03-03 15:23:46 +00:00
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("VFS") protected vfs: VFS,
@inject("JsonUtil") protected jsonUtil: JsonUtil,
@inject("ModCompilerService") protected modCompilerService: ModCompilerService,
@inject("BundleLoader") protected bundleLoader: BundleLoader,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("ConfigServer") protected configServer: ConfigServer,
@inject("ModLoadOrder") protected modLoadOrder: ModLoadOrder,
@inject("ModTypeCheck") protected modTypeCheck: ModTypeCheck,
2023-03-03 15:23:46 +00:00
)
{
this.akiConfig = this.configServer.getConfig<ICoreConfig>(ConfigTypes.CORE);
const packageJsonPath: string = path.join(__dirname, "../../package.json");
this.serverDependencies = JSON.parse(this.vfs.readFile(packageJsonPath)).dependencies;
this.skippedMods = new Set();
2023-03-03 15:23:46 +00:00
}
public async load(container: DependencyContainer): Promise<void>
{
if (globalThis.G_MODS_ENABLED)
{
PreAkiModLoader.container = container;
await this.importModsAsync();
await this.executeModsAsync(container);
2023-03-03 15:23:46 +00:00
}
}
/**
* Returns a list of mods with preserved load order
* @returns Array of mod names in load order
*/
public getImportedModsNames(): string[]
{
return Object.keys(this.imported);
}
public getImportedModDetails(): Record<string, IPackageJsonData>
{
return this.imported;
}
public getProfileModsGroupedByModName(profileMods: ModDetails[]): ModDetails[]
{
// Group all mods used by profile by name
const modsGroupedByName: Record<string, ModDetails[]> = {};
for (const mod of profileMods)
{
if (!modsGroupedByName[mod.name])
{
modsGroupedByName[mod.name] = [];
}
modsGroupedByName[mod.name].push(mod);
}
// Find the highest versioned mod and add to results array
const result = [];
for (const modName in modsGroupedByName)
{
const modDatas = modsGroupedByName[modName];
const modVersions = modDatas.map((x) => x.version);
const highestVersion = semver.maxSatisfying(modVersions, "*");
const chosenVersion = modDatas.find((x) => x.name === modName && x.version === highestVersion);
if (!chosenVersion)
{
continue;
}
result.push(chosenVersion);
}
return result;
}
2023-03-03 15:23:46 +00:00
public getModPath(mod: string): string
{
return `${this.basepath}${mod}/`;
}
protected async importModsAsync(): Promise<void>
2023-03-03 15:23:46 +00:00
{
if (!this.vfs.exists(this.basepath))
{
// no mods folder found
this.logger.info(this.localisationService.getText("modloader-user_mod_folder_missing"));
this.vfs.createDir(this.basepath);
return;
}
/**
* array of mod folder names
*/
const mods: string[] = this.vfs.getDirs(this.basepath);
2023-03-03 15:23:46 +00:00
this.logger.info(this.localisationService.getText("modloader-loading_mods", mods.length));
// Mod order
if (!this.vfs.exists(this.modOrderPath))
2023-03-03 15:23:46 +00:00
{
this.logger.info(this.localisationService.getText("modloader-mod_order_missing"));
// Write file with empty order array to disk
this.vfs.writeFile(this.modOrderPath, this.jsonUtil.serializeAdvanced({ order: [] }, null, 4));
2023-03-03 15:23:46 +00:00
}
else
2023-03-03 15:23:46 +00:00
{
const modOrder = this.vfs.readFile(this.modOrderPath, { encoding: "utf8" });
try
2023-03-03 15:23:46 +00:00
{
const modOrderArray = this.jsonUtil.deserialize<any>(modOrder, this.modOrderPath).order;
for (const [index, mod] of modOrderArray.entries())
{
this.order[mod] = index;
}
2023-03-03 15:23:46 +00:00
}
catch (error)
2023-03-03 15:23:46 +00:00
{
this.logger.error(this.localisationService.getText("modloader-mod_order_error"));
}
}
// Validate and remove broken mods from mod list
const validMods = this.getValidMods(mods);
2023-03-03 15:23:46 +00:00
const modPackageData = this.getModsPackageData(validMods);
2023-03-03 15:23:46 +00:00
this.checkForDuplicateMods(modPackageData);
// Used to check all errors before stopping the load execution
let errorsFound = false;
for (const [modFolderName, modToValidate] of modPackageData)
2023-03-03 15:23:46 +00:00
{
if (this.shouldSkipMod(modToValidate))
{
// skip error checking and dependency install for mods already marked as skipped.
continue;
}
2023-03-03 15:23:46 +00:00
// if the mod has library dependencies check if these dependencies are bundled in the server, if not install them
if (
modToValidate.dependencies && Object.keys(modToValidate.dependencies).length > 0
&& !this.vfs.exists(`${this.basepath}${modFolderName}/node_modules`)
)
{
this.autoInstallDependencies(`${this.basepath}${modFolderName}`, modToValidate);
}
2023-03-03 15:23:46 +00:00
// Returns if any mod dependency is not satisfied
if (!this.areModDependenciesFulfilled(modToValidate, modPackageData))
{
errorsFound = true;
}
// Returns if at least two incompatible mods are found
if (!this.isModCompatible(modToValidate, modPackageData))
{
errorsFound = true;
}
// Returns if mod isnt compatible with this verison of aki
if (!this.isModCombatibleWithAki(modToValidate))
{
errorsFound = true;
}
}
if (errorsFound)
{
this.logger.error(this.localisationService.getText("modloader-no_mods_loaded"));
return;
}
// sort mod order
const missingFromOrderJSON = {};
validMods.sort((prev, next) => this.sortMods(prev, next, missingFromOrderJSON));
2023-03-03 15:23:46 +00:00
// log the missing mods from order.json
for (const missingMod of Object.keys(missingFromOrderJSON))
{
this.logger.debug(this.localisationService.getText("modloader-mod_order_missing_from_json", missingMod));
}
2023-03-03 15:23:46 +00:00
// add mods
for (const mod of validMods)
2023-03-03 15:23:46 +00:00
{
const pkg = modPackageData.get(mod);
if (this.shouldSkipMod(pkg))
{
this.logger.warning(this.localisationService.getText("modloader-skipped_mod", { mod: mod }));
continue;
}
await this.addModAsync(mod, pkg);
2023-03-03 15:23:46 +00:00
}
this.modLoadOrder.setModList(this.imported);
2023-03-03 15:23:46 +00:00
}
protected sortMods(prev: string, next: string, missingFromOrderJSON: Record<string, boolean>): number
{
const previndex = this.order[prev];
const nextindex = this.order[next];
// mod is not on the list, move the mod to last
if (previndex === undefined)
{
missingFromOrderJSON[prev] = true;
return 1;
}
if (nextindex === undefined)
{
missingFromOrderJSON[next] = true;
return -1;
}
return previndex - nextindex;
}
2023-03-03 15:23:46 +00:00
/**
* Check for duplicate mods loaded, show error if any
* @param modPackageData map of mod package.json data
2023-03-03 15:23:46 +00:00
*/
protected checkForDuplicateMods(modPackageData: Map<string, IPackageJsonData>): void
2023-03-03 15:23:46 +00:00
{
const grouppedMods: Map<string, IPackageJsonData[]> = new Map();
for (const mod of modPackageData.values())
2023-03-03 15:23:46 +00:00
{
const name = `${mod.author}-${mod.name}`;
grouppedMods.set(name, [...(grouppedMods.get(name) ?? []), mod]);
// if there's more than one entry for a given mod it means there's at least 2 mods with the same author and name trying to load.
if (grouppedMods.get(name).length > 1 && !this.skippedMods.has(name))
{
this.skippedMods.add(name);
}
2023-03-03 15:23:46 +00:00
}
// at this point this.skippedMods only contains mods that are duplicated, so we can just go through every single entry and log it
for (const modName of this.skippedMods)
2023-03-03 15:23:46 +00:00
{
this.logger.error(this.localisationService.getText("modloader-x_duplicates_found", modName));
2023-03-03 15:23:46 +00:00
}
}
/**
* Returns an array of valid mods.
*
2023-03-03 15:23:46 +00:00
* @param mods mods to validate
* @returns array of mod folder names
2023-03-03 15:23:46 +00:00
*/
protected getValidMods(mods: string[]): string[]
2023-03-03 15:23:46 +00:00
{
const validMods: string[] = [];
2023-03-03 15:23:46 +00:00
for (const mod of mods)
{
if (this.validMod(mod))
2023-03-03 15:23:46 +00:00
{
validMods.push(mod);
2023-03-03 15:23:46 +00:00
}
}
return validMods;
2023-03-03 15:23:46 +00:00
}
/**
* Get packageJson data for mods
* @param mods mods to get packageJson for
* @returns map <modFolderName - package.json>
2023-03-03 15:23:46 +00:00
*/
protected getModsPackageData(mods: string[]): Map<string, IPackageJsonData>
2023-03-03 15:23:46 +00:00
{
const loadedMods = new Map<string, IPackageJsonData>();
2023-03-03 15:23:46 +00:00
for (const mod of mods)
{
loadedMods.set(mod, this.jsonUtil.deserialize(this.vfs.readFile(`${this.getModPath(mod)}/package.json`)));
2023-03-03 15:23:46 +00:00
}
return loadedMods;
}
/**
* Is the passed in mod compatible with the running server version
* @param mod Mod to check compatibiltiy with AKI
* @returns True if compatible
*/
2023-03-03 15:23:46 +00:00
protected isModCombatibleWithAki(mod: IPackageJsonData): boolean
{
const akiVersion = this.akiConfig.akiVersion;
const modName = `${mod.author}-${mod.name}`;
// Error and prevent loading If no akiVersion property exists
if (!mod.akiVersion)
{
this.logger.error(this.localisationService.getText("modloader-missing_akiversion_field", modName));
return false;
}
// Error and prevent loading if akiVersion property is not a valid semver string
if (!(semver.valid(mod.akiVersion) || semver.validRange(mod.akiVersion)))
{
this.logger.error(this.localisationService.getText("modloader-invalid_akiversion_field", modName));
return false;
}
// Warn and allow loading if semver is not satisfied
if (!semver.satisfies(akiVersion, mod.akiVersion))
{
this.logger.warning(this.localisationService.getText("modloader-outdated_akiversion_field", modName));
return true;
}
return true;
}
/**
* Execute each mod found in this.imported
* @param container Dependence container to give to mod when it runs
* @returns void promise
*/
protected async executeModsAsync(container: DependencyContainer): Promise<void>
2023-03-03 15:23:46 +00:00
{
// Sort mods load order
2023-03-03 15:23:46 +00:00
const source = this.sortModsLoadOrder();
// Import mod classes
2023-03-03 15:23:46 +00:00
for (const mod of source)
{
if (!this.imported[mod].main)
{
this.logger.error(this.localisationService.getText("modloader-mod_has_no_main_property", mod));
2023-03-03 15:23:46 +00:00
continue;
}
const filepath = `${this.getModPath(mod)}${this.imported[mod].main}`;
// Import class
const modFilePath = `${process.cwd()}/${filepath}`;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const requiredMod = require(modFilePath);
if (!this.modTypeCheck.isPostV3Compatible(requiredMod.mod))
2023-03-03 15:23:46 +00:00
{
this.logger.error(this.localisationService.getText("modloader-mod_incompatible", mod));
delete this.imported[mod];
2023-03-03 15:23:46 +00:00
return;
}
2023-03-03 15:23:46 +00:00
// Perform async load of mod
if (this.modTypeCheck.isPreAkiLoadAsync(requiredMod.mod))
{
try
2023-03-03 15:23:46 +00:00
{
await (requiredMod.mod as IPreAkiLoadModAsync).preAkiLoadAsync(container);
globalThis[mod] = requiredMod;
2023-03-03 15:23:46 +00:00
}
catch (err)
{
this.logger.error(
this.localisationService.getText(
"modloader-async_mod_error",
`${err?.message ?? ""}\n${err.stack ?? ""}`,
),
);
}
continue;
}
// Perform sync load of mod
if (this.modTypeCheck.isPreAkiLoad(requiredMod.mod))
{
(requiredMod.mod as IPreAkiLoadMod).preAkiLoad(container);
globalThis[mod] = requiredMod;
2023-03-03 15:23:46 +00:00
}
}
}
/**
* Read loadorder.json (create if doesnt exist) and return sorted list of mods
* @returns string array of sorted mod names
*/
2023-03-03 15:23:46 +00:00
public sortModsLoadOrder(): string[]
{
// if loadorder.json exists: load it, otherwise generate load order
const loadOrderPath = `${this.basepath}loadorder.json`;
2023-12-13 22:16:21 +00:00
if (this.vfs.exists(loadOrderPath))
2023-03-03 15:23:46 +00:00
{
2023-12-13 22:16:21 +00:00
return this.jsonUtil.deserialize(this.vfs.readFile(loadOrderPath), loadOrderPath);
2023-03-03 15:23:46 +00:00
}
return this.modLoadOrder.getLoadOrder();
2023-03-03 15:23:46 +00:00
}
/**
* Compile mod and add into class property "imported"
* @param mod Name of mod to compile/add
*/
protected async addModAsync(mod: string, pkg: IPackageJsonData): Promise<void>
2023-03-03 15:23:46 +00:00
{
const modPath = this.getModPath(mod);
const isBundleMod = pkg.isBundleMod ?? false;
2023-03-03 15:23:46 +00:00
if (isBundleMod)
{
this.bundleLoader.addBundles(modPath);
}
const typeScriptFiles = this.vfs.getFilesOfType(`${modPath}src`, ".ts");
if (typeScriptFiles.length > 0)
{
if (globalThis.G_MODS_TRANSPILE_TS)
{
// compile ts into js if ts files exist and globalThis.G_MODS_TRANSPILE_TS is set to true
await this.modCompilerService.compileMod(mod, modPath, typeScriptFiles);
}
else
{
// rename the mod entry point to .ts if it's set to .js because G_MODS_TRANSPILE_TS is set to false
pkg.main = pkg.main.replace(".js", ".ts");
2023-03-03 15:23:46 +00:00
}
}
// Purge scripts data from package object
pkg.scripts = {};
// Add mod to imported list
this.imported[mod] = { ...pkg, dependencies: pkg.modDependencies };
this.logger.info(
this.localisationService.getText("modloader-loaded_mod", {
name: pkg.name,
version: pkg.version,
author: pkg.author,
}),
);
}
/**
* Checks if a given mod should be loaded or skipped.
*
* @param pkg mod package.json data
* @returns
*/
protected shouldSkipMod(pkg: IPackageJsonData): boolean
{
return this.skippedMods.has(`${pkg.author}-${pkg.name}`);
2023-03-03 15:23:46 +00:00
}
protected autoInstallDependencies(modPath: string, pkg: IPackageJsonData): void
{
const dependenciesToInstall: [string, string][] = Object.entries(pkg.dependencies);
let depIdx = 0;
for (const [depName, depVersion] of dependenciesToInstall)
{
// currently not checking for version mismatches, but we could check it, just don't know what we would do afterwards, some options would be:
// 1 - throw an error
// 2 - use the server's version (which is what's currently happening by not checking the version)
// 3 - use the mod's version (don't know the reprecursions this would have, or if it would even work)
// if a dependency from the mod exists in the server dependencies we can safely remove it from the list of dependencies to install since it already comes bundled in the server.
if (this.serverDependencies[depName])
{
dependenciesToInstall.splice(depIdx, 1);
}
depIdx++;
}
// If the mod has no extra dependencies return as there's nothing that needs to be done.
if (dependenciesToInstall.length === 0)
{
return;
}
// If this feature flag is set to false, we warn the user he has a mod that requires extra dependencies and might not work, point them in the right direction on how to enable this feature.
if (!this.akiConfig.features.autoInstallModDependencies)
{
this.logger.warning(
this.localisationService.getText("modloader-installing_external_dependencies_disabled", {
name: pkg.name,
author: pkg.author,
configPath: path.join(
globalThis.G_RELEASE_CONFIGURATION ? "Aki_Data/Server/configs" : "assets/configs",
"core.json",
),
configOption: "autoInstallModDependencies",
}),
);
this.skippedMods.add(`${pkg.author}-${pkg.name}`);
return;
}
// Temporarily rename package.json because otherwise npm, pnpm and any other package manager will forcefully download all packages in dependencies without any way of disabling this behavior
this.vfs.rename(`${modPath}/package.json`, `${modPath}/package.json.bak`);
this.vfs.writeFile(`${modPath}/package.json`, "{}");
this.logger.info(
this.localisationService.getText("modloader-installing_external_dependencies", {
name: pkg.name,
author: pkg.author,
}),
);
const pnpmPath = path.join(
process.cwd(),
globalThis.G_RELEASE_CONFIGURATION ? "Aki_Data/Server/@pnpm/exe" : "node_modules/@pnpm/exe",
os.platform() === "win32" ? "pnpm.exe" : "pnpm",
);
let command = `${pnpmPath} install `;
command += dependenciesToInstall.map(([depName, depVersion]) => `${depName}@${depVersion}`).join(" ");
execSync(command, { cwd: modPath });
// Delete the new blank package.json then rename the backup back to the original name
this.vfs.removeFile(`${modPath}/package.json`);
this.vfs.rename(`${modPath}/package.json.bak`, `${modPath}/package.json`);
}
protected areModDependenciesFulfilled(pkg: IPackageJsonData, loadedMods: Map<string, IPackageJsonData>): boolean
2023-03-03 15:23:46 +00:00
{
if (!pkg.modDependencies)
{
return true;
}
const modName = `${pkg.author}-${pkg.name}`;
for (const [modDependency, requiredVersion] of Object.entries(pkg.modDependencies))
2023-03-03 15:23:46 +00:00
{
// Raise dependency version incompatible if the dependency is not found in the mod list
if (!loadedMods.has(modDependency))
2023-03-03 15:23:46 +00:00
{
this.logger.error(
this.localisationService.getText("modloader-missing_dependency", {
mod: modName,
modDependency: modDependency,
}),
);
2023-03-03 15:23:46 +00:00
return false;
}
if (!semver.satisfies(loadedMods.get(modDependency).version, requiredVersion))
2023-03-03 15:23:46 +00:00
{
this.logger.error(
this.localisationService.getText("modloader-outdated_dependency", {
mod: modName,
modDependency: modDependency,
currentVersion: loadedMods.get(modDependency).version,
requiredVersion: requiredVersion,
}),
);
2023-03-03 15:23:46 +00:00
return false;
}
}
return true;
}
protected isModCompatible(mod: IPackageJsonData, loadedMods: Map<string, IPackageJsonData>): boolean
2023-03-03 15:23:46 +00:00
{
const incompatbileModsList = mod.incompatibilities;
if (!incompatbileModsList)
{
return true;
}
for (const incompatibleModName of incompatbileModsList)
{
// Raise dependency version incompatible if any incompatible mod is found
if (loadedMods.has(incompatibleModName))
2023-03-03 15:23:46 +00:00
{
this.logger.error(
this.localisationService.getText("modloader-incompatible_mod_found", {
author: mod.author,
modName: mod.name,
incompatibleModName: incompatibleModName,
}),
);
2023-03-03 15:23:46 +00:00
return false;
}
}
return true;
}
/**
* Validate a mod passes a number of checks
* @param modName name of mod in /mods/ to validate
* @returns true if valid
*/
protected validMod(modName: string): boolean
{
const modPath = this.getModPath(modName);
const modIsCalledBepinEx = modName.toLowerCase() === "bepinex";
const modIsCalledUser = modName.toLowerCase() === "user";
const modIsCalledSrc = modName.toLowerCase() === "src";
const modIsCalledDb = modName.toLowerCase() === "db";
2023-03-03 15:23:46 +00:00
const hasBepinExFolderStructure = this.vfs.exists(`${modPath}/plugins`);
const containsDll = this.vfs.getFiles(`${modPath}`).find((x) => x.includes(".dll"));
if (modIsCalledSrc || modIsCalledDb || modIsCalledUser)
{
this.logger.error(this.localisationService.getText("modloader-not_correct_mod_folder", modName));
return false;
}
2023-03-03 15:23:46 +00:00
if (modIsCalledBepinEx || hasBepinExFolderStructure || containsDll)
{
this.logger.error(this.localisationService.getText("modloader-is_client_mod", modName));
return false;
}
// Check if config exists
2023-12-13 22:16:21 +00:00
const modPackagePath = `${modPath}/package.json`;
if (!this.vfs.exists(modPackagePath))
2023-03-03 15:23:46 +00:00
{
this.logger.error(this.localisationService.getText("modloader-missing_package_json", modName));
return false;
}
// Validate mod
2023-12-13 22:16:21 +00:00
const config = this.jsonUtil.deserialize<IPackageJsonData>(this.vfs.readFile(modPackagePath), modPackagePath);
2023-03-03 15:23:46 +00:00
const checks = ["name", "author", "version", "license"];
let issue = false;
for (const check of checks)
{
if (!(check in config))
{
this.logger.error(
this.localisationService.getText("modloader-missing_package_json_property", {
modName: modName,
prop: check,
}),
);
2023-03-03 15:23:46 +00:00
issue = true;
}
}
if (!semver.valid(config.version))
{
this.logger.error(this.localisationService.getText("modloader-invalid_version_property", modName));
issue = true;
}
if ("main" in config)
{
if (config.main.split(".").pop() !== "js")
{ // expects js file as entry
2023-03-03 15:23:46 +00:00
this.logger.error(this.localisationService.getText("modloader-main_property_not_js", modName));
issue = true;
}
if (!this.vfs.exists(`${modPath}/${config.main}`))
{
// If TS file exists with same name, dont perform check as we'll generate JS from TS file
const tsFileName = config.main.replace(".js", ".ts");
const tsFileExists = this.vfs.exists(`${modPath}/${tsFileName}`);
if (!tsFileExists)
{
this.logger.error(
this.localisationService.getText("modloader-main_property_points_to_nothing", modName),
);
2023-03-03 15:23:46 +00:00
issue = true;
}
}
}
if (config.incompatibilities && !Array.isArray(config.incompatibilities))
{
this.logger.error(
this.localisationService.getText("modloader-incompatibilities_not_string_array", modName),
);
2023-03-03 15:23:46 +00:00
issue = true;
}
return !issue;
}
public getContainer(): DependencyContainer
{
if (PreAkiModLoader.container)
{
return PreAkiModLoader.container;
}
throw new Error(this.localisationService.getText("modloader-dependency_container_not_initalized"));
2023-03-03 15:23:46 +00:00
}
}