Archived
Adding Mods w/Symlink
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
[General]
|
||||
gameName=spt
|
||||
modid=0
|
||||
version=d2024.12.16.0
|
||||
newestVersion=
|
||||
category="1,2"
|
||||
nexusFileStatus=1
|
||||
installationFile=Corter-ModSync-v0.10.0.zip
|
||||
repository=Nexus
|
||||
ignoredVersion=
|
||||
comments=
|
||||
notes=
|
||||
nexusDescription=
|
||||
url=
|
||||
hasCustomURL=true
|
||||
lastNexusQuery=
|
||||
lastNexusUpdate=
|
||||
nexusLastModified=2024-12-16T07:05:37Z
|
||||
nexusCategory=0
|
||||
converted=false
|
||||
validated=true
|
||||
color=@Variant(\0\0\0\x43\0\xff\xff\0\0\0\0\0\0\0\0)
|
||||
tracked=0
|
||||
|
||||
[installedFiles]
|
||||
1\modid=0
|
||||
1\fileid=0
|
||||
size=1
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "Corter-ModSync",
|
||||
"version": "0.10.0",
|
||||
"main": "src/mod.js",
|
||||
"sptVersion": "~3.10",
|
||||
"loadBefore": [],
|
||||
"loadAfter": [],
|
||||
"incompatibilities": [],
|
||||
"contributors": [],
|
||||
"isBundleMod": false,
|
||||
"scripts": {
|
||||
"setup": "npm i",
|
||||
"build": "node scripts/build.cjs",
|
||||
"bump-version": "node scripts/bump-version.cjs",
|
||||
"test": "node tests",
|
||||
"start": "npx serve ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.3",
|
||||
"@types/node": "20.11",
|
||||
"@types/shelljs": "^0.8.15",
|
||||
"@typescript-eslint/eslint-plugin": "7.2",
|
||||
"@typescript-eslint/parser": "7.2",
|
||||
"@vitest/coverage-istanbul": "^2.1.2",
|
||||
"fs-extra": "11.2",
|
||||
"glob": "^11.0.0",
|
||||
"i18n": "^0.15.1",
|
||||
"ignore": "^5.2",
|
||||
"jsonc": "^2.0.0",
|
||||
"memfs": "^4.9.3",
|
||||
"semver": "^7.6.2",
|
||||
"shelljs": "^0.8.5",
|
||||
"tsyringe": "4.8.0",
|
||||
"typescript": "5.5.2",
|
||||
"vitest": "^2.1.2",
|
||||
"vitest-mock-extended": "^1.3.2",
|
||||
"winston": "3.12",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"author": "Corter",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import path from "node:path";
|
||||
import type { PreSptModLoader } from "@spt/loaders/PreSptModLoader";
|
||||
import type { JsonUtil } from "@spt/utils/JsonUtil";
|
||||
import type { VFS } from "@spt/utils/VFS";
|
||||
import { glob } from "./utility/glob";
|
||||
import { unixPath } from "./utility/misc";
|
||||
import type { ILogger } from "@spt/models/spt/utils/ILogger";
|
||||
|
||||
export type SyncPath = {
|
||||
path: string;
|
||||
enabled?: boolean;
|
||||
enforced?: boolean;
|
||||
silent?: boolean;
|
||||
restartRequired?: boolean;
|
||||
};
|
||||
|
||||
type RawConfig = {
|
||||
syncPaths: (string | SyncPath)[];
|
||||
exclusions: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG = `{
|
||||
"syncPaths": [
|
||||
"BepInEx/plugins",
|
||||
"BepInEx/patchers",
|
||||
"BepInEx/config",
|
||||
{
|
||||
"enabled": false,
|
||||
"path": "user/mods",
|
||||
"restartRequired": false
|
||||
}
|
||||
],
|
||||
"exclusions": [
|
||||
// SPT Installer
|
||||
"BepInEx/plugins/spt",
|
||||
"BepInEx/patchers/spt-prepatch.dll",
|
||||
// Questing Bots
|
||||
"BepInEx/plugins/DanW-SPTQuestingBots/log",
|
||||
// Realism
|
||||
"user/mods/SPT-Realism/ProfileBackups",
|
||||
// Fika
|
||||
"user/mods/fika-server/types",
|
||||
"user/mods/fika-server/cache",
|
||||
"BepInEx/plugins/Fika.Dedicated.dll",
|
||||
// Live Flea Prices
|
||||
"user/mods/zzDrakiaXYZ-LiveFleaPrices/config",
|
||||
// Questing Bots
|
||||
"BepInEx/plugins/DanW-SPTQuestingBots/log",
|
||||
// EFTApi
|
||||
"BepInEx/plugins/kmyuhkyuk-EFTApi/cache",
|
||||
// Expanded Task Text (Accounts for bug with current version)
|
||||
"user/mods/ExpandedTaskText/src/**/cache.json",
|
||||
// Leaves Loot Fuckery
|
||||
"user/mods/leaves-loot_fuckery/output",
|
||||
// ADD MISSING QUEST WEAPON REQUIREMENTS
|
||||
"user/mods/zz_guiltyman-addmissingquestweaponrequirements/log.log",
|
||||
"user/mods/zz_guiltyman-addmissingquestweaponrequirements/user/logs",
|
||||
// Corter ModSync
|
||||
"BepInEx/patchers/Corter-ModSync-Patcher.dll",
|
||||
"**/*.nosync",
|
||||
"**/*.nosync.txt",
|
||||
// General server mods
|
||||
"user/mods/**/.git",
|
||||
"user/mods/**/node_modules",
|
||||
"user/mods/**/*.js",
|
||||
"user/mods/**/*.js.map",
|
||||
"**/*:Zone.Identifier"
|
||||
]
|
||||
}`;
|
||||
|
||||
export class Config {
|
||||
private _globs: RegExp[];
|
||||
constructor(
|
||||
public syncPaths: Required<SyncPath>[],
|
||||
public exclusions: string[],
|
||||
) {
|
||||
this._globs = exclusions.map(glob);
|
||||
}
|
||||
|
||||
public isExcluded(filePath: string): boolean {
|
||||
return this._globs.some(
|
||||
(glob) =>
|
||||
glob.test(unixPath(filePath)),
|
||||
);
|
||||
}
|
||||
}
|
||||
export class ConfigUtil {
|
||||
constructor(
|
||||
private vfs: VFS,
|
||||
private jsonUtil: JsonUtil,
|
||||
private modImporter: PreSptModLoader,
|
||||
private logger: ILogger,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* @throws {Error} If the config file does not exist
|
||||
*/
|
||||
private async readConfigFile(): Promise<RawConfig> {
|
||||
const modPath = this.modImporter.getModPath("Corter-ModSync");
|
||||
const configPath = path.join(modPath, "config.jsonc");
|
||||
|
||||
if (!this.vfs.exists(configPath))
|
||||
await this.vfs.writeFilePromisify(configPath, DEFAULT_CONFIG);
|
||||
|
||||
return this.jsonUtil.deserializeJsonC(
|
||||
// @ts-expect-error I am right, SPT is wrong
|
||||
await this.vfs.readFilePromisify(configPath, { encoding: "utf-8" }),
|
||||
"config.jsonc",
|
||||
) as RawConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {Error} If the config is invalid
|
||||
*/
|
||||
private validateConfig(config: RawConfig): void {
|
||||
if (!Array.isArray(config.syncPaths))
|
||||
throw new Error(
|
||||
"Corter-ModSync: config.jsonc 'syncPaths' is not an array. Please verify your config is correct and try again.",
|
||||
);
|
||||
|
||||
if (!Array.isArray(config.exclusions))
|
||||
throw new Error(
|
||||
"Corter-ModSync: config.jsonc 'exclusions' is not an array. Please verify your config is correct and try again.",
|
||||
);
|
||||
|
||||
for (const syncPath of config.syncPaths) {
|
||||
if (typeof syncPath !== "string" && !("path" in syncPath))
|
||||
throw new Error(
|
||||
"Corter-ModSync: config.jsonc 'syncPaths' is missing 'path'. Please verify your config is correct and try again.",
|
||||
);
|
||||
|
||||
if (
|
||||
typeof syncPath === "string"
|
||||
? path.isAbsolute(syncPath)
|
||||
: path.isAbsolute(syncPath.path)
|
||||
)
|
||||
throw new Error(
|
||||
`Corter-ModSync: SyncPaths must be relative to SPT server root. Invalid path '${syncPath}'`,
|
||||
);
|
||||
|
||||
if (
|
||||
path
|
||||
.relative(
|
||||
process.cwd(),
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
typeof syncPath === "string" ? syncPath : syncPath.path,
|
||||
),
|
||||
)
|
||||
.startsWith("..")
|
||||
)
|
||||
throw new Error(
|
||||
`Corter-ModSync: SyncPaths must within SPT server root. Invalid path '${syncPath}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async load(): Promise<Config> {
|
||||
const rawConfig = await this.readConfigFile();
|
||||
this.validateConfig(rawConfig);
|
||||
|
||||
return new Config(
|
||||
[
|
||||
{
|
||||
enabled: true,
|
||||
enforced: true,
|
||||
silent: true,
|
||||
restartRequired: false,
|
||||
path: "ModSync.Updater.exe",
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
enforced: true,
|
||||
silent: true,
|
||||
restartRequired: true,
|
||||
path: "BepInEx/plugins/Corter-ModSync.dll",
|
||||
},
|
||||
...rawConfig.syncPaths
|
||||
.map((syncPath) => ({
|
||||
enabled: true,
|
||||
enforced: false,
|
||||
silent: false,
|
||||
restartRequired: true,
|
||||
...(typeof syncPath === "string" ? { path: syncPath } : syncPath),
|
||||
}))
|
||||
.sort((a, b) => b.path.length - a.path.length),
|
||||
],
|
||||
rawConfig.exclusions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { DependencyContainer } from "tsyringe";
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { IPreSptLoadMod } from "@spt/models/external/IPreSptLoadMod";
|
||||
import type { ILogger } from "@spt/models/spt/utils/ILogger";
|
||||
import type { HttpListenerModService } from "@spt/services/mod/httpListener/HttpListenerModService";
|
||||
import type { HttpFileUtil } from "@spt/utils/HttpFileUtil";
|
||||
import type { VFS } from "@spt/utils/VFS";
|
||||
import type { JsonUtil } from "@spt/utils/JsonUtil";
|
||||
import { ConfigUtil, type Config } from "./config";
|
||||
import { SyncUtil } from "./sync";
|
||||
import { Router } from "./router";
|
||||
import type { PreSptModLoader } from "@spt/loaders/PreSptModLoader";
|
||||
import type { HttpServerHelper } from "@spt/helpers/HttpServerHelper";
|
||||
|
||||
class Mod implements IPreSptLoadMod {
|
||||
private static container: DependencyContainer;
|
||||
|
||||
private static loadFailed = false;
|
||||
private static config: Config;
|
||||
|
||||
public async preSptLoad(container: DependencyContainer): Promise<void> {
|
||||
Mod.container = container;
|
||||
const logger = container.resolve<ILogger>("WinstonLogger");
|
||||
const vfs = container.resolve<VFS>("VFS");
|
||||
const jsonUtil = container.resolve<JsonUtil>("JsonUtil");
|
||||
const modImporter = container.resolve<PreSptModLoader>("PreSptModLoader");
|
||||
const configUtil = new ConfigUtil(vfs, jsonUtil, modImporter, logger);
|
||||
const httpListenerService = container.resolve<HttpListenerModService>(
|
||||
"HttpListenerModService",
|
||||
);
|
||||
|
||||
httpListenerService.registerHttpListener(
|
||||
"ModSyncListener",
|
||||
this.canHandleOverride,
|
||||
this.handleOverride,
|
||||
);
|
||||
|
||||
try {
|
||||
Mod.config = await configUtil.load();
|
||||
} catch (e) {
|
||||
Mod.loadFailed = true;
|
||||
logger.error("Corter-ModSync: Failed to load config!");
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!vfs.exists("ModSync.Updater.exe")) {
|
||||
Mod.loadFailed = true;
|
||||
logger.error("Corter-ModSync: ModSync.Updater.exe not found! Please ensure ALL files from the release zip are extracted onto the server.");
|
||||
}
|
||||
|
||||
if (!vfs.exists("BepInEx/plugins/Corter-ModSync.dll")) {
|
||||
Mod.loadFailed = true;
|
||||
logger.error("Corter-ModSync: Corter-ModSync.dll not found! Please ensure ALL files from the release zip are extracted onto the server.");
|
||||
}
|
||||
}
|
||||
|
||||
public canHandleOverride(_sessionId: string, req: IncomingMessage): boolean {
|
||||
return !Mod.loadFailed && (req.url?.startsWith("/modsync/") ?? false);
|
||||
}
|
||||
|
||||
public async handleOverride(
|
||||
_sessionId: string,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
const logger = Mod.container.resolve<ILogger>("WinstonLogger");
|
||||
const vfs = Mod.container.resolve<VFS>("VFS");
|
||||
const httpFileUtil = Mod.container.resolve<HttpFileUtil>("HttpFileUtil");
|
||||
const httpServerHelper =
|
||||
Mod.container.resolve<HttpServerHelper>("HttpServerHelper");
|
||||
const modImporter =
|
||||
Mod.container.resolve<PreSptModLoader>("PreSptModLoader");
|
||||
const syncUtil = new SyncUtil(vfs, Mod.config, logger);
|
||||
const router = new Router(
|
||||
Mod.config,
|
||||
syncUtil,
|
||||
vfs,
|
||||
httpFileUtil,
|
||||
httpServerHelper,
|
||||
modImporter,
|
||||
logger,
|
||||
);
|
||||
|
||||
try {
|
||||
router.handleRequest(req, res);
|
||||
} catch (e) {
|
||||
logger.error("Corter-ModSync: Failed to handle request!");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mod = new Mod();
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { HttpFileUtil } from "@spt/utils/HttpFileUtil";
|
||||
import type { SyncUtil } from "./sync";
|
||||
import { glob } from "./utility/glob";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import type { VFS } from "@spt/utils/VFS";
|
||||
import type { Config } from "./config";
|
||||
import { HttpError, winPath } from "./utility/misc";
|
||||
import type { ILogger } from "@spt/models/spt/utils/ILogger";
|
||||
import type { PreSptModLoader } from "@spt/loaders/PreSptModLoader";
|
||||
import type { HttpServerHelper } from "@spt/helpers/HttpServerHelper";
|
||||
|
||||
const FALLBACK_SYNCPATHS: Record<string, object> = {};
|
||||
|
||||
// @ts-expect-error - undefined indicates a version before 0.8.0
|
||||
FALLBACK_SYNCPATHS[undefined] = [
|
||||
"BepInEx\\plugins\\Corter-ModSync.dll",
|
||||
"ModSync.Updater.exe",
|
||||
];
|
||||
FALLBACK_SYNCPATHS["0.8.0"] =
|
||||
FALLBACK_SYNCPATHS["0.8.1"] =
|
||||
FALLBACK_SYNCPATHS["0.8.2"] =
|
||||
[
|
||||
{
|
||||
enabled: true,
|
||||
enforced: true,
|
||||
path: "BepInEx\\plugins\\Corter-ModSync.dll",
|
||||
restartRequired: true,
|
||||
silent: false,
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
enforced: true,
|
||||
path: "ModSync.Updater.exe",
|
||||
restartRequired: false,
|
||||
silent: false,
|
||||
},
|
||||
];
|
||||
|
||||
const FALLBACK_HASHES: Record<string, object> = {};
|
||||
|
||||
// @ts-expect-error - undefined indicates a version before 0.8.0
|
||||
FALLBACK_HASHES[undefined] = {
|
||||
"BepInEx\\plugins\\Corter-ModSync.dll": { crc: 999999999 },
|
||||
"ModSync.Updater.exe": { crc: 999999999 },
|
||||
};
|
||||
FALLBACK_HASHES["0.8.0"] =
|
||||
FALLBACK_HASHES["0.8.1"] =
|
||||
FALLBACK_HASHES["0.8.2"] =
|
||||
{
|
||||
"BepInEx\\plugins\\Corter-ModSync.dll": {
|
||||
"BepInEx\\plugins\\Corter-ModSync.dll": {
|
||||
crc: 999999999,
|
||||
nosync: false,
|
||||
},
|
||||
},
|
||||
"ModSync.Updater.exe": {
|
||||
"ModSync.Updater.exe": { crc: 999999999, nosync: false },
|
||||
},
|
||||
};
|
||||
|
||||
export class Router {
|
||||
constructor(
|
||||
private config: Config,
|
||||
private syncUtil: SyncUtil,
|
||||
private vfs: VFS,
|
||||
private httpFileUtil: HttpFileUtil,
|
||||
private httpServerHelper: HttpServerHelper,
|
||||
private modImporter: PreSptModLoader,
|
||||
private logger: ILogger,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public async getServerVersion(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
_: RegExpMatchArray,
|
||||
_params: URLSearchParams,
|
||||
) {
|
||||
const modPath = this.modImporter.getModPath("Corter-ModSync");
|
||||
const packageJson = JSON.parse(
|
||||
// @ts-expect-error readFile returns a string when given a valid encoding
|
||||
await this.vfs
|
||||
// @ts-expect-error readFile takes in an options object, including an encoding option
|
||||
.readFilePromisify(path.join(modPath, "package.json"), {
|
||||
encoding: "utf-8",
|
||||
}),
|
||||
);
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200, "OK");
|
||||
res.end(JSON.stringify(packageJson.version));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public async getSyncPaths(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
_: RegExpMatchArray,
|
||||
_params: URLSearchParams,
|
||||
) {
|
||||
const version = req.headers["modsync-version"] as string;
|
||||
if (version in FALLBACK_SYNCPATHS) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200, "OK");
|
||||
res.end(JSON.stringify(FALLBACK_SYNCPATHS[version]));
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200, "OK");
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
this.config.syncPaths.map(({ path, ...rest }) => ({
|
||||
path: winPath(path),
|
||||
...rest,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public async getExclusions(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
_: RegExpMatchArray,
|
||||
_params: URLSearchParams,
|
||||
) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200, "OK");
|
||||
res.end(JSON.stringify(this.config.exclusions));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public async getHashes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
_: RegExpMatchArray,
|
||||
params: URLSearchParams,
|
||||
) {
|
||||
const version = req.headers["modsync-version"] as string;
|
||||
if (version in FALLBACK_HASHES) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200, "OK");
|
||||
res.end(JSON.stringify(FALLBACK_HASHES[version]));
|
||||
return;
|
||||
}
|
||||
|
||||
let pathsToHash = this.config.syncPaths;
|
||||
if (params.has("path")) {
|
||||
pathsToHash = this.config.syncPaths.filter(
|
||||
({ path, enforced }) =>
|
||||
enforced || params.getAll("path").includes(path),
|
||||
);
|
||||
}
|
||||
|
||||
const hashes = await this.syncUtil.hashModFiles(pathsToHash);
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200, "OK");
|
||||
res.end(JSON.stringify(hashes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public async fetchModFile(
|
||||
_: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
matches: RegExpMatchArray,
|
||||
_params: URLSearchParams,
|
||||
) {
|
||||
const filePath = decodeURIComponent(matches[1]);
|
||||
|
||||
const sanitizedPath = this.syncUtil.sanitizeDownloadPath(
|
||||
filePath,
|
||||
this.config.syncPaths,
|
||||
);
|
||||
|
||||
if (!this.vfs.exists(sanitizedPath))
|
||||
throw new HttpError(
|
||||
404,
|
||||
`Attempt to access non-existent path ${filePath}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const fileStats = await this.vfs.statPromisify(sanitizedPath);
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
this.httpServerHelper.getMimeText(path.extname(filePath)) ||
|
||||
"text/plain",
|
||||
);
|
||||
res.setHeader("Content-Length", fileStats.size);
|
||||
return this.httpFileUtil.sendFileAsync(res, sanitizedPath);
|
||||
} catch (e) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
`Corter-ModSync: Error reading '${filePath}'\n${e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public handleRequest(req: IncomingMessage, res: ServerResponse) {
|
||||
const routeTable = [
|
||||
{
|
||||
route: glob("/modsync/version"),
|
||||
handler: this.getServerVersion.bind(this),
|
||||
},
|
||||
{
|
||||
route: glob("/modsync/paths"),
|
||||
handler: this.getSyncPaths.bind(this),
|
||||
},
|
||||
{
|
||||
route: glob("/modsync/exclusions"),
|
||||
handler: this.getExclusions.bind(this),
|
||||
},
|
||||
{
|
||||
route: glob("/modsync/hashes"),
|
||||
handler: this.getHashes.bind(this),
|
||||
},
|
||||
{
|
||||
route: glob("/modsync/fetch/**"),
|
||||
handler: this.fetchModFile.bind(this),
|
||||
},
|
||||
];
|
||||
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
|
||||
try {
|
||||
for (const { route, handler } of routeTable) {
|
||||
const matches = route.exec(url.pathname);
|
||||
if (matches) return handler(req, res, matches, url.searchParams);
|
||||
}
|
||||
|
||||
throw new HttpError(404, "Corter-ModSync: Unknown route");
|
||||
} catch (e) {
|
||||
if (e instanceof Error)
|
||||
this.logger.error(
|
||||
`Corter-ModSync: Error when handling [${req.method} ${req.url}]:\n${e.message}\n${e.stack}`,
|
||||
);
|
||||
|
||||
if (e instanceof HttpError) {
|
||||
res.writeHead(e.code, e.codeMessage);
|
||||
res.end(e.message);
|
||||
} else {
|
||||
res.writeHead(500, "Internal server error");
|
||||
res.end(
|
||||
`Corter-ModSync: Error handling [${req.method} ${req.url}]:\n${e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { VFS } from "@spt/utils/VFS";
|
||||
import path from "node:path";
|
||||
import { hashFile } from "./utility/imoHash";
|
||||
import type { Config, SyncPath } from "./config";
|
||||
import { HttpError, winPath } from "./utility/misc";
|
||||
import { Semaphore } from "./utility/semaphore";
|
||||
import type { ILogger } from "@spt/models/spt/utils/ILogger";
|
||||
import type { } from "node:fs";
|
||||
|
||||
type ModFile = {
|
||||
hash: string;
|
||||
directory: boolean;
|
||||
};
|
||||
|
||||
export class SyncUtil {
|
||||
private limiter = new Semaphore(1024);
|
||||
|
||||
constructor(
|
||||
private vfs: VFS,
|
||||
private config: Config,
|
||||
private logger: ILogger,
|
||||
) { }
|
||||
|
||||
private async getFilesInDir(baseDir: string, dir: string): Promise<string[]> {
|
||||
if (!this.vfs.exists(dir)) {
|
||||
this.logger.warning(
|
||||
`Corter-ModSync: Directory '${dir}' does not exist, will be ignored.`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const stats = await this.vfs.statPromisify(dir);
|
||||
if (stats.isFile()) return [dir];
|
||||
|
||||
const files: string[] = [];
|
||||
for (const fileName of this.vfs.getFiles(dir)) {
|
||||
const file = path.join(dir, fileName);
|
||||
|
||||
if (this.config.isExcluded(file)) continue;
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
for (const dirName of this.vfs.getDirs(dir)) {
|
||||
const subDir = path.join(dir, dirName);
|
||||
|
||||
if (this.config.isExcluded(subDir)) continue;
|
||||
|
||||
const subFiles = await this.getFilesInDir(baseDir, subDir);
|
||||
if (!subFiles.length) files.push(subDir)
|
||||
|
||||
files.push(...subFiles);
|
||||
}
|
||||
|
||||
if (stats.isDirectory() && files.length === 0) files.push(dir);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private async buildModFile(
|
||||
file: string,
|
||||
// biome-ignore lint/correctness/noEmptyPattern: <explanation>
|
||||
{ }: Required<SyncPath>,
|
||||
): Promise<ModFile> {
|
||||
const stats = await this.vfs.statPromisify(file);
|
||||
if (stats.isDirectory()) return { hash: "", directory: true };
|
||||
|
||||
let retryCount = 0;
|
||||
const lock = await this.limiter.acquire();
|
||||
while (true) {
|
||||
try {
|
||||
const hash = await hashFile(file);
|
||||
lock.release();
|
||||
|
||||
return {
|
||||
hash,
|
||||
directory: false,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && e.code === "EBUSY" && retryCount < 5) {
|
||||
this.logger.error(`Error reading '${file}'. Retrying (${retryCount}/5)...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.error(`Error reading '${file}'. Exiting...`);
|
||||
this.logger.error(`${e}`);
|
||||
throw new HttpError(500, `Corter-ModSync: Error reading '${file}'\n${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async hashModFiles(
|
||||
syncPaths: Config["syncPaths"],
|
||||
): Promise<Record<string, Record<string, ModFile>>> {
|
||||
const result: Record<string, Record<string, ModFile>> = {};
|
||||
const processedFiles = new Set<string>();
|
||||
|
||||
const startTime = performance.now();
|
||||
for (const syncPath of syncPaths) {
|
||||
const files = await this.getFilesInDir(syncPath.path, syncPath.path);
|
||||
const filesResult: Record<string, ModFile> = {};
|
||||
|
||||
for (const file of files) {
|
||||
if (processedFiles.has(winPath(file))) continue;
|
||||
|
||||
filesResult[winPath(file)] = await this.buildModFile(file, syncPath);
|
||||
|
||||
processedFiles.add(winPath(file));
|
||||
}
|
||||
|
||||
result[winPath(syncPath.path)] = filesResult;
|
||||
}
|
||||
|
||||
this.logger.info(`Corter-ModSync: Hashed ${processedFiles.size} files in ${performance.now() - startTime}ms`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {Error} If file path is invalid
|
||||
*/
|
||||
public sanitizeDownloadPath(
|
||||
file: string,
|
||||
syncPaths: Config["syncPaths"],
|
||||
): string {
|
||||
const normalized = path.join(
|
||||
path.normalize(file).replace(/^(\.\.(\/|\\|$))+/, ""),
|
||||
);
|
||||
|
||||
for (const syncPath of syncPaths) {
|
||||
const fullPath = path.join(process.cwd(), syncPath.path);
|
||||
if (!path.relative(fullPath, normalized).startsWith("..")) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Corter-ModSync: Requested file '${file}' is not in an enabled sync path!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shamelessly stolen from - https://github.com/aleclarson/glob-regex
|
||||
*/
|
||||
|
||||
const dotRE = /\./g;
|
||||
const dotPattern = "\\.";
|
||||
|
||||
const restRE = /\*\*$/g;
|
||||
const restPattern = "(.+)";
|
||||
|
||||
// noinspection RegExpUnnecessaryNonCapturingGroup
|
||||
const globRE = /(?:\*\*\/|\*\*|\*)/g;
|
||||
const globPatterns: Record<string, string> = {
|
||||
"*": "([^/]+)", // no backslashes
|
||||
"**": "(.+/)?([^/]+)", // short for "**/*"
|
||||
"**/": "(.+/)?", // one or more directories
|
||||
};
|
||||
|
||||
function mapToPattern(str: string) {
|
||||
return globPatterns[str];
|
||||
}
|
||||
|
||||
function replace(glob: string) {
|
||||
return glob
|
||||
.replace(dotRE, dotPattern)
|
||||
.replace(restRE, restPattern)
|
||||
.replace(globRE, mapToPattern);
|
||||
}
|
||||
|
||||
function join(globs: string[]) {
|
||||
return `((${globs.map(replace).join(")|(")}))`;
|
||||
}
|
||||
|
||||
export function glob(glob: string | string[]) {
|
||||
return new RegExp(`^${Array.isArray(glob) ? join(glob) : replace(glob)}$`);
|
||||
}
|
||||
|
||||
export function globNoEnd(glob: string | string[]) {
|
||||
return new RegExp(`^${Array.isArray(glob) ? join(glob) : replace(glob)}`);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as util from "node:util";
|
||||
import { metrohash128 } from "./metroHash";
|
||||
|
||||
const SAMPLE_THRESHOLD = 10 * 1024 * 1024;
|
||||
const SAMPLE_SIZE = 32 * 1024;
|
||||
|
||||
// Promisify fs functions
|
||||
const fsOpen = util.promisify(fs.open);
|
||||
const fsClose = util.promisify(fs.close);
|
||||
const fsFstat = util.promisify(fs.fstat);
|
||||
const fsRead = util.promisify(fs.read);
|
||||
|
||||
// Placeholder functions for mmh3 and varint (same as before)
|
||||
|
||||
function putUvarint(buf: Uint8Array, x: number): number {
|
||||
let i = 0;
|
||||
while (x >= 0x80) {
|
||||
buf[i] = (x & 0xff) | 0x80;
|
||||
// biome-ignore lint/style/noParameterAssign: Why allocate more memory when already have memory
|
||||
x >>= 7;
|
||||
i++;
|
||||
}
|
||||
buf[i] = x & 0xff;
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
async function readChunk(
|
||||
fd: number,
|
||||
position: number,
|
||||
length: number,
|
||||
): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await fsRead(fd, buffer, 0, length, position);
|
||||
if (bytesRead < length) {
|
||||
throw new Error("Could not read enough data");
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function hashFileObject(
|
||||
fd: number,
|
||||
sampleThreshold: number = SAMPLE_THRESHOLD,
|
||||
sampleSize: number = SAMPLE_SIZE,
|
||||
): Promise<string> {
|
||||
const stats = await fsFstat(fd);
|
||||
const size = stats.size;
|
||||
|
||||
let data: Buffer;
|
||||
|
||||
if (size < sampleThreshold || sampleSize < 1 || size < 4 * sampleSize) {
|
||||
data = await readChunk(fd, 0, size);
|
||||
} else {
|
||||
const start = await readChunk(fd, 0, sampleSize);
|
||||
const middle = await readChunk(fd, Math.floor(size / 2), sampleSize);
|
||||
const end = await readChunk(fd, size - sampleSize, sampleSize);
|
||||
data = Buffer.concat([start, middle, end]);
|
||||
}
|
||||
|
||||
const hashTmp = metrohash128(data);
|
||||
|
||||
putUvarint(hashTmp, size);
|
||||
|
||||
return Buffer.from(
|
||||
hashTmp.buffer,
|
||||
hashTmp.byteOffset,
|
||||
hashTmp.byteLength,
|
||||
).toString("hex");
|
||||
}
|
||||
|
||||
export async function hashFile(
|
||||
filename: string,
|
||||
sampleThreshold: number = SAMPLE_THRESHOLD,
|
||||
sampleSize: number = SAMPLE_SIZE,
|
||||
): Promise<string> {
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = await fsOpen(filename, "r");
|
||||
return await hashFileObject(fd, sampleThreshold, sampleSize);
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
await fsClose(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
|
||||
const heap = new Array(128).fill(undefined);
|
||||
|
||||
heap.push(undefined, null, true, false);
|
||||
|
||||
function getObject(idx: number) { return heap[idx]; }
|
||||
|
||||
let heap_next = heap.length;
|
||||
|
||||
function dropObject(idx: number) {
|
||||
if (idx < 132) return;
|
||||
heap[idx] = heap_next;
|
||||
heap_next = idx;
|
||||
}
|
||||
|
||||
function takeObject(idx: number) {
|
||||
const ret = getObject(idx);
|
||||
dropObject(idx);
|
||||
return ret;
|
||||
}
|
||||
|
||||
function addHeapObject(obj: Uint8Array) {
|
||||
if (heap_next === heap.length) heap.push(heap.length + 1);
|
||||
const idx = heap_next;
|
||||
heap_next = heap[idx];
|
||||
|
||||
heap[idx] = obj;
|
||||
return idx;
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0: Uint8Array | null = null;
|
||||
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
function passArray8ToWasm0(arg: Uint8Array, malloc: (size: number, count: number) => number) {
|
||||
const ptr = malloc(arg.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} data
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function metrohash128(data: Uint8Array): Uint8Array {
|
||||
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export_0);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.metrohash128(ptr0, len0);
|
||||
return takeObject(ret);
|
||||
}
|
||||
|
||||
const bytes = readFileSync(join(__dirname, "metrohash.wasm"));
|
||||
|
||||
const wasmModule = new WebAssembly.Module(bytes);
|
||||
const wasmInstance = new WebAssembly.Instance(wasmModule, {
|
||||
"__wbindgen_placeholder__": {
|
||||
__wbindgen_object_drop_ref: (arg0: number) => {
|
||||
takeObject(arg0);
|
||||
},
|
||||
|
||||
__wbg_buffer_ccaed51a635d8a2d: (arg0: number) => {
|
||||
const ret = getObject(arg0).buffer;
|
||||
return addHeapObject(ret);
|
||||
},
|
||||
|
||||
__wbg_newwithbyteoffsetandlength_7e3eb787208af730: (arg0: number, arg1: number, arg2: number) => {
|
||||
const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0);
|
||||
return addHeapObject(ret);
|
||||
},
|
||||
|
||||
__wbg_new_fec2611eb9180f95: (arg0: number) => {
|
||||
const ret = new Uint8Array(getObject(arg0));
|
||||
return addHeapObject(ret);
|
||||
},
|
||||
|
||||
__wbindgen_memory: () => {
|
||||
const ret = wasm.memory;
|
||||
return addHeapObject(ret);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: WASM go brrrr...
|
||||
const wasm: any = wasmInstance.exports;
|
||||
module.exports.__wasm = wasm;
|
||||
Binary file not shown.
@@ -0,0 +1,29 @@
|
||||
import path from "node:path";
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
get codeMessage(): string {
|
||||
switch (this.code) {
|
||||
case 400:
|
||||
return "Bad Request";
|
||||
case 404:
|
||||
return "Not Found";
|
||||
default:
|
||||
return "Internal Server Error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function winPath(p: string): string {
|
||||
return p.split(path.posix.sep).join(path.win32.sep);
|
||||
}
|
||||
|
||||
export function unixPath(p: string): string {
|
||||
return p.split(path.win32.sep).join(path.posix.sep);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cpus } from "node:os";
|
||||
|
||||
/**
|
||||
* A lock that is granted when calling [[Semaphore.acquire]].
|
||||
*/
|
||||
type Lock = {
|
||||
release: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A task that has been scheduled with a [[Semaphore]] but not yet started.
|
||||
*/
|
||||
type WaitingPromise = {
|
||||
resolve: (lock: Lock) => void;
|
||||
reject: (err?: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A [[Semaphore]] is a tool that is used to control concurrent access to a common resource. This implementation
|
||||
* is used to apply a max-parallelism threshold.
|
||||
*/
|
||||
export class Semaphore {
|
||||
private running = 0;
|
||||
private waiting: WaitingPromise[] = [];
|
||||
|
||||
constructor(public max: number = cpus().length) {
|
||||
if (max < 1) {
|
||||
throw new Error(
|
||||
`Semaphore was created with a max value of ${max} but the max value cannot be less than 1`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private take() {
|
||||
if (this.waiting.length > 0 && this.running < this.max) {
|
||||
this.running++;
|
||||
|
||||
// Get the next task from the queue
|
||||
const task = this.waiting.shift()!;
|
||||
|
||||
// Resolve the promise to allow it to start, provide a release function
|
||||
task.resolve({ release: this.release });
|
||||
}
|
||||
}
|
||||
|
||||
public acquire(): Promise<Lock> {
|
||||
if (this.running < this.max) {
|
||||
this.running++;
|
||||
return Promise.resolve({ release: this.release });
|
||||
}
|
||||
|
||||
return new Promise<Lock>((resolve, reject) => {
|
||||
this.waiting.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
private release = () => {
|
||||
this.running--;
|
||||
this.take();
|
||||
};
|
||||
|
||||
/**
|
||||
* Purge all waiting tasks from the [[Semaphore]]
|
||||
*/
|
||||
public purge() {
|
||||
this.waiting.forEach((task) => {
|
||||
task.reject(
|
||||
new Error(
|
||||
"The semaphore was purged and as a result this task has been cancelled",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
this.running = 0;
|
||||
this.waiting = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user