diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e35679b..e14ed27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - node-version: [20, 22] + node-version: [24.x] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a553ecd..bac33bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 registry-url: https://registry.npmjs.org - name: Install dependencies diff --git a/Dockerfile b/Dockerfile index fa70c14..bfe1950 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine AS builder +FROM node:24.5-alpine AS builder RUN apk upgrade --no-cache zlib RUN corepack enable pnpm @@ -15,13 +15,20 @@ RUN mkdir /prod_modules && \ cd /prod_modules && \ pnpm install --frozen-lockfile --prod --ignore-scripts -FROM node:22-alpine +FROM node:24.5-alpine RUN apk upgrade --no-cache zlib RUN rm -rf /usr/local/lib/node_modules/npm \ && rm -f /usr/local/bin/npm /usr/local/bin/npx WORKDIR /app ENV HUSKY=0 + +# Node 24.5+ natively honours HTTP_PROXY / HTTPS_PROXY / NO_PROXY for the +# built-in fetch() via the NODE_USE_ENV_PROXY flag introduced in Node 22. +# Set it here so callers can pass -e HTTPS_PROXY=... at docker run time without +# needing any third-party proxy package. +ENV NODE_USE_ENV_PROXY=1 + COPY --from=builder /prod_modules/node_modules/ ./node_modules/ COPY --from=builder /app/dist/ ./dist/ diff --git a/package.json b/package.json index 1b06bab..3839d8c 100644 --- a/package.json +++ b/package.json @@ -56,13 +56,14 @@ "url": "https://github.com/philrox/eurlex-mcp-server/issues" }, "engines": { - "node": ">=20.0.0" + "node": ">=24.5.0" }, "packageManager": "pnpm@10.30.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.0", "express": "^4.18.0", "express-rate-limit": "^8.3.1", + "undici": "^8.2.0", "zod": "^3.22.0" }, "devDependencies": { @@ -70,11 +71,11 @@ "@commitlint/config-conventional": "^20.4.4", "@eslint/js": "^10.0.1", "@types/express": "^4.17.0", - "@types/node": "^20.0.0", + "@types/node": "^24.0.0", "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", "@vitest/coverage-v8": "^2.1.9", - "esbuild": "^0.25.0", + "esbuild": "^0.25.0", "eslint": "^10.0.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import-x": "^4.16.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcc8047..e566473 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,13 +17,16 @@ importers: express-rate-limit: specifier: ^8.3.1 version: 8.3.1(express@4.22.1) + undici: + specifier: ^8.2.0 + version: 8.2.0 zod: specifier: ^3.22.0 version: 3.25.76 devDependencies: '@commitlint/cli': specifier: ^20.4.4 - version: 20.4.4(@types/node@20.19.37)(conventional-commits-parser@6.3.0)(typescript@5.9.3) + version: 20.4.4(@types/node@24.12.4)(conventional-commits-parser@6.3.0)(typescript@5.9.3) '@commitlint/config-conventional': specifier: ^20.4.4 version: 20.4.4 @@ -34,8 +37,8 @@ importers: specifier: ^4.17.0 version: 4.17.25 '@types/node': - specifier: ^20.0.0 - version: 20.19.37 + specifier: ^24.0.0 + version: 24.12.4 '@typescript-eslint/eslint-plugin': specifier: ^8.57.0 version: 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) @@ -44,7 +47,7 @@ importers: version: 8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^2.1.9 - version: 2.1.9(vitest@2.1.9(@types/node@20.19.37)) + version: 2.1.9(vitest@2.1.9(@types/node@24.12.4)) esbuild: specifier: ^0.25.0 version: 0.25.12 @@ -77,7 +80,7 @@ importers: version: 8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@20.19.37) + version: 2.1.9(@types/node@24.12.4) packages: @@ -927,8 +930,8 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/node@20.19.37': - resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -2362,8 +2365,12 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@8.2.0: + resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==} + engines: {node: '>=22.19.0'} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -2530,11 +2537,11 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@commitlint/cli@20.4.4(@types/node@20.19.37)(conventional-commits-parser@6.3.0)(typescript@5.9.3)': + '@commitlint/cli@20.4.4(@types/node@24.12.4)(conventional-commits-parser@6.3.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.4.4 '@commitlint/lint': 20.4.4 - '@commitlint/load': 20.4.4(@types/node@20.19.37)(typescript@5.9.3) + '@commitlint/load': 20.4.4(@types/node@24.12.4)(typescript@5.9.3) '@commitlint/read': 20.4.4(conventional-commits-parser@6.3.0) '@commitlint/types': 20.4.4 tinyexec: 1.0.2 @@ -2583,14 +2590,14 @@ snapshots: '@commitlint/rules': 20.4.4 '@commitlint/types': 20.4.4 - '@commitlint/load@20.4.4(@types/node@20.19.37)(typescript@5.9.3)': + '@commitlint/load@20.4.4(@types/node@24.12.4)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 20.4.4 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.4.4 '@commitlint/types': 20.4.4 cosmiconfig: 9.0.1(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.2.0(@types/node@20.19.37)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.2.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -3090,11 +3097,11 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 20.19.37 + '@types/node': 24.12.4 '@types/connect@3.4.38': dependencies: - '@types/node': 20.19.37 + '@types/node': 24.12.4 '@types/esrecurse@4.3.1': {} @@ -3102,7 +3109,7 @@ snapshots: '@types/express-serve-static-core@4.19.8': dependencies: - '@types/node': 20.19.37 + '@types/node': 24.12.4 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -3120,9 +3127,9 @@ snapshots: '@types/mime@1.3.5': {} - '@types/node@20.19.37': + '@types/node@24.12.4': dependencies: - undici-types: 6.21.0 + undici-types: 7.16.0 '@types/qs@6.15.0': {} @@ -3131,16 +3138,16 @@ snapshots: '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.19.37 + '@types/node': 24.12.4 '@types/send@1.2.1': dependencies: - '@types/node': 20.19.37 + '@types/node': 24.12.4 '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 20.19.37 + '@types/node': 24.12.4 '@types/send': 0.17.6 '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': @@ -3293,7 +3300,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@20.19.37))': + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.4))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -3307,7 +3314,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@20.19.37) + vitest: 2.1.9(@types/node@24.12.4) transitivePeerDependencies: - supports-color @@ -3318,13 +3325,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.37))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.12.4))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@20.19.37) + vite: 5.4.21(@types/node@24.12.4) '@vitest/pretty-format@2.1.9': dependencies: @@ -3544,9 +3551,9 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig-typescript-loader@6.2.0(@types/node@20.19.37)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.2.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 20.19.37 + '@types/node': 24.12.4 cosmiconfig: 9.0.1(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -4680,7 +4687,9 @@ snapshots: typescript@5.9.3: {} - undici-types@6.21.0: {} + undici-types@7.16.0: {} + + undici@8.2.0: {} unpipe@1.0.0: {} @@ -4716,13 +4725,13 @@ snapshots: vary@1.1.2: {} - vite-node@2.1.9(@types/node@20.19.37): + vite-node@2.1.9(@types/node@24.12.4): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@20.19.37) + vite: 5.4.21(@types/node@24.12.4) transitivePeerDependencies: - '@types/node' - less @@ -4734,19 +4743,19 @@ snapshots: - supports-color - terser - vite@5.4.21(@types/node@20.19.37): + vite@5.4.21(@types/node@24.12.4): dependencies: esbuild: 0.21.5 postcss: 8.5.8 rollup: 4.59.0 optionalDependencies: - '@types/node': 20.19.37 + '@types/node': 24.12.4 fsevents: 2.3.3 - vitest@2.1.9(@types/node@20.19.37): + vitest@2.1.9(@types/node@24.12.4): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.37)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.12.4)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -4762,11 +4771,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@20.19.37) - vite-node: 2.1.9(@types/node@20.19.37) + vite: 5.4.21(@types/node@24.12.4) + vite-node: 2.1.9(@types/node@24.12.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 20.19.37 + '@types/node': 24.12.4 transitivePeerDependencies: - less - lightningcss diff --git a/src/services/cellarClient.ts b/src/services/cellarClient.ts index 637db05..da05f3d 100644 --- a/src/services/cellarClient.ts +++ b/src/services/cellarClient.ts @@ -1,3 +1,5 @@ +import { fetch as undiciFetch, ProxyAgent } from 'undici'; + import { SPARQL_ENDPOINT, CELLAR_REST_BASE, @@ -14,6 +16,52 @@ import type { CitationEntry, } from '../types.js'; +/** + * Proxy URL resolved once at module load from standard environment variables. + * Checks HTTPS_PROXY, https_proxy, HTTP_PROXY, http_proxy in that order. + * Undefined when no proxy is configured (direct connection). + * + * Memoized at module init rather than re-read on every request — env vars do + * not change at runtime. If tests mutate process.env, reset this between runs. + */ +const PROXY_URL: string | undefined = + process.env['HTTPS_PROXY'] ?? + process.env['https_proxy'] ?? + process.env['HTTP_PROXY'] ?? + process.env['http_proxy']; + +/** + * Unified fetch wrapper with proxy support. + * + * Without a proxy: delegates to the global fetch() so that test suites can + * stub it with vi.stubGlobal('fetch', mockFetch) and intercept all calls. + * + * With a proxy (HTTPS_PROXY / HTTP_PROXY / … set): uses undici's own fetch() + * together with a ProxyAgent dispatcher. Both must come from the same undici + * build — mixing the npm-installed ProxyAgent with Node's built-in global fetch + * (a different undici build) causes "invalid onRequestStart method" / + * UND_ERR_INVALID_ARG at dispatch time. + */ +const _agentCache = new Map(); +function getProxyAgent(url: string): ProxyAgent { + let agent = _agentCache.get(url); + if (!agent) { + agent = new ProxyAgent(url); + _agentCache.set(url, agent); + } + return agent; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function httpFetch(url: string, init: Parameters[1]): Promise { + if (!PROXY_URL) { + // No proxy: use the global fetch so vi.stubGlobal mocks work in tests + return globalThis.fetch(url, init as RequestInit); + } + // Proxy: both dispatcher and fetch must come from the same undici instance + return undiciFetch(url, { ...init, dispatcher: getProxyAgent(PROXY_URL) }); +} + /** Maps 3-letter language codes to CDM expression language URI suffixes */ const LANGUAGE_URI_MAP: Record = { DEU: 'DEU', @@ -106,7 +154,7 @@ export function escapeSparqlString(input: string): string { export class CellarClient { private async executeSparql(sparql: string): Promise { - const response = await fetch(SPARQL_ENDPOINT, { + const response = await httpFetch(SPARQL_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/sparql-query', @@ -233,7 +281,7 @@ export class CellarClient { const httpLang = LANGUAGE_HTTP_MAP[language] ?? 'de'; const url = `${CELLAR_REST_BASE}/${celex_id}`; - const response = await fetch(url, { + const response = await httpFetch(url, { method: 'GET', headers: { Accept: 'application/xhtml+xml', @@ -649,7 +697,7 @@ export class CellarClient { const httpLang = LANGUAGE_HTTP_MAP[language] ?? 'de'; const url = `${CELLAR_REST_BASE}/${consolidatedCelex}`; - const response = await fetch(url, { + const response = await httpFetch(url, { method: 'GET', headers: { Accept: 'application/xhtml+xml', diff --git a/tsconfig.json b/tsconfig.json index 9f20266..7e2f21b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true,