OctoLauncher 1.3.1
Manifest-based CDN updater and mod manager for the OctoWoW 1.12.1 client: launcher-owned realmlist, torrent-backed content sync with bundled aria2c, antivirus and Defender exclusion handling, hardware-aware render distance, optional client tweaks and mods, and the in-launcher news feed.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Copy this file to server/.env and set SOURCE_DIR to your local WoW client directory.
|
||||
# This is the root of the WoW 1.12.1 client tree that the dev server will serve as CDN.
|
||||
# The directory should contain WoW.exe, Data/, Interface/, etc.
|
||||
SOURCE_DIR=C:\WoW\client
|
||||
|
||||
# optional: path to a JSON file overriding the bundled addon sources list.
|
||||
# ADDONS_SOURCES_PATH=/path/to/addons-sources.json
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY server/package.json server/package-lock.json* ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY server/src/ ./src/
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
RUN adduser -D -H -s /sbin/nologin manifest
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build --chown=manifest:manifest /app /app
|
||||
|
||||
RUN npm install --no-save --no-audit --no-fund tsx@^4.21.0
|
||||
|
||||
USER manifest
|
||||
EXPOSE 5000
|
||||
|
||||
ENV SOURCE_DIR=/srv/source
|
||||
|
||||
ENTRYPOINT ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||
Generated
+1496
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.9.0",
|
||||
"express": "^4.18.2",
|
||||
"fs-extra": "^11.1.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@haaxor1689/eslint-config"
|
||||
},
|
||||
"prettier": "@haaxor1689/prettier-config",
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
Generated
+680
@@ -0,0 +1,680 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
'@types/node':
|
||||
specifier: ^20.9.0
|
||||
version: 20.9.0
|
||||
express:
|
||||
specifier: ^4.18.2
|
||||
version: 4.18.2
|
||||
fs-extra:
|
||||
specifier: ^11.1.1
|
||||
version: 11.1.1
|
||||
ts-node:
|
||||
specifier: ^10.9.1
|
||||
version: 10.9.1(@types/node@20.9.0)(typescript@5.2.2)
|
||||
typescript:
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2
|
||||
|
||||
packages:
|
||||
|
||||
/@cspotcode/source-map-support@0.8.1:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
dev: false
|
||||
|
||||
/@jridgewell/resolve-uri@3.1.1:
|
||||
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
dev: false
|
||||
|
||||
/@jridgewell/sourcemap-codec@1.4.15:
|
||||
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
|
||||
dev: false
|
||||
|
||||
/@jridgewell/trace-mapping@0.3.9:
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.1
|
||||
'@jridgewell/sourcemap-codec': 1.4.15
|
||||
dev: false
|
||||
|
||||
/@tsconfig/node10@1.0.9:
|
||||
resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
|
||||
dev: false
|
||||
|
||||
/@tsconfig/node12@1.0.11:
|
||||
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
|
||||
dev: false
|
||||
|
||||
/@tsconfig/node14@1.0.3:
|
||||
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
|
||||
dev: false
|
||||
|
||||
/@tsconfig/node16@1.0.4:
|
||||
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
||||
dev: false
|
||||
|
||||
/@types/body-parser@1.19.5:
|
||||
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 20.9.0
|
||||
dev: false
|
||||
|
||||
/@types/connect@3.4.38:
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
dependencies:
|
||||
'@types/node': 20.9.0
|
||||
dev: false
|
||||
|
||||
/@types/express-serve-static-core@4.17.41:
|
||||
resolution: {integrity: sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==}
|
||||
dependencies:
|
||||
'@types/node': 20.9.0
|
||||
'@types/qs': 6.9.10
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.4
|
||||
dev: false
|
||||
|
||||
/@types/express@4.17.21:
|
||||
resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==}
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.5
|
||||
'@types/express-serve-static-core': 4.17.41
|
||||
'@types/qs': 6.9.10
|
||||
'@types/serve-static': 1.15.5
|
||||
dev: false
|
||||
|
||||
/@types/http-errors@2.0.4:
|
||||
resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==}
|
||||
dev: false
|
||||
|
||||
/@types/mime@1.3.5:
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
dev: false
|
||||
|
||||
/@types/mime@3.0.4:
|
||||
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
|
||||
dev: false
|
||||
|
||||
/@types/node@20.9.0:
|
||||
resolution: {integrity: sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==}
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
dev: false
|
||||
|
||||
/@types/qs@6.9.10:
|
||||
resolution: {integrity: sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==}
|
||||
dev: false
|
||||
|
||||
/@types/range-parser@1.2.7:
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
dev: false
|
||||
|
||||
/@types/send@0.17.4:
|
||||
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
|
||||
dependencies:
|
||||
'@types/mime': 1.3.5
|
||||
'@types/node': 20.9.0
|
||||
dev: false
|
||||
|
||||
/@types/serve-static@1.15.5:
|
||||
resolution: {integrity: sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==}
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.4
|
||||
'@types/mime': 3.0.4
|
||||
'@types/node': 20.9.0
|
||||
dev: false
|
||||
|
||||
/accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
dev: false
|
||||
|
||||
/acorn-walk@8.3.0:
|
||||
resolution: {integrity: sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
dev: false
|
||||
|
||||
/acorn@8.11.2:
|
||||
resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/arg@4.1.3:
|
||||
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
||||
dev: false
|
||||
|
||||
/array-flatten@1.1.1:
|
||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||
dev: false
|
||||
|
||||
/body-parser@1.20.1:
|
||||
resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
destroy: 1.2.0
|
||||
http-errors: 2.0.0
|
||||
iconv-lite: 0.4.24
|
||||
on-finished: 2.4.1
|
||||
qs: 6.11.0
|
||||
raw-body: 2.5.1
|
||||
type-is: 1.6.18
|
||||
unpipe: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/call-bind@1.0.5:
|
||||
resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==}
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
get-intrinsic: 1.2.2
|
||||
set-function-length: 1.1.1
|
||||
dev: false
|
||||
|
||||
/content-disposition@0.5.4:
|
||||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
dev: false
|
||||
|
||||
/content-type@1.0.5:
|
||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/cookie-signature@1.0.6:
|
||||
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||
dev: false
|
||||
|
||||
/cookie@0.5.0:
|
||||
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/create-require@1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
dev: false
|
||||
|
||||
/debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
dev: false
|
||||
|
||||
/define-data-property@1.1.1:
|
||||
resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
get-intrinsic: 1.2.2
|
||||
gopd: 1.0.1
|
||||
has-property-descriptors: 1.0.1
|
||||
dev: false
|
||||
|
||||
/depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/destroy@1.2.0:
|
||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
dev: false
|
||||
|
||||
/diff@4.0.2:
|
||||
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
dev: false
|
||||
|
||||
/ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
dev: false
|
||||
|
||||
/encodeurl@1.0.2:
|
||||
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
dev: false
|
||||
|
||||
/etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/express@4.18.2:
|
||||
resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
array-flatten: 1.1.1
|
||||
body-parser: 1.20.1
|
||||
content-disposition: 0.5.4
|
||||
content-type: 1.0.5
|
||||
cookie: 0.5.0
|
||||
cookie-signature: 1.0.6
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
encodeurl: 1.0.2
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 1.2.0
|
||||
fresh: 0.5.2
|
||||
http-errors: 2.0.0
|
||||
merge-descriptors: 1.0.1
|
||||
methods: 1.1.2
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 0.1.7
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.11.0
|
||||
range-parser: 1.2.1
|
||||
safe-buffer: 5.2.1
|
||||
send: 0.18.0
|
||||
serve-static: 1.15.0
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.1
|
||||
type-is: 1.6.18
|
||||
utils-merge: 1.0.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/finalhandler@1.2.0:
|
||||
resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
encodeurl: 1.0.2
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.1
|
||||
unpipe: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/fresh@0.5.2:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/fs-extra@11.1.1:
|
||||
resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==}
|
||||
engines: {node: '>=14.14'}
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.1.0
|
||||
universalify: 2.0.1
|
||||
dev: false
|
||||
|
||||
/function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
dev: false
|
||||
|
||||
/get-intrinsic@1.2.2:
|
||||
resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==}
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
has-proto: 1.0.1
|
||||
has-symbols: 1.0.3
|
||||
hasown: 2.0.0
|
||||
dev: false
|
||||
|
||||
/gopd@1.0.1:
|
||||
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
|
||||
dependencies:
|
||||
get-intrinsic: 1.2.2
|
||||
dev: false
|
||||
|
||||
/graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
dev: false
|
||||
|
||||
/has-property-descriptors@1.0.1:
|
||||
resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==}
|
||||
dependencies:
|
||||
get-intrinsic: 1.2.2
|
||||
dev: false
|
||||
|
||||
/has-proto@1.0.1:
|
||||
resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: false
|
||||
|
||||
/has-symbols@1.0.3:
|
||||
resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: false
|
||||
|
||||
/hasown@2.0.0:
|
||||
resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
dev: false
|
||||
|
||||
/http-errors@2.0.0:
|
||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.1
|
||||
toidentifier: 1.0.1
|
||||
dev: false
|
||||
|
||||
/iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
dev: false
|
||||
|
||||
/inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
dev: false
|
||||
|
||||
/ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
dev: false
|
||||
|
||||
/jsonfile@6.1.0:
|
||||
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
dev: false
|
||||
|
||||
/make-error@1.3.6:
|
||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
dev: false
|
||||
|
||||
/media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/merge-descriptors@1.0.1:
|
||||
resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
|
||||
dev: false
|
||||
|
||||
/methods@1.1.2:
|
||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
dev: false
|
||||
|
||||
/mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
dev: false
|
||||
|
||||
/ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
dev: false
|
||||
|
||||
/negotiator@0.6.3:
|
||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/object-inspect@1.13.1:
|
||||
resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
|
||||
dev: false
|
||||
|
||||
/on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
dev: false
|
||||
|
||||
/parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/path-to-regexp@0.1.7:
|
||||
resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==}
|
||||
dev: false
|
||||
|
||||
/proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
dev: false
|
||||
|
||||
/qs@6.11.0:
|
||||
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
|
||||
engines: {node: '>=0.6'}
|
||||
dependencies:
|
||||
side-channel: 1.0.4
|
||||
dev: false
|
||||
|
||||
/range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/raw-body@2.5.1:
|
||||
resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.0
|
||||
iconv-lite: 0.4.24
|
||||
unpipe: 1.0.0
|
||||
dev: false
|
||||
|
||||
/safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
dev: false
|
||||
|
||||
/safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
dev: false
|
||||
|
||||
/send@0.18.0:
|
||||
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
destroy: 1.2.0
|
||||
encodeurl: 1.0.2
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 0.5.2
|
||||
http-errors: 2.0.0
|
||||
mime: 1.6.0
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.2.1
|
||||
statuses: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/serve-static@1.15.0:
|
||||
resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
encodeurl: 1.0.2
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 0.18.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/set-function-length@1.1.1:
|
||||
resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
define-data-property: 1.1.1
|
||||
get-intrinsic: 1.2.2
|
||||
gopd: 1.0.1
|
||||
has-property-descriptors: 1.0.1
|
||||
dev: false
|
||||
|
||||
/setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
dev: false
|
||||
|
||||
/side-channel@1.0.4:
|
||||
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
|
||||
dependencies:
|
||||
call-bind: 1.0.5
|
||||
get-intrinsic: 1.2.2
|
||||
object-inspect: 1.13.1
|
||||
dev: false
|
||||
|
||||
/statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
dev: false
|
||||
|
||||
/ts-node@10.9.1(@types/node@20.9.0)(typescript@5.2.2):
|
||||
resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@swc/core': '>=1.2.50'
|
||||
'@swc/wasm': '>=1.2.50'
|
||||
'@types/node': '*'
|
||||
typescript: '>=2.7'
|
||||
peerDependenciesMeta:
|
||||
'@swc/core':
|
||||
optional: true
|
||||
'@swc/wasm':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
'@tsconfig/node10': 1.0.9
|
||||
'@tsconfig/node12': 1.0.11
|
||||
'@tsconfig/node14': 1.0.3
|
||||
'@tsconfig/node16': 1.0.4
|
||||
'@types/node': 20.9.0
|
||||
acorn: 8.11.2
|
||||
acorn-walk: 8.3.0
|
||||
arg: 4.1.3
|
||||
create-require: 1.1.1
|
||||
diff: 4.0.2
|
||||
make-error: 1.3.6
|
||||
typescript: 5.2.2
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
dev: false
|
||||
|
||||
/type-is@1.6.18:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dependencies:
|
||||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
dev: false
|
||||
|
||||
/typescript@5.2.2:
|
||||
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
dev: false
|
||||
|
||||
/universalify@2.0.1:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
dev: false
|
||||
|
||||
/unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
dev: false
|
||||
|
||||
/v8-compile-cache-lib@3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
dev: false
|
||||
|
||||
/vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/yn@3.1.1:
|
||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
@@ -0,0 +1,285 @@
|
||||
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 normalizeColorCodes = (s: string): string =>
|
||||
s.replace(/\|C(?=[0-9a-fA-F]{8})/g, '|c').replace(/\|R/g, '|r');
|
||||
|
||||
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] = normalizeColorCodes(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);
|
||||
}
|
||||
};
|
||||
|
||||
type RepoMeta = {
|
||||
description?: string;
|
||||
defaultBranch?: string;
|
||||
lastUpdated?: string;
|
||||
stars?: number;
|
||||
};
|
||||
|
||||
type RawMeta = {
|
||||
description?: string | null;
|
||||
default_branch?: string;
|
||||
pushed_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
stargazers_count?: number | null;
|
||||
stars_count?: number | null;
|
||||
};
|
||||
|
||||
type Provider = {
|
||||
apiUrl: (owner: string, repo: string) => string;
|
||||
apiHeaders: () => Record<string, string>;
|
||||
mapMeta: (json: RawMeta) => RepoMeta;
|
||||
tocUrl: (owner: string, repo: string, ref: string, name: string) => string;
|
||||
};
|
||||
|
||||
const githubProvider: Provider = {
|
||||
apiUrl: (o, r) => `https://api.github.com/repos/${o}/${r}`,
|
||||
apiHeaders: () => ({
|
||||
Accept: 'application/vnd.github+json',
|
||||
...(process.env.GITHUB_TOKEN && {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
|
||||
})
|
||||
}),
|
||||
mapMeta: j => ({
|
||||
description: j.description ?? undefined,
|
||||
defaultBranch: j.default_branch,
|
||||
lastUpdated: j.pushed_at ?? undefined,
|
||||
stars: j.stargazers_count ?? undefined
|
||||
}),
|
||||
tocUrl: (o, r, ref, name) =>
|
||||
`https://raw.githubusercontent.com/${o}/${r}/${ref}/${name}.toc`
|
||||
};
|
||||
|
||||
const GITEA_API = 'https://octowow.st/git/api/v1';
|
||||
const giteaProvider: Provider = {
|
||||
apiUrl: (o, r) => `${GITEA_API}/repos/${o}/${r}`,
|
||||
apiHeaders: () => ({ Accept: 'application/json' }),
|
||||
mapMeta: j => ({
|
||||
description: j.description ?? undefined,
|
||||
defaultBranch: j.default_branch,
|
||||
lastUpdated: j.updated_at ?? undefined,
|
||||
stars: j.stars_count ?? undefined
|
||||
}),
|
||||
tocUrl: (o, r, ref, name) =>
|
||||
`${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(
|
||||
ref
|
||||
)}`
|
||||
};
|
||||
|
||||
const parseGitUrl = (
|
||||
git: string
|
||||
): { owner: string; repo: string; provider: Provider } => {
|
||||
const gh = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
||||
if (gh && gh[1] && gh[2]) {
|
||||
return { owner: gh[1], repo: gh[2], provider: githubProvider };
|
||||
}
|
||||
const gitea = git.match(/octowow\.st\/git\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
||||
if (gitea && gitea[1] && gitea[2]) {
|
||||
return { owner: gitea[1], repo: gitea[2], provider: giteaProvider };
|
||||
}
|
||||
throw Error(`Unsupported git URL: ${git}`);
|
||||
};
|
||||
|
||||
const REQUIRED_TOC_KEYS = ['Interface'];
|
||||
|
||||
const tryFetchToc = async (
|
||||
provider: Provider,
|
||||
owner: string,
|
||||
repo: string,
|
||||
name: string,
|
||||
ref: string
|
||||
): Promise<TocData | undefined> => {
|
||||
const res = await fetchWithTimeout(
|
||||
provider.tocUrl(owner, repo, ref, name)
|
||||
).catch(() => null);
|
||||
if (!res?.ok) return undefined;
|
||||
const parsed = parseToc(await res.text());
|
||||
return REQUIRED_TOC_KEYS.every(k => typeof parsed[k] === 'string')
|
||||
? parsed
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
|
||||
try {
|
||||
const { owner, repo, provider } = parseGitUrl(src.git);
|
||||
const name = src.name ?? repo;
|
||||
|
||||
const apiRes = await fetchWithTimeout(provider.apiUrl(owner, repo), {
|
||||
headers: provider.apiHeaders()
|
||||
}).catch(() => null);
|
||||
|
||||
let meta: RepoMeta | undefined;
|
||||
if (apiRes?.ok) meta = provider.mapMeta((await apiRes.json()) as RawMeta);
|
||||
|
||||
const candidates = src.ref
|
||||
? [src.ref]
|
||||
: src.branch
|
||||
? [src.branch]
|
||||
: [
|
||||
...new Set(
|
||||
[meta?.defaultBranch, 'main', 'master'].filter(
|
||||
(b): b is string => !!b
|
||||
)
|
||||
)
|
||||
];
|
||||
|
||||
let toc: TocData | undefined;
|
||||
let resolvedRef: string | undefined;
|
||||
for (const ref of candidates) {
|
||||
toc = await tryFetchToc(provider, owner, repo, name, ref);
|
||||
if (toc) {
|
||||
resolvedRef = ref;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveBranch = src.ref
|
||||
? undefined
|
||||
: src.branch ?? resolvedRef ?? meta?.defaultBranch;
|
||||
|
||||
let description = meta?.description ?? undefined;
|
||||
const lastUpdated = meta?.lastUpdated;
|
||||
const stars = meta?.stars;
|
||||
|
||||
if (src.description) {
|
||||
description = src.description;
|
||||
if (toc) toc = { ...toc, Notes: src.description };
|
||||
}
|
||||
|
||||
const result: ResolvedAddon = { name, owner, git: src.git };
|
||||
if (effectiveBranch !== undefined) result.branch = effectiveBranch;
|
||||
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;
|
||||
}
|
||||
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,253 @@
|
||||
export type AddonSource = {
|
||||
git: string;
|
||||
branch?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
export const defaultSources: AddonSource[] = [
|
||||
{
|
||||
git: 'https://github.com/Alukarho/AI_VoiceOver.git',
|
||||
description: 'Adds AI-generated voice acting to NPC quest and gossip dialogue'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/McPewPew/ArcHUD2.git',
|
||||
description: 'Combat HUD showing health and power as arcs around your character'
|
||||
},
|
||||
{
|
||||
git: 'https://octowow.st/git/shaga/AtlasLoot.git',
|
||||
name: 'AtlasLoot',
|
||||
branch: 'main',
|
||||
description:
|
||||
'Loot browser for every dungeon and raid, including the custom OctoWoW instances (Windhorn Canyon, Dragonmaw Retreat, and more)'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/byCFM2/Atlas-TW.git',
|
||||
name: 'Atlas-CFM'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Road-block/AuldLangSyne.git',
|
||||
description: 'Adds personal notes to friends, ignore, and guild lists, remembered while offline'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/FSuhas/AutoLFM.git',
|
||||
description: 'Automated "Looking For More" broadcaster for Turtle WoW dungeons and raids'
|
||||
},
|
||||
{
|
||||
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' },
|
||||
{ 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/brotalnia/BlizzPlates.git',
|
||||
description: 'Adds castbars, debuffs, and class icons to the default Blizzard nameplates'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/MDGitHubRepo/CallOfElements.git',
|
||||
description: 'All-in-one Shaman totem bar and totem/healing manager'
|
||||
},
|
||||
{ git: 'https://github.com/bhhandley/CleveRoidMacros.git' },
|
||||
{
|
||||
git: 'https://github.com/Cinecom/ConsumesManager.git',
|
||||
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/Zerf/Decursive.git',
|
||||
description: 'Raid/party debuff-cleaning helper that dispels whoever needs it'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/DeterminedPanda/DifficultBulletinBoard.git',
|
||||
description: 'Organizes LFG, profession, and hardcore chat announcements into a bulletin board'
|
||||
},
|
||||
{ git: 'https://github.com/Player-Doite/DoiteAuras.git' },
|
||||
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git' },
|
||||
{
|
||||
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
|
||||
description: 'Adds extra resource bars (mana, energy, rage) to the UI'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/SeVeN7000/FishingBuddy.git',
|
||||
description: 'Auto-equips fishing gear and tracks catches, fish, and zone info'
|
||||
},
|
||||
{ git: 'https://github.com/tilare/FlightTracker.git' },
|
||||
{ git: 'https://github.com/lookino/Flyout.git' },
|
||||
{
|
||||
git: 'https://github.com/trumpetx/GetHead.git',
|
||||
description: 'Recovers Onyxia and Nefarian heads from disenchant grief'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/zanthor/GNS.git',
|
||||
description: 'Custom naming for Goblin Brainwashing Device specializations'
|
||||
},
|
||||
{ git: 'https://github.com/vatichild/guda.git', name: 'Guda' },
|
||||
{ git: 'https://github.com/vatichild/GudaPlates.git' },
|
||||
{ git: 'https://github.com/andresuarezschou/HCDeaths.git' },
|
||||
{
|
||||
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' },
|
||||
{ git: 'https://github.com/tilare/MessageBox.git' },
|
||||
{
|
||||
git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
|
||||
description: "Advanced version of Sinesther's Power Auras"
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/tilare/ModernMapMarkers.git',
|
||||
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' },
|
||||
{
|
||||
git: 'https://github.com/Dusk-92/NampowerSettings.git',
|
||||
description: 'Settings panel for the Nampower spellqueue addon'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/BlackHobbiT/necrosis-twow.git',
|
||||
name: 'Necrosis',
|
||||
description: 'Warlock helper: pets, soul shards, summoning, demon timers'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/gnwl/NotGrid.git',
|
||||
name: 'notgrid',
|
||||
description: 'Grid-like compact party/raid frames with buff/debuff, aggro, and proximity tracking'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/zanthor/OG-RaidHelper.git',
|
||||
description: 'Raid management: roles, trade distribution, soft-reserve validation'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/sica42/Outfitter.git',
|
||||
description: 'Equipment set manager to save and quickly swap gear outfits, with Turtle mount fixes'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/CosminPOP/PallyPower.git',
|
||||
description: 'Paladin buff and assignment manager for raids and parties'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Cliencer/pfExtend.git',
|
||||
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',
|
||||
description: 'pfUI module that re-skins other addons to match the pfUI theme'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/Bombg/pfUI-bettertotems.git',
|
||||
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' },
|
||||
{
|
||||
git: 'https://github.com/npfs666/ProcDoc.git',
|
||||
description: 'Visual proc alerts with pulsing images so you never miss them'
|
||||
},
|
||||
{ git: 'https://github.com/SabineWren/Quiver.git' },
|
||||
{
|
||||
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/balakethelock/SuperAPI.git',
|
||||
description: 'Companion compatibility addon bridging the SuperWoW client mod\'s expanded Lua API'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/jrc13245/SuperMacro-turtle-SuperWoW.git',
|
||||
name: 'SuperMacro',
|
||||
description: 'Extended macros with long macros, keybind execution, item links, and a code editor'
|
||||
},
|
||||
{ git: 'https://github.com/Siventt/AttackBar.git' },
|
||||
{
|
||||
git: 'https://github.com/Player-Doite/Tactica.git',
|
||||
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',
|
||||
description: 'Tracks 0% and 100% rested XP thresholds'
|
||||
},
|
||||
{ git: 'https://github.com/sica42/TurtleCalendar.git' },
|
||||
{
|
||||
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' },
|
||||
{ git: 'https://github.com/CosminPOP/TWThreat.git' },
|
||||
{
|
||||
git: 'https://github.com/RetroCro/unitscan-turtle-hc.git',
|
||||
description: 'Hardcore unitscan fork for Turtle WoW that alerts on rares, elites, and dangerous mobs'
|
||||
},
|
||||
{
|
||||
git: 'https://github.com/whtmst/UnitXP_SP3_Addon.git',
|
||||
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://octowow.st/git/shaga/LifeSafer_LowHealthWarning.git',
|
||||
branch: 'main',
|
||||
description: 'Low health and mana fullscreen flash warnings with heartbeat sound; re-enables the hidden Blizzard alert effect'
|
||||
},
|
||||
{
|
||||
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,346 @@
|
||||
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 raidVisuals = ['patch-O.mpq'];
|
||||
|
||||
const skipFiles = new Set([
|
||||
'manifest.json',
|
||||
'manifest.json.tmp',
|
||||
'wow-client.zip',
|
||||
'.gitkeep',
|
||||
'.manifest-overrides.json'
|
||||
]);
|
||||
|
||||
const skipPatterns: RegExp[] = [
|
||||
/\.bak([.\-]|$)/,
|
||||
/\.crashing(\.|$)/,
|
||||
/\.torrent$/,
|
||||
/^manifest\.json\./
|
||||
];
|
||||
const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file));
|
||||
|
||||
const skipDirsPosix = new Set([
|
||||
'Interface/GlueXML',
|
||||
'Interface/FrameXML',
|
||||
'Errors',
|
||||
'Logs',
|
||||
'Screenshots',
|
||||
'WDB',
|
||||
'WTF/Account'
|
||||
]);
|
||||
const isSkipDir = (...filePath: string[]) =>
|
||||
skipDirsPosix.has(filePath.join('/'));
|
||||
|
||||
type FolderTags = 'allowExtra';
|
||||
type FileTags = 'vanillaFixes' | 'raidVisuals';
|
||||
|
||||
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[];
|
||||
}
|
||||
| { type: 'del' }
|
||||
);
|
||||
|
||||
export type BuildProgress = {
|
||||
state: 'idle' | 'building' | 'ready' | 'failed';
|
||||
done: number;
|
||||
total: number;
|
||||
currentFile: string;
|
||||
startedAt: number | null;
|
||||
finishedAt: number | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type ProgressCallback = (
|
||||
p: Pick<BuildProgress, 'done' | 'total' | 'currentFile'>
|
||||
) => void;
|
||||
|
||||
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()));
|
||||
});
|
||||
|
||||
const countFiles = async (
|
||||
clientPath: string,
|
||||
...filePath: string[]
|
||||
): Promise<number> => {
|
||||
let total = 0;
|
||||
const dir = path.join(clientPath, ...filePath);
|
||||
const files = await fs.readdir(dir);
|
||||
for (const file of files.sort()) {
|
||||
if (skipFiles.has(file)) continue;
|
||||
if (isSkipPattern(file)) continue;
|
||||
const stats = await fs.stat(path.join(dir, file));
|
||||
if (stats.isDirectory()) {
|
||||
if (isSkipDir(...filePath, file)) continue;
|
||||
if (file.match(/patch-./)) {
|
||||
const mpqPath = path.join(dir, `${file}.mpq`);
|
||||
if (await fs.pathExists(mpqPath)) total += 1;
|
||||
}
|
||||
total += await countFiles(clientPath, ...filePath, file);
|
||||
} else {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
export const buildCache = async (
|
||||
clientPath: string,
|
||||
onProgress?: ProgressCallback
|
||||
) => {
|
||||
console.log('Building cache...');
|
||||
|
||||
const prevManifestPath = path.join(clientPath, 'manifest.json');
|
||||
let prevManifestMtimeMs = 0;
|
||||
const prevHashByPath = new Map<string, string>();
|
||||
const prevVersionByPath = new Map<string, number | undefined>();
|
||||
const prevSizeByPath = new Map<string, number>();
|
||||
try {
|
||||
const prevStat = await fs.stat(prevManifestPath);
|
||||
prevManifestMtimeMs = prevStat.mtimeMs;
|
||||
const prev = await fs.readJSON(prevManifestPath);
|
||||
const walk = (node: FileManifest, prefix: string[]) => {
|
||||
if (node.type === 'dir' || node.type === 'mpq') {
|
||||
const newPrefix = node.name ? [...prefix, node.name] : prefix;
|
||||
if (node.type === 'mpq') {
|
||||
const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'].join(
|
||||
'/'
|
||||
);
|
||||
prevHashByPath.set(mpqKey, node.hash);
|
||||
prevSizeByPath.set(mpqKey, node.size);
|
||||
}
|
||||
for (const child of node.files) walk(child, newPrefix);
|
||||
} else {
|
||||
const key = [...prefix, node.name].join('/');
|
||||
prevHashByPath.set(key, node.hash);
|
||||
prevVersionByPath.set(key, node.version);
|
||||
prevSizeByPath.set(key, node.size);
|
||||
}
|
||||
};
|
||||
walk(prev.root, []);
|
||||
console.log(
|
||||
`mtime-skip: loaded ${prevHashByPath.size} cached hashes from ` +
|
||||
`prior manifest (mtime=${new Date(prevManifestMtimeMs).toISOString()})`
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
'mtime-skip: no usable prior manifest, full rebuild ' +
|
||||
`(${(e as Error).message})`
|
||||
);
|
||||
}
|
||||
|
||||
const total = await countFiles(clientPath);
|
||||
let done = 0;
|
||||
let reused = 0;
|
||||
const tick = (currentFile: string) => {
|
||||
done += 1;
|
||||
onProgress?.({ done, total, currentFile });
|
||||
};
|
||||
console.log(`Building cache: ${total} files to hash...`);
|
||||
|
||||
const getHashCached = async (
|
||||
relPath: string,
|
||||
mtimeMs: number,
|
||||
...filePath: string[]
|
||||
): Promise<string> => {
|
||||
if (prevManifestMtimeMs > 0 && mtimeMs <= prevManifestMtimeMs) {
|
||||
const cached = prevHashByPath.get(relPath);
|
||||
if (cached) {
|
||||
reused++;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
return getHash(clientPath, ...filePath);
|
||||
};
|
||||
|
||||
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;
|
||||
if (isSkipPattern(file)) continue;
|
||||
|
||||
const stats = await fs.stat(path.join(clientPath, ...filePath, file));
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
if (isSkipDir(...filePath, file)) continue;
|
||||
if (file.match(/patch-./)) {
|
||||
if (raidVisuals.includes(`${file}.mpq`))
|
||||
throw new Error(
|
||||
`${file}/ exists beside ${file}.mpq. Opt-in archives must stay ` +
|
||||
'whole-file: an mpq node carries no tags, so this would ' +
|
||||
'ship the patch to every player regardless of preference.'
|
||||
);
|
||||
patches.push(file);
|
||||
const mpqRelPath = path
|
||||
.join(...filePath, `${file}.mpq`)
|
||||
.split(path.sep)
|
||||
.join('/');
|
||||
const mpqStat = await fs.stat(
|
||||
path.join(clientPath, ...filePath, `${file}.mpq`)
|
||||
);
|
||||
tree.push({
|
||||
type: 'mpq',
|
||||
name: file,
|
||||
files: await buildTree(...filePath, file),
|
||||
size: mpqStat.size,
|
||||
hash: await getHashCached(
|
||||
mpqRelPath,
|
||||
mpqStat.mtimeMs,
|
||||
...filePath,
|
||||
`${file}.mpq`
|
||||
)
|
||||
});
|
||||
tick(mpqRelPath);
|
||||
} 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;
|
||||
}
|
||||
|
||||
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');
|
||||
raidVisuals.includes(file) && tags.push('raidVisuals');
|
||||
|
||||
tree.push({
|
||||
type: 'file',
|
||||
name: file,
|
||||
hash: await getHashCached(fullPath, stats.mtimeMs, ...filePath, file),
|
||||
version: allowModified ? stats.mtimeMs : undefined,
|
||||
size: stats.size,
|
||||
tags: tags.length ? tags : undefined
|
||||
});
|
||||
tick(fullPath);
|
||||
}
|
||||
return tree;
|
||||
};
|
||||
|
||||
const rootFiles = await buildTree();
|
||||
|
||||
const overridesPath = path.join(clientPath, '.manifest-overrides.json');
|
||||
try {
|
||||
if (await fs.pathExists(overridesPath)) {
|
||||
const ov = await fs.readJSON(overridesPath);
|
||||
const dels: string[] = Array.isArray(ov.del) ? ov.del : [];
|
||||
for (const relPath of dels) {
|
||||
const parts = relPath.split('/').filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
const fileName = parts.pop()!;
|
||||
let dirNode: FileManifest = {
|
||||
type: 'dir',
|
||||
name: '',
|
||||
files: rootFiles
|
||||
} as FileManifest;
|
||||
let ok = true;
|
||||
for (const seg of parts) {
|
||||
if (dirNode.type !== 'dir' && dirNode.type !== 'mpq') {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
let child = dirNode.files.find(f => f.name === seg);
|
||||
if (!child) {
|
||||
child = {
|
||||
type: 'dir',
|
||||
name: seg,
|
||||
files: [],
|
||||
tags: ['allowExtra']
|
||||
};
|
||||
dirNode.files.push(child);
|
||||
}
|
||||
dirNode = child;
|
||||
}
|
||||
if (!ok || (dirNode.type !== 'dir' && dirNode.type !== 'mpq')) {
|
||||
console.warn(
|
||||
`manifest-overrides: del path "${relPath}" hit a non-dir ` +
|
||||
`node, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const exists = dirNode.files.some(
|
||||
f => f.name === fileName && f.type === 'del'
|
||||
);
|
||||
if (!exists) {
|
||||
dirNode.files.push({ type: 'del', name: fileName } as FileManifest);
|
||||
console.log(
|
||||
`manifest-overrides: inserted {type:'del', name:'${fileName}'} ` +
|
||||
`under ${parts.join('/') || '<root>'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`manifest-overrides: failed to apply ${overridesPath}, continuing` +
|
||||
`without overrides (${(e as Error).message})`
|
||||
);
|
||||
}
|
||||
|
||||
const finalPath = path.join(clientPath, 'manifest.json');
|
||||
const tmpPath = path.join(clientPath, 'manifest.json.tmp');
|
||||
await fs.writeJSON(tmpPath, {
|
||||
build: 3,
|
||||
buildName: '3',
|
||||
root: {
|
||||
type: 'dir',
|
||||
name: '',
|
||||
files: rootFiles
|
||||
}
|
||||
});
|
||||
await fs.rename(tmpPath, finalPath);
|
||||
if (prevManifestMtimeMs > 0) {
|
||||
console.log(
|
||||
`mtime-skip: reused ${reused}/${total} cached hashes ` +
|
||||
`(re-hashed ${total - reused})`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import path from 'path';
|
||||
|
||||
import express from 'express';
|
||||
import fs from 'fs-extra';
|
||||
import { buildCache, type BuildProgress } from './cache.js';
|
||||
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
|
||||
|
||||
const SourceDir = process.env.SOURCE_DIR || './client';
|
||||
|
||||
const app = express();
|
||||
const port = 5000;
|
||||
|
||||
const buildProgress: BuildProgress = {
|
||||
state: 'idle',
|
||||
done: 0,
|
||||
total: 0,
|
||||
currentFile: '',
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
error: null
|
||||
};
|
||||
|
||||
let buildInFlight: Promise<void> | null = null;
|
||||
const ensureManifestBuilt = (): Promise<void> => {
|
||||
if (buildInFlight) return buildInFlight;
|
||||
buildProgress.state = 'building';
|
||||
buildProgress.done = 0;
|
||||
buildProgress.total = 0;
|
||||
buildProgress.currentFile = '';
|
||||
buildProgress.startedAt = Date.now();
|
||||
buildProgress.finishedAt = null;
|
||||
buildProgress.error = null;
|
||||
buildInFlight = buildCache(SourceDir, p => {
|
||||
buildProgress.done = p.done;
|
||||
buildProgress.total = p.total;
|
||||
buildProgress.currentFile = p.currentFile;
|
||||
})
|
||||
.then(() => {
|
||||
buildProgress.state = 'ready';
|
||||
buildProgress.finishedAt = Date.now();
|
||||
})
|
||||
.catch(e => {
|
||||
buildProgress.state = 'failed';
|
||||
buildProgress.error = e instanceof Error ? e.message : String(e);
|
||||
buildProgress.finishedAt = Date.now();
|
||||
buildInFlight = null;
|
||||
throw e;
|
||||
});
|
||||
return buildInFlight;
|
||||
};
|
||||
|
||||
app.get('/api/build-status', (_req, res) => {
|
||||
res.json(buildProgress);
|
||||
});
|
||||
|
||||
app.get('/api/file/:version/manifest.json', async (_req, res) => {
|
||||
console.log(`Fetching manifest`);
|
||||
const filePath = path.join(SourceDir, 'manifest.json');
|
||||
|
||||
if (await fs.pathExists(filePath)) {
|
||||
res.json(await fs.readJSON(filePath));
|
||||
return;
|
||||
}
|
||||
|
||||
void ensureManifestBuilt().catch(() => {});
|
||||
res.setHeader('Retry-After', '5');
|
||||
res.status(503).json({
|
||||
error: 'manifest_building',
|
||||
message:
|
||||
'Manifest is being built for the first time on this server. ' +
|
||||
'Poll /api/build-status for progress; retry this endpoint when ready.',
|
||||
buildProgress
|
||||
});
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/file/:version/*',
|
||||
async (req: express.Request<{ 0: string }>, res) => {
|
||||
const filePath = req.params[0];
|
||||
console.log(`Fetching file: ${filePath}`);
|
||||
|
||||
const root = path.resolve(SourceDir);
|
||||
const target = path.resolve(SourceDir, filePath);
|
||||
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.sendFile(target);
|
||||
}
|
||||
);
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
const newestSourceMtime = async (dir: string): Promise<number> => {
|
||||
let newest = 0;
|
||||
const entries = await fs.readdir(dir);
|
||||
for (const name of entries) {
|
||||
if (name === 'manifest.json' || name === 'manifest.json.tmp') continue;
|
||||
const full = path.join(dir, name);
|
||||
const stat = await fs.stat(full);
|
||||
if (stat.isDirectory()) {
|
||||
const inner = await newestSourceMtime(full);
|
||||
if (inner > newest) newest = inner;
|
||||
} else if (stat.mtimeMs > newest) {
|
||||
newest = stat.mtimeMs;
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
};
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server listening on port ${port}`);
|
||||
warmUpAddons();
|
||||
|
||||
void (async () => {
|
||||
const manifestPath = path.join(SourceDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
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 (will fall back to lazy build on first request):',
|
||||
e
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buildProgress.state = 'ready';
|
||||
buildProgress.finishedAt = Date.now();
|
||||
|
||||
try {
|
||||
const manifestStat = await fs.stat(manifestPath);
|
||||
const newest = await newestSourceMtime(SourceDir);
|
||||
if (newest > manifestStat.mtimeMs) {
|
||||
console.log(
|
||||
`Manifest is stale (newest source mtime ${new Date(
|
||||
newest
|
||||
).toISOString()} > manifest ${new Date(
|
||||
manifestStat.mtimeMs
|
||||
).toISOString()}); rebuilding in background.`
|
||||
);
|
||||
ensureManifestBuilt()
|
||||
.then(() => console.log('Background manifest rebuild complete.'))
|
||||
.catch(e => console.error('Background manifest rebuild failed:', e));
|
||||
} else {
|
||||
console.log(
|
||||
`Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Manifest staleness check failed:', e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
Reference in New Issue
Block a user