Adding Mods w/Symlink

This commit is contained in:
2025-01-02 20:23:50 -05:00
parent 88fc2c3c93
commit 404890ecbb
10218 changed files with 3191380 additions and 0 deletions
Binary file not shown.
Binary file not shown.
+28
View File
@@ -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;
@@ -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 = [];
}
}