diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..44a21c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + server: + name: Server (Typecheck + Tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: server/package-lock.json + - run: npm ci + working-directory: server + - run: npm run typecheck + working-directory: server + - run: npm test + working-directory: server + + client: + name: Client (Lint + Build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: client/package-lock.json + - run: npm ci + working-directory: client + - run: npm run lint + working-directory: client + - run: npm run build + working-directory: client diff --git a/.gitignore b/.gitignore index 14233be..4c9835d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ server/.env *.log .DS_Store client/dist/ +server/.cache/ diff --git a/README.md b/README.md index cfd7cb3..9fb4876 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,22 @@ COINGECKO_API_KEY=CG-dein_coingecko_key | `CMC_API_KEY` | CoinMarketCap | Alle Coins (`#/coins`) | | `COINGECKO_API_KEY` | CoinGecko | Markets, Charts, F&G | -Status: `GET http://localhost:3001/api/status` +Status: `GET http://localhost:3001/api/status` · Health: `GET http://localhost:3001/api/health` + +## Ausfallsicherheit + +Fällt eine API aus, springt automatisch eine andere Quelle ein: + +| Daten | Primär | Fallback | +|-------|--------|----------| +| Fear & Greed | Alternative.me | CoinMarketCap F&G → eigener Composite aus Marktdaten | +| Alle Coins | CoinMarketCap | CoinGecko (gleiche Datenform) | +| Markets | CoinGecko | Aufbereitung aus gecachten CMC-Daten | + +Zusätzlich: Die letzten guten Daten bleiben im Cache (statt 503) und werden +als Snapshot unter `server/.cache/` gespeichert — nach einem Neustart sind +sofort Daten da. `GET /api/health` zeigt pro Datensatz Quelle, Alter und +letzten Fehler. ## Features diff --git a/client/package-lock.json b/client/package-lock.json index acecf2e..e38cf56 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1180,9 +1180,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1200,9 +1197,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1220,9 +1214,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1240,9 +1231,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1260,9 +1248,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1280,9 +1265,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1554,9 +1536,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1574,9 +1553,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1594,9 +1570,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1614,9 +1587,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3210,9 +3180,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3234,9 +3201,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3258,9 +3222,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3282,9 +3243,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/client/src/hooks/usePolling.ts b/client/src/hooks/usePolling.ts index 886ced0..3df8f66 100644 --- a/client/src/hooks/usePolling.ts +++ b/client/src/hooks/usePolling.ts @@ -5,16 +5,18 @@ export function usePolling(fetcher: () => Promise, intervalMs: number) { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - const load = useCallback(async () => { - try { - const result = await fetcher(); - setData(result); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Fehler beim Laden'); - } finally { - setLoading(false); - } + const load = useCallback(() => { + return fetcher() + .then((result) => { + setData(result); + setError(null); + }) + .catch((e: unknown) => { + setError(e instanceof Error ? e.message : 'Fehler beim Laden'); + }) + .finally(() => { + setLoading(false); + }); }, [fetcher]); useEffect(() => { diff --git a/client/src/main.tsx b/client/src/main.tsx index 5d8c682..0d11e79 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,22 +1,15 @@ -import { StrictMode, useEffect } from 'react'; +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; -function Root() { - useEffect(() => { - document.documentElement.classList.add('dark'); - document.documentElement.setAttribute('data-theme', 'dark'); - // Remove the boot skeleton once React has mounted - const boot = document.getElementById('ts-boot'); - if (boot) boot.remove(); - }, []); +document.documentElement.classList.add('dark'); +document.documentElement.setAttribute('data-theme', 'dark'); +// Boot-Skeleton entfernen, sobald das React-Bundle ausgeführt wird +document.getElementById('ts-boot')?.remove(); - return ( - - - - ); -} - -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/client/src/pages/CoinDetail.tsx b/client/src/pages/CoinDetail.tsx index a1eca8c..12328db 100644 --- a/client/src/pages/CoinDetail.tsx +++ b/client/src/pages/CoinDetail.tsx @@ -28,12 +28,13 @@ export function CoinDetail() { const [live, setLive] = useState(null); const [chart, setChart] = useState(null); const [range, setRange] = useState('24h'); - const [loading, setLoading] = useState(true); + // Abgeleiteter Ladezustand: lädt, solange die Daten nicht zum aktuellen Slug gehören + const [loadedSlug, setLoadedSlug] = useState(null); + const loading = Boolean(slug) && loadedSlug !== slug; const liveTimer = useRef | null>(null); useEffect(() => { if (!slug) return; - setLoading(true); void Promise.allSettled([ fetchCoinDetail(slug), fetchCoinLive(slug), @@ -42,7 +43,7 @@ export function CoinDetail() { if (d.status === 'fulfilled') setDetail(d.value); if (l.status === 'fulfilled') setLive(l.value); if (c.status === 'fulfilled') setChart(c.value); - setLoading(false); + setLoadedSlug(slug); }); }, [slug]); diff --git a/package-lock.json b/package-lock.json index d571348..528a945 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,8 +7,12 @@ "": { "name": "tokensync", "version": "1.0.0", + "hasInstallScript": true, "devDependencies": { "concurrently": "^9.1.2" + }, + "engines": { + "node": ">=20" } }, "node_modules/ansi-regex": { diff --git a/server/package-lock.json b/server/package-lock.json index bd8f26e..8152acb 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -25,13 +25,48 @@ "@types/node": "^22.15.21", "@types/sanitize-html": "^2.16.0", "tsx": "^4.19.4", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.1.9" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -46,9 +81,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -63,9 +98,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -80,9 +115,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -97,9 +132,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -114,9 +149,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -131,9 +166,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -148,9 +183,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -165,9 +200,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -182,9 +217,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -199,9 +234,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -216,9 +251,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -233,9 +268,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -250,9 +285,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -267,9 +302,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -284,9 +319,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -301,9 +336,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -318,9 +353,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -335,9 +370,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -352,9 +387,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -369,9 +404,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -386,9 +421,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -403,9 +438,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -420,9 +455,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -437,9 +472,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -454,9 +489,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -470,6 +505,324 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -481,6 +834,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -501,6 +865,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -563,35 +941,148 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/sanitize-html": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", - "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==", + "node_modules/@types/sanitize-html": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", + "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^10.1" + } + }, + "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": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "htmlparser2": "^10.1" + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/accepts": { @@ -607,6 +1098,16 @@ "node": ">= 0.6" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -669,6 +1170,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -700,6 +1211,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -776,6 +1294,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -911,6 +1439,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -924,9 +1459,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -937,32 +1472,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-html": { @@ -983,6 +1518,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -992,6 +1537,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1050,6 +1605,24 @@ "express": ">= 4.11" } }, + "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/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1223,85 +1796,356 @@ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8" + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/parcel" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/parcel" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "license": "MIT", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/launder": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", - "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { - "dayjs": "^1.11.7" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/math-intrinsics": { @@ -1434,6 +2278,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1480,16 +2338,36 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -1566,6 +2444,40 @@ "node": ">= 0.10" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1745,6 +2657,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1754,6 +2673,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1763,6 +2689,57 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1772,6 +2749,14 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.22.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", @@ -1861,6 +2846,191 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/server/package.json b/server/package.json index 5ef03d7..b1b7014 100644 --- a/server/package.json +++ b/server/package.json @@ -7,6 +7,9 @@ "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "audit": "npm audit" }, "dependencies": { @@ -27,6 +30,7 @@ "@types/node": "^22.15.21", "@types/sanitize-html": "^2.16.0", "tsx": "^4.19.4", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.1.9" } } diff --git a/server/src/index.ts b/server/src/index.ts index c1f32c0..a5d3777 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,9 +12,12 @@ import { aggregateNews } from './services/newsAggregator.js'; import { fetchFearGreedData } from './services/fearGreedService.js'; import { fetchMarketOverview } from './services/marketService.js'; import { fetchAllCmcCoins, fetchCmcLiveSnapshot, filterAndPaginateCoins } from './services/cmcService.js'; +import { fetchCoinsFromCoinGecko } from './services/coinsFallbackService.js'; +import { buildMarketOverviewFromCmc } from './services/marketFallbackService.js'; import { fetchCoinChart, VALID_CHART_RANGES, type ChartRange } from './services/coinChartService.js'; import { fetchLivePrice } from './services/livePriceService.js'; import { fetchBinanceLivePrice } from './services/binancePriceService.js'; +import { loadSnapshot, saveSnapshot } from './lib/snapshotStore.js'; import type { FearGreedResponse, NewsResponse } from './types/index.js'; import type { MarketOverviewResponse } from './types/market.js'; import type { CmcCoin } from './types/cmc.js'; @@ -23,18 +26,65 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLIENT_DIST = path.resolve(__dirname, '../../client/dist'); const PUBLIC_DIR = path.resolve(__dirname, '../../public'); const STATIC_DIR = existsSync(path.join(CLIENT_DIST, 'index.html')) ? CLIENT_DIST : PUBLIC_DIR; +// Das Legacy-Frontend (public/) nutzt Inline-Scripts — nur dann CSP lockern +const LEGACY_STATIC = STATIC_DIR === PUBLIC_DIR; const PORT = Number(process.env.PORT) || 3001; const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:3001'; const app = express(); -const cache = new NodeCache(); +// TTL 0 = Daten verfallen nie: Wenn ein Refresh fehlschlägt, servieren wir +// die letzten guten Daten (stale) statt 503. Cron ersetzt sie bei Erfolg. +const cache = new NodeCache({ stdTTL: 0, useClones: false }); const newsCacheKey = 'news'; const fgCacheKey = 'fearGreed'; const marketCacheKey = 'markets'; const cmcCacheKey = 'cmcCoins'; +interface DatasetHealth { + lastSuccess: string | null; + lastError: string | null; + lastErrorAt: string | null; + source: string | null; +} + +const datasetHealth: Record = { + [newsCacheKey]: { lastSuccess: null, lastError: null, lastErrorAt: null, source: null }, + [fgCacheKey]: { lastSuccess: null, lastError: null, lastErrorAt: null, source: null }, + [marketCacheKey]: { lastSuccess: null, lastError: null, lastErrorAt: null, source: null }, + [cmcCacheKey]: { lastSuccess: null, lastError: null, lastErrorAt: null, source: null }, +}; + +function recordSuccess(key: string, source: string): void { + datasetHealth[key].lastSuccess = new Date().toISOString(); + datasetHealth[key].source = source; +} + +function recordFailure(key: string, err: unknown): void { + datasetHealth[key].lastError = err instanceof Error ? err.message : String(err); + datasetHealth[key].lastErrorAt = new Date().toISOString(); +} + +function storeDataset(key: string, data: T, source: string): void { + cache.set(key, data); + saveSnapshot(key, data); + recordSuccess(key, source); +} + +/** Warm-Start: letzte gute Daten von Disk laden, damit nach Neustart kein 503 kommt */ +function loadPersistedSnapshots(): void { + for (const key of [newsCacheKey, fgCacheKey, marketCacheKey, cmcCacheKey]) { + const snap = loadSnapshot(key); + if (snap) { + cache.set(key, snap.data); + datasetHealth[key].lastSuccess = snap.savedAt; + datasetHealth[key].source = 'disk-snapshot'; + console.log(`Snapshot geladen: ${key} (Stand ${snap.savedAt})`); + } + } +} + let newsRefreshing = false; let fgRefreshing = false; let marketRefreshing = false; @@ -45,14 +95,16 @@ async function refreshNews(): Promise { newsRefreshing = true; try { const data = await aggregateNews(); + if (!data.articles.length) throw new Error('Keine Artikel von den RSS-Quellen erhalten'); const payload: NewsResponse = { articles: data.articles, lastUpdated: new Date().toISOString(), sourcesActive: data.sourcesActive, totalFetched: data.totalFetched, }; - cache.set(newsCacheKey, payload, 120); + storeDataset(newsCacheKey, payload, 'rss'); } catch (err) { + recordFailure(newsCacheKey, err); console.warn('News refresh failed:', err instanceof Error ? err.message : err); } finally { newsRefreshing = false; @@ -63,9 +115,11 @@ async function refreshFearGreed(): Promise { if (fgRefreshing) return; fgRefreshing = true; try { + // fetchFearGreedData hat intern die Kette alternative.me → CMC → Composite const data = await fetchFearGreedData(); - cache.set(fgCacheKey, data, 600); + storeDataset(fgCacheKey, data, data.current.source); } catch (err) { + recordFailure(fgCacheKey, err); console.warn('Fear & Greed refresh failed:', err instanceof Error ? err.message : err); } finally { fgRefreshing = false; @@ -77,14 +131,51 @@ async function refreshMarkets(): Promise { marketRefreshing = true; try { const data = await fetchMarketOverview(); - cache.set(marketCacheKey, data, 300); + storeDataset(marketCacheKey, data, 'coingecko'); } catch (err) { - console.warn('Markets refresh failed:', err instanceof Error ? err.message : err); + recordFailure(marketCacheKey, err); + console.warn('Markets refresh (CoinGecko) failed:', err instanceof Error ? err.message : err); + // Fallback: Überblick aus den gecachten CMC-Coins bauen + const cmcCoins = cache.get(cmcCacheKey); + if (cmcCoins?.length) { + try { + const fallback = buildMarketOverviewFromCmc(cmcCoins); + storeDataset(marketCacheKey, fallback, 'coinmarketcap-fallback'); + console.log('Markets: Fallback aus CMC-Daten aktiv'); + } catch (fbErr) { + console.warn('Markets CMC fallback failed:', fbErr instanceof Error ? fbErr.message : fbErr); + } + } } finally { marketRefreshing = false; } } +async function refreshCmc(): Promise { + if (cmcRefreshing) return; + cmcRefreshing = true; + try { + const coins = await fetchAllCmcCoins(); + if (!coins.length) throw new Error('CoinMarketCap lieferte keine Coins'); + storeDataset(cmcCacheKey, coins, 'coinmarketcap'); + } catch (err) { + recordFailure(cmcCacheKey, err); + console.warn('CMC refresh failed:', err instanceof Error ? err.message : err); + // Fallback: gleiche Datenform aus CoinGecko + try { + const coins = await fetchCoinsFromCoinGecko(500); + if (coins.length) { + storeDataset(cmcCacheKey, coins, 'coingecko-fallback'); + console.log(`Coins: Fallback über CoinGecko aktiv (${coins.length} Coins)`); + } + } catch (fbErr) { + console.warn('Coins CoinGecko fallback failed:', fbErr instanceof Error ? fbErr.message : fbErr); + } + } finally { + cmcRefreshing = false; + } +} + const isProd = process.env.NODE_ENV === 'production'; // Build allowed origins list — includes any value set via CLIENT_ORIGIN @@ -92,7 +183,6 @@ const allowedOrigins = [ CLIENT_ORIGIN, 'http://127.0.0.1:3001', 'http://localhost:3001', - ...(isProd ? [] : []), ].filter(Boolean); app.use( @@ -100,10 +190,10 @@ app.use( contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: LEGACY_STATIC ? ["'self'", "'unsafe-inline'"] : ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'], - imgSrc: ["'self'", 'https:', 'data:', 'https://s2.coinmarketcap.com', 'https://images.unsplash.com'], + imgSrc: ["'self'", 'https:', 'data:'], connectSrc: [ "'self'", ...allowedOrigins, @@ -129,7 +219,10 @@ app.use( methods: ['GET'], }), ); +// Nur API-Routen limitieren — statische Assets (JS/CSS/Bilder) zählen sonst mit +// und ein normaler Seitenaufruf kann das Limit sprengen app.use( + '/api', rateLimit({ windowMs: 60_000, max: 120, @@ -138,8 +231,36 @@ app.use( }), ); +function datasetReport(key: string) { + const health = datasetHealth[key]; + const hasData = cache.get(key) !== undefined; + const ageSeconds = health.lastSuccess + ? Math.round((Date.now() - new Date(health.lastSuccess).getTime()) / 1000) + : null; + return { + available: hasData, + source: health.source, + lastSuccess: health.lastSuccess, + ageSeconds, + lastError: health.lastError, + lastErrorAt: health.lastErrorAt, + }; +} + app.get('/api/health', (_req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); + const datasets = { + news: datasetReport(newsCacheKey), + fearGreed: datasetReport(fgCacheKey), + markets: datasetReport(marketCacheKey), + coins: datasetReport(cmcCacheKey), + }; + const allAvailable = Object.values(datasets).every((d) => d.available); + const anyAvailable = Object.values(datasets).some((d) => d.available); + res.json({ + status: allAvailable ? 'ok' : anyAvailable ? 'degraded' : 'starting', + datasets, + timestamp: new Date().toISOString(), + }); }); app.get('/api/status', (_req, res) => { @@ -161,6 +282,12 @@ app.get('/api/status', (_req, res) => { hyperliquid: { configured: true, note: 'Öffentliche Mid-Preise (Perp)' }, rssNews: { configured: true, endpoint: '/api/news' }, }, + datasets: { + news: datasetReport(newsCacheKey), + fearGreed: datasetReport(fgCacheKey), + markets: datasetReport(marketCacheKey), + coins: datasetReport(cmcCacheKey), + }, timestamp: new Date().toISOString(), }); }); @@ -191,19 +318,6 @@ app.get('/api/fear-greed', (_req, res) => { res.json(cached); }); -async function refreshCmc(): Promise { - if (cmcRefreshing) return; - cmcRefreshing = true; - try { - const coins = await fetchAllCmcCoins(); - cache.set(cmcCacheKey, coins, 900); - } catch (err) { - console.warn('CMC refresh failed:', err instanceof Error ? err.message : err); - } finally { - cmcRefreshing = false; - } -} - app.get('/api/cmc/summary', (_req, res) => { const cached = cache.get(cmcCacheKey); if (!cached?.length) { @@ -223,7 +337,7 @@ app.get('/api/cmc/summary', (_req, res) => { btcPrice: btc?.price ?? 0, btcChange24h: btc?.change24h ?? 0, coinCount: cached.length, - source: 'CoinMarketCap', + source: datasetHealth[cmcCacheKey].source === 'coingecko-fallback' ? 'CoinGecko (Fallback)' : 'CoinMarketCap', lastUpdated: cached[0]?.lastUpdated ?? new Date().toISOString(), }); }); @@ -314,7 +428,7 @@ app.get('/api/cmc/list', (_req, res) => { coins: cached, total: cached.length, lastUpdated: cached[0]?.lastUpdated ?? new Date().toISOString(), - source: 'CoinMarketCap', + source: datasetHealth[cmcCacheKey].source === 'coingecko-fallback' ? 'CoinGecko (Fallback)' : 'CoinMarketCap', }); }); @@ -342,11 +456,22 @@ app.get('/api/cmc/live', async (req, res) => { cache.set( cmcCacheKey, main.map((c) => (byId.has(c.id) ? { ...c, ...byId.get(c.id)! } : c)), - 900, ); } res.json(payload); } catch (err) { + // Fallback: letzte bekannte Liste ausliefern statt Fehler + const main = cache.get(cmcCacheKey); + if (main?.length) { + res.json({ + coins: main.slice(0, limit), + total: Math.min(limit, main.length), + lastUpdated: main[0]?.lastUpdated ?? new Date().toISOString(), + source: 'Cache (Live-Update nicht verfügbar)', + refreshSeconds: 30, + }); + return; + } res.status(502).json({ error: err instanceof Error ? err.message : 'Live-Update nicht verfügbar', }); @@ -370,7 +495,7 @@ app.get('/api/cmc/coins', (req, res) => { limit, totalPages: Math.ceil(total / limit), lastUpdated: cached[0]?.lastUpdated ?? new Date().toISOString(), - source: 'CoinMarketCap', + source: datasetHealth[cmcCacheKey].source === 'coingecko-fallback' ? 'CoinGecko (Fallback)' : 'CoinMarketCap', }); }); @@ -424,10 +549,12 @@ cron.schedule('*/10 * * * *', () => void refreshMarkets()); cron.schedule('*/15 * * * *', () => void refreshCmc()); async function bootstrap() { + loadPersistedSnapshots(); app.listen(PORT, () => { console.log(`TokenSync → http://localhost:${PORT}`); }); - void refreshCmc(); + // CMC zuerst anstoßen, damit der Markets-Fallback notfalls Daten hat + await refreshCmc(); await Promise.allSettled([refreshNews(), refreshFearGreed(), refreshMarkets()]); } diff --git a/server/src/lib/http.ts b/server/src/lib/http.ts new file mode 100644 index 0000000..b15c640 --- /dev/null +++ b/server/src/lib/http.ts @@ -0,0 +1,42 @@ +/** Gemeinsamer HTTP-Helfer: Fehler mit Status + Retry mit Backoff */ + +export class HttpError extends Error { + constructor(message: string, readonly status?: number) { + super(message); + this.name = 'HttpError'; + } +} + +export interface RetryOptions { + attempts?: number; + baseDelayMs?: number; + /** Entscheidet, ob nach einem Fehler erneut versucht wird (Default: keine 4xx-Retries) */ + shouldRetry?: (err: unknown) => boolean; +} + +export function defaultShouldRetry(err: unknown): boolean { + if (err instanceof HttpError && err.status !== undefined) { + // 4xx (inkl. 429 Rate Limit) nicht wiederholen — das macht es nur schlimmer + return err.status >= 500; + } + // Netzwerkfehler / Timeouts → Retry + return true; +} + +export async function withRetry(fn: () => Promise, opts: RetryOptions = {}): Promise { + const attempts = Math.max(1, opts.attempts ?? 3); + const baseDelayMs = opts.baseDelayMs ?? 500; + const shouldRetry = opts.shouldRetry ?? defaultShouldRetry; + + let lastErr: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (i === attempts - 1 || !shouldRetry(err)) break; + await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** i)); + } + } + throw lastErr; +} diff --git a/server/src/lib/snapshotStore.ts b/server/src/lib/snapshotStore.ts new file mode 100644 index 0000000..de69381 --- /dev/null +++ b/server/src/lib/snapshotStore.ts @@ -0,0 +1,57 @@ +/** + * Persistiert die zuletzt erfolgreichen API-Antworten auf Disk, + * damit nach einem Neustart (Deploy) sofort Daten da sind statt 503. + */ +import { mkdirSync, readFileSync, renameSync, writeFileSync, readdirSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_DIR = path.resolve(__dirname, '../../.cache'); + +interface SnapshotFile { + savedAt: string; + data: T; +} + +export interface Snapshot { + savedAt: string; + data: T; +} + +function fileFor(dir: string, key: string): string { + const safe = key.replace(/[^a-zA-Z0-9_-]/g, '_'); + return path.join(dir, `${safe}.json`); +} + +export function saveSnapshot(key: string, data: T, dir: string = DEFAULT_DIR): void { + try { + mkdirSync(dir, { recursive: true }); + const file = fileFor(dir, key); + const tmp = `${file}.tmp`; + const payload: SnapshotFile = { savedAt: new Date().toISOString(), data }; + writeFileSync(tmp, JSON.stringify(payload)); + renameSync(tmp, file); + } catch (err) { + console.warn(`Snapshot save failed (${key}):`, err instanceof Error ? err.message : err); + } +} + +export function loadSnapshot(key: string, dir: string = DEFAULT_DIR): Snapshot | null { + try { + const raw = readFileSync(fileFor(dir, key), 'utf8'); + const parsed = JSON.parse(raw) as SnapshotFile; + if (!parsed || typeof parsed.savedAt !== 'string' || parsed.data === undefined) return null; + return { savedAt: parsed.savedAt, data: parsed.data }; + } catch { + return null; + } +} + +export function listSnapshots(dir: string = DEFAULT_DIR): string[] { + try { + return readdirSync(dir).filter((f) => f.endsWith('.json')); + } catch { + return []; + } +} diff --git a/server/src/services/cmcService.ts b/server/src/services/cmcService.ts index 7d2e578..deae015 100644 --- a/server/src/services/cmcService.ts +++ b/server/src/services/cmcService.ts @@ -1,4 +1,5 @@ import type { CmcCoin } from '../types/cmc.js'; +import { HttpError, withRetry } from '../lib/http.js'; const CMC_BASE = 'https://pro-api.coinmarketcap.com'; const BATCH = 200; @@ -31,7 +32,7 @@ function getApiKey(): string { return key; } -async function fetchListingsPage(start: number): Promise { +async function fetchListingsPageOnce(start: number): Promise { const url = `${CMC_BASE}/v1/cryptocurrency/listings/latest?start=${start}&limit=${BATCH}&convert=USD&sort=market_cap&sort_dir=desc`; const res = await fetch(url, { headers: { @@ -41,16 +42,20 @@ async function fetchListingsPage(start: number): Promise { signal: AbortSignal.timeout(25000), }); - if (res.status === 429) throw new Error('CoinMarketCap Rate Limit — bitte später erneut versuchen'); + if (res.status === 429) throw new HttpError('CoinMarketCap Rate Limit — bitte später erneut versuchen', 429); if (!res.ok) { const body = await res.text(); - throw new Error(`CoinMarketCap ${res.status}: ${body.slice(0, 120)}`); + throw new HttpError(`CoinMarketCap ${res.status}: ${body.slice(0, 120)}`, res.status); } const json = (await res.json()) as { data: CmcListingRow[] }; return json.data ?? []; } +async function fetchListingsPage(start: number): Promise { + return withRetry(() => fetchListingsPageOnce(start), { attempts: 2, baseDelayMs: 1000 }); +} + function cmcLogoUrl(id: number): string { return `https://s2.coinmarketcap.com/static/img/coins/64x64/${id}.png`; } diff --git a/server/src/services/coingeckoClient.ts b/server/src/services/coingeckoClient.ts index 6a03e79..7a19861 100644 --- a/server/src/services/coingeckoClient.ts +++ b/server/src/services/coingeckoClient.ts @@ -1,4 +1,5 @@ /** CoinGecko API — mit Demo/Pro Key aus COINGECKO_API_KEY */ +import { HttpError, withRetry } from '../lib/http.js'; export function coingeckoHeaders(): HeadersInit { const headers: Record = { Accept: 'application/json' }; @@ -9,12 +10,12 @@ export function coingeckoHeaders(): HeadersInit { return headers; } -export async function coingeckoFetch(url: string, timeoutMs = 20000): Promise { +async function coingeckoFetchOnce(url: string, timeoutMs: number): Promise { const res = await fetch(url, { headers: coingeckoHeaders(), signal: AbortSignal.timeout(timeoutMs), }); - if (res.status === 429) throw new Error('CoinGecko Rate Limit'); + if (res.status === 429) throw new HttpError('CoinGecko Rate Limit', 429); if (!res.ok) { let detail = ''; try { @@ -23,7 +24,11 @@ export async function coingeckoFetch(url: string, timeoutMs = 20000): Promise } catch { /* ignore */ } - throw new Error(detail ? `CoinGecko: ${detail}` : `CoinGecko ${res.status}`); + throw new HttpError(detail ? `CoinGecko: ${detail}` : `CoinGecko ${res.status}`, res.status); } return res.json() as Promise; } + +export async function coingeckoFetch(url: string, timeoutMs = 20000): Promise { + return withRetry(() => coingeckoFetchOnce(url, timeoutMs), { attempts: 3, baseDelayMs: 600 }); +} diff --git a/server/src/services/coinsFallbackService.ts b/server/src/services/coinsFallbackService.ts new file mode 100644 index 0000000..b3be711 --- /dev/null +++ b/server/src/services/coinsFallbackService.ts @@ -0,0 +1,72 @@ +/** + * Fallback für die Coin-Liste: Wenn CoinMarketCap nicht erreichbar ist + * (kein Key, Rate Limit, Ausfall), liefern wir dieselbe Datenform aus CoinGecko. + */ +import type { CmcCoin } from '../types/cmc.js'; +import { coingeckoFetch } from './coingeckoClient.js'; + +export interface GeckoMarketRow { + id: string; + symbol: string; + name: string; + image: string; + current_price: number | null; + market_cap: number | null; + market_cap_rank: number | null; + total_volume: number | null; + circulating_supply: number | null; + max_supply: number | null; + last_updated: string | null; + price_change_percentage_24h_in_currency?: number | null; + price_change_percentage_7d_in_currency?: number | null; + price_change_percentage_30d_in_currency?: number | null; +} + +/** Stabile numerische Pseudo-ID aus dem CoinGecko-String-Slug (CmcCoin.id ist number) */ +export function numericIdFromSlug(slug: string): number { + let hash = 0; + for (let i = 0; i < slug.length; i++) { + hash = (hash * 31 + slug.charCodeAt(i)) | 0; + } + // Negativ vermeiden und Kollision mit echten CMC-IDs (klein) unwahrscheinlich machen + return 1_000_000_000 + Math.abs(hash); +} + +export function mapGeckoToCmcCoin(row: GeckoMarketRow, fallbackRank: number): CmcCoin { + return { + id: numericIdFromSlug(row.id), + rank: row.market_cap_rank ?? fallbackRank, + name: row.name, + symbol: (row.symbol || '').toUpperCase(), + slug: row.id, + imageUrl: row.image || '', + price: row.current_price ?? 0, + marketCap: row.market_cap ?? 0, + volume24h: row.total_volume ?? 0, + change24h: row.price_change_percentage_24h_in_currency ?? null, + change7d: row.price_change_percentage_7d_in_currency ?? null, + change30d: row.price_change_percentage_30d_in_currency ?? null, + circulatingSupply: row.circulating_supply ?? 0, + maxSupply: row.max_supply, + lastUpdated: row.last_updated ?? new Date().toISOString(), + }; +} + +const PER_PAGE = 250; + +export async function fetchCoinsFromCoinGecko(maxCoins = 500): Promise { + const pages = Math.max(1, Math.ceil(maxCoins / PER_PAGE)); + const all: CmcCoin[] = []; + + for (let page = 1; page <= pages; page++) { + const url = + `https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc` + + `&per_page=${PER_PAGE}&page=${page}&sparkline=false&price_change_percentage=24h,7d,30d`; + const rows = await coingeckoFetch(url); + all.push(...rows.map((r, i) => mapGeckoToCmcCoin(r, (page - 1) * PER_PAGE + i + 1))); + if (rows.length < PER_PAGE) break; + if (page < pages) await new Promise((r) => setTimeout(r, 500)); + } + + return all.slice(0, maxCoins); +} diff --git a/server/src/services/fearGreedService.ts b/server/src/services/fearGreedService.ts index 9929170..f6a0c77 100644 --- a/server/src/services/fearGreedService.ts +++ b/server/src/services/fearGreedService.ts @@ -1,7 +1,9 @@ import type { FearGreedResponse, FearGreedSnapshot, MarketContext } from '../types/index.js'; import { coingeckoFetch } from './coingeckoClient.js'; +import { HttpError, withRetry } from '../lib/http.js'; const ALT_URL = 'https://api.alternative.me/fng/?limit=90'; +const CMC_FNG_URL = 'https://pro-api.coinmarketcap.com/v3/fear-and-greed/historical?limit=90'; const CG_URL = 'https://api.coingecko.com/api/v3/global'; const CG_PRICE = 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd&include_24hr_change=true'; @@ -14,7 +16,7 @@ function classify(value: number): string { return 'Extreme Greed'; } -function buildComponents(value: number, market: MarketContext): Record { +export function buildComponents(value: number, market: MarketContext): Record { const momentum = Math.min(100, Math.max(0, 50 + market.btcChange24h * 3)); const volatility = Math.min(100, Math.max(0, 100 - Math.abs(market.btcChange24h) * 4)); const dominance = Math.min(100, Math.max(0, market.btcDominance)); @@ -30,18 +32,71 @@ function buildComponents(value: number, market: MarketContext): Record { - const res = await fetch(ALT_URL, { signal: AbortSignal.timeout(15000) }); - if (!res.ok) throw new Error('F&G API failed'); - const json = (await res.json()) as { - data: { value: string; value_classification: string; timestamp: string }[]; +/** + * Notfall-Index, wenn beide F&G-APIs ausfallen: aus Marktdaten abgeleitet + * (Momentum, Volatilität, Market-Cap-Trend) — als 'composite' gekennzeichnet. + */ +export function deriveCompositeSnapshot(market: MarketContext): FearGreedSnapshot { + const momentum = Math.min(100, Math.max(0, 50 + market.btcChange24h * 3)); + const volatility = Math.min(100, Math.max(0, 100 - Math.abs(market.btcChange24h) * 4)); + const capTrend = Math.min(100, Math.max(0, 50 + market.marketCapChange24h * 2)); + const value = Math.round(momentum * 0.5 + volatility * 0.2 + capTrend * 0.3); + return { + timestamp: new Date().toISOString(), + value, + classification: classify(value), + source: 'composite', }; - return json.data.map((d) => ({ - timestamp: new Date(Number(d.timestamp) * 1000).toISOString(), - value: Number(d.value), - classification: d.value_classification, - source: 'alternative.me' as const, - })); +} + +async function fetchAlternativeMe(): Promise { + return withRetry(async () => { + const res = await fetch(ALT_URL, { signal: AbortSignal.timeout(15000) }); + if (!res.ok) throw new HttpError(`Alternative.me ${res.status}`, res.status); + const json = (await res.json()) as { + data: { value: string; value_classification: string; timestamp: string }[]; + }; + if (!json.data?.length) throw new Error('Alternative.me: leere Antwort'); + return json.data.map((d) => ({ + timestamp: new Date(Number(d.timestamp) * 1000).toISOString(), + value: Number(d.value), + classification: d.value_classification, + source: 'alternative.me' as const, + })); + }, { attempts: 2, baseDelayMs: 800 }); +} + +function parseCmcTimestamp(ts: string | number): string { + if (typeof ts === 'number' || /^\d+$/.test(String(ts))) { + return new Date(Number(ts) * 1000).toISOString(); + } + const parsed = new Date(ts); + return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString(); +} + +/** Fallback: CoinMarketCap Fear & Greed (gleicher CMC_API_KEY wie die Coin-Liste) */ +async function fetchCmcFearGreed(): Promise { + const key = process.env.CMC_API_KEY; + if (!key) throw new Error('CMC_API_KEY fehlt — kein CMC F&G Fallback möglich'); + return withRetry(async () => { + const res = await fetch(CMC_FNG_URL, { + headers: { 'X-CMC_PRO_API_KEY': key, Accept: 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) throw new HttpError(`CMC F&G ${res.status}`, res.status); + const json = (await res.json()) as { + data: { timestamp: string | number; value: number; value_classification?: string }[]; + }; + if (!json.data?.length) throw new Error('CMC F&G: leere Antwort'); + return json.data + .map((d) => ({ + timestamp: parseCmcTimestamp(d.timestamp), + value: Number(d.value), + classification: d.value_classification ?? classify(Number(d.value)), + source: 'coinmarketcap' as const, + })) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + }, { attempts: 2, baseDelayMs: 800 }); } const emptyMarket = (): MarketContext => ({ @@ -85,8 +140,27 @@ async function fetchMarket(): Promise { } } +async function fetchHistoryWithFallback(market: MarketContext): Promise { + try { + return await fetchAlternativeMe(); + } catch (err) { + console.warn('Alternative.me nicht erreichbar, versuche CMC:', err instanceof Error ? err.message : err); + } + try { + return await fetchCmcFearGreed(); + } catch (err) { + console.warn('CMC F&G nicht erreichbar:', err instanceof Error ? err.message : err); + } + // Letzte Rettung: aus Marktdaten ableiten (nur aktueller Wert, keine Historie) + if (market.totalMarketCap > 0) { + return [deriveCompositeSnapshot(market)]; + } + throw new Error('Fear & Greed: alle Quellen nicht erreichbar'); +} + export async function fetchFearGreedData(): Promise { - const [history, market] = await Promise.all([fetchAlternativeMe(), fetchMarket()]); + const market = await fetchMarket(); + const history = await fetchHistoryWithFallback(market); const current = history[0]; const yesterday = history[1] ?? null; const components = buildComponents(current.value, market); diff --git a/server/src/services/marketFallbackService.ts b/server/src/services/marketFallbackService.ts new file mode 100644 index 0000000..ef80b53 --- /dev/null +++ b/server/src/services/marketFallbackService.ts @@ -0,0 +1,86 @@ +/** + * Fallback für den Markets-Überblick: Wenn CoinGecko ausfällt, bauen wir + * dieselbe Datenform aus den bereits gecachten CoinMarketCap-Coins. + */ +import type { CoinRow, GlobalMarketStats, MarketOverviewResponse } from '../types/market.js'; +import type { CmcCoin } from '../types/cmc.js'; +import { calcAltcoinSeason, calcAverageCrypto } from './marketService.js'; + +export function cmcToCoinRow(c: CmcCoin): CoinRow { + return { + id: c.slug, + symbol: c.symbol, + name: c.name, + image: c.imageUrl, + price: c.price, + marketCap: c.marketCap, + volume24h: c.volume24h, + change24h: c.change24h, + change7d: c.change7d, + change30d: c.change30d, + change90d: null, // CMC-Listings liefern keine 90d-Änderung + rank: c.rank, + }; +} + +export function buildMarketOverviewFromCmc(cmcCoins: CmcCoin[]): MarketOverviewResponse { + const coins = cmcCoins + .slice() + .sort((a, b) => a.rank - b.rank) + .slice(0, 100) + .map(cmcToCoinRow); + + const totalMarketCap = cmcCoins.reduce((s, c) => s + c.marketCap, 0); + const totalVolume24h = cmcCoins.reduce((s, c) => s + c.volume24h, 0); + const btc = coins.find((c) => c.symbol === 'BTC'); + const eth = coins.find((c) => c.symbol === 'ETH'); + + // Market-Cap-gewichtete 24h-Änderung als Näherung für die globale Veränderung + let capChangeWeighted = 0; + let capWeight = 0; + for (const c of coins) { + if (c.change24h !== null && c.marketCap > 0) { + capChangeWeighted += c.change24h * c.marketCap; + capWeight += c.marketCap; + } + } + + const global: GlobalMarketStats = { + totalMarketCap, + totalVolume24h, + marketCapChange24h: capWeight ? capChangeWeighted / capWeight : 0, + btcDominance: totalMarketCap && btc ? (btc.marketCap / totalMarketCap) * 100 : 0, + ethDominance: totalMarketCap && eth ? (eth.marketCap / totalMarketCap) * 100 : 0, + activeCryptos: cmcCoins.length, + markets: 0, + defiMarketCap: 0, + defiChange24h: 0, + stablecoinMarketCap: 0, + stablecoinChange24h: 0, + }; + + const withChange = coins.filter((c) => c.change24h !== null); + const gainers = [...withChange].sort((a, b) => (b.change24h ?? 0) - (a.change24h ?? 0)).slice(0, 8); + const losers = [...withChange].sort((a, b) => (a.change24h ?? 0) - (b.change24h ?? 0)).slice(0, 8); + + return { + global, + topCoins: coins.slice(0, 50), + gainers, + losers, + categories: [], // Kategorien gibt es nur über CoinGecko + altcoinSeason: calcAltcoinSeason(coins, 'change30d'), + averageCrypto: calcAverageCrypto(coins), + dominanceChart: [ + { name: 'Bitcoin', value: global.btcDominance, color: '#f7931a' }, + { name: 'Ethereum', value: global.ethDominance, color: '#627eea' }, + { + name: 'Andere', + value: Math.max(0, 100 - global.btcDominance - global.ethDominance), + color: '#22d3ee', + }, + ], + sources: ['CoinMarketCap (Fallback — CoinGecko nicht erreichbar)'], + lastUpdated: new Date().toISOString(), + }; +} diff --git a/server/src/services/marketService.ts b/server/src/services/marketService.ts index 0b90722..10f2233 100644 --- a/server/src/services/marketService.ts +++ b/server/src/services/marketService.ts @@ -49,19 +49,22 @@ function mapCoin(c: CGMarket): CoinRow { }; } -function isStable(coin: CoinRow): boolean { +export function isStable(coin: CoinRow): boolean { return STABLE_SYMBOLS.has(coin.symbol.toLowerCase()) || coin.name.toLowerCase().includes('stable'); } -function calcAltcoinSeason(coins: CoinRow[]): AltcoinSeasonData { +export function calcAltcoinSeason( + coins: CoinRow[], + field: 'change90d' | 'change30d' = 'change90d', +): AltcoinSeasonData { const btc = coins.find((c) => c.symbol === 'BTC'); - const btc90 = btc?.change90d ?? 0; + const btcChange = btc?.[field] ?? 0; const alts = coins - .filter((c) => c.symbol !== 'BTC' && c.symbol !== 'ETH' && !isStable(c) && c.change90d !== null) + .filter((c) => c.symbol !== 'BTC' && c.symbol !== 'ETH' && !isStable(c) && c[field] !== null) .slice(0, 50); - const outperforming = alts.filter((c) => (c.change90d ?? 0) > btc90).length; + const outperforming = alts.filter((c) => (c[field] ?? 0) > btcChange).length; const sampleSize = alts.length || 1; const index = Math.round((outperforming / Math.min(50, sampleSize)) * 100); @@ -69,18 +72,18 @@ function calcAltcoinSeason(coins: CoinRow[]): AltcoinSeasonData { if (index >= 75) classification = 'Altcoin Season'; else if (index <= 25) classification = 'Bitcoin Season'; + const horizon = field === 'change90d' ? '90-Tage' : '30-Tage'; return { index, classification, outperformingCount: outperforming, sampleSize: Math.min(50, sampleSize), - btcChange90d: btc90, - description: - 'Misst, wie viele der Top-Altcoins Bitcoin auf 90-Tage-Sicht outperformen (CMC/Blockchaincenter-Methodik).', + btcChange90d: btcChange, + description: `Misst, wie viele der Top-Altcoins Bitcoin auf ${horizon}-Sicht outperformen (CMC/Blockchaincenter-Methodik).`, }; } -function calcAverageCrypto(coins: CoinRow[]): AverageCryptoIndex { +export function calcAverageCrypto(coins: CoinRow[]): AverageCryptoIndex { const top = coins.filter((c) => !isStable(c)).slice(0, 50); const totalCap = top.reduce((s, c) => s + c.marketCap, 0) || 1; diff --git a/server/src/services/newsAggregator.ts b/server/src/services/newsAggregator.ts index ba34d14..8aa268a 100644 --- a/server/src/services/newsAggregator.ts +++ b/server/src/services/newsAggregator.ts @@ -51,11 +51,11 @@ function extractImage(item: Record): string | undefined { return undefined; } -function normalizeTitle(title: string): string { +export function normalizeTitle(title: string): string { return title.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim(); } -function titleSimilarity(a: string, b: string): number { +export function titleSimilarity(a: string, b: string): number { const wa = new Set(normalizeTitle(a).split(' ')); const wb = new Set(normalizeTitle(b).split(' ')); if (wa.size === 0 || wb.size === 0) return 0; @@ -64,7 +64,7 @@ function titleSimilarity(a: string, b: string): number { return inter / Math.max(wa.size, wb.size); } -function categorize(title: string, summary: string, base: string[]): string[] { +export function categorize(title: string, summary: string, base: string[]): string[] { const text = `${title} ${summary}`.toLowerCase(); const cats = new Set(base); if (/bitcoin|btc/.test(text)) cats.add('bitcoin'); @@ -75,7 +75,7 @@ function categorize(title: string, summary: string, base: string[]): string[] { return [...cats]; } -function inferSentiment(title: string, summary: string): 'bullish' | 'bearish' | 'neutral' { +export function inferSentiment(title: string, summary: string): 'bullish' | 'bearish' | 'neutral' { const text = `${title} ${summary}`.toLowerCase(); const bull = /surge|rally|record high|approval|adoption|bull|gain|soar|breakout/.test(text); const bear = /crash|hack|ban|fraud|bear|drop|plunge|collapse|lawsuit|sec charge/.test(text); @@ -97,7 +97,7 @@ async function fetchFeed(config: FeedConfig): Promise { id: hashId(url), title, summary: summary || title.slice(0, 200), - imageUrl: extractImage(item as Record), + imageUrl: extractImage(item as unknown as Record), source: config.source, sourceId: config.sourceId, url, @@ -112,7 +112,7 @@ async function fetchFeed(config: FeedConfig): Promise { } } -function deduplicate(articles: NewsArticle[]): NewsArticle[] { +export function deduplicate(articles: NewsArticle[]): NewsArticle[] { const seen = new Map(); const sorted = [...articles].sort( (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 0af2ffa..0446462 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -24,7 +24,7 @@ export interface FearGreedSnapshot { timestamp: string; value: number; classification: string; - source: 'alternative.me' | 'composite'; + source: 'alternative.me' | 'coinmarketcap' | 'composite'; } export interface FearGreedHistory { diff --git a/server/test/fallbacks.test.ts b/server/test/fallbacks.test.ts new file mode 100644 index 0000000..dd48694 --- /dev/null +++ b/server/test/fallbacks.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { mapGeckoToCmcCoin, numericIdFromSlug, type GeckoMarketRow } from '../src/services/coinsFallbackService.js'; +import { buildMarketOverviewFromCmc, cmcToCoinRow } from '../src/services/marketFallbackService.js'; +import { filterAndPaginateCoins } from '../src/services/cmcService.js'; +import type { CmcCoin } from '../src/types/cmc.js'; + +function geckoRow(overrides: Partial = {}): GeckoMarketRow { + return { + id: 'bitcoin', + symbol: 'btc', + name: 'Bitcoin', + image: 'https://assets.coingecko.com/coins/images/1/large/bitcoin.png', + current_price: 100000, + market_cap: 2e12, + market_cap_rank: 1, + total_volume: 5e10, + circulating_supply: 19_800_000, + max_supply: 21_000_000, + last_updated: '2026-07-03T12:00:00.000Z', + price_change_percentage_24h_in_currency: 1.5, + price_change_percentage_7d_in_currency: -2.3, + price_change_percentage_30d_in_currency: 10.1, + ...overrides, + }; +} + +function cmcCoin(overrides: Partial = {}): CmcCoin { + return { + id: 1, + rank: 1, + name: 'Bitcoin', + symbol: 'BTC', + slug: 'bitcoin', + imageUrl: 'https://s2.coinmarketcap.com/static/img/coins/64x64/1.png', + price: 100000, + marketCap: 2e12, + volume24h: 5e10, + change24h: 1.5, + change7d: -2.3, + change30d: 10.1, + circulatingSupply: 19_800_000, + maxSupply: 21_000_000, + lastUpdated: '2026-07-03T12:00:00.000Z', + ...overrides, + }; +} + +describe('numericIdFromSlug', () => { + it('ist stabil und kollidiert nicht mit kleinen CMC-IDs', () => { + expect(numericIdFromSlug('bitcoin')).toBe(numericIdFromSlug('bitcoin')); + expect(numericIdFromSlug('bitcoin')).not.toBe(numericIdFromSlug('ethereum')); + expect(numericIdFromSlug('bitcoin')).toBeGreaterThanOrEqual(1_000_000_000); + }); +}); + +describe('mapGeckoToCmcCoin', () => { + it('mappt eine CoinGecko-Zeile in die CmcCoin-Form', () => { + const coin = mapGeckoToCmcCoin(geckoRow(), 1); + expect(coin.symbol).toBe('BTC'); + expect(coin.slug).toBe('bitcoin'); + expect(coin.rank).toBe(1); + expect(coin.price).toBe(100000); + expect(coin.change7d).toBe(-2.3); + expect(coin.maxSupply).toBe(21_000_000); + }); + + it('kommt mit fehlenden Werten klar', () => { + const coin = mapGeckoToCmcCoin( + geckoRow({ + current_price: null, + market_cap: null, + market_cap_rank: null, + price_change_percentage_24h_in_currency: null, + last_updated: null, + }), + 7, + ); + expect(coin.price).toBe(0); + expect(coin.marketCap).toBe(0); + expect(coin.rank).toBe(7); + expect(coin.change24h).toBeNull(); + expect(new Date(coin.lastUpdated).getTime()).toBeGreaterThan(0); + }); +}); + +describe('buildMarketOverviewFromCmc', () => { + const coins = [ + cmcCoin(), + cmcCoin({ id: 2, rank: 2, name: 'Ethereum', symbol: 'ETH', slug: 'ethereum', marketCap: 5e11, change24h: -1.2 }), + cmcCoin({ id: 3, rank: 3, name: 'Solana', symbol: 'SOL', slug: 'solana', marketCap: 1e11, change24h: 5.5 }), + cmcCoin({ id: 4, rank: 4, name: 'Tether', symbol: 'USDT', slug: 'tether', marketCap: 1.5e11, change24h: 0 }), + ]; + + it('baut einen vollständigen Markets-Überblick', () => { + const overview = buildMarketOverviewFromCmc(coins); + expect(overview.topCoins.length).toBeGreaterThan(0); + expect(overview.global.totalMarketCap).toBeCloseTo(2e12 + 5e11 + 1e11 + 1.5e11); + expect(overview.global.btcDominance).toBeGreaterThan(50); + expect(overview.gainers[0].symbol).toBe('SOL'); + expect(overview.losers[0].symbol).toBe('ETH'); + expect(overview.sources[0]).toContain('CoinMarketCap'); + expect(overview.altcoinSeason.index).toBeGreaterThanOrEqual(0); + expect(overview.averageCrypto.score).toBeGreaterThanOrEqual(0); + }); + + it('dominanceChart summiert sich auf 100', () => { + const overview = buildMarketOverviewFromCmc(coins); + const sum = overview.dominanceChart.reduce((s, d) => s + d.value, 0); + expect(sum).toBeCloseTo(100, 5); + }); +}); + +describe('cmcToCoinRow', () => { + it('mappt CmcCoin in die CoinRow-Form (ohne 90d)', () => { + const row = cmcToCoinRow(cmcCoin()); + expect(row.id).toBe('bitcoin'); + expect(row.change90d).toBeNull(); + expect(row.image).toContain('coinmarketcap.com'); + }); +}); + +describe('filterAndPaginateCoins', () => { + const coins = Array.from({ length: 25 }, (_, i) => + cmcCoin({ id: i + 1, rank: i + 1, name: `Coin${i + 1}`, symbol: `C${i + 1}`, slug: `coin-${i + 1}` }), + ); + + it('paginiert korrekt', () => { + const { items, total } = filterAndPaginateCoins(coins, { page: 2, limit: 10 }); + expect(total).toBe(25); + expect(items).toHaveLength(10); + expect(items[0].rank).toBe(11); + }); + + it('filtert per Suche über Name/Symbol/Slug', () => { + const { items, total } = filterAndPaginateCoins(coins, { page: 1, limit: 10, search: 'coin-2' }); + expect(total).toBeGreaterThanOrEqual(1); + expect(items.every((c) => c.slug.includes('coin-2'))).toBe(true); + }); +}); diff --git a/server/test/fearGreedService.test.ts b/server/test/fearGreedService.test.ts new file mode 100644 index 0000000..6750847 --- /dev/null +++ b/server/test/fearGreedService.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { buildComponents, classify, deriveCompositeSnapshot } from '../src/services/fearGreedService.js'; +import type { MarketContext } from '../src/types/index.js'; + +function market(overrides: Partial = {}): MarketContext { + return { + btcPrice: 100000, + btcChange24h: 0, + ethPrice: 4000, + ethChange24h: 0, + totalMarketCap: 4e12, + marketCapChange24h: 0, + btcDominance: 55, + lastUpdated: new Date().toISOString(), + ...overrides, + }; +} + +describe('classify', () => { + it('ordnet Werte den richtigen Labels zu', () => { + expect(classify(0)).toBe('Extreme Fear'); + expect(classify(24)).toBe('Extreme Fear'); + expect(classify(25)).toBe('Fear'); + expect(classify(50)).toBe('Neutral'); + expect(classify(60)).toBe('Greed'); + expect(classify(75)).toBe('Extreme Greed'); + expect(classify(100)).toBe('Extreme Greed'); + }); +}); + +describe('buildComponents', () => { + it('liefert alle Komponenten in 0–100', () => { + const comps = buildComponents(50, market({ btcChange24h: 3, marketCapChange24h: 2 })); + for (const [name, value] of Object.entries(comps)) { + expect(value, name).toBeGreaterThanOrEqual(0); + expect(value, name).toBeLessThanOrEqual(100); + } + expect(comps['Index (Alternative.me)']).toBe(50); + expect(comps['TokenSync Composite']).toBeTypeOf('number'); + }); + + it('klemmt Extremwerte auf die Grenzen', () => { + const comps = buildComponents(100, market({ btcChange24h: 50, marketCapChange24h: 50 })); + expect(comps.Momentum).toBe(100); + expect(comps.Volatility).toBe(0); + expect(comps['Market Cap Trend']).toBe(100); + }); +}); + +describe('deriveCompositeSnapshot', () => { + it('erzeugt einen gültigen Snapshot aus Marktdaten', () => { + const snap = deriveCompositeSnapshot(market({ btcChange24h: 2, marketCapChange24h: 1 })); + expect(snap.source).toBe('composite'); + expect(snap.value).toBeGreaterThanOrEqual(0); + expect(snap.value).toBeLessThanOrEqual(100); + expect(snap.classification).toBe(classify(snap.value)); + }); + + it('neutraler Markt ergibt einen mittleren Wert', () => { + const snap = deriveCompositeSnapshot(market()); + expect(snap.value).toBeGreaterThanOrEqual(40); + expect(snap.value).toBeLessThanOrEqual(75); + }); +}); diff --git a/server/test/http.test.ts b/server/test/http.test.ts new file mode 100644 index 0000000..c086abb --- /dev/null +++ b/server/test/http.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { HttpError, defaultShouldRetry, withRetry } from '../src/lib/http.js'; + +describe('withRetry', () => { + it('gibt das Ergebnis beim ersten Erfolg zurück', async () => { + const fn = vi.fn().mockResolvedValue(42); + await expect(withRetry(fn)).resolves.toBe(42); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('wiederholt bei Netzwerkfehlern und liefert dann das Ergebnis', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('ECONNRESET')) + .mockResolvedValueOnce('ok'); + await expect(withRetry(fn, { baseDelayMs: 1 })).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('wirft nach Ausschöpfen aller Versuche den letzten Fehler', async () => { + const fn = vi.fn().mockRejectedValue(new Error('down')); + await expect(withRetry(fn, { attempts: 3, baseDelayMs: 1 })).rejects.toThrow('down'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('wiederholt NICHT bei 4xx-Fehlern (z. B. 429 Rate Limit)', async () => { + const fn = vi.fn().mockRejectedValue(new HttpError('Rate Limit', 429)); + await expect(withRetry(fn, { attempts: 3, baseDelayMs: 1 })).rejects.toThrow('Rate Limit'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('wiederholt bei 5xx-Fehlern', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new HttpError('Server Error', 503)) + .mockResolvedValueOnce('recovered'); + await expect(withRetry(fn, { baseDelayMs: 1 })).resolves.toBe('recovered'); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); + +describe('defaultShouldRetry', () => { + it('klassifiziert Fehler korrekt', () => { + expect(defaultShouldRetry(new HttpError('x', 429))).toBe(false); + expect(defaultShouldRetry(new HttpError('x', 404))).toBe(false); + expect(defaultShouldRetry(new HttpError('x', 500))).toBe(true); + expect(defaultShouldRetry(new HttpError('x'))).toBe(true); + expect(defaultShouldRetry(new Error('timeout'))).toBe(true); + }); +}); diff --git a/server/test/newsAggregator.test.ts b/server/test/newsAggregator.test.ts new file mode 100644 index 0000000..f4bd7ac --- /dev/null +++ b/server/test/newsAggregator.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { + categorize, + deduplicate, + inferSentiment, + titleSimilarity, +} from '../src/services/newsAggregator.js'; +import type { NewsArticle } from '../src/types/index.js'; + +function article(overrides: Partial): NewsArticle { + return { + id: Math.random().toString(36).slice(2), + title: 'Titel', + summary: 'Zusammenfassung', + source: 'Test', + sourceId: 'test', + url: `https://example.com/${Math.random().toString(36).slice(2)}`, + publishedAt: new Date().toISOString(), + categories: [], + tags: [], + ...overrides, + }; +} + +describe('titleSimilarity', () => { + it('erkennt identische Titel', () => { + expect(titleSimilarity('Bitcoin hits new high', 'Bitcoin hits new high')).toBe(1); + }); + + it('erkennt sehr ähnliche Titel (Interpunktion/Groß-Klein egal)', () => { + const sim = titleSimilarity( + 'Bitcoin Hits New Record High Above $100k', + 'bitcoin hits new record high above $100k!', + ); + expect(sim).toBeGreaterThan(0.82); + }); + + it('unterscheidet verschiedene Titel', () => { + const sim = titleSimilarity('Ethereum upgrade delayed again', 'SEC sues major exchange'); + expect(sim).toBeLessThan(0.5); + }); +}); + +describe('deduplicate', () => { + it('entfernt Artikel mit gleicher URL', () => { + const a = article({ url: 'https://example.com/same' }); + const b = article({ url: 'https://example.com/same', title: 'Ganz anderer Titel hier' }); + expect(deduplicate([a, b])).toHaveLength(1); + }); + + it('entfernt Artikel mit fast identischem Titel', () => { + const a = article({ title: 'Bitcoin surges past 100k as ETF inflows accelerate' }); + const b = article({ title: 'Bitcoin surges past 100k as ETF inflows accelerate!' }); + expect(deduplicate([a, b])).toHaveLength(1); + }); + + it('behält unterschiedliche Artikel', () => { + const a = article({ title: 'Ethereum staking rewards drop sharply' }); + const b = article({ title: 'Solana network activity reaches record levels' }); + expect(deduplicate([a, b])).toHaveLength(2); + }); + + it('behält den neuesten Artikel eines Duplikat-Paars', () => { + const older = article({ + title: 'Bitcoin surges past 100k as ETF inflows accelerate', + publishedAt: '2026-01-01T00:00:00.000Z', + }); + const newer = article({ + title: 'Bitcoin surges past 100k as ETF inflows accelerate!', + publishedAt: '2026-01-02T00:00:00.000Z', + }); + const result = deduplicate([older, newer]); + expect(result).toHaveLength(1); + expect(result[0].publishedAt).toBe(newer.publishedAt); + }); +}); + +describe('categorize', () => { + it('erkennt Bitcoin-Themen', () => { + expect(categorize('BTC rally continues', '', ['crypto'])).toContain('bitcoin'); + }); + + it('erkennt Regulierungs-Themen', () => { + expect(categorize('SEC announces new rules', '', [])).toContain('regulation'); + }); + + it('behält Basis-Kategorien', () => { + expect(categorize('Some title', '', ['crypto', 'markets'])).toEqual( + expect.arrayContaining(['crypto', 'markets']), + ); + }); +}); + +describe('inferSentiment', () => { + it('bullish bei positiven Signalwörtern', () => { + expect(inferSentiment('Bitcoin surge to record high', '')).toBe('bullish'); + }); + + it('bearish bei negativen Signalwörtern', () => { + expect(inferSentiment('Exchange hack causes crash', '')).toBe('bearish'); + }); + + it('neutral bei gemischten oder fehlenden Signalen', () => { + expect(inferSentiment('Rally ends in crash', '')).toBe('neutral'); + expect(inferSentiment('Weekly market report', '')).toBe('neutral'); + }); +}); diff --git a/server/test/snapshotStore.test.ts b/server/test/snapshotStore.test.ts new file mode 100644 index 0000000..2d9caca --- /dev/null +++ b/server/test/snapshotStore.test.ts @@ -0,0 +1,29 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { loadSnapshot, saveSnapshot } from '../src/lib/snapshotStore.js'; + +const dir = mkdtempSync(path.join(tmpdir(), 'tokensync-snap-')); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe('snapshotStore', () => { + it('speichert und lädt Daten (Roundtrip)', () => { + const data = { coins: [{ symbol: 'BTC', price: 100000 }], total: 1 }; + saveSnapshot('testKey', data, dir); + const loaded = loadSnapshot('testKey', dir); + expect(loaded).not.toBeNull(); + expect(loaded!.data).toEqual(data); + expect(new Date(loaded!.savedAt).getTime()).toBeGreaterThan(0); + }); + + it('gibt null zurück, wenn kein Snapshot existiert', () => { + expect(loadSnapshot('missing', dir)).toBeNull(); + }); + + it('bereinigt unsichere Zeichen im Key', () => { + saveSnapshot('../weird/key!', { v: 1 }, dir); + expect(loadSnapshot<{ v: number }>('../weird/key!', dir)?.data).toEqual({ v: 1 }); + }); +});