@@ -0,0 +1,192 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { defaultSources, type AddonSource } from './addons-sources.js';
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const FETCH_CONCURRENCY = 8;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const SOURCES_OVERRIDE_PATH = process.env.ADDONS_SOURCES_PATH ?? '';
|
||||
|
||||
export type TocData = Record<string, string>;
|
||||
|
||||
export type ResolvedAddon = {
|
||||
name: string;
|
||||
owner: string;
|
||||
git: string;
|
||||
branch?: string;
|
||||
ref?: string;
|
||||
toc?: TocData;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
stars?: number;
|
||||
};
|
||||
|
||||
type CacheEntry = { at: number; data: ResolvedAddon[] };
|
||||
let cache: CacheEntry | undefined;
|
||||
let inFlight: Promise<ResolvedAddon[]> | undefined;
|
||||
|
||||
const parseToc = (content: string): TocData =>
|
||||
content
|
||||
.split('\n')
|
||||
.filter(l => l.startsWith('## '))
|
||||
.map(l => l.slice(3))
|
||||
.map(l => {
|
||||
const idx = l.indexOf(':');
|
||||
if (idx === -1) return null;
|
||||
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()] as const;
|
||||
})
|
||||
.filter((e): e is readonly [string, string] => !!e)
|
||||
.reduce<TocData>((acc, [k, v]) => {
|
||||
acc[k] = v;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const fetchWithTimeout = async (url: string, init?: RequestInit) => {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
};
|
||||
|
||||
const parseGitUrl = (git: string) => {
|
||||
// https://github.com/{owner}/{repo}.git
|
||||
const m = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
||||
if (!m || !m[1] || !m[2]) throw Error(`Unsupported git URL: ${git}`);
|
||||
return { owner: m[1], repo: m[2] };
|
||||
};
|
||||
|
||||
const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
|
||||
try {
|
||||
const { owner, repo } = parseGitUrl(src.git);
|
||||
const name = src.name ?? repo;
|
||||
const branch = src.branch ?? 'master';
|
||||
const tocRef = src.ref ?? branch;
|
||||
|
||||
const tocUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${tocRef}/${name}.toc`;
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
||||
|
||||
const [tocRes, apiRes] = await Promise.all([
|
||||
fetchWithTimeout(tocUrl).catch(() => null),
|
||||
fetchWithTimeout(apiUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
...(process.env.GITHUB_TOKEN && {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
|
||||
})
|
||||
}
|
||||
}).catch(() => null)
|
||||
]);
|
||||
|
||||
let toc: TocData | undefined;
|
||||
if (tocRes?.ok) {
|
||||
const parsed = parseToc(await tocRes.text());
|
||||
const required = ['Interface', 'Title', 'Author', 'Notes', 'Version'];
|
||||
if (required.every(k => typeof parsed[k] === 'string')) {
|
||||
toc = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
let description: string | undefined;
|
||||
let lastUpdated: string | undefined;
|
||||
let stars: number | undefined;
|
||||
if (apiRes?.ok) {
|
||||
const meta = (await apiRes.json()) as {
|
||||
description?: string;
|
||||
pushed_at?: string;
|
||||
stargazers_count?: number;
|
||||
};
|
||||
description = meta.description ?? undefined;
|
||||
lastUpdated = meta.pushed_at;
|
||||
stars = meta.stargazers_count;
|
||||
}
|
||||
|
||||
if (src.description) {
|
||||
description = src.description;
|
||||
if (toc) toc = { ...toc, Notes: src.description };
|
||||
}
|
||||
|
||||
const result: ResolvedAddon = { name, owner, git: src.git };
|
||||
if (src.branch !== undefined) result.branch = src.branch;
|
||||
if (src.ref !== undefined) result.ref = src.ref;
|
||||
if (toc !== undefined) result.toc = toc;
|
||||
if (description !== undefined) result.description = description;
|
||||
if (lastUpdated !== undefined) result.lastUpdated = lastUpdated;
|
||||
if (stars !== undefined) result.stars = stars;
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(`Failed to resolve ${src.git}:`, e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const poolMap = async <T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> => {
|
||||
const results: R[] = new Array(items.length);
|
||||
let idx = 0;
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const i = idx++;
|
||||
if (i >= items.length) return;
|
||||
const item = items[i];
|
||||
if (item === undefined) return;
|
||||
results[i] = await fn(item);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
return results;
|
||||
};
|
||||
|
||||
const loadSources = async (): Promise<AddonSource[]> => {
|
||||
if (!SOURCES_OVERRIDE_PATH) return defaultSources;
|
||||
try {
|
||||
if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) {
|
||||
const override = (await fs.readJSON(SOURCES_OVERRIDE_PATH)) as AddonSource[];
|
||||
if (Array.isArray(override) && override.length > 0) {
|
||||
console.log(`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`);
|
||||
return override;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`, e);
|
||||
}
|
||||
return defaultSources;
|
||||
};
|
||||
|
||||
const buildList = async (): Promise<ResolvedAddon[]> => {
|
||||
const sources = await loadSources();
|
||||
console.log(`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`);
|
||||
const t0 = Date.now();
|
||||
const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne);
|
||||
const ok = results.filter((r): r is ResolvedAddon => r !== null);
|
||||
ok.sort((a, b) => a.name.localeCompare(b.name));
|
||||
console.log(`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`);
|
||||
return ok;
|
||||
};
|
||||
|
||||
export const getAddons = async (force = false): Promise<ResolvedAddon[]> => {
|
||||
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) {
|
||||
return cache.data;
|
||||
}
|
||||
// Deduplicate concurrent callers — only one scrape in flight at a time.
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = buildList()
|
||||
.then(data => {
|
||||
cache = { at: Date.now(), data };
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
});
|
||||
return inFlight;
|
||||
};
|
||||
|
||||
export const warmUp = () => {
|
||||
getAddons().catch(e => console.error('Addon resolver warm-up failed:', e));
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
export type AddonSource = {
|
||||
git: string;
|
||||
branch?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
export const defaultSources: AddonSource[] = [
|
||||
{ git: 'https://github.com/CosminPOP/AtlasLoot.git', name: 'AtlasLoot' },
|
||||
{
|
||||
git: 'https://github.com/byCFM2/Atlas-TW.git',
|
||||
branch: 'main',
|
||||
ref: 'pre-rewrite-backup'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/shirsig/aux-addon-vanilla.git',
|
||||
name: 'aux-addon',
|
||||
description: 'Auction House replacement with advanced filtering and search'
|
||||
},
|
||||
{ git: 'https://github.com/absir/Bagshui.git', branch: 'main' },
|
||||
{ git: 'https://github.com/pepopo978/BetterCharacterStats.git', branch: 'main' },
|
||||
{ git: 'https://github.com/pepopo978/BigWigs.git' },
|
||||
{
|
||||
git: 'https://github.com/DBFBlackbull/BitesCookBook.git',
|
||||
description: 'Tracks which items are used in cooking and what they create'
|
||||
},
|
||||
{ git: 'https://github.com/bhhandley/CleveRoidMacros.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/Cinecom/ConsumesManager.git',
|
||||
branch: 'main',
|
||||
description: 'Tracks consumables and food buffs across alts, bank, and mail'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Kirchlive/cursive-raid.git',
|
||||
name: 'Cursive-Raid',
|
||||
description: 'Raid debuff tracker with profiles and multi-curse assist (SuperWoW)'
|
||||
},
|
||||
{ git: 'https://github.com/Player-Doite/DoiteAuras.git', branch: 'main' },
|
||||
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
|
||||
description: 'Adds extra resource bars (mana, energy, rage) to the UI'
|
||||
},
|
||||
{ git: 'https://github.com/tilare/FlightTracker.git', branch: 'main' },
|
||||
{ git: 'https://github.com/lookino/Flyout.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/trumpetx/GetHead.git',
|
||||
description: 'Recovers Onyxia and Nefarian heads from disenchant grief'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/zanthor/GNS.git',
|
||||
branch: 'main',
|
||||
description: 'Custom naming for Goblin Brainwashing Device specializations'
|
||||
},
|
||||
{ git: 'https://github.com/vatichild/guda.git', name: 'Guda', branch: 'main' },
|
||||
{ git: 'https://github.com/vatichild/GudaPlates.git', branch: 'main' },
|
||||
{ git: 'https://github.com/andresuarezschou/HCDeaths.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/Arthur-Helias/InstanceJournal.git',
|
||||
description: "Encounter Journal reimagined for Turtle WoW"
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Einherjarn/ItemRack.git',
|
||||
description: 'Item set manager with quick-swap menus for inventory'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/CosminPOP/_LazyPig.git',
|
||||
name: '_LazyPig',
|
||||
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure'
|
||||
},
|
||||
{ git: 'https://github.com/Spartelfant/LevelRange-Turtle.git', branch: 'main' },
|
||||
{ git: 'https://github.com/tilare/MessageBox.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
|
||||
description: "Advanced version of Sinesther's Power Auras"
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/tilare/ModernMapMarkers.git',
|
||||
branch: 'main',
|
||||
description: 'Shows dungeons, raids, world bosses, and travel routes on the world map'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/vegeta1k95/ModernSpellBook.git',
|
||||
description: 'Retail-style spellbook UI for vanilla'
|
||||
},
|
||||
{ git: 'https://github.com/tilare/MovementTracker.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/pepopo978/NampowerSettings.git',
|
||||
description: 'Settings panel for the Nampower spellqueue addon'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/BlackHobbiT/necrosis-twow.git',
|
||||
branch: 'main',
|
||||
description: 'Warlock helper: pets, soul shards, summoning, demon timers'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/zanthor/OG-RaidHelper.git',
|
||||
branch: 'main',
|
||||
description: 'Raid management: roles, trade distribution, soft-reserve validation'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/CosminPOP/PallyPower.git',
|
||||
description: 'Paladin buff and assignment manager for raids and parties'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Cliencer/pfExtend.git',
|
||||
branch: 'main',
|
||||
description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
|
||||
},
|
||||
{ git: 'https://github.com/shagu/pfQuest.git' },
|
||||
{ git: 'https://github.com/shagu/pfQuest-turtle.git' },
|
||||
{ git: 'https://github.com/shagu/pfUI.git' },
|
||||
{
|
||||
git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
|
||||
branch: 'main',
|
||||
description: 'pfUI module that re-skins other addons to match the pfUI theme'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Bombg/pfUI-bettertotems.git',
|
||||
branch: 'main',
|
||||
description: 'pfUI module with improved Shaman totem timers'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Arthur-Helias/pfUI-LocationPlus.git',
|
||||
name: 'pfUI-locplus',
|
||||
description: 'Adds a location panel and zone info to pfUI'
|
||||
},
|
||||
{ git: 'https://github.com/acid9000/PizzaWorldBuffs.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/npfs666/ProcDoc.git',
|
||||
branch: 'main',
|
||||
description: 'Visual proc alerts with pulsing images so you never miss them'
|
||||
},
|
||||
{ git: 'https://github.com/SabineWren/Quiver.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/hazlema/Rested.git',
|
||||
description: 'Progress bar showing your rested XP while resting'
|
||||
},
|
||||
{ git: 'https://github.com/Otari98/Rinse.git' },
|
||||
{
|
||||
git: 'https://github.com/anzz1/SellValue.git',
|
||||
description: 'Shows item vendor sell value in tooltips when not at a vendor'
|
||||
},
|
||||
{ git: 'https://github.com/shagu/ShaguDPS.git' },
|
||||
{
|
||||
git: 'https://github.com/shagu/ShaguPlates.git',
|
||||
description: 'Nameplates with castbars and class colors. /splates'
|
||||
},
|
||||
{ git: 'https://github.com/shagu/ShaguTweaks.git' },
|
||||
{
|
||||
git: 'https://github.com/shagu/ShaguTweaks-extras.git',
|
||||
description: 'Extras module for ShaguTweaks (additional UI tweaks)'
|
||||
},
|
||||
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' },
|
||||
{ git: 'https://github.com/Siventt/AttackBar.git' },
|
||||
{
|
||||
git: 'https://github.com/Player-Doite/Tactica.git',
|
||||
branch: 'main',
|
||||
description: 'Auto-build raids: invite/gearcheck, tactics, masterloot, role sync'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Otari98/Tmog.git',
|
||||
description: 'Transmog item browser with collection info in tooltips'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/whtmst/T-RestedXP.git',
|
||||
branch: 'main',
|
||||
description: 'Tracks 0% and 100% rested XP thresholds'
|
||||
},
|
||||
{ git: 'https://github.com/sica42/TurtleCalendar.git', branch: 'main' },
|
||||
{
|
||||
git: 'https://github.com/sica42/TurtleMail.git',
|
||||
description: 'Mailbox UI enhancement: bulk send, search, multi-mail'
|
||||
},
|
||||
{ git: 'https://github.com/tempranova/turtlerp.git', name: 'TurtleRP', branch: 'main' },
|
||||
{ git: 'https://github.com/CosminPOP/TWThreat.git' },
|
||||
{
|
||||
git: 'https://github.com/whtmst/UnitXP_SP3_Addon.git',
|
||||
branch: 'main',
|
||||
description: 'Settings UI for the UnitXP SuperWoW client patch'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/tdymel/VCB.git',
|
||||
description: 'Smart consolidated buff frames with extensive customization'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Fiurs-Hearth/WIIIUI.git',
|
||||
description: 'Compact custom UI replacement for Turtle WoW'
|
||||
},
|
||||
{ git: 'https://github.com/refaim/WIM.git' },
|
||||
{
|
||||
git: 'https://github.com/Arthur-Helias/ZonesLevel.git',
|
||||
description: "Shows zone level range under the title on the world map"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,123 @@
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const allowedExtra = [
|
||||
'.launcher',
|
||||
'Data',
|
||||
'Errors',
|
||||
'Interface\\AddOns',
|
||||
'Logs',
|
||||
'Screenshots',
|
||||
'WDB',
|
||||
'WTF\\Account'
|
||||
];
|
||||
|
||||
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
|
||||
|
||||
const skipFiles = new Set(['manifest.json', 'wow-client.zip', '.gitkeep']);
|
||||
|
||||
type FolderTags = 'allowExtra';
|
||||
type FileTags = 'vanillaFixes';
|
||||
|
||||
type FileManifest = { name: string } & (
|
||||
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
|
||||
| { type: 'mpq'; files: FileManifest[]; hash: string; size: number }
|
||||
| {
|
||||
type: 'file';
|
||||
hash: string;
|
||||
version?: number;
|
||||
size: number;
|
||||
tags?: FileTags[];
|
||||
}
|
||||
);
|
||||
|
||||
const getHash = (...filePath: string[]): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha1');
|
||||
const stream = fs.createReadStream(path.join(...filePath));
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk: Buffer) => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase()));
|
||||
});
|
||||
|
||||
export const buildCache = async (clientPath: string) => {
|
||||
console.log('Building cache...');
|
||||
|
||||
const buildTree = async (...filePath: string[]): Promise<FileManifest[]> => {
|
||||
const files = await fs.readdir(path.join(clientPath, ...filePath));
|
||||
|
||||
const patches: string[] = [];
|
||||
const tree: FileManifest[] = [];
|
||||
for (const file of files.sort()) {
|
||||
if (skipFiles.has(file)) continue;
|
||||
|
||||
const stats = await fs.stat(path.join(clientPath, ...filePath, file));
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
if (file.match(/patch-./)) {
|
||||
patches.push(file);
|
||||
tree.push({
|
||||
type: 'mpq',
|
||||
name: file,
|
||||
files: await buildTree(...filePath, file),
|
||||
size: (
|
||||
await fs.stat(path.join(clientPath, ...filePath, `${file}.mpq`))
|
||||
).size,
|
||||
hash: await getHash(clientPath, ...filePath, `${file}.mpq`)
|
||||
});
|
||||
} else {
|
||||
const tags: FolderTags[] = [];
|
||||
allowedExtra.includes(path.join(...filePath, file)) &&
|
||||
tags.push('allowExtra');
|
||||
tree.push({
|
||||
type: 'dir',
|
||||
name: file,
|
||||
files: await buildTree(...filePath, file),
|
||||
tags: tags.length ? tags : undefined
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if extracted mpq patch
|
||||
if (patches.find(v => file.match(v))) continue;
|
||||
const allowModifiedPaths = new Set([
|
||||
'WTF/Config.wtf',
|
||||
'Data/fonts.MPQ',
|
||||
'Data/sound.MPQ',
|
||||
'Data/speech.MPQ'
|
||||
]);
|
||||
const fullPath = path
|
||||
.join(...filePath, file)
|
||||
.split(path.sep)
|
||||
.join('/');
|
||||
const allowModified =
|
||||
file === 'WoW.exe' || allowModifiedPaths.has(fullPath);
|
||||
|
||||
const tags: FileTags[] = [];
|
||||
vanillaFixes.includes(file) && tags.push('vanillaFixes');
|
||||
|
||||
tree.push({
|
||||
type: 'file',
|
||||
name: file,
|
||||
hash: await getHash(clientPath, ...filePath, file),
|
||||
version: allowModified ? stats.mtimeMs : undefined,
|
||||
size: stats.size,
|
||||
tags: tags.length ? tags : undefined
|
||||
});
|
||||
}
|
||||
return tree;
|
||||
};
|
||||
|
||||
await fs.writeJSON(path.join(clientPath, 'manifest.json'), {
|
||||
build: 3,
|
||||
buildName: '3',
|
||||
root: {
|
||||
type: 'dir',
|
||||
name: '',
|
||||
files: await buildTree()
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import path from 'path';
|
||||
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import express from 'express';
|
||||
|
||||
loadEnv();
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { buildCache } from './cache.js';
|
||||
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
|
||||
|
||||
// Set SOURCE_DIR to your local WoW client directory (see server/.env.example).
|
||||
const SourceDir: string = (() => {
|
||||
const dir = process.env.SOURCE_DIR;
|
||||
if (!dir) {
|
||||
console.error(
|
||||
'ERROR: SOURCE_DIR is not set.\n' +
|
||||
'Set it to your local WoW client directory.\n' +
|
||||
'Example: SOURCE_DIR="C:\\\\WoW\\\\client" npm run dev\n' +
|
||||
'Or create server/.env — see server/.env.example.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return dir;
|
||||
})();
|
||||
|
||||
const app = express();
|
||||
const port = 5000;
|
||||
|
||||
let buildInFlight: Promise<void> | null = null;
|
||||
const ensureManifestBuilt = (): Promise<void> => {
|
||||
if (buildInFlight) return buildInFlight;
|
||||
buildInFlight = buildCache(SourceDir).catch(e => {
|
||||
buildInFlight = null;
|
||||
throw e;
|
||||
});
|
||||
return buildInFlight;
|
||||
};
|
||||
|
||||
app.get('/api/file/:version/manifest.json', async (_req, res) => {
|
||||
console.log(`Fetching manifest`);
|
||||
const filePath = path.join(SourceDir, 'manifest.json');
|
||||
if (!fs.existsSync(filePath)) await ensureManifestBuilt();
|
||||
|
||||
res.json(await fs.readJSON(filePath));
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/file/:version/*',
|
||||
async (req: express.Request<{ 0: string }>, res) => {
|
||||
const filePath = req.params[0];
|
||||
const resolved = path.resolve(SourceDir, filePath);
|
||||
if (!resolved.startsWith(path.resolve(SourceDir) + path.sep)) {
|
||||
res.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
console.log(`Fetching file: ${filePath}`);
|
||||
res.sendFile(resolved);
|
||||
}
|
||||
);
|
||||
|
||||
app.get('/api/addons.json', async (req, res) => {
|
||||
try {
|
||||
const force = req.query.refresh === '1';
|
||||
const addons = await getAddons(force);
|
||||
res.json(addons);
|
||||
} catch (e) {
|
||||
console.error('Failed to resolve addons:', e);
|
||||
res.status(500).json({ error: 'Failed to resolve addons' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server listening on port ${port}`);
|
||||
warmUpAddons();
|
||||
|
||||
void (async () => {
|
||||
const manifestPath = path.join(SourceDir, 'manifest.json');
|
||||
if (fs.existsSync(manifestPath)) return;
|
||||
console.log(`Pre-warming manifest cache for ${SourceDir}...`);
|
||||
try {
|
||||
await ensureManifestBuilt();
|
||||
console.log(`Manifest cache pre-warm complete.`);
|
||||
} catch (e) {
|
||||
console.error('Manifest pre-warm failed:', e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
Reference in New Issue
Block a user