From a50457f5011115b64a11e978407f6d70767475cb Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 21:28:56 +1300 Subject: [PATCH 1/4] chore: prepare strict backend TypeScript migration --- .github/workflows/unit-tests.yml | 3 + eslint.config.mjs | 20 ++ package-lock.json | 445 +++++++++++++++++++++++++++++++ package.json | 4 + server/types/globals.d.ts | 6 + test/server/globals.types.ts | 8 + tsconfig.server.json | 2 +- 7 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 server/types/globals.d.ts create mode 100644 test/server/globals.types.ts diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ada128d1e..e5f6e35c1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -37,5 +37,8 @@ jobs: - name: Run lint run: npm run lint + - name: Check server types + run: npm run typecheck:server + - name: Run tests run: npm test diff --git a/eslint.config.mjs b/eslint.config.mjs index 901117512..344f56160 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,6 @@ +import tsParser from '@typescript-eslint/parser' +import tsPlugin from '@typescript-eslint/eslint-plugin' + const nodeGlobals = { AbortController: 'readonly', Buffer: 'readonly', @@ -32,6 +35,23 @@ const rules = { } export default [ + { + files: ['server/**/*.ts', 'test/**/*.ts'], + languageOptions: { + parser: tsParser, + sourceType: 'module', + parserOptions: { + project: './tsconfig.server.json', + tsconfigRootDir: import.meta.dirname + } + }, + plugins: { '@typescript-eslint': tsPlugin }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...tsPlugin.configs['recommended-type-checked'].rules, + '@typescript-eslint/no-explicit-any': 'error' + } + }, { ignores: ['**/node_modules/**', 'client/**', 'dist/**', 'dist-server/**', 'coverage/**', 'server/libs/**'] }, diff --git a/package-lock.json b/package-lock.json index 81dbfc0d8..b9799196a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ }, "devDependencies": { "@types/node": "^24.13.6", + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", "chai": "^4.3.10", "eslint": "^10.11.0", "mocha": "^10.2.0", @@ -1029,6 +1031,388 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + }, + "dev": true + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -6020,6 +6404,54 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -6066,6 +6498,19 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 9d018cddf..a6260156c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "description": "Self-hosted audiobook and podcast server", "main": "dist-server/index.js", "scripts": { + "clean:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"", + "typecheck:server": "tsc -p tsconfig.server.json --noEmit --incremental false", "build:server": "tsc -p tsconfig.server.json", "dev": "nodemon --watch server --watch index.js --watch test --watch dev.js --ext js,ts --exec \"npm run build:server && node dist-server/index.js\" -- --dev", "start": "node dist-server/index.js", @@ -63,6 +65,8 @@ }, "devDependencies": { "@types/node": "^24.13.6", + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", "chai": "^4.3.10", "eslint": "^10.11.0", "mocha": "^10.2.0", diff --git a/server/types/globals.d.ts b/server/types/globals.d.ts new file mode 100644 index 000000000..a6dcefb2e --- /dev/null +++ b/server/types/globals.d.ts @@ -0,0 +1,6 @@ +export {} + +declare global { + // Server initialization sets this flag; earlier consumers may see undefined. + var isWin: boolean | undefined +} diff --git a/test/server/globals.types.ts b/test/server/globals.types.ts new file mode 100644 index 000000000..24196a2c5 --- /dev/null +++ b/test/server/globals.types.ts @@ -0,0 +1,8 @@ +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +// No declaration import: the server compiler must load ambient globals itself. +export type GlobalsContract = [ + Assert>, + Assert> +] diff --git a/tsconfig.server.json b/tsconfig.server.json index a3c6ce5c8..4c8e3b97e 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -16,5 +16,5 @@ "forceConsistentCasingInFileNames": true }, "include": ["index.js", "dev.js", "server/**/*.js", "server/**/*.ts", "test/**/*.js", "test/**/*.ts"], - "exclude": ["node_modules", "dist-server", "client", "**/*.d.ts"] + "exclude": ["node_modules", "dist-server", "client"] } From a8a4fb52db230188b376fc141ca23d987d8ba0fa Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 21:32:20 +1300 Subject: [PATCH 2/4] fix: preserve fluent ffmpeg runtime compilation --- tsconfig.server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.server.json b/tsconfig.server.json index 4c8e3b97e..69909cf98 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -16,5 +16,5 @@ "forceConsistentCasingInFileNames": true }, "include": ["index.js", "dev.js", "server/**/*.js", "server/**/*.ts", "test/**/*.js", "test/**/*.ts"], - "exclude": ["node_modules", "dist-server", "client"] + "exclude": ["node_modules", "dist-server", "client", "server/libs/fluentFfmpeg/index.d.ts"] } From 37dec87315fece01103983ca7d1bcfc47a38a991 Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 22:04:24 +1300 Subject: [PATCH 3/4] refactor: rename foundation and logging modules to TypeScript --- server/{Logger.js => Logger.ts} | 0 server/utils/{areEquivalent.js => areEquivalent.ts} | 0 server/utils/{constants.js => constants.ts} | 0 server/utils/{globals.js => globals.ts} | 0 .../utils/parsers/{parseSeriesString.js => parseSeriesString.ts} | 0 server/utils/{requestUtils.js => requestUtils.ts} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename server/{Logger.js => Logger.ts} (100%) rename server/utils/{areEquivalent.js => areEquivalent.ts} (100%) rename server/utils/{constants.js => constants.ts} (100%) rename server/utils/{globals.js => globals.ts} (100%) rename server/utils/parsers/{parseSeriesString.js => parseSeriesString.ts} (100%) rename server/utils/{requestUtils.js => requestUtils.ts} (100%) diff --git a/server/Logger.js b/server/Logger.ts similarity index 100% rename from server/Logger.js rename to server/Logger.ts diff --git a/server/utils/areEquivalent.js b/server/utils/areEquivalent.ts similarity index 100% rename from server/utils/areEquivalent.js rename to server/utils/areEquivalent.ts diff --git a/server/utils/constants.js b/server/utils/constants.ts similarity index 100% rename from server/utils/constants.js rename to server/utils/constants.ts diff --git a/server/utils/globals.js b/server/utils/globals.ts similarity index 100% rename from server/utils/globals.js rename to server/utils/globals.ts diff --git a/server/utils/parsers/parseSeriesString.js b/server/utils/parsers/parseSeriesString.ts similarity index 100% rename from server/utils/parsers/parseSeriesString.js rename to server/utils/parsers/parseSeriesString.ts diff --git a/server/utils/requestUtils.js b/server/utils/requestUtils.ts similarity index 100% rename from server/utils/requestUtils.js rename to server/utils/requestUtils.ts From 803c109807dd5113a61bad5eb39f3fa6acabcd74 Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 22:06:48 +1300 Subject: [PATCH 4/4] refactor: type foundation logging and request utilities --- package-lock.json | 109 ++++++++++++ package.json | 1 + server/Logger.ts | 106 +++++++----- server/libs/dateAndTime.d.ts | 3 + server/utils/areEquivalent.ts | 42 +++-- server/utils/constants.ts | 12 +- server/utils/globals.ts | 2 +- server/utils/parsers/parseSeriesString.ts | 6 +- server/utils/requestUtils.ts | 27 +-- test/server/Logger.test.js | 42 +++++ test/server/Logger.types.ts | 39 +++++ test/server/utils/foundationUtils.test.js | 185 +++++++++++++++++++++ test/server/utils/foundationUtils.types.ts | 33 ++++ test/server/utils/requestUtils.test.js | 22 +++ test/server/utils/requestUtils.types.ts | 13 ++ 15 files changed, 556 insertions(+), 86 deletions(-) create mode 100644 server/libs/dateAndTime.d.ts create mode 100644 test/server/Logger.types.ts create mode 100644 test/server/utils/foundationUtils.test.js create mode 100644 test/server/utils/foundationUtils.types.ts create mode 100644 test/server/utils/requestUtils.types.ts diff --git a/package-lock.json b/package-lock.json index b9799196a..a4e07a4f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "audiobookshelf": "dist-server/index.js" }, "devDependencies": { + "@types/express": "4.17.25", "@types/node": "^24.13.6", "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", @@ -970,6 +971,27 @@ "node": ">= 6" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -1005,6 +1027,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1012,6 +1067,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -1026,6 +1088,53 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/validator": { "version": "13.7.17", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", diff --git a/package.json b/package.json index a6260156c..5f12fa152 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "xml2js": "^0.5.0" }, "devDependencies": { + "@types/express": "4.17.25", "@types/node": "^24.13.6", "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", diff --git a/server/Logger.ts b/server/Logger.ts index 3589dc192..1b1392e19 100644 --- a/server/Logger.ts +++ b/server/Logger.ts @@ -1,10 +1,37 @@ -const date = require('./libs/dateAndTime') -const { LogLevel } = require('./utils/constants') -const util = require('util') +import date from './libs/dateAndTime' +import { LogLevel } from './utils/constants' +import util from 'util' +import type LogManager from './managers/LogManager' + +type LogLevelName = keyof typeof LogLevel +type ConsoleMethod = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'log' + +type LogObject = { + timestamp: string + source: string + message: string + levelName: LogLevelName + level: number +} + +type LogSocket = { + id: string + emit(event: 'log', log: LogObject): unknown +} + +type SocketListener = { + id: string + socket: LogSocket + level: number +} class Logger { + logManager: LogManager | null + isDev: boolean + logLevel: number + socketListeners: SocketListener[] + constructor() { - /** @type {import('./managers/LogManager')} */ this.logManager = null this.isDev = process.env.NODE_ENV !== 'production' @@ -13,35 +40,31 @@ class Logger { this.socketListeners = [] } - /** - * @returns {string} - */ - get timestamp() { + get timestamp(): string { return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS') } - get levelString() { + get levelString(): string { return this.getLogLevelString(this.logLevel) } - /** - * @returns {string} - */ - get source() { + get source(): string { const regex = global.isWin ? /^.*\\([^\\:]*:[0-9]*):[0-9]*\)*/ : /^.*\/([^/:]*:[0-9]*):[0-9]*\)*/ - return Error().stack.split('\n')[3].replace(regex, '$1') + // Node's default V8 stack is a string. Preserve the existing failure if a custom stack formatter removes it. + return Error().stack!.split('\n')[3].replace(regex, '$1') } - getLogLevelString(level) { + getLogLevelString(level: number): string { for (const key in LogLevel) { - if (LogLevel[key] === level) { + // The loop enumerates the names in the log level table. + if (LogLevel[key as LogLevelName] === level) { return key } } return 'UNKNOWN' } - addSocketListener(socket, level) { + addSocketListener(socket: LogSocket, level: number): void { var index = this.socketListeners.findIndex((s) => s.id === socket.id) if (index >= 0) { this.socketListeners.splice(index, 1, { @@ -58,20 +81,13 @@ class Logger { } } - removeSocketListener(socketId) { + removeSocketListener(socketId: string): void { this.socketListeners = this.socketListeners.filter((s) => s.id !== socketId) } - /** - * - * @param {number} level - * @param {string} levelName - * @param {string[]} args - * @param {string} src - */ - async #logToFileAndListeners(level, levelName, args, src) { + async #logToFileAndListeners(level: number, levelName: LogLevelName, args: unknown[], src: string): Promise { const expandedArgs = args.map((arg) => (typeof arg !== 'string' ? util.inspect(arg) : arg)) - const logObj = { + const logObj: LogObject = { timestamp: this.timestamp, source: src, message: expandedArgs.join(' '), @@ -92,12 +108,12 @@ class Logger { } } - setLogLevel(level) { + setLogLevel(level: number): void { this.logLevel = level this.debug(`Set Log Level to ${this.levelString}`) } - static ConsoleMethods = { + static ConsoleMethods: Record = { TRACE: 'trace', DEBUG: 'debug', INFO: 'info', @@ -107,7 +123,7 @@ class Logger { NOTE: 'log' } - #log(levelName, source, ...args) { + #log(levelName: LogLevelName, source: string, ...args: unknown[]): Promise | undefined { const level = LogLevel[levelName] if (level < LogLevel.FATAL && level < this.logLevel) return const consoleMethod = Logger.ConsoleMethods[levelName] @@ -115,32 +131,34 @@ class Logger { return this.#logToFileAndListeners(level, levelName, args, source) } - trace(...args) { - this.#log('TRACE', this.source, ...args) + trace(...args: unknown[]): void { + void this.#log('TRACE', this.source, ...args) } - debug(...args) { - this.#log('DEBUG', this.source, ...args) + debug(...args: unknown[]): void { + void this.#log('DEBUG', this.source, ...args) } - info(...args) { - this.#log('INFO', this.source, ...args) + info(...args: unknown[]): void { + void this.#log('INFO', this.source, ...args) } - warn(...args) { - this.#log('WARN', this.source, ...args) + warn(...args: unknown[]): void { + void this.#log('WARN', this.source, ...args) } - error(...args) { - this.#log('ERROR', this.source, ...args) + error(...args: unknown[]): void { + void this.#log('ERROR', this.source, ...args) } - fatal(...args) { + fatal(...args: unknown[]): Promise | undefined { return this.#log('FATAL', this.source, ...args) } - note(...args) { - this.#log('NOTE', this.source, ...args) + note(...args: unknown[]): void { + void this.#log('NOTE', this.source, ...args) } } -module.exports = new Logger() + +const logger = new Logger() +export = logger diff --git a/server/libs/dateAndTime.d.ts b/server/libs/dateAndTime.d.ts new file mode 100644 index 000000000..7a9570a42 --- /dev/null +++ b/server/libs/dateAndTime.d.ts @@ -0,0 +1,3 @@ +// Boundary declaration for the formatting API used by application code. +// Keep this outside the library directory so index.js remains in the build output. +export function format(date: Date, format: string | string[], utc?: boolean): string diff --git a/server/utils/areEquivalent.ts b/server/utils/areEquivalent.ts index 924d53109..ae5721431 100644 --- a/server/utils/areEquivalent.ts +++ b/server/utils/areEquivalent.ts @@ -17,10 +17,19 @@ @param value2 Other item to compare @param stack Used internally to track circular refs - don't set it */ -module.exports = function areEquivalent(value1, value2, numToString = false, stack = []) { +function areEquivalent(value1: unknown, value2: unknown, numToString = false, stack: unknown[] = []): boolean { if (numToString) { - if (value1 !== null && !isNaN(value1)) value1 = String(value1) - if (value2 !== null && !isNaN(value2)) value2 = String(value2) + // Native isNaN accepts arbitrary JS values and throws for symbols and bigints, + // including objects that coerce to them. Number(value) would accept bigints. + if (value1 !== null) { + const isValue1NaN: unknown = Reflect.apply(isNaN, undefined, [value1]) + // Preserve native string coercion, including plain-object formatting. + if (isValue1NaN === false) value1 = Reflect.apply(String, undefined, [value1]) + } + if (value2 !== null) { + const isValue2NaN: unknown = Reflect.apply(isNaN, undefined, [value2]) + if (isValue2NaN === false) value2 = Reflect.apply(String, undefined, [value2]) + } } // Numbers, strings, null, undefined, symbols, functions, booleans. @@ -46,13 +55,13 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // Special case for number: check for NaN on both sides // (only way they can still be equivalent but not equal) - if (type1 === 'number') { + if (typeof value1 === 'number' && typeof value2 === 'number') { // Failed initial equals test, but could still both be NaN return (isNaN(value1) && isNaN(value2)); } // Special case for function: check for toString() equivalence - if (type1 === 'function') { + if (typeof value1 === 'function' && typeof value2 === 'function') { // Failed initial equals test, but could still have equivalent // implementations - note, will match on functions that have same name // and are native code: `function abc() { [native code] }` @@ -61,7 +70,7 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // For these types, cannot still be equal at this point, so fast-fail if (type1 === 'bigint' || type1 === 'boolean' || - type1 === 'function' || type1 === 'string' || + type1 === 'string' || type1 === 'symbol') { // console.log('no match for values', value1, value2) return false @@ -88,6 +97,8 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta } // breadcrumb + // Preserve the original stack lifetime: array and empty-object success paths + // leave entries behind, so some repeated references also throw as circular. stack.push(value1) // Handle arrays @@ -96,14 +107,16 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta return false } - const length = value1.length + const array1: unknown[] = value1 + const array2: unknown[] = value2 + const length = array1.length - if (length !== value2.length) { + if (length !== array2.length) { return false } for (let i = 0; i < length; i++) { - if (!areEquivalent(value1[i], value2[i], numToString, stack)) { + if (!areEquivalent(array1[i], array2[i], numToString, stack)) { return false } } @@ -112,6 +125,9 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // Final case: object + // Equality and type checks above have already handled null and undefined. + if (typeof value1 !== 'object' || value1 === null || typeof value2 !== 'object' || value2 === null) return false + // get both key lists and check length const keys1 = Object.keys(value1) const keys2 = Object.keys(value2) @@ -142,7 +158,9 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // Ensure perfect match across all values for (let i = 0; i < numKeys; i++) { - if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], numToString, stack)) { + const child1: unknown = Reflect.get(value1, keys1[i]) + const child2: unknown = Reflect.get(value2, keys1[i]) + if (!areEquivalent(child1, child2, numToString, stack)) { // console.log('2 subobjects not equiv', keys1[i], value1[keys1[i]], value2[keys1[i]]) return false } @@ -154,4 +172,6 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // Walk the same, talk the same - matching ducks. Quack. // 🦆🦆 return true; -} \ No newline at end of file +} + +export = areEquivalent diff --git a/server/utils/constants.ts b/server/utils/constants.ts index 925035e17..903bf7f6d 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -1,4 +1,4 @@ -module.exports.ScanResult = { +export const ScanResult = { NOTHING: 0, ADDED: 1, UPDATED: 2, @@ -6,17 +6,17 @@ module.exports.ScanResult = { UPTODATE: 4 } -module.exports.BookCoverAspectRatio = { +export const BookCoverAspectRatio = { STANDARD: 0, // 1.6:1 SQUARE: 1 } -module.exports.BookshelfView = { +export const BookshelfView = { STANDARD: 0, DETAIL: 1 } -module.exports.LogLevel = { +export const LogLevel = { TRACE: 0, DEBUG: 1, INFO: 2, @@ -26,14 +26,14 @@ module.exports.LogLevel = { NOTE: 6 } -module.exports.PlayMethod = { +export const PlayMethod = { DIRECTPLAY: 0, DIRECTSTREAM: 1, TRANSCODE: 2, LOCAL: 3 } -module.exports.AudioMimeType = { +export const AudioMimeType = { MP3: 'audio/mpeg', M4B: 'audio/mp4', M4A: 'audio/mp4', diff --git a/server/utils/globals.ts b/server/utils/globals.ts index 713b180f6..1db234da3 100644 --- a/server/utils/globals.ts +++ b/server/utils/globals.ts @@ -6,4 +6,4 @@ const globals = { MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] } -module.exports = globals +export = globals diff --git a/server/utils/parsers/parseSeriesString.ts b/server/utils/parsers/parseSeriesString.ts index ed5f00e3f..c6d705bb8 100644 --- a/server/utils/parsers/parseSeriesString.ts +++ b/server/utils/parsers/parseSeriesString.ts @@ -5,13 +5,11 @@ * Name #1a => { name: 'Name', sequence: '1a' } * Name #1 => { name: 'Name', sequence: '1' } * - * @param {string} seriesString - * @returns {{name: string, sequence: string}|null} */ -module.exports.parse = (seriesString) => { +export const parse = (seriesString: unknown): { name: string; sequence: string | null } | null => { if (!seriesString || typeof seriesString !== 'string') return null - let sequence = null + let sequence: string | null = null let name = seriesString // Series sequence match any characters after " #" other than whitespace and another # // e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid. diff --git a/server/utils/requestUtils.ts b/server/utils/requestUtils.ts index afac4fcc0..6d8375137 100644 --- a/server/utils/requestUtils.ts +++ b/server/utils/requestUtils.ts @@ -1,11 +1,12 @@ +import type { Request } from 'express' + +type RequestContext = Pick + /** * Whether the request was made over HTTPS. * Uses Express `req.secure` and `x-forwarded-proto` - * - * @param {import('express').Request} req - * @returns {boolean} */ -function isRequestSecure(req) { +export function isRequestSecure(req: RequestContext): boolean { if (req.secure) return true const xfp = (req.get('x-forwarded-proto') || '').toLowerCase() // Nginx Proxy Manager sends "http, https"; see https://github.com/advplyr/audiobookshelf/pull/4635 @@ -18,26 +19,12 @@ function isRequestSecure(req) { ) } -/** - * @param {import('express').Request} req - * @returns {'https' | 'http'} - */ -function getRequestProtocol(req) { +export function getRequestProtocol(req: RequestContext): 'https' | 'http' { return isRequestSecure(req) ? 'https' : 'http' } -/** - * @param {import('express').Request} req - * @returns {{ protocol: 'https' | 'http', host: string, origin: string }} - */ -function getRequestOrigin(req) { +export function getRequestOrigin(req: RequestContext): { protocol: 'https' | 'http'; host: string | undefined; origin: string } { const protocol = getRequestProtocol(req) const host = req.get('host') return { protocol, host, origin: `${protocol}://${host}` } } - -module.exports = { - isRequestSecure, - getRequestProtocol, - getRequestOrigin -} diff --git a/test/server/Logger.test.js b/test/server/Logger.test.js index 43b8e9afd..448b65001 100644 --- a/test/server/Logger.test.js +++ b/test/server/Logger.test.js @@ -282,4 +282,46 @@ describe('Logger', function () { expect(debugSpy.calledWithExactly('Set Log Level to WARN')).to.be.true }) }) + + describe('return and initialization compatibility', function () { + it('should keep ordinary logging methods synchronous', function () { + Logger.logLevel = LogLevel.TRACE + + for (const method of ['trace', 'debug', 'info', 'warn', 'error', 'note']) { + expect(Logger[method]('Test message')).to.equal(undefined) + } + }) + + it('should wait for file logging when awaiting fatal', async function () { + let finishLogging + const pendingLog = new Promise((resolve) => { + finishLogging = resolve + }) + Logger.logManager.logToFile.returns(pendingLog) + + const result = Logger.fatal('Fatal error') + expect(result).to.be.instanceOf(Promise) + let completed = false + const completion = result.then(() => { + completed = true + }) + await Promise.resolve() + expect(completed).to.be.false + + finishLogging() + await completion + expect(completed).to.be.true + }) + + it('should log before the log manager is initialized', async function () { + Logger.logManager = null + Logger.logLevel = LogLevel.TRACE + + expect(Logger.info('Early log')).to.equal(undefined) + await Logger.fatal('Early fatal log') + + expect(consoleInfoStub.calledOnce).to.be.true + expect(consoleErrorStub.calledOnce).to.be.true + }) + }) }) diff --git a/test/server/Logger.types.ts b/test/server/Logger.types.ts new file mode 100644 index 000000000..2f6200101 --- /dev/null +++ b/test/server/Logger.types.ts @@ -0,0 +1,39 @@ +import type Logger from '../../server/Logger' +import type LogManager from '../../server/managers/LogManager' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +type LogObject = { + timestamp: string + source: string + message: string + levelName: 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL' | 'NOTE' + level: number +} + +// Keep the CommonJS singleton, arbitrary log arguments, and return semantics typed. +export type LoggerContract = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, void>>, + Assert, void>>, + Assert, void>>, + Assert, void>>, + Assert, void>>, + Assert, Promise | undefined>>, + Assert, void>>, + Assert, [level: number]>>, + Assert, [socketId: string]>>, + Assert[0]['id'], string>>, + Assert[0]['emit']>, [event: 'log', log: LogObject]>> +] diff --git a/test/server/utils/foundationUtils.test.js b/test/server/utils/foundationUtils.test.js new file mode 100644 index 000000000..1e82c7636 --- /dev/null +++ b/test/server/utils/foundationUtils.test.js @@ -0,0 +1,185 @@ +const { expect } = require('chai') +const constants = require('../../../server/utils/constants') +const globals = require('../../../server/utils/globals') +const areEquivalent = require('../../../server/utils/areEquivalent') +const seriesParser = require('../../../server/utils/parsers/parseSeriesString') + +describe('foundation utilities', () => { + it('preserves CommonJS export shapes and constant values', () => { + expect(Object.keys(constants)).to.deep.equal(['ScanResult', 'BookCoverAspectRatio', 'BookshelfView', 'LogLevel', 'PlayMethod', 'AudioMimeType']) + expect(constants).to.deep.equal({ + ScanResult: { NOTHING: 0, ADDED: 1, UPDATED: 2, REMOVED: 3, UPTODATE: 4 }, + BookCoverAspectRatio: { STANDARD: 0, SQUARE: 1 }, + BookshelfView: { STANDARD: 0, DETAIL: 1 }, + LogLevel: { TRACE: 0, DEBUG: 1, INFO: 2, WARN: 3, ERROR: 4, FATAL: 5, NOTE: 6 }, + PlayMethod: { DIRECTPLAY: 0, DIRECTSTREAM: 1, TRANSCODE: 2, LOCAL: 3 }, + AudioMimeType: { + MP3: 'audio/mpeg', M4B: 'audio/mp4', M4A: 'audio/mp4', MP4: 'audio/mp4', + OGG: 'audio/ogg', OGA: 'audio/ogg', OPUS: 'audio/ogg', AAC: 'audio/aac', + FLAC: 'audio/flac', WMA: 'audio/x-ms-wma', AIFF: 'audio/x-aiff', AIF: 'audio/x-aiff', + WEBM: 'audio/webm', WEBMA: 'audio/webm', MKA: 'audio/x-matroska', + AWB: 'audio/amr-wb', CAF: 'audio/x-caf', MPEG: 'audio/mpeg', MPG: 'audio/mpeg' + } + }) + expect(areEquivalent).to.be.a('function') + expect(Object.keys(areEquivalent)).to.deep.equal([]) + expect(Object.keys(seriesParser)).to.deep.equal(['parse']) + }) + + it('preserves the extension lists and direct globals export', () => { + expect(globals).to.deep.equal({ + SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], + SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'aif', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpg', 'mpeg'], + SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], + TextFileTypes: ['txt', 'nfo'], + MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] + }) + expect(Object.keys(globals)).to.deep.equal(['SupportedImageTypes', 'SupportedAudioTypes', 'SupportedEbookTypes', 'TextFileTypes', 'MetadataFileTypes']) + }) + + it('keeps constants and extension arrays mutable', () => { + const original = constants.LogLevel.INFO + try { + constants.LogLevel.INFO = 100 + globals.SupportedImageTypes.push('test-extension') + expect(constants.LogLevel.INFO).to.equal(100) + expect(globals.SupportedImageTypes[globals.SupportedImageTypes.length - 1]).to.equal('test-extension') + } finally { + constants.LogLevel.INFO = original + if (globals.SupportedImageTypes[globals.SupportedImageTypes.length - 1] === 'test-extension') globals.SupportedImageTypes.pop() + } + }) + + describe('areEquivalent', () => { + it('compares primitives and identical references', () => { + const symbol = Symbol('same') + for (const value of [null, undefined, 0, -0, 1, '', 'name', true, false, 1n, symbol, {}, [], () => {}]) { + expect(areEquivalent(value, value)).to.equal(true) + } + for (const [left, right] of [[null, undefined], [null, {}], [0, false], [1, '1'], [1, 2], ['a', 'b'], [true, false], [1n, 2n], [symbol, Symbol('same')]]) { + expect(areEquivalent(left, right)).to.equal(false) + } + expect(areEquivalent(NaN, NaN)).to.equal(true) + expect(areEquivalent(NaN, 0)).to.equal(false) + }) + + it('preserves coercive numeric string comparisons', () => { + for (const [left, right, expected] of [ + [1, '1', true], [false, 'false', true], [true, '1', false], + ['', 0, false], [null, 0, false], [undefined, NaN, false], [NaN, NaN, true], + [[1], '1', true], [[], '', true], [{ valueOf: () => 1, toString: () => 'one' }, 'one', true], + [{ valueOf: () => 1 }, '[object Object]', true], + [{ part: 1 }, { part: '1' }, true] + ]) { + expect(areEquivalent(left, right, true)).to.equal(expected) + } + }) + + it('preserves native conversion errors, including identical values', () => { + for (const value of [Symbol('value'), 1n, Object(1n), { [Symbol.toPrimitive]: () => 1n }, { [Symbol.toPrimitive]: () => Symbol('value') }]) { + expect(() => areEquivalent(value, value, true)).to.throw(TypeError) + expect(() => areEquivalent(null, value, true)).to.throw(TypeError) + } + const failure = new Error('conversion failed') + expect(() => areEquivalent({ valueOf() { throw failure } }, 1, true)).to.throw(failure) + }) + + it('compares functions by their own toString methods', () => { + const makeFunction = () => function same() { return 1 } + expect(areEquivalent(makeFunction(), makeFunction())).to.equal(true) + expect(areEquivalent(makeFunction(), function different() { return 2 })).to.equal(false) + const left = () => 1 + const right = () => 2 + left.toString = right.toString = () => 'custom source' + expect(areEquivalent(left, right)).to.equal(true) + }) + + it('compares valid and invalid dates and preserves asymmetric object checks', () => { + expect(areEquivalent(new Date(123), new Date(123))).to.equal(true) + expect(areEquivalent(new Date(123), new Date(124))).to.equal(false) + expect(areEquivalent(new Date(NaN), new Date(NaN))).to.equal(true) + expect(areEquivalent(new Date(NaN), new Date(0))).to.equal(false) + expect(areEquivalent(new Date(0), {})).to.equal(false) + expect(areEquivalent({}, new Date(0))).to.equal(true) + }) + + it('compares nested enumerable properties regardless of order or prototype', () => { + expect(areEquivalent({ a: [1, { b: 'two' }], c: true }, { c: true, a: [1, { b: 'two' }] })).to.equal(true) + expect(areEquivalent({ a: 1 }, { b: 1 })).to.equal(false) + expect(areEquivalent({ a: 1 }, { a: 1, b: 2 })).to.equal(false) + expect(areEquivalent({ a: 1 }, { a: 2 })).to.equal(false) + expect(areEquivalent({ a: 1 }, Object.assign(Object.create(null), { a: 1 }))).to.equal(true) + expect(areEquivalent(Object.create({ inherited: 1 }), {})).to.equal(true) + expect(areEquivalent({ [Symbol('hidden')]: 1 }, {})).to.equal(true) + expect(areEquivalent({ get a() { return 1 } }, { a: 1 })).to.equal(true) + }) + + it('compares array indices and preserves holes and object asymmetry', () => { + const left = [1] + left.extra = 'ignored' + expect(areEquivalent(left, [1])).to.equal(true) + expect(areEquivalent([1], [2])).to.equal(false) + expect(areEquivalent([1], [1, 2])).to.equal(false) + expect(areEquivalent(new Array(1), [undefined])).to.equal(true) + expect(areEquivalent([], {})).to.equal(false) + expect(areEquivalent({}, [])).to.equal(true) + }) + + it('preserves circular detection and repeated-reference stack behavior', () => { + const left = {} + const right = {} + left.self = left + right.self = right + expect(() => areEquivalent(left, right)).to.throw(Error, 'areEquivalent value1 is circular') + expect(areEquivalent(left, left)).to.equal(true) + for (const shared of [[], {}]) { + expect(() => areEquivalent({ a: shared, b: shared }, { a: [], b: [] })).to.throw(Error, 'areEquivalent value1 is circular') + } + const shared = { value: 1 } + expect(areEquivalent({ a: shared, b: shared }, { a: { value: 1 }, b: { value: 1 } })).to.equal(true) + expect(areEquivalent({ a: {} }, right)).to.equal(false) + }) + + it('preserves caller-supplied stack mutations', () => { + const stack = [] + const array = [1] + expect(areEquivalent(array, [1], false, stack)).to.equal(true) + expect(stack).to.deep.equal([array]) + const empty = {} + expect(areEquivalent(empty, {}, false, stack)).to.equal(true) + expect(stack).to.deep.equal([array, empty]) + expect(areEquivalent({ a: 1 }, { a: 1 }, false, stack)).to.equal(true) + expect(stack).to.deep.equal([array, empty]) + expect(() => areEquivalent(array, [1], false, stack)).to.throw(Error, 'areEquivalent value1 is circular') + }) + }) + + describe('parseSeriesString', () => { + it('parses names and trailing sequences without trimming', () => { + for (const [input, expected] of [ + ['Name #1', { name: 'Name', sequence: '1' }], + ['Name #1a', { name: 'Name', sequence: '1a' }], + ['Name #1.5', { name: 'Name', sequence: '1.5' }], + ['Name', { name: 'Name', sequence: null }], + [' Name ', { name: ' Name ', sequence: null }], + [' ', { name: ' ', sequence: null }], + [' #1', { name: '', sequence: '1' }], + ['Name #1 #2', { name: 'Name #1', sequence: '2' }] + ]) { + expect(seriesParser.parse(input)).to.deep.equal(expected) + } + }) + + it('keeps malformed sequences as part of the name', () => { + for (const input of ['Name #1#a', 'Name #1 a', 'Name #', 'Name#1', 'Name #1 ', 'Name #1\n']) { + expect(seriesParser.parse(input)).to.deep.equal({ name: input, sequence: null }) + } + }) + + it('rejects empty strings and all non-string inputs', () => { + for (const input of ['', null, undefined, 0, 42, false, true, NaN, 1n, Symbol('name'), [], {}, new String('Name #1'), () => 'Name']) { + expect(seriesParser.parse(input)).to.equal(null) + } + }) + }) +}) diff --git a/test/server/utils/foundationUtils.types.ts b/test/server/utils/foundationUtils.types.ts new file mode 100644 index 000000000..e9dac043d --- /dev/null +++ b/test/server/utils/foundationUtils.types.ts @@ -0,0 +1,33 @@ +import type * as constants from '../../../server/utils/constants' +import type globals from '../../../server/utils/globals' +import type areEquivalent from '../../../server/utils/areEquivalent' +import type { parse } from '../../../server/utils/parsers/parseSeriesString' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +// Exact types keep the exports mutable and detect missing or weakened types. +export type FoundationUtilsContract = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert, [value1: unknown, value2: unknown, numToString?: boolean, stack?: unknown[]]>>, + Assert, boolean>>, + Assert, [seriesString: unknown]>>, + Assert, { name: string; sequence: string | null } | null>> +] diff --git a/test/server/utils/requestUtils.test.js b/test/server/utils/requestUtils.test.js index 589c6f1cd..b86e37e1e 100644 --- a/test/server/utils/requestUtils.test.js +++ b/test/server/utils/requestUtils.test.js @@ -30,6 +30,28 @@ describe('requestUtils', () => { expect(getRequestProtocol(mockReq())).to.equal('http') }) + it('keeps secure requests HTTPS even when the proxy reports HTTP', () => { + expect(getRequestProtocol(mockReq({ secure: true, xForwardedProto: 'http' }))).to.equal('https') + }) + + it('normalizes proxy header case and whitespace', () => { + expect(isRequestSecure(mockReq({ xForwardedProto: ' HTTP , HTTPS ' }))).to.equal(true) + expect(isRequestSecure(mockReq({ xForwardedProto: 'http, https-other' }))).to.equal(false) + }) + + it('uses HTTP when the proxy header is missing or empty', () => { + expect(getRequestProtocol({ secure: false, get: () => undefined })).to.equal('http') + expect(getRequestProtocol(mockReq({ xForwardedProto: '' }))).to.equal('http') + }) + + it('preserves the existing origin when Host is missing', () => { + expect(getRequestOrigin({ secure: false, get: () => undefined })).to.deep.equal({ + protocol: 'http', + host: undefined, + origin: 'http://undefined' + }) + }) + it('getRequestOrigin builds origin from protocol and host', () => { expect(getRequestOrigin(mockReq({ secure: true }))).to.deep.equal({ protocol: 'https', diff --git a/test/server/utils/requestUtils.types.ts b/test/server/utils/requestUtils.types.ts new file mode 100644 index 000000000..ccbcab768 --- /dev/null +++ b/test/server/utils/requestUtils.types.ts @@ -0,0 +1,13 @@ +import type { Request } from 'express' +import type { getRequestOrigin, getRequestProtocol, isRequestSecure } from '../../../server/utils/requestUtils' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +// Exact comparisons also fail if a migrated export loses its type and becomes any. +export type RequestUtilsContract = [ + Assert, boolean>>, + Assert, 'http' | 'https'>>, + Assert, { protocol: 'http' | 'https'; host: string | undefined; origin: string }>>, + Assert[0], Pick>> +]