diff --git a/.github/workflows/performance-regression.yml b/.github/workflows/performance-regression.yml new file mode 100644 index 0000000..d401149 --- /dev/null +++ b/.github/workflows/performance-regression.yml @@ -0,0 +1,105 @@ +name: Performance Regression + +on: + pull_request: + branches: [main, develop] + paths: + - "src/**" + - "performance/**" + - "tests/performance/**" + - "scripts/analyze-performance.ts" + - "scripts/update-performance-baseline.ts" + - "playwright.performance.config.ts" + - ".github/workflows/performance-regression.yml" + - "package.json" + - "package-lock.json" + push: + branches: [main] + paths: + - "src/**" + - "performance/budgets.json" + - "tests/performance/**" + - "scripts/**" + - ".github/workflows/performance-regression.yml" + schedule: + - cron: "0 4 * * 1" + workflow_dispatch: + +concurrency: + group: performance-regression-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +env: + NODE_VERSION: 20 + +jobs: + performance-regression: + name: Performance Regression + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Build application + run: npm run build + + - name: Run performance benchmarks + run: npx playwright test --config=playwright.performance.config.ts + + - name: Analyze performance regression + id: analyze + continue-on-error: true + run: npx tsx scripts/analyze-performance.ts + + - name: Upload performance report + if: always() + uses: actions/upload-artifact@v4 + with: + name: performance-report + path: performance-results/ + retention-days: 30 + + - name: Post PR comment + if: github.event_name == 'pull_request' && always() + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ ! -f performance-results/performance-comment.md ]; then + echo "No performance comment found — skipping." + exit 0 + fi + gh pr comment ${{ github.event.pull_request.number }} --body-file performance-results/performance-comment.md || echo "Could not post PR comment (may be a fork PR)." + + - name: Refresh baseline on main + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.analyze.outcome == 'success' + run: | + npx tsx scripts/update-performance-baseline.ts + if git diff --quiet performance/baseline.json; then + echo "Baseline unchanged — skipping commit." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add performance/baseline.json + git commit -m "chore(perf): refresh performance baseline [skip ci]" + git push + fi + + - name: Fail on performance regression + if: github.event_name == 'pull_request' && steps.analyze.outcome == 'failure' + run: exit 1 diff --git a/.gitignore b/.gitignore index a7a7d03..0588c20 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ next-env.d.ts # Lighthouse CI local artifacts .lighthouseci/ + +# performance regression +/performance-results/ diff --git a/docs/performance-regression-detection.md b/docs/performance-regression-detection.md new file mode 100644 index 0000000..2222845 --- /dev/null +++ b/docs/performance-regression-detection.md @@ -0,0 +1,157 @@ +# Automated Performance Regression Detection in CI + +## Problem statement + +Performance regressions can silently slip into the frontend: a heavier bundle, +a slower API handler, or an expensive render path degrades the P99 latency of +critical paths without breaking any functional test. This document describes +the automated performance regression detection pipeline that runs in CI to +catch those regressions before they reach production. + +## Goals and technical bounds + +- **Performance target:** critical paths must stay below **100ms P99**. +- **Availability target:** 99.99% uptime — performance gates run on every pull + request so regressions never merge. +- **Scope:** system-wide; the same measurement + baseline + budget mechanism is + used by the PR gate, the canary analysis, and the SLO monitoring burn-rate + alerts (`docs/slo-monitoring.md`). +- **Security:** the pipeline only measures HTTP endpoints and browser metrics + exposed by the application. No secrets are used; the baseline is a committed, + reviewable JSON file. + +## Architecture + +``` + Pull request / main push + │ + ▼ + ┌─────────────────────────────┐ + │ performance-regression.yml │ (GitHub Actions) + └─────────────────────────────┘ + │ + build app · start server · run benchmarks + │ + ▼ + ┌─────────────────────────────────────────────┐ + │ Playwright benchmark (tests/performance) │ + │ measures critical paths: API latency, TTFB │ + └─────────────────────────────────────────────┘ + │ writes raw samples + ▼ + ┌─────────────────────────────────────────────┐ + │ scripts/analyze-performance.ts │ + │ summarize → compare vs budgets + baseline │ + └─────────────────────────────────────────────┘ + │ + ┌─────────────────────┼──────────────────────┐ + ▼ ▼ ▼ + report.json report.html PR comment + (machine- (dashboard for (human-readable + readable) operators) findings table) + │ + ▼ + Gate: exit 1 when a budget is breached or a regression + exceeds the baseline tolerance → PR is blocked. +``` + +### Components + +| Component | Location | Responsibility | +| --- | --- | --- | +| Budgets | `performance/budgets.json` | Declarative critical-path definitions with P99 budgets and regression tolerance. | +| Baseline | `performance/baseline.json` | Committed, reviewable reference measurements refreshed on `main`. | +| Core logic | `src/utils/performanceRegression.ts` | Pure percentile/summary/regression-detection functions with unit tests. | +| Benchmark | `tests/performance/performance.spec.ts` | Playwright tests that capture raw latency samples. | +| Analyzer | `scripts/analyze-performance.ts` | Summarizes samples, evaluates budgets + baseline, writes reports, sets the gate. | +| Baseline updater | `scripts/update-performance-baseline.ts` | Copies the latest measurements into the committed baseline. | +| CI workflow | `.github/workflows/performance-regression.yml` | Builds, measures, analyzes, comments on the PR, blocks on regressions. | + +## Critical paths and budgets + +The budgets file declares every critical path that is measured. Each entry +carries: + +- `id` and `name` — stable identifier and human-readable label. +- `metric` — the summary metric compared against the budget (`p99`, `p95`, or + `mean`). Critical paths use `p99` by default. +- `budgetMs` — the hard ceiling (e.g. `100` for API critical paths, per the + 100ms P99 target). +- `regressionTolerancePercent` — how far the current measurement may drift + above the committed baseline before it is flagged as a regression (guards + against CI runner noise). +- `minSampleCount` — minimum number of samples required to make a decision; + below this the finding is `insufficient-data` and does not fail the gate. + +Current critical paths: + +| id | Path | Metric | Budget | +| --- | --- | --- | ---: | +| `api-runtime-config-audit` | `/api/runtime-config/audit` | P99 | 100ms | +| `api-rate-limit` | `/api/rate-limit` | P99 | 100ms | +| `page-home-ttfb` | `/` | P99 | 1000ms | +| `page-export-ttfb` | `/export` | P99 | 1000ms | +| `page-home-lcp` | `/` | P99 | 2500ms | +| `page-export-lcp` | `/export` | P99 | 2500ms | + +## Regression detection rules + +For each budget, the analyzer computes percentiles from the raw samples +(`p50`, `p95`, `p99`, `mean`) and classifies the finding: + +| Status | Condition | Gate | +| --- | --- | --- | +| `pass` | Measurement within budget and within baseline tolerance | ✅ | +| `budget-breach` | `current > budgetMs` | ❌ blocks PR | +| `regression` | `current > baseline × (1 + tolerance)` while still under budget | ❌ blocks PR | +| `insufficient-data` | Fewer than `minSampleCount` samples collected | ⚠️ does not block | + +Regressions are relative to the committed baseline so meaningful slowdowns are +caught, while tolerance absorbs CI-runner variance. Hard budget breaches always +fail regardless of the baseline. + +## Monitoring, alerting, and dashboards + +1. **PR status check** — `Performance Regression / performance` is the alert: + a regression or budget breach marks the check red and blocks the merge. +2. **PR comment** — the workflow posts a markdown table of findings to the PR + (`performance-results/performance-comment.md`), so reviewers see exactly + which critical path regressed and by how much. +3. **HTML dashboard** — `performance-results/report.html` is uploaded as a + workflow artifact for the run-level breakdown. +4. **Machine-readable report** — `performance-results/report.json` is consumed + by tooling and can be folded into the SLO monitoring panel + (`src/utils/slo.ts`), which already targets `< 100ms` P99 for critical + paths. + +## Blue-green deployment and canary analysis + +The same baseline mechanism supports the blue-green rollout described in +`docs/TRACING_DEPLOYMENT.md`: + +1. The **green (candidate)** environment runs the identical benchmark suite. +2. `scripts/analyze-performance.ts` compares the green measurements against the + committed baseline (which reflects the current **blue** environment). +3. Promotion proceeds only when the canary is within the budget and the + baseline tolerance — mirroring the "Critical Path Latency P99 < 100ms" + canary threshold in the tracing deployment runbook. +4. If the canary regresses, the load balancer stays on blue and the incident + runbook (`docs/runbooks/performance-regression.md`) applies. + +## Baseline lifecycle + +- `performance/baseline.json` is committed and reviewed like any other source + file. +- On every push to `main`, the workflow re-measures the critical paths and + refreshes the baseline so it tracks the actual runner environment. +- The baseline can also be refreshed manually: + + ```bash + npm run test:performance # measure critical paths locally + npm run performance:update-baseline + ``` + +## Runbook + +See `docs/runbooks/performance-regression.md` for triage, remediation, and how +to update budgets. diff --git a/docs/runbooks/performance-regression.md b/docs/runbooks/performance-regression.md new file mode 100644 index 0000000..07888f2 --- /dev/null +++ b/docs/runbooks/performance-regression.md @@ -0,0 +1,106 @@ +# Runbook: Performance Regression Gate + +The `Performance Regression` workflow measures critical-path latency on every +pull request and blocks the merge when a budget is breached or a regression +exceeds the baseline tolerance. This runbook covers triage, remediation, and +maintenance. + +Architecture and design: [docs/performance-regression-detection.md](../performance-regression-detection.md). + +## What the gate does + +1. Builds the application and starts the production server. +2. Runs the Playwright benchmark (`tests/performance/performance.spec.ts`), + which samples: + - API round-trip latency for `/api/runtime-config/audit` and + `/api/rate-limit` (P99 budget: **100ms**). + - TTFB and LCP for `/` and `/export`. +3. `scripts/analyze-performance.ts` summarizes the samples and compares them + against `performance/budgets.json` and `performance/baseline.json`. +4. Findings are posted as a PR comment and uploaded as artifacts + (`performance-report`), and the check fails when anything is not green. + +## Reading the report + +Every finding has a status: + +| Status | Meaning | Blocks merge? | +| --- | --- | --- | +| `pass` | Within budget and baseline tolerance | No | +| `budget-breach` | Hard budget exceeded (e.g. P99 > 100ms) | **Yes** | +| `regression` | Slower than the committed baseline by > tolerance | **Yes** | +| `insufficient-data` | Fewer than `minSampleCount` samples | No | + +Look at the **Δ vs Baseline** column first: a large positive delta means the +PR introduced a slowdown on that critical path. A `budget-breach` means the +path is over the absolute SLO target regardless of history. + +## Triage + +1. Open the `performance-report` artifact from the failed run and open + `report.html` (dashboard) or `report.json` (machine-readable). +2. Identify the failing budget id and metric. +3. Compare the current value against the budget and baseline. + +### Common causes + +- **Bundle bloat:** a new dependency or heavier import in a critical route. +- **API handler slowdown:** new work in an API route (I/O, crypto, parsing). +- **Render path regression:** heavier client components affecting LCP. +- **Baseline drift:** CI runner noise or a stale baseline. If the same code + re-runs green on a retry, the failure was noise. + +## Remediation + +1. Optimize the affected path (code-split the component, cache the API + response, move work off the critical path). +2. Re-run the benchmark locally to confirm the improvement: + + ```bash + npm run test:performance + npm run performance:analyze + ``` + +3. Push and confirm the check is green. + +## Updating budgets or tolerances + +Budgets are declared in `performance/budgets.json`: + +```json +{ + "id": "api-runtime-config-audit", + "name": "Runtime Config Audit API", + "path": "/api/runtime-config/audit", + "metric": "p99", + "budgetMs": 100, + "regressionTolerancePercent": 20, + "minSampleCount": 5 +} +``` + +- `budgetMs` is the hard SLO ceiling. Do **not** raise it for the 100ms + critical-path target without an explicit exception. +- `regressionTolerancePercent` absorbs runner noise; raise it only when + measuring on a new, noisier environment. +- `minSampleCount` guards the gate against too few samples to be meaningful. + +## Refreshing the baseline + +The baseline refreshes automatically on pushes to `main` (and the weekly +schedule) when the run is green. To refresh manually: + +```bash +npm run test:performance +npm run performance:update-baseline +``` + +Commit the updated `performance/baseline.json` in a reviewable PR. + +## Escalation + +If a critical path exceeds its budget in production, apply the SLO incident +runbook ([docs/slo-monitoring.md](../slo-monitoring.md)) and the blue-green +rollback procedure in [docs/TRACING_DEPLOYMENT.md](../TRACING_DEPLOYMENT.md): +keep the load balancer on blue, investigate the canary measurements, and only +promote when the canary is within budget and baseline tolerance. diff --git a/package-lock.json b/package-lock.json index f5445a4..a9a1762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,12 @@ "name": "utility-frontend", "version": "0.1.0", "dependencies": { - "@stellar/stellar-sdk": "^15.1.0", + "@stellar/stellar-sdk": "^16.2.0", "@types/three": "^0.180.0", "bignumber.js": "^11.1.3", "idb": "^8.0.3", "intl-messageformat": "^11.2.8", - "next": "16.2.9", + "next": "^16.3.1", "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", @@ -1258,9 +1258,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -1270,19 +1270,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1292,19 +1292,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1318,9 +1337,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1334,9 +1353,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -1350,9 +1369,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -1366,9 +1385,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -1382,9 +1401,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -1398,9 +1417,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -1414,9 +1433,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -1430,9 +1449,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -1446,9 +1465,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -1462,9 +1481,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -1474,19 +1493,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -1496,19 +1515,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -1518,19 +1537,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -1540,19 +1559,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1562,19 +1581,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1584,19 +1603,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1606,19 +1625,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1628,38 +1647,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1669,16 +1704,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1688,16 +1723,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1707,7 +1742,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1783,9 +1818,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", - "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz", + "integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1799,9 +1834,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", - "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz", + "integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==", "cpu": [ "arm64" ], @@ -1815,9 +1850,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", - "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz", + "integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==", "cpu": [ "x64" ], @@ -1831,9 +1866,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", - "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz", + "integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==", "cpu": [ "arm64" ], @@ -1847,9 +1882,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", - "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz", + "integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==", "cpu": [ "arm64" ], @@ -1863,9 +1898,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", - "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz", + "integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==", "cpu": [ "x64" ], @@ -1879,9 +1914,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", - "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz", + "integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==", "cpu": [ "x64" ], @@ -1895,9 +1930,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", - "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz", + "integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==", "cpu": [ "arm64" ], @@ -1911,9 +1946,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", - "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz", + "integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==", "cpu": [ "x64" ], @@ -1926,28 +1961,22 @@ "node": ">= 10" } }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "node_modules/@noble/ed25519": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", + "integrity": "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==", "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -2338,118 +2367,85 @@ "pnpm": ">=9.0.0" } }, - "node_modules/@stellar/stellar-base": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-15.0.0.tgz", - "integrity": "sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==", - "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", + "node_modules/@stellar/stellar-sdk": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.2.0.tgz", + "integrity": "sha512-FV/Rm11QvrFzR5X9fIfb6Pg30KyYyrRkNezELGFmYDAYrzoPTjqo7vklnEPd2HEontzjJmZizWk8CjyIh5vp0w==", "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^1.9.7", - "@stellar/js-xdr": "^4.0.0", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.18.0", "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", + "bignumber.js": "^11.1.4", "buffer": "^6.0.3", - "sha.js": "^2.4.12" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@stellar/stellar-base/node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@stellar/stellar-sdk": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-15.1.0.tgz", - "integrity": "sha512-GsJUcWx2yboVzYdhTe/LHS3V1wVLSHkUkglC5bBoYWGJt31vzIhbSGno60NP9CdCTNkLJdnrsLJ63oA58Zvh5A==", - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-base": "^15.0.0", - "axios": "1.15.0", - "bignumber.js": "^9.3.1", "commander": "^14.0.3", - "eventsource": "^2.0.2", + "eventsource": "^4.1.0", "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.11" + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" }, "bin": { "stellar-js": "bin/stellar-js" }, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@stellar/stellar-sdk/node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" + "node": ">=22.0.0" } }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" } }, "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -2464,9 +2460,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -2481,9 +2477,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -2498,9 +2494,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -2515,9 +2511,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -2532,9 +2528,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -2549,9 +2545,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -2566,9 +2562,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -2583,9 +2579,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -2600,9 +2596,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2618,9 +2614,9 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" @@ -2630,9 +2626,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -2647,9 +2643,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -2664,17 +2660,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz", - "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "postcss": "8.5.15", - "tailwindcss": "4.3.1" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" } }, "node_modules/@testing-library/dom": { @@ -3100,16 +3096,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -3688,6 +3684,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3943,6 +3951,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -3965,13 +3974,14 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -4050,9 +4060,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -4135,6 +4145,7 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4166,6 +4177,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4436,7 +4448,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4477,6 +4488,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -4593,9 +4605,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -5312,12 +5324,24 @@ } }, "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" } }, "node_modules/expect-type": { @@ -5494,6 +5518,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -5764,6 +5789,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -5857,6 +5883,19 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/idb": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", @@ -5930,12 +5969,6 @@ "node": ">=8" } }, - "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/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6059,6 +6092,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6361,6 +6395,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -6422,6 +6457,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -6467,9 +6503,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -7079,13 +7115,12 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -7124,16 +7159,16 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", - "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz", + "integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==", "license": "MIT", "dependencies": { - "@next/env": "16.2.9", - "@swc/helpers": "0.5.15", + "@next/env": "16.3.1", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -7143,15 +7178,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.9", - "@next/swc-darwin-x64": "16.2.9", - "@next/swc-linux-arm64-gnu": "16.2.9", - "@next/swc-linux-arm64-musl": "16.2.9", - "@next/swc-linux-x64-gnu": "16.2.9", - "@next/swc-linux-x64-musl": "16.2.9", - "@next/swc-win32-arm64-msvc": "16.2.9", - "@next/swc-win32-x64-msvc": "16.2.9", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.1", + "@next/swc-darwin-x64": "16.3.1", + "@next/swc-linux-arm64-gnu": "16.3.1", + "@next/swc-linux-arm64-musl": "16.3.1", + "@next/swc-linux-x64-gnu": "16.3.1", + "@next/swc-linux-x64-musl": "16.3.1", + "@next/swc-win32-arm64-msvc": "16.3.1", + "@next/swc-win32-x64-msvc": "16.3.1", + "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -7177,9 +7212,9 @@ } }, "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -7196,9 +7231,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -7584,15 +7619,16 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, "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.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -7610,7 +7646,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7743,15 +7779,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -7997,26 +8024,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8091,6 +8098,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -8135,69 +8143,54 @@ "node": ">= 0.4" } }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { @@ -8319,6 +8312,18 @@ "dev": true, "license": "ISC" }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8602,9 +8607,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -8723,20 +8728,6 @@ "dev": true, "license": "MIT" }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8750,12 +8741,6 @@ "node": ">=8.0" } }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "license": "MIT" - }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -8878,6 +8863,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -8989,6 +8975,18 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -9009,9 +9007,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -9104,12 +9102,6 @@ "punycode": "^2.1.0" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT" - }, "node_modules/vite": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", @@ -9460,6 +9452,7 @@ "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", diff --git a/package.json b/package.json index 0e77da7..74f4322 100644 --- a/package.json +++ b/package.json @@ -12,17 +12,20 @@ "test:ui": "vitest --ui", "test:visual": "npx playwright test tests/visual", "test:a11y": "npx playwright test --config=playwright.a11y.config.ts", + "test:performance": "npx playwright test --config=playwright.performance.config.ts", + "performance:analyze": "npx tsx scripts/analyze-performance.ts", + "performance:update-baseline": "npx tsx scripts/update-performance-baseline.ts", "visual:update-baselines": "npx tsx scripts/update-baselines.ts", "test:coverage": "vitest --run --coverage.enabled --coverage.provider=v8", "test:coverage:ci": "vitest --run --coverage.enabled --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=lcov" }, "dependencies": { - "@stellar/stellar-sdk": "^15.1.0", + "@stellar/stellar-sdk": "^16.2.0", "@types/three": "^0.180.0", "bignumber.js": "^11.1.3", "idb": "^8.0.3", "intl-messageformat": "^11.2.8", - "next": "16.2.9", + "next": "^16.3.1", "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/performance/baseline.json b/performance/baseline.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/performance/baseline.json @@ -0,0 +1 @@ +{} diff --git a/performance/budgets.json b/performance/budgets.json new file mode 100644 index 0000000..1a691b3 --- /dev/null +++ b/performance/budgets.json @@ -0,0 +1,58 @@ +{ + "budgets": [ + { + "id": "api-runtime-config-audit", + "name": "Runtime Config Audit API", + "path": "/api/runtime-config/audit", + "metric": "p99", + "budgetMs": 100, + "regressionTolerancePercent": 20, + "minSampleCount": 5 + }, + { + "id": "api-rate-limit", + "name": "Rate Limit API", + "path": "/api/rate-limit", + "metric": "p99", + "budgetMs": 100, + "regressionTolerancePercent": 20, + "minSampleCount": 5 + }, + { + "id": "page-home-ttfb", + "name": "Home page TTFB", + "path": "/", + "metric": "p99", + "budgetMs": 1000, + "regressionTolerancePercent": 25, + "minSampleCount": 3 + }, + { + "id": "page-export-ttfb", + "name": "Export page TTFB", + "path": "/export", + "metric": "p99", + "budgetMs": 1000, + "regressionTolerancePercent": 25, + "minSampleCount": 3 + }, + { + "id": "page-home-lcp", + "name": "Home page LCP", + "path": "/", + "metric": "p99", + "budgetMs": 2500, + "regressionTolerancePercent": 25, + "minSampleCount": 3 + }, + { + "id": "page-export-lcp", + "name": "Export page LCP", + "path": "/export", + "metric": "p99", + "budgetMs": 2500, + "regressionTolerancePercent": 25, + "minSampleCount": 3 + } + ] +} diff --git a/playwright.performance.config.ts b/playwright.performance.config.ts new file mode 100644 index 0000000..fc2af01 --- /dev/null +++ b/playwright.performance.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/performance", + fullyParallel: false, + retries: 0, + workers: 1, + reporter: "list", + use: { + baseURL: "http://localhost:3000", + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run start", + url: "http://localhost:3000", + reuseExistingServer: !!process.env.CI, + timeout: 120_000, + }, +}); diff --git a/scripts/analyze-performance.ts b/scripts/analyze-performance.ts new file mode 100644 index 0000000..bd31930 --- /dev/null +++ b/scripts/analyze-performance.ts @@ -0,0 +1,211 @@ +#!/usr/bin/env -S npx tsx + +/** + * Performance regression analyzer. + * + * Reads the raw samples written by the Playwright benchmark + * (`performance-results/samples.json`), evaluates them against the budgets in + * `performance/budgets.json` and the committed baseline + * (`performance/baseline.json`), then: + * + * 1. writes a machine-readable report (`performance-results/report.json`), + * 2. writes a human-readable HTML dashboard + * (`performance-results/report.html`), + * 3. writes a markdown summary for the PR comment + * (`performance-results/performance-comment.md`), + * 4. exits non-zero when a budget breach or regression is detected so the + * CI gate blocks the PR. + * + * Flags: + * --no-fail Always exit 0 (used when refreshing the baseline on main). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + detectRegressions, + summarizeAll, + type PerformanceBaseline, + type PerformanceBudget, + type RegressionReport, +} from "@/utils/performanceRegression"; + +const RESULTS_DIR = path.resolve(process.cwd(), "performance-results"); +const SAMPLES_FILE = path.join(RESULTS_DIR, "samples.json"); +const REPORT_FILE = path.join(RESULTS_DIR, "report.json"); +const HTML_REPORT_FILE = path.join(RESULTS_DIR, "report.html"); +const COMMENT_FILE = path.join(RESULTS_DIR, "performance-comment.md"); +const BUDGETS_FILE = path.resolve(process.cwd(), "performance", "budgets.json"); +const BASELINE_FILE = path.resolve(process.cwd(), "performance", "baseline.json"); + +interface SamplesFile { + generatedAt?: string; + samples: Record; +} + +function loadBudgets(): PerformanceBudget[] { + const parsed = JSON.parse(fs.readFileSync(BUDGETS_FILE, "utf8")) as { budgets: PerformanceBudget[] }; + return parsed.budgets; +} + +function loadBaseline(): PerformanceBaseline | null { + if (!fs.existsSync(BASELINE_FILE)) return null; + const parsed = JSON.parse(fs.readFileSync(BASELINE_FILE, "utf8")) as PerformanceBaseline; + return Object.keys(parsed).length === 0 ? null : parsed; +} + +function loadSamples(): SamplesFile { + if (!fs.existsSync(SAMPLES_FILE)) { + throw new Error(`Samples file not found: ${SAMPLES_FILE}. Run the performance benchmark first (npm run test:performance).`); + } + return JSON.parse(fs.readFileSync(SAMPLES_FILE, "utf8")) as SamplesFile; +} + +function statusIcon(status: string): string { + switch (status) { + case "pass": + return "✅"; + case "budget-breach": + return "❌"; + case "regression": + return "🔺"; + case "insufficient-data": + return "⚠️"; + default: + return "❓"; + } +} + +function generateMarkdownComment(report: RegressionReport): string { + const lines = [ + "## 📈 Performance Regression Results", + "", + "| Critical Path | Metric | Current | Budget | Baseline | Δ vs Baseline | Status |", + "|---|---|---|---|---|---|---|", + ]; + + for (const finding of report.findings) { + const current = finding.currentMs !== null ? `${finding.currentMs.toFixed(1)}ms` : "—"; + const budget = finding.budgetMs !== null ? `${finding.budgetMs.toFixed(1)}ms` : "—"; + const baseline = finding.baselineMs !== null ? `${finding.baselineMs.toFixed(1)}ms` : "—"; + const delta = finding.deltaPercent !== null ? `${finding.deltaPercent > 0 ? "+" : ""}${finding.deltaPercent.toFixed(1)}%` : "—"; + lines.push( + `| ${finding.name} | ${finding.metric.toUpperCase()} | ${current} | ${budget} | ${baseline} | ${delta} | ${statusIcon(finding.status)} ${finding.status} |` + ); + } + + lines.push("", `📊 **Result: ${report.passed ? "PASS" : "FAIL"}**`); + lines.push( + "", + "📎 [View workflow artifacts](https://github.com/Utility-Protocol/utility-frontend/actions) for the full HTML report." + ); + return lines.join("\n"); +} + +function generateHtmlReport(report: RegressionReport): string { + const rows = report.findings + .map((f) => { + const current = f.currentMs !== null ? `${f.currentMs.toFixed(1)}ms` : "—"; + const budget = f.budgetMs !== null ? `${f.budgetMs.toFixed(1)}ms` : "—"; + const baseline = f.baselineMs !== null ? `${f.baselineMs.toFixed(1)}ms` : "—"; + const delta = f.deltaPercent !== null ? `${f.deltaPercent > 0 ? "+" : ""}${f.deltaPercent.toFixed(1)}%` : "—"; + return ` + ${escapeHtml(f.name)}
${escapeHtml(f.path)} + ${f.metric.toUpperCase()} + ${current} + ${budget} + ${baseline} + ${delta} + ${f.status.replace("-", " ")} + ${escapeHtml(f.message)} + `; + }) + .join("\n"); + + return ` + + + + +Performance Regression Report + + + +

Performance Regression Report

+
${report.passed ? "✅ PASS" : "❌ FAIL"}
+ + + + + +${rows} + +
Critical PathMetricCurrentBudgetBaselineΔ BaselineStatusMessage
+ +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function main(): void { + const noFail = process.argv.includes("--no-fail"); + + const budgets = loadBudgets(); + const baseline = loadBaseline(); + const { samples } = loadSamples(); + + const report = detectRegressions(budgets, samples, baseline); + const measurements = summarizeAll(budgets, samples); + + fs.mkdirSync(RESULTS_DIR, { recursive: true }); + + fs.writeFileSync( + REPORT_FILE, + JSON.stringify({ ...report, measurements }, null, 2) + ); + fs.writeFileSync(HTML_REPORT_FILE, generateHtmlReport(report)); + fs.writeFileSync(COMMENT_FILE, generateMarkdownComment(report)); + + // eslint-disable-next-line no-console + console.log(`Report written to ${REPORT_FILE}`); + + const findingsSummary = report.findings + .map((f) => ` ${statusIcon(f.status)} ${f.name}: ${f.message}`) + .join("\n"); + // eslint-disable-next-line no-console + console.log(`\n${findingsSummary}`); + + if (!report.passed && !noFail) { + console.error("\n❌ Performance regression detected — blocking the CI gate."); + process.exit(1); + } +} + +main(); diff --git a/scripts/update-performance-baseline.ts b/scripts/update-performance-baseline.ts new file mode 100644 index 0000000..825a809 --- /dev/null +++ b/scripts/update-performance-baseline.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env -S npx tsx + +/** + * Performance baseline update script. + * + * Reads the latest raw samples from `performance-results/samples.json`, + * summarizes them, and writes the result into the committed baseline + * (`performance/baseline.json`). + * + * The baseline is refreshed automatically by the performance-regression + * workflow on pushes to `main` so the PR gate compares against the most + * recent known-good measurements. It can also be run manually: + * + * npm run test:performance + * npm run performance:update-baseline + */ + +import fs from "node:fs"; +import path from "node:path"; +import { summarizeAll, type PerformanceBaseline, type PerformanceBudget } from "@/utils/performanceRegression"; + +const RESULTS_DIR = path.resolve(process.cwd(), "performance-results"); +const SAMPLES_FILE = path.join(RESULTS_DIR, "samples.json"); +const BUDGETS_FILE = path.resolve(process.cwd(), "performance", "budgets.json"); +const BASELINE_FILE = path.resolve(process.cwd(), "performance", "baseline.json"); + +interface SamplesFile { + samples: Record; +} + +function main(): void { + if (!fs.existsSync(SAMPLES_FILE)) { + console.error( + `✗ Samples file not found: ${SAMPLES_FILE}\n` + + ` Run \`npm run test:performance\` first to generate measurements.` + ); + process.exit(1); + } + + const budgets = JSON.parse(fs.readFileSync(BUDGETS_FILE, "utf8")) as { budgets: PerformanceBudget[] }; + const { samples } = JSON.parse(fs.readFileSync(SAMPLES_FILE, "utf8")) as SamplesFile; + + const measurements = summarizeAll(budgets.budgets, samples); + + const baseline: PerformanceBaseline = {}; + const now = new Date().toISOString(); + for (const m of measurements) { + if (m.sampleCount === 0) continue; + baseline[m.id] = { + p50Ms: m.p50Ms, + p95Ms: m.p95Ms, + p99Ms: m.p99Ms, + meanMs: m.meanMs, + sampleCount: m.sampleCount, + recordedAt: now, + }; + } + + fs.writeFileSync(BASELINE_FILE, JSON.stringify(baseline, null, 2) + "\n"); + // eslint-disable-next-line no-console + console.log(`✓ Performance baseline updated: ${Object.keys(baseline).length} measurement(s) written to ${BASELINE_FILE}`); +} + +main(); diff --git a/src/utils/performanceRegression.ts b/src/utils/performanceRegression.ts new file mode 100644 index 0000000..0e00c78 --- /dev/null +++ b/src/utils/performanceRegression.ts @@ -0,0 +1,252 @@ +/** + * Performance regression detection core. + * + * Pure, framework-free logic used by the CI performance pipeline: + * + * - `computePercentile` / `summarizeSamples` turn raw latency samples into + * P50/P95/P99 summaries. + * - `detectRegressions` compares current measurements against declarative + * budgets (`performance/budgets.json`) and the committed baseline + * (`performance/baseline.json`). + * + * Keeping the logic here (and unit-testing it) lets the Playwright benchmark + * stay a thin measurement harness while every decision the gate makes is + * deterministic and reviewed. + */ + +export type PerformanceMetric = "p99" | "p95" | "mean"; + +export interface PerformanceBudget { + id: string; + name: string; + path: string; + metric: PerformanceMetric; + /** Hard ceiling in milliseconds for the configured metric. */ + budgetMs: number; + /** Max allowed % drift above the baseline before flagging a regression. */ + regressionTolerancePercent?: number; + /** Minimum sample count required before a decision is made. */ + minSampleCount?: number; +} + +export interface BaselineMeasurement { + p50Ms: number; + p95Ms: number; + p99Ms: number; + meanMs: number; + sampleCount: number; + recordedAt: string; +} + +export type PerformanceBaseline = Record; + +export interface PerformanceMeasurement { + id: string; + name: string; + path: string; + p50Ms: number; + p95Ms: number; + p99Ms: number; + meanMs: number; + sampleCount: number; +} + +export type FindingStatus = "pass" | "budget-breach" | "regression" | "insufficient-data"; + +export interface Finding { + id: string; + name: string; + path: string; + status: FindingStatus; + metric: PerformanceMetric; + currentMs: number | null; + budgetMs: number | null; + baselineMs: number | null; + deltaPercent: number | null; + sampleCount: number; + message: string; +} + +export interface RegressionReport { + generatedAt: string; + findings: Finding[]; + passed: boolean; +} + +export interface AnalyzeOptions { + /** Override the tolerance used for every budget (percent, e.g. 20 => 20%). */ + defaultTolerancePercent?: number; +} + +/** Nearest-rank percentile: returns the value at the given percentile (0-100). */ +export function computePercentile(values: number[], percentile: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const rank = Math.max(0, Math.ceil((percentile / 100) * sorted.length) - 1); + return sorted[Math.min(rank, sorted.length - 1)]; +} + +/** Summarize a batch of latency samples into P50/P95/P99/mean. */ +export function summarizeSamples(valuesMs: number[]): Omit { + if (valuesMs.length === 0) { + return { p50Ms: 0, p95Ms: 0, p99Ms: 0, meanMs: 0, sampleCount: 0 }; + } + const sum = valuesMs.reduce((acc, v) => acc + v, 0); + return { + p50Ms: computePercentile(valuesMs, 50), + p95Ms: computePercentile(valuesMs, 95), + p99Ms: computePercentile(valuesMs, 99), + meanMs: sum / valuesMs.length, + sampleCount: valuesMs.length, + }; +} + +/** + * Evaluate one budget against the measured samples and the committed baseline. + * Pure function — all inputs are explicit. + */ +export function evaluateBudget( + budget: PerformanceBudget, + samplesMs: number[], + baseline: PerformanceBaseline | null, + options: AnalyzeOptions = {} +): Finding { + const summary = summarizeSamples(samplesMs); + const minSampleCount = budget.minSampleCount ?? 1; + + if (summary.sampleCount < minSampleCount) { + return { + id: budget.id, + name: budget.name, + path: budget.path, + status: "insufficient-data", + metric: budget.metric, + currentMs: null, + budgetMs: budget.budgetMs, + baselineMs: null, + deltaPercent: null, + sampleCount: summary.sampleCount, + message: `Insufficient samples (${summary.sampleCount}/${minSampleCount}) to evaluate "${budget.name}".`, + }; + } + + const currentMs = summary[metricField(budget.metric)]; + const baselineEntry = baseline?.[budget.id]; + const baselineMs = baselineEntry ? baselineEntry[metricField(budget.metric)] : null; + + // Hard budget breach always fails, regardless of the baseline. + if (currentMs > budget.budgetMs) { + return { + id: budget.id, + name: budget.name, + path: budget.path, + status: "budget-breach", + metric: budget.metric, + currentMs, + budgetMs: budget.budgetMs, + baselineMs, + deltaPercent: percentDelta(currentMs, baselineMs), + sampleCount: summary.sampleCount, + message: `"${budget.name}" ${budget.metric.toUpperCase()} ${formatMs(currentMs)} exceeds the ${formatMs(budget.budgetMs)} budget.`, + }; + } + + // Relative regression check against the committed baseline. + if (baselineMs !== null && baselineMs > 0) { + const tolerancePercent = budget.regressionTolerancePercent ?? options.defaultTolerancePercent ?? 20; + const delta = percentDelta(currentMs, baselineMs) ?? 0; + if (delta > tolerancePercent) { + return { + id: budget.id, + name: budget.name, + path: budget.path, + status: "regression", + metric: budget.metric, + currentMs, + budgetMs: budget.budgetMs, + baselineMs, + deltaPercent: delta, + sampleCount: summary.sampleCount, + message: `"${budget.name}" ${budget.metric.toUpperCase()} ${formatMs(currentMs)} is ${formatPercent(delta)} above the baseline ${formatMs(baselineMs)}.`, + }; + } + } + + return { + id: budget.id, + name: budget.name, + path: budget.path, + status: "pass", + metric: budget.metric, + currentMs, + budgetMs: budget.budgetMs, + baselineMs, + deltaPercent: percentDelta(currentMs, baselineMs), + sampleCount: summary.sampleCount, + message: `"${budget.name}" ${budget.metric.toUpperCase()} ${formatMs(currentMs)} is within budget and baseline tolerance.`, + }; +} + +/** + * Evaluate all budgets against a raw samples map keyed by budget id. + * Returns a report that the CI gate can fail on. + */ +export function detectRegressions( + budgets: PerformanceBudget[], + samplesByBudgetId: Record, + baseline: PerformanceBaseline | null, + options: AnalyzeOptions = {} +): RegressionReport { + const findings = budgets.map((budget) => + evaluateBudget(budget, samplesByBudgetId[budget.id] ?? [], baseline, options) + ); + + return { + generatedAt: new Date().toISOString(), + findings, + passed: findings.every( + (finding) => finding.status === "pass" || finding.status === "insufficient-data" + ), + }; +} + +/** Convert a raw samples map into full measurement summaries (for reports). */ +export function summarizeAll( + budgets: PerformanceBudget[], + samplesByBudgetId: Record +): PerformanceMeasurement[] { + return budgets.map((budget) => { + const summary = summarizeSamples(samplesByBudgetId[budget.id] ?? []); + return { + id: budget.id, + name: budget.name, + path: budget.path, + ...summary, + }; + }); +} + +/** Map a budget metric to the summary/baseline field that stores it. */ +function metricField(metric: PerformanceMetric): "p95Ms" | "p99Ms" | "meanMs" { + switch (metric) { + case "p95": + return "p95Ms"; + case "p99": + return "p99Ms"; + case "mean": + return "meanMs"; + } +} + +function percentDelta(currentMs: number, baselineMs: number | null): number | null { + if (baselineMs === null || baselineMs <= 0) return null; + return ((currentMs - baselineMs) / baselineMs) * 100; +} + +function formatMs(ms: number): string { + return `${ms.toFixed(1)}ms`; +} + +function formatPercent(pct: number): string { + return `${pct.toFixed(1)}%`; +} diff --git a/tests/performance/performance.spec.ts b/tests/performance/performance.spec.ts new file mode 100644 index 0000000..1bee443 --- /dev/null +++ b/tests/performance/performance.spec.ts @@ -0,0 +1,154 @@ +/** + * Performance benchmark suite. + * + * Measures the critical paths declared in `performance/budgets.json` and + * writes the raw latency samples to `performance-results/samples.json`, + * keyed by budget `id` so the analyzer can compare them directly. + * + * - API critical paths: round-trip latency of key API routes. + * - Page critical paths: TTFB and LCP for the main routes. + * + * The raw samples are consumed by `scripts/analyze-performance.ts`, which + * applies budgets and the committed baseline and drives the CI gate. The + * benchmark itself never asserts on timing — measurement and gating are kept + * separate so flaky CI runners cannot fail the suite accidentally. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { test, type APIRequestContext, type Page } from "@playwright/test"; + +const RESULTS_DIR = path.resolve(process.cwd(), "performance-results"); +const SAMPLES_FILE = path.join(RESULTS_DIR, "samples.json"); +const BUDGETS_FILE = path.resolve(process.cwd(), "performance", "budgets.json"); + +interface Budget { + id: string; + name: string; + path: string; + metric: "p99" | "p95" | "mean"; + budgetMs: number; + regressionTolerancePercent?: number; + minSampleCount?: number; +} + +const API_SAMPLES = 30; +const PAGE_NAVIGATIONS = 5; +const API_ROUTE = /^\/api\//; + +const samples: Record = {}; + +function record(id: string, valueMs: number): void { + (samples[id] ??= []).push(valueMs); +} + +function loadBudgets(): Budget[] { + const raw = fs.readFileSync(BUDGETS_FILE, "utf8"); + const parsed = JSON.parse(raw) as { budgets: Budget[] }; + return parsed.budgets; +} + +function apiTargets(budgets: Budget[]): Budget[] { + return budgets.filter((b) => API_ROUTE.test(b.path)); +} + +function pageTargets(budgets: Budget[]): Budget[] { + return budgets.filter((b) => !API_ROUTE.test(b.path)); +} + +async function measureApiLatency(request: APIRequestContext, url: string, id: string): Promise { + const start = Date.now(); + const response = await request.get(url); + const elapsed = Date.now() - start; + // Measure round-trip latency regardless of the HTTP status. Some endpoints + // (e.g. /api/runtime-config/audit) deliberately return 503 when drift is + // detected — the request still completed and the latency sample is valid. + // A network-level failure (server down) throws before this point and fails + // the test as intended. + await response.body(); + record(id, elapsed); +} + +/** + * Navigate once and capture TTFB + LCP, recording each into the budget ids + * that target this page route. + */ +async function measurePageTiming(page: Page, url: string, budgets: Budget[]): Promise { + await page.goto(url, { waitUntil: "load" }); + await page.waitForLoadState("networkidle"); + const timing = await page.evaluate(() => { + const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; + const ttfb = nav ? nav.responseStart - nav.requestStart : 0; + return { ttfb }; + }); + const lcp = await page.evaluate( + () => + new Promise((resolve) => { + const entries = performance.getEntriesByType("largest-contentful-paint"); + if (entries.length > 0) { + resolve(entries[entries.length - 1].startTime); + return; + } + const observer = new PerformanceObserver((list) => { + const latest = list.getEntries(); + if (latest.length > 0) { + observer.disconnect(); + resolve(latest[latest.length - 1].startTime); + } + }); + observer.observe({ type: "largest-contentful-paint", buffered: true }); + setTimeout(() => { + observer.disconnect(); + resolve(0); + }, 15_000); + }) + ); + + for (const budget of budgets) { + if (budget.id.endsWith("-ttfb")) record(budget.id, timing.ttfb); + if (budget.id.endsWith("-lcp")) record(budget.id, lcp); + } +} + +function writeSamples(): void { + fs.mkdirSync(RESULTS_DIR, { recursive: true }); + fs.writeFileSync( + SAMPLES_FILE, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + samples, + }, + null, + 2 + ) + ); + // eslint-disable-next-line no-console + console.log(`✓ Performance samples written to ${SAMPLES_FILE}`); +} + +test.describe("performance benchmark", () => { + const budgets = loadBudgets(); + + test("API critical paths", async ({ request }) => { + for (const budget of apiTargets(budgets)) { + for (let i = 0; i < API_SAMPLES; i++) { + await measureApiLatency(request, budget.path, budget.id); + } + } + }); + + test("page critical paths", async ({ page }) => { + const pageBudgets = pageTargets(budgets); + const paths = [...new Set(pageBudgets.map((b) => b.path))]; + for (const pagePath of paths) { + for (let i = 0; i < PAGE_NAVIGATIONS; i++) { + await measurePageTiming(page, pagePath, pageBudgets); + } + } + }); + + test.afterAll(() => { + writeSamples(); + }); +}); diff --git a/tests/unit/performanceRegression.test.ts b/tests/unit/performanceRegression.test.ts new file mode 100644 index 0000000..d824c95 --- /dev/null +++ b/tests/unit/performanceRegression.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { + computePercentile, + detectRegressions, + evaluateBudget, + summarizeAll, + summarizeSamples, + type PerformanceBaseline, + type PerformanceBudget, +} from "@/utils/performanceRegression"; + +const BUDGET: PerformanceBudget = { + id: "api-runtime-config-audit", + name: "Runtime Config Audit API", + path: "/api/runtime-config/audit", + metric: "p99", + budgetMs: 100, + regressionTolerancePercent: 20, + minSampleCount: 5, +}; + +describe("computePercentile", () => { + it("computes nearest-rank percentiles", () => { + const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + expect(computePercentile(values, 50)).toBe(5); + expect(computePercentile(values, 90)).toBe(9); + expect(computePercentile(values, 100)).toBe(10); + expect(computePercentile(values, 0)).toBe(1); + }); + + it("handles unsorted input", () => { + expect(computePercentile([10, 1, 9, 2, 8, 3, 7, 4, 6, 5], 50)).toBe(5); + }); + + it("handles single element and empty input", () => { + expect(computePercentile([42], 99)).toBe(42); + expect(computePercentile([], 99)).toBe(0); + }); + + it("does not mutate the input array", () => { + const values = [3, 1, 2]; + computePercentile(values, 50); + expect(values).toEqual([3, 1, 2]); + }); +}); + +describe("summarizeSamples", () => { + it("computes P50/P95/P99/mean with counts", () => { + // Nearest-rank percentiles: p95 of 10 values is the 10th (rank 9), p99 is also the max. + const summary = summarizeSamples([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); + expect(summary.p50Ms).toBe(50); + expect(summary.p95Ms).toBe(100); + expect(summary.p99Ms).toBe(100); + expect(summary.meanMs).toBe(55); + expect(summary.sampleCount).toBe(10); + }); + + it("returns zeros for empty input", () => { + expect(summarizeSamples([])).toEqual({ p50Ms: 0, p95Ms: 0, p99Ms: 0, meanMs: 0, sampleCount: 0 }); + }); +}); + +describe("evaluateBudget", () => { + it("passes when within budget and baseline tolerance", () => { + const baseline: PerformanceBaseline = { + [BUDGET.id]: { p50Ms: 40, p95Ms: 70, p99Ms: 80, meanMs: 45, sampleCount: 20, recordedAt: "" }, + }; + const finding = evaluateBudget(BUDGET, [40, 45, 50, 55, 60, 65], baseline); + expect(finding.status).toBe("pass"); + expect(finding.currentMs).toBe(65); // p99 (nearest rank) of the sample set + }); + + it("flags a budget breach when current exceeds the hard budget", () => { + const finding = evaluateBudget(BUDGET, [110, 120, 130, 140, 150], null); + expect(finding.status).toBe("budget-breach"); + expect(finding.currentMs).toBe(150); + expect(finding.message).toContain("exceeds"); + }); + + it("flags a regression when current drifts above the baseline tolerance", () => { + const baseline: PerformanceBaseline = { + [BUDGET.id]: { p50Ms: 30, p95Ms: 45, p99Ms: 50, meanMs: 32, sampleCount: 20, recordedAt: "" }, + }; + // p99 = 82ms, 64% above the 50ms baseline > 20% tolerance, but under the 100ms budget. + const finding = evaluateBudget(BUDGET, [70, 75, 78, 80, 82], baseline); + expect(finding.status).toBe("regression"); + expect(finding.deltaPercent).toBeCloseTo(64, 5); + expect(finding.message).toContain("above the baseline"); + }); + + it("passes when drift is within tolerance", () => { + const baseline: PerformanceBaseline = { + [BUDGET.id]: { p50Ms: 30, p95Ms: 45, p99Ms: 50, meanMs: 32, sampleCount: 20, recordedAt: "" }, + }; + // p99 = 55ms, 10% above baseline < 20% tolerance. + const finding = evaluateBudget(BUDGET, [50, 52, 53, 54, 55], baseline); + expect(finding.status).toBe("pass"); + }); + + it("treats a missing baseline as pass (no comparison possible)", () => { + const finding = evaluateBudget(BUDGET, [40, 45, 50, 55, 60], null); + expect(finding.status).toBe("pass"); + expect(finding.baselineMs).toBeNull(); + expect(finding.deltaPercent).toBeNull(); + }); + + it("reports insufficient data below minSampleCount without failing", () => { + const finding = evaluateBudget(BUDGET, [45, 50], null); + expect(finding.status).toBe("insufficient-data"); + expect(finding.sampleCount).toBe(2); + }); + + it("supports p95 and mean metrics via the budget", () => { + const p95Budget: PerformanceBudget = { ...BUDGET, metric: "p95", budgetMs: 80 }; + const p95 = evaluateBudget(p95Budget, [70, 75, 80, 85, 90], null); + expect(p95.status).toBe("budget-breach"); + expect(p95.currentMs).toBe(90); // p95 (nearest rank) of 5 values is the max + + const meanBudget: PerformanceBudget = { ...BUDGET, metric: "mean", budgetMs: 50 }; + const mean = evaluateBudget(meanBudget, [60, 60, 60, 60, 60], null); + expect(mean.status).toBe("budget-breach"); + expect(mean.currentMs).toBe(60); + }); + + it("respects the default tolerance option when the budget has no tolerance", () => { + const baseline: PerformanceBaseline = { + [BUDGET.id]: { p50Ms: 30, p95Ms: 45, p99Ms: 50, meanMs: 32, sampleCount: 20, recordedAt: "" }, + }; + // Budget without an explicit tolerance; 10% drift fails under defaultTolerancePercent: 5. + const noToleranceBudget: PerformanceBudget = { ...BUDGET, regressionTolerancePercent: undefined }; + const finding = evaluateBudget(noToleranceBudget, [50, 52, 53, 54, 55], baseline, { defaultTolerancePercent: 5 }); + expect(finding.status).toBe("regression"); + }); + + it("budget-level tolerance wins over the default option", () => { + const baseline: PerformanceBaseline = { + [BUDGET.id]: { p50Ms: 30, p95Ms: 45, p99Ms: 50, meanMs: 32, sampleCount: 20, recordedAt: "" }, + }; + // Budget tolerance is 20%; a 10% drift passes even with a 5% default. + const finding = evaluateBudget(BUDGET, [50, 52, 53, 54, 55], baseline, { defaultTolerancePercent: 5 }); + expect(finding.status).toBe("pass"); + }); +}); + +describe("detectRegressions", () => { + it("returns a passing report when everything is healthy", () => { + const report = detectRegressions([BUDGET], { [BUDGET.id]: [40, 45, 50, 55, 60, 65] }, null); + expect(report.passed).toBe(true); + expect(report.findings).toHaveLength(1); + expect(report.findings[0].status).toBe("pass"); + }); + + it("fails the report on a budget breach", () => { + const report = detectRegressions([BUDGET], { [BUDGET.id]: [110, 120, 130, 140, 150] }, null); + expect(report.passed).toBe(false); + expect(report.findings[0].status).toBe("budget-breach"); + }); + + it("fails the report on a regression beyond baseline tolerance", () => { + const baseline: PerformanceBaseline = { + [BUDGET.id]: { p50Ms: 30, p95Ms: 45, p99Ms: 50, meanMs: 32, sampleCount: 20, recordedAt: "" }, + }; + const report = detectRegressions([BUDGET], { [BUDGET.id]: [70, 75, 78, 80, 82] }, baseline); + expect(report.passed).toBe(false); + expect(report.findings[0].status).toBe("regression"); + }); + + it("does not fail on insufficient data", () => { + const report = detectRegressions([BUDGET], { [BUDGET.id]: [50, 55] }, null); + expect(report.passed).toBe(true); + expect(report.findings[0].status).toBe("insufficient-data"); + }); + + it("handles multiple budgets independently", () => { + const second: PerformanceBudget = { ...BUDGET, id: "api-rate-limit", name: "Rate Limit API", path: "/api/rate-limit" }; + const report = detectRegressions( + [BUDGET, second], + { [BUDGET.id]: [40, 45, 50, 55, 60], [second.id]: [120, 125, 130, 135, 140] }, + null + ); + expect(report.passed).toBe(false); + const statuses = report.findings.map((f) => f.status); + expect(statuses).toContain("pass"); + expect(statuses).toContain("budget-breach"); + }); + + it("treats missing sample entries as insufficient data", () => { + const report = detectRegressions([BUDGET], {}, null); + expect(report.findings[0].status).toBe("insufficient-data"); + expect(report.findings[0].sampleCount).toBe(0); + }); + + it("generates an ISO timestamp in the report", () => { + const report = detectRegressions([BUDGET], { [BUDGET.id]: [40, 45, 50, 55, 60] }, null); + expect(Number.isNaN(Date.parse(report.generatedAt))).toBe(false); + }); +}); + +describe("summarizeAll", () => { + it("produces measurements for every budget", () => { + const measurements = summarizeAll([BUDGET], { [BUDGET.id]: [10, 20, 30, 40, 50] }); + expect(measurements).toHaveLength(1); + expect(measurements[0]).toMatchObject({ + id: BUDGET.id, + name: BUDGET.name, + path: BUDGET.path, + p50Ms: 30, + sampleCount: 5, + }); + }); + + it("produces zeroed measurements when samples are missing", () => { + const measurements = summarizeAll([BUDGET], {}); + expect(measurements[0].sampleCount).toBe(0); + expect(measurements[0].p99Ms).toBe(0); + }); +});