From 14ab791f9bf062a1c8d527a3da27597055c2ab9b Mon Sep 17 00:00:00 2001 From: OctoWoW Date: Sat, 20 Jun 2026 01:43:20 -0700 Subject: [PATCH] updating open source launcher --- server/Dockerfile | 23 ++ server/package-lock.json | 545 ++++++++++++++++++++++++++++++++++ server/package.json | 8 +- server/src/addons-resolver.ts | 458 ++++++++++++++++------------ server/src/addons-sources.ts | 442 +++++++++++++++------------ server/src/cache.ts | 230 +++++++++++++- server/src/index.ts | 143 ++++++--- 7 files changed, 1412 insertions(+), 437 deletions(-) create mode 100644 server/Dockerfile diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..c6cfc75 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/package-lock.json b/server/package-lock.json index d872412..294dca3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,6 +14,9 @@ "fs-extra": "^11.1.1", "ts-node": "^10.9.1", "typescript": "^5.2.2" + }, + "devDependencies": { + "tsx": "^4.21.0" } }, "node_modules/@cspotcode/source-map-support": { @@ -27,6 +30,448 @@ "node": ">=12" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", @@ -336,6 +781,48 @@ "node": ">= 0.8" } }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -436,6 +923,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -458,6 +960,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -717,6 +1232,16 @@ "node": ">= 0.8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -873,6 +1398,26 @@ } } }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/server/package.json b/server/package.json index 79a22f9..f5d3161 100644 --- a/server/package.json +++ b/server/package.json @@ -4,12 +4,11 @@ "main": "index.ts", "type": "module", "scripts": { - "dev": "node --loader ts-node/esm src/index.ts" + "dev": "tsx src/index.ts" }, "dependencies": { "@types/express": "^4.17.21", "@types/node": "^20.9.0", - "dotenv": "^16.0.0", "express": "^4.18.2", "fs-extra": "^11.1.1", "ts-node": "^10.9.1", @@ -18,5 +17,8 @@ "eslintConfig": { "extends": "@haaxor1689/eslint-config" }, - "prettier": "@haaxor1689/prettier-config" + "prettier": "@haaxor1689/prettier-config", + "devDependencies": { + "tsx": "^4.21.0" + } } diff --git a/server/src/addons-resolver.ts b/server/src/addons-resolver.ts index 2243924..fb860a0 100644 --- a/server/src/addons-resolver.ts +++ b/server/src/addons-resolver.ts @@ -1,192 +1,266 @@ -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; - -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 | 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((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 => { - 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 ( - items: T[], - concurrency: number, - fn: (item: T) => Promise -): Promise => { - 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 => { - 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 => { - 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 => { - 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)); -}; +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; + +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 | 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((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; + 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 => { + 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 => { + 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, 'master', 'main'].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 ( + items: T[], + concurrency: number, + fn: (item: T) => Promise +): Promise => { + 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 => { + 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 => { + 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 => { + 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)); +}; diff --git a/server/src/addons-sources.ts b/server/src/addons-sources.ts index 9e4e897..8a8736e 100644 --- a/server/src/addons-sources.ts +++ b/server/src/addons-sources.ts @@ -1,196 +1,246 @@ -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" - } -]; +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://github.com/CosminPOP/AtlasLoot.git', name: 'AtlasLoot' }, + { + 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://github.com/acolis/VitalWatch.git', + description: 'Customizable health, mana, and aggro alert system for solo, group, and raid play' + }, + { + 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" + } +]; diff --git a/server/src/cache.ts b/server/src/cache.ts index 1ec6e5d..1603777 100644 --- a/server/src/cache.ts +++ b/server/src/cache.ts @@ -16,7 +16,29 @@ const allowedExtra = [ const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf']; -const skipFiles = new Set(['manifest.json', 'wow-client.zip', '.gitkeep']); +const skipFiles = new Set([ + 'manifest.json', + 'manifest.json.tmp', + 'wow-client.zip', + '.gitkeep', + '.manifest-overrides.json' +]); + +const skipPatterns: RegExp[] = [/\.bak(\.|$)/, /\.crashing(\.|$)/]; +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'; @@ -31,8 +53,21 @@ type FileManifest = { name: string } & ( 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) => void; + const getHash = (...filePath: string[]): Promise => new Promise((resolve, reject) => { const hash = crypto.createHash('sha1'); @@ -42,9 +77,96 @@ const getHash = (...filePath: string[]): Promise => stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase())); }); -export const buildCache = async (clientPath: string) => { +const countFiles = async (clientPath: string, ...filePath: string[]): Promise => { + 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(); + const prevVersionByPath = new Map(); + const prevSizeByPath = new Map(); + 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 => { + 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 => { const files = await fs.readdir(path.join(clientPath, ...filePath)); @@ -52,21 +174,34 @@ export const buildCache = async (clientPath: 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-./)) { 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: ( - await fs.stat(path.join(clientPath, ...filePath, `${file}.mpq`)) - ).size, - hash: await getHash(clientPath, ...filePath, `${file}.mpq`) + size: mpqStat.size, + hash: await getHashCached( + mpqRelPath, + mpqStat.mtimeMs, + ...filePath, + `${file}.mpq` + ) }); + tick(mpqRelPath); } else { const tags: FolderTags[] = []; allowedExtra.includes(path.join(...filePath, file)) && @@ -81,8 +216,8 @@ export const buildCache = async (clientPath: string) => { continue; } - // Skip if extracted mpq patch if (patches.find(v => file.match(v))) continue; + const allowModifiedPaths = new Set([ 'WTF/Config.wtf', 'Data/fonts.MPQ', @@ -102,22 +237,97 @@ export const buildCache = async (clientPath: string) => { tree.push({ type: 'file', name: file, - hash: await getHash(clientPath, ...filePath, 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; }; - await fs.writeJSON(path.join(clientPath, 'manifest.json'), { + 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('/') || ''}` + ); + } + } + } + } 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: await buildTree() + files: rootFiles } }); + await fs.rename(tmpPath, finalPath); + if (prevManifestMtimeMs > 0) { + console.log( + `mtime-skip: reused ${reused}/${total} cached hashes ` + + `(re-hashed ${total - reused})` + ); + } }; diff --git a/server/src/index.ts b/server/src/index.ts index 149c6fe..363dbd8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,61 +1,86 @@ 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 { buildCache, type BuildProgress } 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 SourceDir = process.env.SOURCE_DIR || 'C:\\WoW\\TurtleFresh'; 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 | null = null; const ensureManifestBuilt = (): Promise => { if (buildInFlight) return buildInFlight; - buildInFlight = buildCache(SourceDir).catch(e => { - buildInFlight = null; - throw e; - }); + 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 (!fs.existsSync(filePath)) await ensureManifestBuilt(); - res.json(await fs.readJSON(filePath)); + 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]; - 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); + + res.sendFile(path.join(SourceDir, filePath)); } ); @@ -70,19 +95,65 @@ app.get('/api/addons.json', async (req, res) => { } }); +const newestSourceMtime = async (dir: string): Promise => { + 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)) return; - console.log(`Pre-warming manifest cache for ${SourceDir}...`); + 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 { - await ensureManifestBuilt(); - console.log(`Manifest cache pre-warm complete.`); + 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 pre-warm failed:', e); + console.error('Manifest staleness check failed:', e); } })(); });